Compare commits
64 Commits
3b8a8a7f1a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
| 924090190f | |||
|
|
945b2ff1f3 | ||
| 77932f802a | |||
|
|
5944f263cd | ||
|
|
a3318adf5e | ||
|
|
a3807faf2d | ||
| 93ab7b3e16 | |||
|
|
a663bc03cd | ||
| b13f58ec81 | |||
|
|
451866e988 | ||
| 0848675016 | |||
|
|
79c4c6e290 | ||
| ee8a0c478f | |||
|
|
282cd1b3a3 | ||
| 24cc05389e | |||
|
|
40d436ad9c | ||
| e1b1910c06 | |||
|
|
473eb271cc | ||
|
|
877e699c56 | ||
|
|
d372a5c77f | ||
|
|
a1dc17bf00 | ||
|
|
c58313496e | ||
|
|
636193fae1 | ||
|
|
faef20e5b8 | ||
|
|
5fa1cc3921 | ||
|
|
a51d2abd47 | ||
|
|
2a062cf484 | ||
|
|
02e8d5141a | ||
|
|
cd67f32302 | ||
|
|
79d9b7cb1d | ||
|
|
d666d0991a | ||
|
|
4e575a22c8 | ||
|
|
d538123251 | ||
|
|
54c1b05411 | ||
|
|
99fc3c91f5 | ||
|
|
ca7640b100 | ||
|
|
719134c6c7 | ||
|
|
143e356b75 |
19
.gitignore
vendored
19
.gitignore
vendored
@@ -34,3 +34,22 @@ Pods/
|
|||||||
screens/
|
screens/
|
||||||
conjugato/
|
conjugato/
|
||||||
conjuu-es/
|
conjuu-es/
|
||||||
|
|
||||||
|
# Video scraping pipeline (kept locally for reruns, not committed)
|
||||||
|
scrape/
|
||||||
|
*.webm
|
||||||
|
*.mp4
|
||||||
|
*.mkv
|
||||||
|
|
||||||
|
# Third-party textbook sources (not redistributable)
|
||||||
|
*.pdf
|
||||||
|
*.epub
|
||||||
|
epub_extract/
|
||||||
|
|
||||||
|
# Textbook extraction artifacts — regenerate locally via run_pipeline.sh.
|
||||||
|
# Scripts are committed; their generated outputs are not.
|
||||||
|
Conjuga/Scripts/textbook/*.json
|
||||||
|
Conjuga/Scripts/textbook/review.html
|
||||||
|
# Note: the app-bundle copies (Conjuga/Conjuga/textbook_{data,vocab}.json)
|
||||||
|
# ARE committed so `xcodebuild` works on a fresh clone without first running
|
||||||
|
# the pipeline. They're regenerated from the scripts when content changes.
|
||||||
|
|||||||
5
CLAUDE.md
Normal file
5
CLAUDE.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Project rules
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- **Never run `git commit` or `git push` without an explicit request from the user in the current turn.** File edits are fine; committing and pushing are not. Wait to be told.
|
||||||
@@ -8,10 +8,16 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; };
|
00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; };
|
||||||
|
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D232CDA43CC9218D748BA121 /* ClozeView.swift */; };
|
||||||
|
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */; };
|
||||||
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; };
|
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; };
|
||||||
|
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; };
|
||||||
|
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; };
|
||||||
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; };
|
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; };
|
||||||
|
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; };
|
||||||
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; };
|
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; };
|
||||||
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; };
|
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; };
|
||||||
|
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */; };
|
||||||
218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; };
|
218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; };
|
||||||
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; };
|
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; };
|
||||||
27BA7FA9356467846A07697D /* TypingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C16AA6022E4742898745CE /* TypingView.swift */; };
|
27BA7FA9356467846A07697D /* TypingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C16AA6022E4742898745CE /* TypingView.swift */; };
|
||||||
@@ -20,34 +26,61 @@
|
|||||||
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; };
|
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; };
|
||||||
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; };
|
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; };
|
||||||
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; };
|
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; };
|
||||||
|
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */; };
|
||||||
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; };
|
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; };
|
||||||
|
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */ = {isa = PBXBuildFile; productRef = 08D6313690BEE4E2F18EADC3 /* YouTubeKit */; };
|
||||||
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; };
|
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; };
|
||||||
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
|
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
|
||||||
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; };
|
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; };
|
||||||
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.swift */; };
|
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.swift */; };
|
||||||
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; };
|
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; };
|
||||||
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; };
|
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; };
|
||||||
|
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */; };
|
||||||
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; };
|
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; };
|
||||||
|
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */; };
|
||||||
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */; };
|
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */; };
|
||||||
|
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA01795655C444795577A22 /* LyricsConfirmationView.swift */; };
|
||||||
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; };
|
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; };
|
||||||
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; };
|
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; };
|
||||||
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
|
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
|
||||||
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
|
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
|
||||||
|
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */; };
|
||||||
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
|
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
|
||||||
|
61328552866DE185B15011A9 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15AC27B1E3D332709657F20B /* StoryLibraryView.swift */; };
|
||||||
|
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
|
||||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
|
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
|
||||||
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
|
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
|
||||||
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; };
|
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; };
|
||||||
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
|
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
|
||||||
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
|
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
|
||||||
|
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D95887B18216FCA71643D6 /* VocabReviewView.swift */; };
|
||||||
|
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; };
|
||||||
|
81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */; };
|
||||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
|
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
|
||||||
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
|
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
|
||||||
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
|
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
|
||||||
|
8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */; };
|
||||||
|
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79576893566932D2BE207528 /* ChatView.swift */; };
|
||||||
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; };
|
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; };
|
||||||
|
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */; };
|
||||||
|
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BE2A08EC694FF784ED5575 /* DictionaryService.swift */; };
|
||||||
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; };
|
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; };
|
||||||
|
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */ = {isa = PBXBuildFile; fileRef = 3644B5ED77F29A65877D926A /* reflexive_verbs.json */; };
|
||||||
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
|
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
|
||||||
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
|
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
|
||||||
|
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */; };
|
||||||
|
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */; };
|
||||||
|
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D1904DF07E0A6816134CF3 /* ListeningView.swift */; };
|
||||||
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
|
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
|
||||||
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
|
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
|
||||||
|
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96C065B8787DEC6818E497 /* ConversationService.swift */; };
|
||||||
|
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */; };
|
||||||
|
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F23A9C2935408B136C7C7 /* StoryGenerator.swift */; };
|
||||||
|
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 */; };
|
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
|
||||||
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; };
|
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; };
|
||||||
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; };
|
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; };
|
||||||
@@ -56,12 +89,14 @@
|
|||||||
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; };
|
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; };
|
||||||
C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; };
|
C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; };
|
||||||
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; };
|
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; };
|
||||||
|
CC886125F8ECE72D1AAD4861 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A48474D969CEF5F573DF09B /* StoryReaderView.swift */; };
|
||||||
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */; };
|
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */; };
|
||||||
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
|
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
|
||||||
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; };
|
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; };
|
||||||
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; };
|
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; };
|
||||||
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
|
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
|
||||||
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; };
|
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; };
|
||||||
|
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */; };
|
||||||
DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; };
|
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; };
|
||||||
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
|
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
|
||||||
@@ -69,9 +104,11 @@
|
|||||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
|
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
|
||||||
ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; };
|
ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; };
|
||||||
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; };
|
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; };
|
||||||
|
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */ = {isa = PBXBuildFile; fileRef = 6658C35E454C137B53FC05A4 /* youtube_videos.json */; };
|
||||||
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
|
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
|
||||||
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
|
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
|
||||||
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; };
|
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; };
|
||||||
|
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -99,65 +136,95 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleGenerator.swift; sourceTree = "<group>"; };
|
||||||
0313D24F96E6A0039C34341F /* DailyLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyLog.swift; sourceTree = "<group>"; };
|
0313D24F96E6A0039C34341F /* DailyLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyLog.swift; sourceTree = "<group>"; };
|
||||||
|
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
|
||||||
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = "<group>"; };
|
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = "<group>"; };
|
||||||
102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; };
|
102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; };
|
||||||
10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; };
|
10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; };
|
||||||
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; };
|
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; };
|
||||||
|
15AC27B1E3D332709657F20B /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = "<group>"; };
|
||||||
16C1F74196C3C5628953BE3F /* Conjuga.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Conjuga.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
16C1F74196C3C5628953BE3F /* Conjuga.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Conjuga.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; };
|
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; };
|
||||||
18AC3C548BDB9EF8701BE64C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
|
18AC3C548BDB9EF8701BE64C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
|
||||||
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusMonitor.swift; sourceTree = "<group>"; };
|
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusMonitor.swift; sourceTree = "<group>"; };
|
||||||
195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressWidget.swift; sourceTree = "<group>"; };
|
195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressWidget.swift; sourceTree = "<group>"; };
|
||||||
1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekProgressWidget.swift; sourceTree = "<group>"; };
|
1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekProgressWidget.swift; sourceTree = "<group>"; };
|
||||||
|
1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.swift; sourceTree = "<group>"; };
|
||||||
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = "<group>"; };
|
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = "<group>"; };
|
||||||
1C4B5204F6B8647C816814F0 /* SyncToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncToast.swift; sourceTree = "<group>"; };
|
1C4B5204F6B8647C816814F0 /* SyncToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncToast.swift; sourceTree = "<group>"; };
|
||||||
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = "<group>"; };
|
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = "<group>"; };
|
||||||
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = "<group>"; };
|
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = "<group>"; };
|
||||||
1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; };
|
1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; };
|
||||||
|
20D1904DF07E0A6816134CF3 /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = "<group>"; };
|
||||||
2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; };
|
2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; };
|
||||||
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; };
|
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; };
|
||||||
30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; };
|
30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; };
|
||||||
|
34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
|
||||||
|
3644B5ED77F29A65877D926A /* reflexive_verbs.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = reflexive_verbs.json; sourceTree = "<group>"; };
|
||||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingRecognizer.swift; sourceTree = "<group>"; };
|
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingRecognizer.swift; sourceTree = "<group>"; };
|
||||||
|
39908548430FDF01D76201FB /* TextbookChapterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterView.swift; sourceTree = "<group>"; };
|
||||||
|
3A96C065B8787DEC6818E497 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; };
|
||||||
3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveContainer.swift; sourceTree = "<group>"; };
|
3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveContainer.swift; sourceTree = "<group>"; };
|
||||||
3BC3247457109FC6BF00D85B /* TenseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseInfo.swift; sourceTree = "<group>"; };
|
3BC3247457109FC6BF00D85B /* TenseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseInfo.swift; sourceTree = "<group>"; };
|
||||||
3CC1AD23158CBABBB753FA1E /* ConjugaWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ConjugaWidget.entitlements; sourceTree = "<group>"; };
|
3CC1AD23158CBABBB753FA1E /* ConjugaWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ConjugaWidget.entitlements; sourceTree = "<group>"; };
|
||||||
|
3EA01795655C444795577A22 /* LyricsConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsConfirmationView.swift; sourceTree = "<group>"; };
|
||||||
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNotesView.swift; sourceTree = "<group>"; };
|
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNotesView.swift; sourceTree = "<group>"; };
|
||||||
42ADC600530309A9B147A663 /* IrregularHighlightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularHighlightText.swift; sourceTree = "<group>"; };
|
42ADC600530309A9B147A663 /* IrregularHighlightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularHighlightText.swift; sourceTree = "<group>"; };
|
||||||
43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedWidget.swift; sourceTree = "<group>"; };
|
43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedWidget.swift; sourceTree = "<group>"; };
|
||||||
|
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchService.swift; sourceTree = "<group>"; };
|
||||||
|
496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterListView.swift; sourceTree = "<group>"; };
|
||||||
49E3AD244327CBF24B7A2752 /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.swift; sourceTree = "<group>"; };
|
49E3AD244327CBF24B7A2752 /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.swift; sourceTree = "<group>"; };
|
||||||
|
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
|
||||||
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = "<group>"; };
|
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = "<group>"; };
|
||||||
|
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
|
||||||
|
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadService.swift; sourceTree = "<group>"; };
|
||||||
|
58394296923991E56BAC2B02 /* LyricsReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsReaderView.swift; sourceTree = "<group>"; };
|
||||||
5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
||||||
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; };
|
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; };
|
||||||
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = "<group>"; };
|
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = "<group>"; };
|
||||||
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = "<group>"; };
|
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = "<group>"; };
|
||||||
626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = "<group>"; };
|
626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = "<group>"; };
|
||||||
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; };
|
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; };
|
||||||
|
648436F8326CF95777E2FA58 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; };
|
||||||
|
6658C35E454C137B53FC05A4 /* youtube_videos.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = youtube_videos.json; sourceTree = "<group>"; };
|
||||||
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; };
|
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; };
|
||||||
|
6A48474D969CEF5F573DF09B /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
|
||||||
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; };
|
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; };
|
||||||
|
70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = "<group>"; };
|
||||||
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = "<group>"; };
|
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = "<group>"; };
|
||||||
|
713F23A9C2935408B136C7C7 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.swift; sourceTree = "<group>"; };
|
||||||
72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWordIntent.swift; sourceTree = "<group>"; };
|
72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWordIntent.swift; sourceTree = "<group>"; };
|
||||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.swift; sourceTree = "<group>"; };
|
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.swift; sourceTree = "<group>"; };
|
||||||
|
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; };
|
||||||
777C696A841803D5B775B678 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = "<group>"; };
|
777C696A841803D5B775B678 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = "<group>"; };
|
||||||
|
79576893566932D2BE207528 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||||
7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = "<group>"; };
|
80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = "<group>"; };
|
||||||
833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = "<group>"; };
|
833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = "<group>"; };
|
||||||
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; };
|
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; };
|
||||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
|
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
|
||||||
|
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookExerciseView.swift; sourceTree = "<group>"; };
|
||||||
|
86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoActionsView.swift; sourceTree = "<group>"; };
|
||||||
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; };
|
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; };
|
||||||
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; };
|
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; };
|
||||||
8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; };
|
8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; };
|
||||||
|
940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReflexiveVerbStore.swift; sourceTree = "<group>"; };
|
||||||
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
|
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
|
||||||
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
|
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
|
||||||
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
|
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
|
||||||
|
A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
|
||||||
AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeVideoStore.swift; sourceTree = "<group>"; };
|
||||||
|
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerChecker.swift; sourceTree = "<group>"; };
|
||||||
BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; };
|
BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; };
|
||||||
BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; };
|
C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; };
|
||||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
|
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
|
||||||
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; };
|
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; };
|
||||||
|
D232CDA43CC9218D748BA121 /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
|
||||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; };
|
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; };
|
||||||
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; };
|
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; };
|
||||||
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; };
|
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; };
|
||||||
@@ -166,8 +233,14 @@
|
|||||||
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; };
|
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; };
|
||||||
E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; };
|
E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; };
|
||||||
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; };
|
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; };
|
||||||
|
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; };
|
||||||
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; };
|
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; };
|
||||||
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
|
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleCache.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>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -184,6 +257,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */,
|
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */,
|
||||||
|
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -199,8 +273,11 @@
|
|||||||
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
|
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
|
||||||
BC273716CD14A99EFF8206CA /* course_data.json */,
|
BC273716CD14A99EFF8206CA /* course_data.json */,
|
||||||
7E6AF62A3A949630E067DC22 /* Info.plist */,
|
7E6AF62A3A949630E067DC22 /* Info.plist */,
|
||||||
|
3644B5ED77F29A65877D926A /* reflexive_verbs.json */,
|
||||||
|
6658C35E454C137B53FC05A4 /* youtube_videos.json */,
|
||||||
353C5DE41FD410FA82E3AED7 /* Models */,
|
353C5DE41FD410FA82E3AED7 /* Models */,
|
||||||
1994867BC8E985795A172854 /* Services */,
|
1994867BC8E985795A172854 /* Services */,
|
||||||
|
BFC1AEBE02CE22E6474FFEA6 /* Utilities */,
|
||||||
3C75490F53C34A37084FF478 /* ViewModels */,
|
3C75490F53C34A37084FF478 /* ViewModels */,
|
||||||
A81CA75762B08D35D5B7A44D /* Views */,
|
A81CA75762B08D35D5B7A44D /* Views */,
|
||||||
);
|
);
|
||||||
@@ -210,6 +287,8 @@
|
|||||||
0931AEB5B728C3A03F06A1CA /* Settings */ = {
|
0931AEB5B728C3A03F06A1CA /* Settings */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */,
|
||||||
|
1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */,
|
||||||
BCCC95A95581458E068E0484 /* SettingsView.swift */,
|
BCCC95A95581458E068E0484 /* SettingsView.swift */,
|
||||||
);
|
);
|
||||||
path = Settings;
|
path = Settings;
|
||||||
@@ -228,18 +307,30 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
|
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
|
||||||
|
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */,
|
||||||
|
3A96C065B8787DEC6818E497 /* ConversationService.swift */,
|
||||||
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
|
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
|
||||||
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
|
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
|
||||||
|
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */,
|
||||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
||||||
|
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
|
||||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
||||||
|
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */,
|
||||||
777C696A841803D5B775B678 /* ReferenceStore.swift */,
|
777C696A841803D5B775B678 /* ReferenceStore.swift */,
|
||||||
|
940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */,
|
||||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
|
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
|
||||||
49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
|
49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
|
||||||
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */,
|
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */,
|
||||||
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */,
|
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */,
|
||||||
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */,
|
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */,
|
||||||
|
713F23A9C2935408B136C7C7 /* StoryGenerator.swift */,
|
||||||
|
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */,
|
||||||
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
|
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
|
||||||
|
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */,
|
||||||
|
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */,
|
||||||
|
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
|
||||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
|
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
|
||||||
|
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -256,6 +347,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0313D24F96E6A0039C34341F /* DailyLog.swift */,
|
0313D24F96E6A0039C34341F /* DailyLog.swift */,
|
||||||
|
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */,
|
||||||
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
|
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
|
||||||
626873572466403C0288090D /* QuizType.swift */,
|
626873572466403C0288090D /* QuizType.swift */,
|
||||||
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
|
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
|
||||||
@@ -288,6 +380,16 @@
|
|||||||
path = ViewModels;
|
path = ViewModels;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
43E4D263B0AF47E401A51601 /* Stories */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
15AC27B1E3D332709657F20B /* StoryLibraryView.swift */,
|
||||||
|
A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */,
|
||||||
|
6A48474D969CEF5F573DF09B /* StoryReaderView.swift */,
|
||||||
|
);
|
||||||
|
path = Stories;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
4B183AB0C56BC2EC302531E7 /* ConjugaWidget */ = {
|
4B183AB0C56BC2EC302531E7 /* ConjugaWidget */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -308,14 +410,20 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */,
|
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */,
|
||||||
|
D232CDA43CC9218D748BA121 /* ClozeView.swift */,
|
||||||
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */,
|
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */,
|
||||||
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */,
|
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */,
|
||||||
1F842EB5E566C74658D918BB /* HandwritingView.swift */,
|
1F842EB5E566C74658D918BB /* HandwritingView.swift */,
|
||||||
|
20D1904DF07E0A6816134CF3 /* ListeningView.swift */,
|
||||||
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
|
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
|
||||||
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
|
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
|
||||||
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
|
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
|
||||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
||||||
10C16AA6022E4742898745CE /* TypingView.swift */,
|
10C16AA6022E4742898745CE /* TypingView.swift */,
|
||||||
|
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */,
|
||||||
|
8FB89F19B33894DDF27C8EC2 /* Chat */,
|
||||||
|
895E547BEFB5D0FBF676BE33 /* Lyrics */,
|
||||||
|
43E4D263B0AF47E401A51601 /* Stories */,
|
||||||
);
|
);
|
||||||
path = Practice;
|
path = Practice;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -323,12 +431,34 @@
|
|||||||
8102F7FA5BFE6D38B2212AD3 /* Guide */ = {
|
8102F7FA5BFE6D38B2212AD3 /* Guide */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */,
|
||||||
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
|
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
|
||||||
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
|
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
|
||||||
|
86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */,
|
||||||
);
|
);
|
||||||
path = Guide;
|
path = Guide;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
895E547BEFB5D0FBF676BE33 /* Lyrics */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3EA01795655C444795577A22 /* LyricsConfirmationView.swift */,
|
||||||
|
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */,
|
||||||
|
58394296923991E56BAC2B02 /* LyricsReaderView.swift */,
|
||||||
|
70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */,
|
||||||
|
);
|
||||||
|
path = Lyrics;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
8FB89F19B33894DDF27C8EC2 /* Chat */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
648436F8326CF95777E2FA58 /* ChatLibraryView.swift */,
|
||||||
|
79576893566932D2BE207528 /* ChatView.swift */,
|
||||||
|
);
|
||||||
|
path = Chat;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
A591A3B6F1F13D23D68D7A9D = {
|
A591A3B6F1F13D23D68D7A9D = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -367,15 +497,27 @@
|
|||||||
BE5A40BAC9DD6884C58A2096 /* Course */ = {
|
BE5A40BAC9DD6884C58A2096 /* Course */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */,
|
||||||
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */,
|
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */,
|
||||||
833516C5D57F164C8660A479 /* CourseView.swift */,
|
833516C5D57F164C8660A479 /* CourseView.swift */,
|
||||||
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
|
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
|
||||||
|
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */,
|
||||||
|
496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */,
|
||||||
|
39908548430FDF01D76201FB /* TextbookChapterView.swift */,
|
||||||
|
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */,
|
||||||
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
|
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
|
||||||
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
|
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
|
||||||
);
|
);
|
||||||
path = Course;
|
path = Course;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
BFC1AEBE02CE22E6474FFEA6 /* Utilities */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
);
|
||||||
|
path = Utilities;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
F605D24E5EA11065FD18AF7E /* Products */ = {
|
F605D24E5EA11065FD18AF7E /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -413,6 +555,7 @@
|
|||||||
name = Conjuga;
|
name = Conjuga;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
BCCBABD74CADDB118179D8E9 /* SharedModels */,
|
BCCBABD74CADDB118179D8E9 /* SharedModels */,
|
||||||
|
08D6313690BEE4E2F18EADC3 /* YouTubeKit */,
|
||||||
);
|
);
|
||||||
productName = Conjuga;
|
productName = Conjuga;
|
||||||
productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */;
|
productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */;
|
||||||
@@ -467,6 +610,7 @@
|
|||||||
mainGroup = A591A3B6F1F13D23D68D7A9D;
|
mainGroup = A591A3B6F1F13D23D68D7A9D;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
|
E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */,
|
||||||
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
|
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
@@ -487,6 +631,8 @@
|
|||||||
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */,
|
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */,
|
||||||
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */,
|
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */,
|
||||||
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */,
|
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */,
|
||||||
|
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */,
|
||||||
|
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -499,8 +645,14 @@
|
|||||||
files = (
|
files = (
|
||||||
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */,
|
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */,
|
||||||
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */,
|
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */,
|
||||||
|
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */,
|
||||||
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */,
|
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */,
|
||||||
|
B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */,
|
||||||
|
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */,
|
||||||
|
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */,
|
||||||
|
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */,
|
||||||
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */,
|
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */,
|
||||||
|
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */,
|
||||||
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */,
|
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */,
|
||||||
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */,
|
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */,
|
||||||
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */,
|
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */,
|
||||||
@@ -509,8 +661,13 @@
|
|||||||
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */,
|
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */,
|
||||||
C8C3880535008764B7117049 /* DataLoader.swift in Sources */,
|
C8C3880535008764B7117049 /* DataLoader.swift in Sources */,
|
||||||
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
|
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
|
||||||
|
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */,
|
||||||
|
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */,
|
||||||
|
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */,
|
||||||
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */,
|
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */,
|
||||||
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */,
|
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */,
|
||||||
|
8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */,
|
||||||
|
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */,
|
||||||
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */,
|
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */,
|
||||||
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */,
|
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */,
|
||||||
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */,
|
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */,
|
||||||
@@ -518,6 +675,12 @@
|
|||||||
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
|
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
|
||||||
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
|
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
|
||||||
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
|
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
|
||||||
|
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */,
|
||||||
|
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */,
|
||||||
|
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */,
|
||||||
|
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */,
|
||||||
|
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */,
|
||||||
|
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */,
|
||||||
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
|
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
|
||||||
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
|
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
|
||||||
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,
|
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,
|
||||||
@@ -525,8 +688,10 @@
|
|||||||
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */,
|
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */,
|
||||||
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */,
|
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */,
|
||||||
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */,
|
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */,
|
||||||
|
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */,
|
||||||
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */,
|
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */,
|
||||||
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */,
|
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */,
|
||||||
|
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */,
|
||||||
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */,
|
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */,
|
||||||
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */,
|
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */,
|
||||||
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */,
|
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */,
|
||||||
@@ -534,21 +699,36 @@
|
|||||||
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */,
|
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */,
|
||||||
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */,
|
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */,
|
||||||
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */,
|
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */,
|
||||||
|
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */,
|
||||||
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */,
|
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */,
|
||||||
|
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */,
|
||||||
|
61328552866DE185B15011A9 /* StoryLibraryView.swift in Sources */,
|
||||||
|
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */,
|
||||||
|
CC886125F8ECE72D1AAD4861 /* StoryReaderView.swift in Sources */,
|
||||||
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */,
|
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */,
|
||||||
|
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */,
|
||||||
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */,
|
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */,
|
||||||
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */,
|
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */,
|
||||||
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */,
|
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */,
|
||||||
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */,
|
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */,
|
||||||
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */,
|
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */,
|
||||||
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */,
|
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */,
|
||||||
|
81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */,
|
||||||
|
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */,
|
||||||
|
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */,
|
||||||
27BA7FA9356467846A07697D /* TypingView.swift in Sources */,
|
27BA7FA9356467846A07697D /* TypingView.swift in Sources */,
|
||||||
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */,
|
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */,
|
||||||
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */,
|
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */,
|
||||||
|
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */,
|
||||||
|
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */,
|
||||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
||||||
|
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */,
|
||||||
|
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */,
|
||||||
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
||||||
|
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */,
|
||||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
||||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
||||||
|
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -821,7 +1001,23 @@
|
|||||||
};
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/alexeichhorn/YouTubeKit.git";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 0.3.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
08D6313690BEE4E2F18EADC3 /* YouTubeKit */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */;
|
||||||
|
productName = YouTubeKit;
|
||||||
|
};
|
||||||
4A4D7B02884EBA9ACD93F0FD /* SharedModels */ = {
|
4A4D7B02884EBA9ACD93F0FD /* SharedModels */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = SharedModels;
|
productName = SharedModels;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "1b6ada17bf1104878f9520a6f7cb3cd84338c0da74dc3761cef075709d7df45d",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "youtubekit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/alexeichhorn/YouTubeKit.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "65be95dbb1dbd749499e0638871568c823822276",
|
||||||
|
"version" : "0.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ private enum CloudPreviewContainer {
|
|||||||
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
|
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||||
return try! ModelContainer(
|
return try! ModelContainer(
|
||||||
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||||
TestResult.self, DailyLog.self,
|
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||||
configurations: configuration
|
configurations: configuration
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
@@ -36,8 +36,13 @@ extension EnvironmentValues {
|
|||||||
struct ConjugaApp: App {
|
struct ConjugaApp: App {
|
||||||
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var isReady = false
|
@State private var isReady = true
|
||||||
@State private var syncMonitor = SyncStatusMonitor()
|
@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 localContainer: ModelContainer
|
||||||
let cloudContainer: ModelContainer
|
let cloudContainer: ModelContainer
|
||||||
@@ -66,15 +71,18 @@ struct ConjugaApp: App {
|
|||||||
"cloud",
|
"cloud",
|
||||||
schema: Schema([
|
schema: Schema([
|
||||||
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||||
TestResult.self, DailyLog.self,
|
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||||
|
TextbookExerciseAttempt.self,
|
||||||
]),
|
]),
|
||||||
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
||||||
)
|
)
|
||||||
cloudContainer = try ModelContainer(
|
cloudContainer = try ModelContainer(
|
||||||
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||||
TestResult.self, DailyLog.self,
|
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||||
|
TextbookExerciseAttempt.self,
|
||||||
configurations: cloudConfig
|
configurations: cloudConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
fatalError("Failed to create ModelContainer: \(error)")
|
fatalError("Failed to create ModelContainer: \(error)")
|
||||||
}
|
}
|
||||||
@@ -106,15 +114,26 @@ struct ConjugaApp: App {
|
|||||||
.animation(.spring(duration: 0.35), value: syncMonitor.shouldShowToast)
|
.animation(.spring(duration: 0.35), value: syncMonitor.shouldShowToast)
|
||||||
.environment(syncMonitor)
|
.environment(syncMonitor)
|
||||||
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
|
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
|
||||||
|
.environment(studyTimer)
|
||||||
|
.environment(dictionary)
|
||||||
|
.environment(verbExampleCache)
|
||||||
|
.environment(reflexiveStore)
|
||||||
|
.environment(youtubeVideoStore)
|
||||||
.task {
|
.task {
|
||||||
if let url = SharedStore.localStoreURL() {
|
let needsSeed = await DataLoader.needsSeeding(container: localContainer)
|
||||||
StoreInspector.dump(at: url, label: "before-bootstrap")
|
if needsSeed {
|
||||||
|
isReady = false
|
||||||
}
|
}
|
||||||
|
|
||||||
await StartupCoordinator.bootstrap(localContainer: localContainer)
|
await StartupCoordinator.bootstrap(localContainer: localContainer)
|
||||||
if let url = SharedStore.localStoreURL() {
|
|
||||||
StoreInspector.dump(at: url, label: "after-bootstrap")
|
if !isReady {
|
||||||
|
isReady = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
dictionary.buildIfNeeded(context: localContainer.mainContext)
|
||||||
}
|
}
|
||||||
isReady = true
|
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
syncMonitor.beginSync()
|
syncMonitor.beginSync()
|
||||||
@@ -130,6 +149,15 @@ struct ConjugaApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
|
switch newPhase {
|
||||||
|
case .active:
|
||||||
|
studyTimer.start()
|
||||||
|
case .inactive, .background:
|
||||||
|
studyTimer.stop(context: cloudContainer.mainContext)
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
if newPhase == .background {
|
if newPhase == .background {
|
||||||
WidgetDataService.update(
|
WidgetDataService.update(
|
||||||
localContainer: localContainer,
|
localContainer: localContainer,
|
||||||
@@ -178,7 +206,7 @@ struct ConjugaApp: App {
|
|||||||
|
|
||||||
deleteStoreFiles(at: url)
|
deleteStoreFiles(at: url)
|
||||||
UserDefaults.standard.removeObject(forKey: "courseDataVersion")
|
UserDefaults.standard.removeObject(forKey: "courseDataVersion")
|
||||||
print("Reset corrupted local reference store")
|
print("[ConjugaApp] ⚠️ Reset corrupted local reference store — this triggers full re-seed")
|
||||||
|
|
||||||
return try makeLocalContainer(at: url)
|
return try makeLocalContainer(at: url)
|
||||||
}
|
}
|
||||||
@@ -189,6 +217,8 @@ struct ConjugaApp: App {
|
|||||||
schema: Schema([
|
schema: Schema([
|
||||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||||
|
TextbookChapter.self,
|
||||||
|
DownloadedVideo.self,
|
||||||
]),
|
]),
|
||||||
url: url,
|
url: url,
|
||||||
cloudKitDatabase: .none
|
cloudKitDatabase: .none
|
||||||
@@ -196,6 +226,8 @@ struct ConjugaApp: App {
|
|||||||
return try ModelContainer(
|
return try ModelContainer(
|
||||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||||
|
TextbookChapter.self,
|
||||||
|
DownloadedVideo.self,
|
||||||
configurations: localConfig
|
configurations: localConfig
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -224,7 +256,7 @@ struct ConjugaApp: App {
|
|||||||
/// Clears accumulated stale schema metadata from previous container configurations.
|
/// Clears accumulated stale schema metadata from previous container configurations.
|
||||||
/// Bump the version number to force another reset if the schema changes again.
|
/// Bump the version number to force another reset if the schema changes again.
|
||||||
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
|
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
|
||||||
let resetVersion = 2 // bump: widget schema moved to SharedModels
|
let resetVersion = 4 // bump: DownloadedVideo added to local container (Issue #21)
|
||||||
let key = "localStoreResetVersion"
|
let key = "localStoreResetVersion"
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
|
|
||||||
@@ -239,4 +271,5 @@ struct ConjugaApp: App {
|
|||||||
|
|
||||||
defaults.set(resetVersion, forKey: key)
|
defaults.set(resetVersion, forKey: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.conjuga.app.refresh</string>
|
||||||
|
</array>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
@@ -22,17 +26,13 @@
|
|||||||
<string>1</string>
|
<string>1</string>
|
||||||
<key>LSApplicationCategoryType</key>
|
<key>LSApplicationCategoryType</key>
|
||||||
<string>public.app-category.education</string>
|
<string>public.app-category.education</string>
|
||||||
<key>UILaunchScreen</key>
|
|
||||||
<dict/>
|
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>fetch</string>
|
<string>fetch</string>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
</array>
|
</array>
|
||||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
<key>UILaunchScreen</key>
|
||||||
<array>
|
<dict/>
|
||||||
<string>com.conjuga.app.refresh</string>
|
|
||||||
</array>
|
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ final class DailyLog {
|
|||||||
var dateString: String = ""
|
var dateString: String = ""
|
||||||
var reviewCount: Int = 0
|
var reviewCount: Int = 0
|
||||||
var correctCount: Int = 0
|
var correctCount: Int = 0
|
||||||
|
var studySeconds: Int = 0
|
||||||
|
|
||||||
var accuracy: Double {
|
var accuracy: Double {
|
||||||
guard reviewCount > 0 else { return 0 }
|
guard reviewCount > 0 else { return 0 }
|
||||||
|
|||||||
1819
Conjuga/Conjuga/Models/GrammarExercise.swift
Normal file
1819
Conjuga/Conjuga/Models/GrammarExercise.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,9 @@ struct GrammarNote: Identifiable {
|
|||||||
accentMarksStress,
|
accentMarksStress,
|
||||||
seConstructions,
|
seConstructions,
|
||||||
estarGerundProgressive,
|
estarGerundProgressive,
|
||||||
|
spanishSuffixes,
|
||||||
|
commonIrregularVerbs,
|
||||||
|
typesOfIrregularVerbs,
|
||||||
]
|
]
|
||||||
|
|
||||||
// MARK: - 1. Ser vs Estar
|
// MARK: - 1. Ser vs Estar
|
||||||
@@ -887,4 +890,829 @@ struct GrammarNote: Identifiable {
|
|||||||
**Other verbs with gerunds:** While *estar* is the most common, other verbs can also combine with gerunds: *seguir* (to keep on), *continuar* (to continue), *andar* (to go around), *llevar* (duration). *Sigo estudiando español.* — I keep on studying Spanish. *Llevo tres años viviendo aquí.* — I've been living here for three years.
|
**Other verbs with gerunds:** While *estar* is the most common, other verbs can also combine with gerunds: *seguir* (to keep on), *continuar* (to continue), *andar* (to go around), *llevar* (duration). *Sigo estudiando español.* — I keep on studying Spanish. *Llevo tres años viviendo aquí.* — I've been living here for three years.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MARK: - 21. Spanish Suffixes
|
||||||
|
|
||||||
|
private static let spanishSuffixes = GrammarNote(
|
||||||
|
id: "spanish-suffixes",
|
||||||
|
title: "Spanish Suffixes",
|
||||||
|
category: "Word Building",
|
||||||
|
body: """
|
||||||
|
Spanish uses suffixes — endings added to root words — to change meaning, size, tone, or part of speech. Learning these patterns lets you decode unfamiliar words and build new ones from roots you already know.
|
||||||
|
|
||||||
|
**Diminutives — smallness and affection**
|
||||||
|
|
||||||
|
Diminutive suffixes make a noun feel smaller, cuter, or more endearing. They are extremely common in everyday speech.
|
||||||
|
|
||||||
|
*-ito / -ita* — the most common diminutive. Expresses smallness or affection.
|
||||||
|
*perro → perrito* — little dog
|
||||||
|
*casa → casita* — little house
|
||||||
|
*momento → momentito* — just a moment
|
||||||
|
*abuela → abuelita* — grandma (affectionate)
|
||||||
|
|
||||||
|
*-cito / -cita* — used after words ending in a consonant or -e.
|
||||||
|
*café → cafecito* — a nice little coffee
|
||||||
|
*pobre → pobrecito* — poor little thing
|
||||||
|
*amor → amorcito* — sweetheart
|
||||||
|
|
||||||
|
*-illo / -illa* — a slightly less affectionate diminutive.
|
||||||
|
*chico → chiquillo* — little kid
|
||||||
|
*guerra → guerrilla* — small war / guerrilla
|
||||||
|
*pan → panecillo* — bread roll
|
||||||
|
|
||||||
|
*-ico / -ica* — regional diminutive popular in Colombia, Cuba, and Costa Rica.
|
||||||
|
*rato → ratico* — a little while
|
||||||
|
*gato → gatico* — little cat
|
||||||
|
|
||||||
|
**Augmentatives — bigness and intensity**
|
||||||
|
|
||||||
|
Augmentative suffixes make nouns feel bigger, more intense, or sometimes clumsier. They often carry a slightly negative or humorous tone.
|
||||||
|
|
||||||
|
*-ón / -ona* — big or intense, sometimes negative.
|
||||||
|
*silla → sillón* — armchair (big chair)
|
||||||
|
*soltar → solterón* — confirmed bachelor
|
||||||
|
*cabeza → cabezón* — big-headed / stubborn
|
||||||
|
|
||||||
|
*-ote / -ota* — big, often clumsy or exaggerated.
|
||||||
|
*grande → grandote* — really big
|
||||||
|
*amigo → amigote* — a big buddy (casual)
|
||||||
|
*palabra → palabrota* — a swear word (a "big word")
|
||||||
|
|
||||||
|
*-azo / -aza* — big; also means a blow or hit with something.
|
||||||
|
*perro → perrazo* — huge dog
|
||||||
|
*puño → puñetazo* — a punch
|
||||||
|
*gol → golazo* — an amazing goal
|
||||||
|
*codo → codazo* — an elbow jab
|
||||||
|
|
||||||
|
*-udo / -uda* — having a lot of something.
|
||||||
|
*pelo → peludo* — hairy
|
||||||
|
*barba → barbudo* — bearded
|
||||||
|
*panza → panzudo* — big-bellied
|
||||||
|
|
||||||
|
**Verb-related endings**
|
||||||
|
|
||||||
|
These endings are built into the verb conjugation system and tell you when and how the action occurs.
|
||||||
|
|
||||||
|
*-ando* — gerund for -ar verbs (happening now, equivalent to English "-ing").
|
||||||
|
*hablar → hablando* — speaking
|
||||||
|
*caminar → caminando* — walking
|
||||||
|
*estudiar → estudiando* — studying
|
||||||
|
|
||||||
|
*-iendo* — gerund for -er and -ir verbs.
|
||||||
|
*comer → comiendo* — eating
|
||||||
|
*vivir → viviendo* — living
|
||||||
|
*leer → leyendo* — reading (note the spelling change)
|
||||||
|
|
||||||
|
*-ado* — past participle for -ar verbs (equivalent to English "-ed").
|
||||||
|
*hablar → hablado* — spoken
|
||||||
|
*cerrar → cerrado* — closed
|
||||||
|
*cansar → cansado* — tired
|
||||||
|
|
||||||
|
*-ido* — past participle for -er and -ir verbs.
|
||||||
|
*comer → comido* — eaten
|
||||||
|
*vivir → vivido* — lived
|
||||||
|
*dormir → dormido* — slept
|
||||||
|
|
||||||
|
**Noun-forming suffixes**
|
||||||
|
|
||||||
|
These suffixes turn verbs or adjectives into nouns, creating words for actions, results, places, and people.
|
||||||
|
|
||||||
|
*-ción / -sión* — action or result, equivalent to English "-tion."
|
||||||
|
*actuar → acción* — action
|
||||||
|
*decidir → decisión* — decision
|
||||||
|
*comunicar → comunicación* — communication
|
||||||
|
*expresar → expresión* — expression
|
||||||
|
|
||||||
|
*-miento* — action or result, similar to -ción.
|
||||||
|
*conocer → conocimiento* — knowledge
|
||||||
|
*sentir → sentimiento* — feeling
|
||||||
|
*mover → movimiento* — movement
|
||||||
|
|
||||||
|
*-ería* — a shop, business, or place associated with something.
|
||||||
|
*pan → panadería* — bakery
|
||||||
|
*libro → librería* — bookstore
|
||||||
|
*zapato → zapatería* — shoe store
|
||||||
|
*carne → carnicería* — butcher shop
|
||||||
|
|
||||||
|
*-ero / -era* — a person who does or makes something.
|
||||||
|
*pan → panadero* — baker
|
||||||
|
*cocina → cocinero* — cook
|
||||||
|
*carta → cartero* — mail carrier
|
||||||
|
*enfermo → enfermera* — nurse
|
||||||
|
|
||||||
|
*-dor / -dora* — a person or thing that does something.
|
||||||
|
*jugar → jugador* — player
|
||||||
|
*trabajar → trabajador* — worker
|
||||||
|
*computar → computadora* — computer
|
||||||
|
*lavar → lavadora* — washing machine
|
||||||
|
|
||||||
|
*-ista* — a person devoted to something (gender-neutral form).
|
||||||
|
*arte → artista* — artist
|
||||||
|
*diente → dentista* — dentist
|
||||||
|
*fútbol → futbolista* — soccer player
|
||||||
|
*piano → pianista* — pianist
|
||||||
|
|
||||||
|
*-ura* — a quality or result.
|
||||||
|
*dulce → dulzura* — sweetness
|
||||||
|
*loco → locura* — madness
|
||||||
|
*abrir → abertura* — opening
|
||||||
|
*pintar → pintura* — painting / paint
|
||||||
|
|
||||||
|
*-eza* — an abstract quality.
|
||||||
|
*bello → belleza* — beauty
|
||||||
|
*triste → tristeza* — sadness
|
||||||
|
*puro → pureza* — purity
|
||||||
|
*natural → naturaleza* — nature
|
||||||
|
|
||||||
|
*-dad / -tad* — a quality, equivalent to English "-ty."
|
||||||
|
*libre → libertad* — liberty
|
||||||
|
*real → realidad* — reality
|
||||||
|
*feliz → felicidad* — happiness
|
||||||
|
*amigo → amistad* — friendship
|
||||||
|
|
||||||
|
*-anza* — action or result.
|
||||||
|
*esperar → esperanza* — hope
|
||||||
|
*enseñar → enseñanza* — teaching
|
||||||
|
*confiar → confianza* — trust / confidence
|
||||||
|
|
||||||
|
**Adjective-forming suffixes**
|
||||||
|
|
||||||
|
These turn nouns or verbs into adjectives that describe qualities, origins, or tendencies.
|
||||||
|
|
||||||
|
*-oso / -osa* — full of something, equivalent to English "-ous."
|
||||||
|
*peligro → peligroso* — dangerous
|
||||||
|
*hermosura → hermoso* — beautiful
|
||||||
|
*cariño → cariñoso* — affectionate
|
||||||
|
*éxito → exitoso* — successful
|
||||||
|
|
||||||
|
*-ano / -ana* — relating to a place or group.
|
||||||
|
*México → mexicano* — Mexican
|
||||||
|
*América → americano* — American
|
||||||
|
*cristiano* — Christian
|
||||||
|
*humano* — human
|
||||||
|
|
||||||
|
*-eño / -eña* — from a place.
|
||||||
|
*Panamá → panameño* — Panamanian
|
||||||
|
*Brasil → brasileño* — Brazilian
|
||||||
|
*isla → isleño* — islander
|
||||||
|
|
||||||
|
*-ense* — from a place (often countries or cities).
|
||||||
|
*Costa Rica → costarricense* — Costa Rican
|
||||||
|
*Estados Unidos → estadounidense* — American (USA)
|
||||||
|
*Canadá → canadiense* — Canadian
|
||||||
|
|
||||||
|
*-ble* — able to be, equivalent to English "-ble."
|
||||||
|
*posible* — possible
|
||||||
|
*increíble* — incredible
|
||||||
|
*responsable* — responsible
|
||||||
|
*agradable* — pleasant
|
||||||
|
|
||||||
|
*-ivo / -iva* — tending toward, equivalent to English "-ive."
|
||||||
|
*actuar → activo* — active
|
||||||
|
*crear → creativo* — creative
|
||||||
|
*producir → productivo* — productive
|
||||||
|
|
||||||
|
**Adverb and intensity suffixes**
|
||||||
|
|
||||||
|
*-mente* — turns adjectives into adverbs, equivalent to English "-ly." Attach it to the feminine form of the adjective.
|
||||||
|
*rápida → rápidamente* — quickly
|
||||||
|
*fácil → fácilmente* — easily
|
||||||
|
*tranquila → tranquilamente* — calmly
|
||||||
|
*completa → completamente* — completely
|
||||||
|
|
||||||
|
*-ísimo / -ísima* — absolute superlative, meaning "extremely" or "super."
|
||||||
|
*rápido → rapidísimo* — super fast
|
||||||
|
*bella → bellísima* — extremely beautiful
|
||||||
|
*mucho → muchísimo* — very very much
|
||||||
|
*grande → grandísimo* — enormous
|
||||||
|
|
||||||
|
**Pejorative suffixes — negative or ugly tone**
|
||||||
|
|
||||||
|
These suffixes add a negative, derogatory, or ugly connotation.
|
||||||
|
|
||||||
|
*-ucho / -ucha* — ugly, run-down.
|
||||||
|
*casa → casucha* — a shack, a dump
|
||||||
|
*delgado → delgaducho* — scrawny
|
||||||
|
|
||||||
|
*-aco / -aca* — ugly, derogatory.
|
||||||
|
*pájaro → pajarraco* — ugly bird
|
||||||
|
*libro → libraco* — a terrible book
|
||||||
|
|
||||||
|
**Key insight:** Once you recognize root + suffix patterns, you can decode unfamiliar words. For example, *panadería* = *pan* (bread) + *-ero* (person) + *-ía* (place) = "place where the bread-person works" = bakery. The suffix system makes Spanish vocabulary highly predictable and composable.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - 22. Most Common Irregular Verbs
|
||||||
|
|
||||||
|
private static let commonIrregularVerbs = GrammarNote(
|
||||||
|
id: "common-irregular-verbs",
|
||||||
|
title: "Most Common Irregular Verbs",
|
||||||
|
category: "Irregular Verbs",
|
||||||
|
body: """
|
||||||
|
These 15 verbs are among the most used in Spanish — and they are all irregular. Mastering their forms is essential because you will encounter them in nearly every conversation.
|
||||||
|
|
||||||
|
**ser** — to be (permanent)
|
||||||
|
|
||||||
|
*yo soy, tú eres, él es* — Present
|
||||||
|
*yo fui, tú fuiste, él fue* — Preterite
|
||||||
|
*Soy estudiante.* — I am a student.
|
||||||
|
|
||||||
|
**estar** — to be (temporary/location)
|
||||||
|
|
||||||
|
*yo estoy, tú estás, él está* — Present
|
||||||
|
*yo estuve, tú estuviste, él estuvo* — Preterite
|
||||||
|
*Estoy cansado.* — I am tired.
|
||||||
|
|
||||||
|
**ir** — to go
|
||||||
|
|
||||||
|
*yo voy, tú vas, él va* — Present
|
||||||
|
*yo fui, tú fuiste, él fue* — Preterite (same as ser!)
|
||||||
|
*Voy al supermercado.* — I'm going to the supermarket.
|
||||||
|
|
||||||
|
**haber** — to have (auxiliary)
|
||||||
|
|
||||||
|
*yo he, tú has, él ha* — Present
|
||||||
|
*yo hube, tú hubiste, él hubo* — Preterite
|
||||||
|
*He comido ya.* — I have already eaten.
|
||||||
|
|
||||||
|
**tener** — to have (possession)
|
||||||
|
|
||||||
|
*yo tengo, tú tienes, él tiene* — Present
|
||||||
|
*yo tuve, tú tuviste, él tuvo* — Preterite
|
||||||
|
*Tengo dos hermanos.* — I have two siblings.
|
||||||
|
|
||||||
|
**hacer** — to do / to make
|
||||||
|
|
||||||
|
*yo hago, tú haces, él hace* — Present
|
||||||
|
*yo hice, tú hiciste, él hizo* — Preterite
|
||||||
|
*¿Qué haces?* — What are you doing?
|
||||||
|
|
||||||
|
**poder** — to be able to / can
|
||||||
|
|
||||||
|
*yo puedo, tú puedes, él puede* — Present
|
||||||
|
*yo pude, tú pudiste, él pudo* — Preterite
|
||||||
|
*No puedo dormir.* — I can't sleep.
|
||||||
|
|
||||||
|
**querer** — to want / to love
|
||||||
|
|
||||||
|
*yo quiero, tú quieres, él quiere* — Present
|
||||||
|
*yo quise, tú quisiste, él quiso* — Preterite
|
||||||
|
*Quiero agua, por favor.* — I want water, please.
|
||||||
|
|
||||||
|
**decir** — to say / to tell
|
||||||
|
|
||||||
|
*yo digo, tú dices, él dice* — Present
|
||||||
|
*yo dije, tú dijiste, él dijo* — Preterite
|
||||||
|
*¿Qué dices?* — What are you saying?
|
||||||
|
|
||||||
|
**venir** — to come
|
||||||
|
|
||||||
|
*yo vengo, tú vienes, él viene* — Present
|
||||||
|
*yo vine, tú viniste, él vino* — Preterite
|
||||||
|
*Vengo de la tienda.* — I'm coming from the store.
|
||||||
|
|
||||||
|
**saber** — to know (facts)
|
||||||
|
|
||||||
|
*yo sé, tú sabes, él sabe* — Present
|
||||||
|
*yo supe, tú supiste, él supo* — Preterite
|
||||||
|
*No sé la respuesta.* — I don't know the answer.
|
||||||
|
|
||||||
|
**poner** — to put / to place
|
||||||
|
|
||||||
|
*yo pongo, tú pones, él pone* — Present
|
||||||
|
*yo puse, tú pusiste, él puso* — Preterite
|
||||||
|
*Pon el libro en la mesa.* — Put the book on the table.
|
||||||
|
|
||||||
|
**salir** — to leave / to go out
|
||||||
|
|
||||||
|
*yo salgo, tú sales, él sale* — Present
|
||||||
|
*yo salí, tú saliste, él salió* — Preterite
|
||||||
|
*Salgo a las ocho.* — I leave at eight.
|
||||||
|
|
||||||
|
**dar** — to give
|
||||||
|
|
||||||
|
*yo doy, tú das, él da* — Present
|
||||||
|
*yo di, tú diste, él dio* — Preterite
|
||||||
|
*Dame un momento.* — Give me a moment.
|
||||||
|
|
||||||
|
**ver** — to see
|
||||||
|
|
||||||
|
*yo veo, tú ves, él ve* — Present
|
||||||
|
*yo vi, tú viste, él vio* — Preterite
|
||||||
|
*¿Ves eso?* — Do you see that?
|
||||||
|
|
||||||
|
**Tip:** These verbs appear so frequently that you will naturally memorize them through practice. Focus on the present and preterite forms first — those are the ones you need every day.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - 23. Types of Irregular Verbs
|
||||||
|
|
||||||
|
private static let typesOfIrregularVerbs = GrammarNote(
|
||||||
|
id: "types-of-irregular-verbs",
|
||||||
|
title: "Types of Irregular Verbs",
|
||||||
|
category: "Irregular Verbs",
|
||||||
|
body: """
|
||||||
|
Spanish irregular verbs follow predictable patterns. Once you learn the pattern, you can conjugate dozens of verbs correctly. There are three main types of irregularity.
|
||||||
|
|
||||||
|
**Spelling Changes**
|
||||||
|
|
||||||
|
These verbs change their spelling to preserve the correct pronunciation. The sound stays the same — only the letters change. This happens because Spanish spelling rules require certain letter combinations before specific vowels.
|
||||||
|
|
||||||
|
*c → qu* (before e): buscar → busqué, tocar → toqué, sacar → saqué
|
||||||
|
*g → gu* (before e): pagar → pagué, llegar → llegué, jugar → jugué
|
||||||
|
*z → c* (before e): empezar → empecé, almorzar → almorcé, cruzar → crucé
|
||||||
|
*g → j* (before a/o): coger → cojo, elegir → elijo, proteger → protejo
|
||||||
|
*gu → gü* (before e): averiguar → averigüé
|
||||||
|
*i → y* (between vowels): leer → leyó/leyeron, oír → oyó/oyeron
|
||||||
|
|
||||||
|
*Busqué mis llaves por toda la casa.* — I looked for my keys all over the house.
|
||||||
|
*Llegué tarde al trabajo.* — I arrived late to work.
|
||||||
|
*Empecé a estudiar español el año pasado.* — I started studying Spanish last year.
|
||||||
|
|
||||||
|
**Stem Changes**
|
||||||
|
|
||||||
|
These verbs change a vowel in the stem when it is stressed. The change only happens in certain persons (usually all except nosotros and vosotros in the present tense). Common patterns:
|
||||||
|
|
||||||
|
*e → ie:* pensar → pienso, querer → quiero, preferir → prefiero, cerrar → cierro, entender → entiendo
|
||||||
|
|
||||||
|
*Quiero ir al cine.* — I want to go to the movies.
|
||||||
|
*Prefiero quedarme en casa.* — I prefer to stay at home.
|
||||||
|
|
||||||
|
*o → ue:* poder → puedo, dormir → duermo, volver → vuelvo, encontrar → encuentro, recordar → recuerdo
|
||||||
|
|
||||||
|
*No puedo encontrar mi teléfono.* — I can't find my phone.
|
||||||
|
*Duermo ocho horas cada noche.* — I sleep eight hours every night.
|
||||||
|
|
||||||
|
*e → i* (only -ir verbs): pedir → pido, servir → sirvo, repetir → repito, seguir → sigo, vestirse → me visto
|
||||||
|
|
||||||
|
*Pido una cerveza, por favor.* — I'll have a beer, please.
|
||||||
|
*Sigo estudiando todos los días.* — I keep studying every day.
|
||||||
|
|
||||||
|
*u → ue:* jugar → juego (the only verb with this pattern!)
|
||||||
|
|
||||||
|
*Juego al fútbol los sábados.* — I play soccer on Saturdays.
|
||||||
|
|
||||||
|
**Unique Irregulars**
|
||||||
|
|
||||||
|
These verbs have forms that don't follow any predictable pattern — you simply have to memorize them. The good news is that the most common ones (ser, ir, haber, etc.) are used so frequently that you learn them quickly through exposure.
|
||||||
|
|
||||||
|
*ser:* soy, eres, es, somos, sois, son (present) — fui, fuiste, fue... (preterite)
|
||||||
|
*ir:* voy, vas, va, vamos, vais, van (present) — fui, fuiste, fue... (preterite, same as ser!)
|
||||||
|
*haber:* he, has, ha, hemos, habéis, han (present auxiliary)
|
||||||
|
*hacer:* hago (yo present), hice/hizo (preterite)
|
||||||
|
*decir:* digo (yo present), dije/dijo (preterite)
|
||||||
|
*tener:* tengo (yo present), tuve (preterite)
|
||||||
|
*venir:* vengo (yo present), vine (preterite)
|
||||||
|
|
||||||
|
**The "go" verbs — yo forms ending in -go:** Many common verbs have an irregular *yo* form in the present tense that ends in *-go*, while all other persons are regular or follow a stem change.
|
||||||
|
|
||||||
|
*tener → tengo, poner → pongo, salir → salgo, venir → vengo, hacer → hago, decir → digo, traer → traigo, oír → oigo, caer → caigo*
|
||||||
|
|
||||||
|
*Tengo que salir ahora.* — I have to leave now.
|
||||||
|
*Pongo la mesa antes de cenar.* — I set the table before dinner.
|
||||||
|
|
||||||
|
**Tip:** Use the Irregularity Drills in the Practice tab to focus on each type separately — spelling changes, stem changes, or unique irregulars.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - BEGIN generated notes (do not edit — regenerate via scrape/work/generate_swift.py)
|
||||||
|
|
||||||
|
static let generatedNotes: [GrammarNote] = [
|
||||||
|
GrammarNote(
|
||||||
|
id: "present-indicative-conjugation",
|
||||||
|
title: "Present Indicative Conjugation",
|
||||||
|
category: "Core Concepts",
|
||||||
|
body: """
|
||||||
|
The present indicative describes what someone **does** or **is doing** right now, habitually, or as a general truth. To conjugate a regular verb, drop the infinitive ending (-ar, -er, -ir) and add the person-ending that matches the subject.
|
||||||
|
|
||||||
|
**-ar verbs** (hablar — to speak):
|
||||||
|
*hablo, hablas, habla, hablamos, habláis, hablan*
|
||||||
|
|
||||||
|
**-er verbs** (comer — to eat):
|
||||||
|
*como, comes, come, comemos, coméis, comen*
|
||||||
|
|
||||||
|
**-ir verbs** (vivir — to live):
|
||||||
|
*vivo, vives, vive, vivimos, vivís, viven*
|
||||||
|
|
||||||
|
Notice -er and -ir verbs share the same endings EXCEPT in the nosotros and vosotros forms (-emos/-éis vs -imos/-ís).
|
||||||
|
|
||||||
|
*Yo hablo español todos los días.* — I speak Spanish every day.
|
||||||
|
*¿Tú comes carne?* — Do you eat meat?
|
||||||
|
*Ellos viven en Madrid.* — They live in Madrid.
|
||||||
|
|
||||||
|
**Key tip:** Since the verb ending already tells you the subject, Spanish often drops subject pronouns: *Hablo español* already means *I speak Spanish.*
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
GrammarNote(
|
||||||
|
id: "articles-and-gender",
|
||||||
|
title: "Articles & Gender",
|
||||||
|
category: "Core Concepts",
|
||||||
|
body: """
|
||||||
|
Every Spanish noun has a grammatical gender (masculine or feminine) and a number (singular or plural). The article must agree with both.
|
||||||
|
|
||||||
|
**Definite articles** (the):
|
||||||
|
*el* (m. sing.), *la* (f. sing.), *los* (m. pl.), *las* (f. pl.)
|
||||||
|
|
||||||
|
**Indefinite articles** (a/an/some):
|
||||||
|
*un* (m. sing.), *una* (f. sing.), *unos* (m. pl.), *unas* (f. pl.)
|
||||||
|
|
||||||
|
*el libro / los libros* — the book(s)
|
||||||
|
*la mesa / las mesas* — the table(s)
|
||||||
|
*un amigo / unos amigos* — a friend / some friends
|
||||||
|
|
||||||
|
**General rules:**
|
||||||
|
- Nouns ending in -o are usually masculine: *el carro, el vino.*
|
||||||
|
- Nouns ending in -a are usually feminine: *la casa, la silla.*
|
||||||
|
- Nouns ending in -ción, -sión, -dad, -tad are feminine: *la canción, la ciudad.*
|
||||||
|
- Nouns ending in -ma, -pa, -ta from Greek are often masculine: *el problema, el mapa, el planeta.*
|
||||||
|
|
||||||
|
**Common exceptions to memorize:**
|
||||||
|
*la mano, la foto, la moto, la radio* (feminine but end in -o)
|
||||||
|
*el día, el clima, el tema, el idioma, el sofá* (masculine but end in -a)
|
||||||
|
|
||||||
|
**Tip:** Always learn a new noun together with its article — *la mano*, not just *mano*.
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
GrammarNote(
|
||||||
|
id: "possessive-adjectives",
|
||||||
|
title: "Possessive Adjectives",
|
||||||
|
category: "Adjectives",
|
||||||
|
body: """
|
||||||
|
Spanish possessive adjectives go **before** the noun and agree in number with the item possessed (not with the possessor). Only *nuestro* and *vuestro* also agree in gender.
|
||||||
|
|
||||||
|
| | Singular | Plural |
|
||||||
|
|---|---|---|
|
||||||
|
| my | mi | mis |
|
||||||
|
| your (tú) | tu | tus |
|
||||||
|
| his/her/your(Ud.) | su | sus |
|
||||||
|
| our | nuestro/a | nuestros/as |
|
||||||
|
| your (vosotros) | vuestro/a | vuestros/as |
|
||||||
|
| their/your(Uds.) | su | sus |
|
||||||
|
|
||||||
|
*mi libro / mis libros* — my book(s)
|
||||||
|
*tu casa / tus casas* — your house(s)
|
||||||
|
*nuestra familia / nuestros amigos* — our family / our friends
|
||||||
|
|
||||||
|
**Note:** *tu* (your) has no accent; *tú* (you) does.
|
||||||
|
|
||||||
|
**Ambiguity of *su*:** *su* can mean his, her, its, your (Ud./Uds.), or their. Context or *de + pronoun* clarifies: *la casa de él* (his house), *la casa de ella* (her house).
|
||||||
|
|
||||||
|
*Su coche es rojo* — His / Her / Their / Your car is red.
|
||||||
|
*El coche de ella es rojo.* — Her car is red.
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
GrammarNote(
|
||||||
|
id: "demonstrative-adjectives",
|
||||||
|
title: "Demonstrative Adjectives",
|
||||||
|
category: "Adjectives",
|
||||||
|
body: """
|
||||||
|
Demonstratives point to something and agree with the noun in gender and number. Spanish has three levels of distance:
|
||||||
|
|
||||||
|
**este/esta/estos/estas** — this/these (near the speaker)
|
||||||
|
**ese/esa/esos/esas** — that/those (near the listener)
|
||||||
|
**aquel/aquella/aquellos/aquellas** — that/those over there (far from both)
|
||||||
|
|
||||||
|
*este libro* — this book
|
||||||
|
*esta mesa* — this table
|
||||||
|
*esos zapatos* — those shoes (near you)
|
||||||
|
*aquellas montañas* — those mountains (in the distance)
|
||||||
|
|
||||||
|
**Neuter pronouns** — use *esto, eso, aquello* when pointing to something unidentified or to an abstract idea. They don't change form.
|
||||||
|
|
||||||
|
*¿Qué es esto?* — What is this?
|
||||||
|
*Eso no me gusta.* — I don't like that.
|
||||||
|
*Aquello fue un desastre.* — That was a disaster.
|
||||||
|
|
||||||
|
**Tip:** *este/ese/aquel* follow the distance triangle — speaker → listener → beyond. The *-ese* forms (ese/esos) sit in the middle.
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
GrammarNote(
|
||||||
|
id: "greetings-farewells",
|
||||||
|
title: "Greetings & Farewells",
|
||||||
|
category: "Core Concepts",
|
||||||
|
body: """
|
||||||
|
Spanish greetings are highly time-of-day aware. Using the wrong one sounds jarring.
|
||||||
|
|
||||||
|
**Greetings**
|
||||||
|
*hola* — hi (anytime)
|
||||||
|
*buenos días* — good morning (until ~noon)
|
||||||
|
*buenas tardes* — good afternoon (noon until sunset)
|
||||||
|
*buenas noches* — good evening / good night (after dark, also used when leaving)
|
||||||
|
|
||||||
|
**Common questions**
|
||||||
|
*¿cómo estás?* — how are you? (tú)
|
||||||
|
*¿cómo está usted?* — how are you? (formal)
|
||||||
|
*¿qué tal?* — what's up?
|
||||||
|
*¿qué hay?* — what's new?
|
||||||
|
*¿cómo te va?* — how's it going?
|
||||||
|
|
||||||
|
**Responses**
|
||||||
|
*bien, gracias* — fine, thanks
|
||||||
|
*muy bien* — very well
|
||||||
|
*más o menos* — so-so
|
||||||
|
*todo bien* — all good
|
||||||
|
|
||||||
|
**Introductions**
|
||||||
|
*mucho gusto* — nice to meet you
|
||||||
|
*encantado / encantada* — delighted (m./f.)
|
||||||
|
*igualmente* — likewise
|
||||||
|
|
||||||
|
**Farewells**
|
||||||
|
*adiós* — goodbye
|
||||||
|
*hasta luego* — see you later
|
||||||
|
*hasta pronto* — see you soon
|
||||||
|
*hasta mañana* — see you tomorrow
|
||||||
|
*nos vemos* — see you (around)
|
||||||
|
*chau / chao* — bye (informal)
|
||||||
|
|
||||||
|
**Tip:** *buenas noches* works for both *good evening* (hello) and *good night* (goodbye) — context tells which.
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
GrammarNote(
|
||||||
|
id: "poder-infinitive",
|
||||||
|
title: "Poder + Infinitive",
|
||||||
|
category: "Irregular Verbs",
|
||||||
|
body: """
|
||||||
|
*Poder* means "to be able to / can" and is one of the most-used verbs in Spanish. It's an o→ue stem-changer in the present tense (all forms except nosotros and vosotros) and takes an infinitive directly — no preposition.
|
||||||
|
|
||||||
|
**Present indicative:**
|
||||||
|
*puedo, puedes, puede, podemos, podéis, pueden*
|
||||||
|
|
||||||
|
**Preterite (irregular stem pud-):**
|
||||||
|
*pude, pudiste, pudo, pudimos, pudisteis, pudieron*
|
||||||
|
|
||||||
|
**Future / Conditional stem:** *podr-*
|
||||||
|
*podré, podrás, podrá...* (future)
|
||||||
|
*podría, podrías, podría...* (conditional — often softens a request)
|
||||||
|
|
||||||
|
*Puedo hablar tres idiomas.* — I can speak three languages.
|
||||||
|
*¿Puedes ayudarme?* — Can you help me?
|
||||||
|
*No pudimos ir al concierto.* — We couldn't go to the concert.
|
||||||
|
*¿Podrías pasar la sal?* — Could you pass the salt? (polite request)
|
||||||
|
|
||||||
|
**Pattern:** poder + infinitive. Never *poder a* or *poder de*.
|
||||||
|
|
||||||
|
**Nuance:** The preterite *pude* often carries the meaning "managed to / succeeded in"; *no pude* means "I failed to / couldn't."
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
GrammarNote(
|
||||||
|
id: "al-del-contractions",
|
||||||
|
title: "al & del Contractions",
|
||||||
|
category: "Core Concepts",
|
||||||
|
body: """
|
||||||
|
Spanish has only **two mandatory contractions**, and they both collapse the redundant vowel between a preposition and the article *el*.
|
||||||
|
|
||||||
|
**a + el = al** (to the)
|
||||||
|
**de + el = del** (of the / from the)
|
||||||
|
|
||||||
|
*Voy al mercado.* — I'm going to the market. (NOT *a el*)
|
||||||
|
*La puerta del coche está abierta.* — The door of the car is open. (NOT *de el*)
|
||||||
|
|
||||||
|
No other preposition + article pair contracts:
|
||||||
|
*en el parque, por el camino, con el amigo* — all uncontracted.
|
||||||
|
|
||||||
|
None of the other articles contract either:
|
||||||
|
*a la escuela, a los niños, a las chicas, de la casa, de los libros* — all separate.
|
||||||
|
|
||||||
|
**Exception — proper names:** Don't contract with *Él* (the pronoun *he*) or when *El* is part of a name (*El Salvador*, *El Paso*).
|
||||||
|
|
||||||
|
*Le doy el regalo a él.* — I give the gift to him. (NOT *al*)
|
||||||
|
*Vuelvo de El Salvador.* — I'm coming back from El Salvador. (NOT *del*)
|
||||||
|
|
||||||
|
**Tip:** Think of al/del as a pronunciation shortcut — Spanish hates two vowels smashed together (a-el, de-el).
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
GrammarNote(
|
||||||
|
id: "prepositional-pronouns",
|
||||||
|
title: "Prepositional Pronouns",
|
||||||
|
category: "Pronouns",
|
||||||
|
body: """
|
||||||
|
After most prepositions (a, de, en, para, por, sin, sobre, etc.) Spanish uses a special set of object-of-preposition pronouns. Only *yo* and *tú* change form.
|
||||||
|
|
||||||
|
| Subject | After preposition |
|
||||||
|
|---|---|
|
||||||
|
| yo | **mí** |
|
||||||
|
| tú | **ti** |
|
||||||
|
| él / ella / usted | él / ella / usted |
|
||||||
|
| nosotros/as | nosotros/as |
|
||||||
|
| vosotros/as | vosotros/as |
|
||||||
|
| ellos/as / ustedes | ellos/as / ustedes |
|
||||||
|
|
||||||
|
*Este regalo es para mí.* — This gift is for me.
|
||||||
|
*No puedo ir sin ti.* — I can't go without you.
|
||||||
|
*Hablan de nosotros.* — They're talking about us.
|
||||||
|
*Pienso en ella.* — I'm thinking about her.
|
||||||
|
|
||||||
|
**Note the accent:** *mí* (me) has an accent, *mi* (my) doesn't. *ti* never has an accent.
|
||||||
|
|
||||||
|
**Special: con + mí/ti**
|
||||||
|
The preposition *con* fuses with *mí* and *ti* into single words:
|
||||||
|
*conmigo* — with me
|
||||||
|
*contigo* — with you
|
||||||
|
*consigo* — with himself/herself/yourself (reflexive, less common)
|
||||||
|
|
||||||
|
*¿Quieres venir conmigo?* — Do you want to come with me?
|
||||||
|
*Iré contigo.* — I'll go with you.
|
||||||
|
|
||||||
|
**Exceptions:** After *entre, según, incluso, excepto, menos* use subject pronouns: *entre tú y yo* — between you and me.
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
GrammarNote(
|
||||||
|
id: "irregular-yo-verbs",
|
||||||
|
title: "Irregular Yo Verbs",
|
||||||
|
category: "Irregular Verbs",
|
||||||
|
body: """
|
||||||
|
A group of Spanish verbs conjugates regularly in every present-tense form **except yo**, which takes a special irregular ending. Once you learn these yo forms, the rest of the conjugation behaves normally.
|
||||||
|
|
||||||
|
**-go endings (very common):**
|
||||||
|
*hacer → hago* (I do/make)
|
||||||
|
*poner → pongo* (I put)
|
||||||
|
*salir → salgo* (I leave)
|
||||||
|
*tener → tengo* (I have) — also stem-changes in tú/él forms
|
||||||
|
*venir → vengo* (I come) — also stem-changes
|
||||||
|
*decir → digo* (I say) — also stem-changes
|
||||||
|
*traer → traigo* (I bring)
|
||||||
|
*oír → oigo* (I hear)
|
||||||
|
*caer → caigo* (I fall)
|
||||||
|
|
||||||
|
**-zco endings** (verbs ending in -cer or -cir after a vowel):
|
||||||
|
*conocer → conozco* (I know)
|
||||||
|
*conducir → conduzco* (I drive)
|
||||||
|
*traducir → traduzco* (I translate)
|
||||||
|
*ofrecer → ofrezco* (I offer)
|
||||||
|
*parecer → parezco* (I seem)
|
||||||
|
|
||||||
|
**-oy endings:**
|
||||||
|
*dar → doy* (I give)
|
||||||
|
*estar → estoy* (I am)
|
||||||
|
*ir → voy* (I go)
|
||||||
|
*ser → soy* (I am)
|
||||||
|
|
||||||
|
**Standalone irregulars:**
|
||||||
|
*ver → veo* (I see)
|
||||||
|
*saber → sé* (I know — has an accent)
|
||||||
|
*caber → quepo* (I fit)
|
||||||
|
|
||||||
|
*Yo tengo dos hermanos.* — I have two brothers.
|
||||||
|
*Hago la tarea cada día.* — I do homework every day.
|
||||||
|
*Conozco a tu padre.* — I know your father.
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
GrammarNote(
|
||||||
|
id: "stem-changing-verbs",
|
||||||
|
title: "Stem-Changing Verbs",
|
||||||
|
category: "Irregular Verbs",
|
||||||
|
body: """
|
||||||
|
Stem-changing verbs modify the vowel inside their stem in **all present-tense forms except nosotros and vosotros**. The affected forms map out like a boot on a conjugation chart — hence *boot verbs*.
|
||||||
|
|
||||||
|
**Four categories:**
|
||||||
|
|
||||||
|
**1. e → ie** (pensar — to think):
|
||||||
|
*pienso, piensas, piensa, pensamos, pensáis, piensan*
|
||||||
|
Also: cerrar, empezar, querer, entender, preferir, sentir.
|
||||||
|
|
||||||
|
**2. o → ue** (poder — to be able):
|
||||||
|
*puedo, puedes, puede, podemos, podéis, pueden*
|
||||||
|
Also: dormir, contar, volver, encontrar, recordar, morir.
|
||||||
|
|
||||||
|
**3. e → i** (only -ir verbs) (pedir — to ask for):
|
||||||
|
*pido, pides, pide, pedimos, pedís, piden*
|
||||||
|
Also: servir, repetir, seguir, vestirse.
|
||||||
|
|
||||||
|
**4. u → ue** (*jugar* — to play — the ONLY one):
|
||||||
|
*juego, juegas, juega, jugamos, jugáis, juegan*
|
||||||
|
|
||||||
|
**Why nosotros/vosotros are spared:** the stress falls on the ending in those forms, so the stem vowel stays unstressed and unchanged.
|
||||||
|
|
||||||
|
*Yo quiero un café.* — I want a coffee.
|
||||||
|
*Nosotros queremos café.* — We want coffee. (no change)
|
||||||
|
*Ella duerme ocho horas.* — She sleeps eight hours.
|
||||||
|
*Nosotros dormimos poco.* — We sleep little. (no change)
|
||||||
|
*Pido la cuenta.* — I ask for the check.
|
||||||
|
|
||||||
|
**Tip:** Stem changes carry through into the present subjunctive and sometimes affect the gerund (*durmiendo, pidiendo*) and 3rd-person preterite (*durmió, pidió*) — but only for -ir verbs.
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
GrammarNote(
|
||||||
|
id: "stressed-possessives",
|
||||||
|
title: "Stressed Possessive Adjectives",
|
||||||
|
category: "Adjectives",
|
||||||
|
body: """
|
||||||
|
Stressed possessives are the "long-form" possessives used for emphasis, after the noun, or after *ser*. Unlike the short forms (mi, tu, su…), they agree in BOTH gender and number with the item possessed.
|
||||||
|
|
||||||
|
| | Singular m. / f. | Plural m. / f. |
|
||||||
|
|---|---|---|
|
||||||
|
| mine | mío / mía | míos / mías |
|
||||||
|
| yours (tú) | tuyo / tuya | tuyos / tuyas |
|
||||||
|
| his/hers/yours (Ud.) | suyo / suya | suyos / suyas |
|
||||||
|
| ours | nuestro / nuestra | nuestros / nuestras |
|
||||||
|
| yours (vosotros) | vuestro / vuestra | vuestros / vuestras |
|
||||||
|
| theirs/yours (Uds.) | suyo / suya | suyos / suyas |
|
||||||
|
|
||||||
|
**Used three ways:**
|
||||||
|
|
||||||
|
1. **After a noun** (emphatic, often with *un/una*):
|
||||||
|
*un amigo mío* — a friend of mine
|
||||||
|
*una idea tuya* — an idea of yours
|
||||||
|
*unos primos nuestros* — some cousins of ours
|
||||||
|
|
||||||
|
2. **After ser** (ownership):
|
||||||
|
*El libro es mío.* — The book is mine.
|
||||||
|
*Esta casa es nuestra.* — This house is ours.
|
||||||
|
*¿Son tuyas estas llaves?* — Are these keys yours?
|
||||||
|
|
||||||
|
3. **As a pronoun** with *el / la / los / las*:
|
||||||
|
*Mi coche es rojo; el tuyo es azul.* — My car is red; yours is blue.
|
||||||
|
*Tus hijos y los míos juegan juntos.* — Your kids and mine play together.
|
||||||
|
|
||||||
|
**Ambiguity of *suyo***: Like short *su*, stressed *suyo/a* can mean his, hers, yours (Ud./Uds.), or theirs. Use *de él / de ella* if ambiguous.
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
GrammarNote(
|
||||||
|
id: "present-perfect-tense",
|
||||||
|
title: "Present Perfect",
|
||||||
|
category: "Verb Tenses",
|
||||||
|
body: """
|
||||||
|
The present perfect (*pretérito perfecto*) describes what someone **has done** — an action completed in the recent past or within an unfinished time frame that extends to now (today, this week, this year, ever).
|
||||||
|
|
||||||
|
**Formula:** *haber* (present) + past participle
|
||||||
|
|
||||||
|
| | haber |
|
||||||
|
|---|---|
|
||||||
|
| yo | he |
|
||||||
|
| tú | has |
|
||||||
|
| él/ella/Ud. | ha |
|
||||||
|
| nosotros | hemos |
|
||||||
|
| vosotros | habéis |
|
||||||
|
| ellos/Uds. | han |
|
||||||
|
|
||||||
|
**Regular participles:**
|
||||||
|
-ar verbs → -ado: *hablar → hablado*
|
||||||
|
-er/-ir verbs → -ido: *comer → comido, vivir → vivido*
|
||||||
|
|
||||||
|
**Common irregular participles:**
|
||||||
|
*abrir → abierto* (opened)
|
||||||
|
*decir → dicho* (said)
|
||||||
|
*escribir → escrito* (written)
|
||||||
|
*hacer → hecho* (done/made)
|
||||||
|
*morir → muerto* (died)
|
||||||
|
*poner → puesto* (put)
|
||||||
|
*romper → roto* (broken)
|
||||||
|
*ver → visto* (seen)
|
||||||
|
*volver → vuelto* (returned)
|
||||||
|
*cubrir → cubierto* (covered)
|
||||||
|
*resolver → resuelto* (solved)
|
||||||
|
|
||||||
|
*He comido demasiado hoy.* — I have eaten too much today.
|
||||||
|
*¿Has visto la nueva película?* — Have you seen the new movie?
|
||||||
|
*Todavía no han llegado.* — They haven't arrived yet.
|
||||||
|
*Este año hemos viajado mucho.* — This year we've traveled a lot.
|
||||||
|
|
||||||
|
**Key rule:** *Haber* and the participle must stay together. Never put a pronoun between them: *Lo he comido*, NOT *He lo comido*.
|
||||||
|
|
||||||
|
**Tip:** The participle never changes in this tense — always ends in -o. (It only agrees in gender/number when used as an adjective with *ser/estar*.)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
GrammarNote(
|
||||||
|
id: "future-perfect-tense",
|
||||||
|
title: "Future Perfect",
|
||||||
|
category: "Verb Tenses",
|
||||||
|
body: """
|
||||||
|
The future perfect (*futuro perfecto*) describes what **will have happened** by some point in the future. It's also used to speculate about the recent past ("they must have…").
|
||||||
|
|
||||||
|
**Formula:** *haber* (future) + past participle
|
||||||
|
|
||||||
|
| | haber |
|
||||||
|
|---|---|
|
||||||
|
| yo | habré |
|
||||||
|
| tú | habrás |
|
||||||
|
| él/ella/Ud. | habrá |
|
||||||
|
| nosotros | habremos |
|
||||||
|
| vosotros | habréis |
|
||||||
|
| ellos/Uds. | habrán |
|
||||||
|
|
||||||
|
**Two main uses:**
|
||||||
|
|
||||||
|
1. **Will-have-happened before a future point:**
|
||||||
|
*Para las ocho, habré terminado el trabajo.* — By eight, I will have finished the work.
|
||||||
|
*Cuando lleguen, ya habremos cenado.* — By the time they arrive, we'll have eaten.
|
||||||
|
*En un año, habrás aprendido mucho español.* — In a year, you'll have learned a lot of Spanish.
|
||||||
|
|
||||||
|
2. **Speculation / guess about recent past** (like English "must have…"):
|
||||||
|
*Habrá olvidado la cita.* — He must have forgotten the appointment.
|
||||||
|
*Se habrán ido ya.* — They must have left already.
|
||||||
|
*¿Habrás dejado las llaves en casa?* — Could you have left the keys at home?
|
||||||
|
|
||||||
|
**Pattern reminder:** Same irregular participles as present perfect (dicho, hecho, visto, escrito, puesto, abierto, etc.).
|
||||||
|
|
||||||
|
**Tip:** When preceded by a time clause with *cuando/antes de que/para cuando*, Spanish usually puts a subjunctive in the time clause and future perfect in the main clause: *Cuando vengas, ya habré salido.*
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Combined list used by the UI: 24 hand-authored notes + generated ones.
|
||||||
|
static let allNotesIncludingGenerated: [GrammarNote] = allNotes + generatedNotes
|
||||||
|
|
||||||
|
// MARK: - END generated notes
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,16 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
|||||||
case typingEsToEn = "typing_es_to_en"
|
case typingEsToEn = "typing_es_to_en"
|
||||||
case handwritingEnToEs = "hw_en_to_es"
|
case handwritingEnToEs = "hw_en_to_es"
|
||||||
case handwritingEsToEn = "hw_es_to_en"
|
case handwritingEsToEn = "hw_es_to_en"
|
||||||
|
case completeSentenceES = "complete_sentence_es"
|
||||||
|
case checkpoint = "checkpoint"
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
/// Quiz types shown in the weekly test picker (excludes checkpoint).
|
||||||
|
static var weeklyQuizTypes: [QuizType] {
|
||||||
|
allCases.filter { $0 != .checkpoint }
|
||||||
|
}
|
||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .mcEnToEs: "Multiple Choice: EN → ES"
|
case .mcEnToEs: "Multiple Choice: EN → ES"
|
||||||
@@ -19,6 +26,8 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
|||||||
case .typingEsToEn: "Fill in the Blank: ES → EN"
|
case .typingEsToEn: "Fill in the Blank: ES → EN"
|
||||||
case .handwritingEnToEs: "Handwriting: EN → ES"
|
case .handwritingEnToEs: "Handwriting: EN → ES"
|
||||||
case .handwritingEsToEn: "Handwriting: ES → EN"
|
case .handwritingEsToEn: "Handwriting: ES → EN"
|
||||||
|
case .completeSentenceES: "Complete the Sentence"
|
||||||
|
case .checkpoint: "Checkpoint Exam"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +36,8 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
|||||||
case .mcEnToEs, .mcEsToEn: "list.bullet"
|
case .mcEnToEs, .mcEsToEn: "list.bullet"
|
||||||
case .typingEnToEs, .typingEsToEn: "keyboard"
|
case .typingEnToEs, .typingEsToEn: "keyboard"
|
||||||
case .handwritingEnToEs, .handwritingEsToEn: "pencil.and.outline"
|
case .handwritingEnToEs, .handwritingEsToEn: "pencil.and.outline"
|
||||||
|
case .completeSentenceES: "text.badge.checkmark"
|
||||||
|
case .checkpoint: "checkmark.seal"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +49,8 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
|||||||
case .typingEsToEn: "See Spanish, type the English word"
|
case .typingEsToEn: "See Spanish, type the English word"
|
||||||
case .handwritingEnToEs: "See English, handwrite the Spanish word"
|
case .handwritingEnToEs: "See English, handwrite the Spanish word"
|
||||||
case .handwritingEsToEn: "See Spanish, handwrite the English word"
|
case .handwritingEsToEn: "See Spanish, handwrite the English word"
|
||||||
|
case .completeSentenceES: "Read a Spanish sentence and pick the missing word"
|
||||||
|
case .checkpoint: "Cumulative review of all weeks so far"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,20 +58,20 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
|||||||
var promptLanguage: String {
|
var promptLanguage: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "English"
|
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "English"
|
||||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: "Spanish"
|
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES, .checkpoint: "Spanish"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var answerLanguage: String {
|
var answerLanguage: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "Spanish"
|
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: "Spanish"
|
||||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: "English"
|
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .checkpoint: "English"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isMultipleChoice: Bool {
|
var isMultipleChoice: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .mcEnToEs, .mcEsToEn: true
|
case .mcEnToEs, .mcEsToEn, .completeSentenceES, .checkpoint: true
|
||||||
case .typingEnToEs, .typingEsToEn, .handwritingEnToEs, .handwritingEsToEn: false
|
case .typingEnToEs, .typingEsToEn, .handwritingEnToEs, .handwritingEsToEn: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,17 +83,21 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isCompleteSentence: Bool {
|
||||||
|
self == .completeSentenceES
|
||||||
|
}
|
||||||
|
|
||||||
func prompt(for card: VocabCard) -> String {
|
func prompt(for card: VocabCard) -> String {
|
||||||
switch self {
|
switch self {
|
||||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: card.back
|
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: card.back
|
||||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: card.front
|
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES, .checkpoint: card.front
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func answer(for card: VocabCard) -> String {
|
func answer(for card: VocabCard) -> String {
|
||||||
switch self {
|
switch self {
|
||||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: card.front
|
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: card.front
|
||||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: card.back
|
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .checkpoint: card.back
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,18 @@ enum TenseID: String, CaseIterable, Codable, Sendable, Hashable {
|
|||||||
.ind_futuro,
|
.ind_futuro,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// The 6 most essential tenses every learner should master.
|
||||||
|
static let coreTenses: Set<TenseID> = [
|
||||||
|
.ind_presente,
|
||||||
|
.ind_preterito,
|
||||||
|
.ind_imperfecto,
|
||||||
|
.ind_futuro,
|
||||||
|
.subj_presente,
|
||||||
|
.imp_afirmativo,
|
||||||
|
]
|
||||||
|
|
||||||
|
static let coreTenseIDs = coreTenses.map(\.rawValue)
|
||||||
|
|
||||||
static let defaultPracticeIDs = defaultPractice.map(\.rawValue)
|
static let defaultPracticeIDs = defaultPractice.map(\.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +51,10 @@ struct TenseInfo: Identifiable, Hashable, Sendable {
|
|||||||
let mood: String
|
let mood: String
|
||||||
let order: Int
|
let order: Int
|
||||||
|
|
||||||
|
var isCore: Bool {
|
||||||
|
TenseID(rawValue: id).map { TenseID.coreTenses.contains($0) } ?? false
|
||||||
|
}
|
||||||
|
|
||||||
static let all: [TenseInfo] = [
|
static let all: [TenseInfo] = [
|
||||||
TenseInfo(id: TenseID.ind_presente.rawValue, spanish: "Indicativo Presente", english: "Present", mood: "Indicative", order: 0),
|
TenseInfo(id: TenseID.ind_presente.rawValue, spanish: "Indicativo Presente", english: "Present", mood: "Indicative", order: 0),
|
||||||
TenseInfo(id: TenseID.ind_preterito.rawValue, spanish: "Indicativo Pretérito", english: "Preterite", mood: "Indicative", order: 1),
|
TenseInfo(id: TenseID.ind_preterito.rawValue, spanish: "Indicativo Pretérito", english: "Preterite", mood: "Indicative", order: 1),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ final class UserProgress {
|
|||||||
var selectedLevel: String = "basic"
|
var selectedLevel: String = "basic"
|
||||||
var showVosotros: Bool = true
|
var showVosotros: Bool = true
|
||||||
var autoFillStem: Bool = false
|
var autoFillStem: Bool = false
|
||||||
|
var showReflexiveVerbsOnly: Bool = false
|
||||||
|
|
||||||
// Legacy CloudKit array-backed fields retained for migration compatibility.
|
// Legacy CloudKit array-backed fields retained for migration compatibility.
|
||||||
var enabledTenses: [String] = []
|
var enabledTenses: [String] = []
|
||||||
@@ -21,6 +22,10 @@ final class UserProgress {
|
|||||||
var enabledTensesBlob: String = ""
|
var enabledTensesBlob: String = ""
|
||||||
var unlockedBadgesBlob: String = ""
|
var unlockedBadgesBlob: String = ""
|
||||||
|
|
||||||
|
// Multi-select level + irregularity filters (Issue #26).
|
||||||
|
var selectedLevelsBlob: String = ""
|
||||||
|
var enabledIrregularCategoriesBlob: String = ""
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
|
|
||||||
var selectedVerbLevel: VerbLevel {
|
var selectedVerbLevel: VerbLevel {
|
||||||
@@ -44,6 +49,44 @@ final class UserProgress {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Levels currently enabled for practice. Multi-select per Issue #26.
|
||||||
|
/// Setting this also syncs `selectedLevel` to the highest-ranked selection so
|
||||||
|
/// legacy single-level consumers (widget, AI scenarios, word-of-day) stay consistent.
|
||||||
|
var selectedVerbLevels: Set<VerbLevel> {
|
||||||
|
get {
|
||||||
|
let raw = decodeStringArray(from: selectedLevelsBlob, fallback: [])
|
||||||
|
let decoded = Set(raw.compactMap(VerbLevel.init(rawValue:)))
|
||||||
|
if !decoded.isEmpty { return decoded }
|
||||||
|
// Pre-migration users: treat the single selectedLevel as the set.
|
||||||
|
if let legacy = VerbLevel(rawValue: selectedLevel) {
|
||||||
|
return [legacy]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let sorted = newValue.map(\.rawValue)
|
||||||
|
selectedLevelsBlob = Self.encodeStringArray(sorted)
|
||||||
|
selectedLevel = VerbLevel.highest(in: newValue)?.rawValue ?? VerbLevel.basic.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single representative level for callers that need one value
|
||||||
|
/// (word-of-day widget, AI chat/story scenarios). Highest selected level.
|
||||||
|
var primaryLevel: VerbLevel {
|
||||||
|
VerbLevel.highest(in: selectedVerbLevels) ?? selectedVerbLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
var enabledIrregularCategories: Set<IrregularSpan.SpanCategory> {
|
||||||
|
get {
|
||||||
|
let raw = decodeStringArray(from: enabledIrregularCategoriesBlob, fallback: [])
|
||||||
|
return Set(raw.compactMap(IrregularSpan.SpanCategory.init(rawValue:)))
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let sorted = newValue.map(\.rawValue)
|
||||||
|
enabledIrregularCategoriesBlob = Self.encodeStringArray(sorted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setTenseEnabled(_ tenseId: String, enabled: Bool) {
|
func setTenseEnabled(_ tenseId: String, enabled: Bool) {
|
||||||
var values = Set(enabledTenseIDs)
|
var values = Set(enabledTenseIDs)
|
||||||
if enabled {
|
if enabled {
|
||||||
@@ -54,6 +97,26 @@ final class UserProgress {
|
|||||||
enabledTenseIDs = values.sorted()
|
enabledTenseIDs = values.sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setLevelEnabled(_ level: VerbLevel, enabled: Bool) {
|
||||||
|
var values = selectedVerbLevels
|
||||||
|
if enabled {
|
||||||
|
values.insert(level)
|
||||||
|
} else {
|
||||||
|
values.remove(level)
|
||||||
|
}
|
||||||
|
selectedVerbLevels = values
|
||||||
|
}
|
||||||
|
|
||||||
|
func setIrregularCategoryEnabled(_ category: IrregularSpan.SpanCategory, enabled: Bool) {
|
||||||
|
var values = enabledIrregularCategories
|
||||||
|
if enabled {
|
||||||
|
values.insert(category)
|
||||||
|
} else {
|
||||||
|
values.remove(category)
|
||||||
|
}
|
||||||
|
enabledIrregularCategories = values
|
||||||
|
}
|
||||||
|
|
||||||
func unlockBadge(_ badgeId: String) {
|
func unlockBadge(_ badgeId: String) {
|
||||||
var values = Set(unlockedBadgeIDs)
|
var values = Set(unlockedBadgeIDs)
|
||||||
values.insert(badgeId)
|
values.insert(badgeId)
|
||||||
@@ -67,6 +130,9 @@ final class UserProgress {
|
|||||||
if unlockedBadgesBlob.isEmpty && !unlockedBadges.isEmpty {
|
if unlockedBadgesBlob.isEmpty && !unlockedBadges.isEmpty {
|
||||||
unlockedBadgeIDs = unlockedBadges
|
unlockedBadgeIDs = unlockedBadges
|
||||||
}
|
}
|
||||||
|
if selectedLevelsBlob.isEmpty, let legacy = VerbLevel(rawValue: selectedLevel) {
|
||||||
|
selectedVerbLevels = [legacy]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func decodeStringArray(from blob: String, fallback: [String]) -> [String] {
|
private func decodeStringArray(from blob: String, fallback: [String]) -> [String] {
|
||||||
@@ -86,4 +152,5 @@ final class UserProgress {
|
|||||||
}
|
}
|
||||||
return string
|
return string
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
10
Conjuga/Conjuga/Services/AnswerChecker.swift
Normal file
10
Conjuga/Conjuga/Services/AnswerChecker.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
78
Conjuga/Conjuga/Services/ConversationService.swift
Normal file
78
Conjuga/Conjuga/Services/ConversationService.swift
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
import SharedModels
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class ConversationService {
|
||||||
|
var isResponding = false
|
||||||
|
|
||||||
|
private var session: LanguageModelSession?
|
||||||
|
|
||||||
|
static let scenarios = [
|
||||||
|
"Ordering at a restaurant",
|
||||||
|
"Asking for directions",
|
||||||
|
"Shopping at a market",
|
||||||
|
"Checking into a hotel",
|
||||||
|
"Making plans with a friend",
|
||||||
|
"At the doctor's office",
|
||||||
|
"Job interview",
|
||||||
|
"Renting an apartment",
|
||||||
|
"At the airport",
|
||||||
|
"Meeting someone new",
|
||||||
|
]
|
||||||
|
|
||||||
|
func startConversation(scenario: String, level: String) -> String {
|
||||||
|
session = LanguageModelSession(instructions: """
|
||||||
|
You are a friendly Spanish conversation partner. The scenario is: \(scenario).
|
||||||
|
The student's level is: \(level).
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Respond ONLY in Spanish appropriate for the student's level.
|
||||||
|
- Keep responses to 1-3 sentences.
|
||||||
|
- If the student makes a grammar mistake, gently correct it in parentheses \
|
||||||
|
at the end of your response, like: (Pequeña corrección: "fuiste" en vez de "fue")
|
||||||
|
- Stay in character for the scenario.
|
||||||
|
- Be encouraging and natural.
|
||||||
|
""")
|
||||||
|
|
||||||
|
// Return the opening message based on scenario
|
||||||
|
switch scenario {
|
||||||
|
case "Ordering at a restaurant":
|
||||||
|
return "¡Bienvenido! Soy su mesero. ¿Ya sabe qué le gustaría ordenar?"
|
||||||
|
case "Asking for directions":
|
||||||
|
return "¡Hola! ¿En qué le puedo ayudar? ¿Está buscando algún lugar?"
|
||||||
|
case "Shopping at a market":
|
||||||
|
return "¡Buenos días! Tenemos frutas muy frescas hoy. ¿Qué le gustaría comprar?"
|
||||||
|
case "Checking into a hotel":
|
||||||
|
return "Buenas tardes, bienvenido al Hotel Sol. ¿Tiene una reservación?"
|
||||||
|
case "Making plans with a friend":
|
||||||
|
return "¡Oye! ¿Qué quieres hacer este fin de semana? Estoy libre el sábado."
|
||||||
|
case "At the doctor's office":
|
||||||
|
return "Buenos días. Soy el doctor García. ¿Cómo se siente hoy? ¿Qué le pasa?"
|
||||||
|
case "Job interview":
|
||||||
|
return "Buenos días, gracias por venir. Cuénteme un poco sobre usted."
|
||||||
|
case "Renting an apartment":
|
||||||
|
return "¡Hola! Gracias por su interés en el apartamento. ¿Qué preguntas tiene?"
|
||||||
|
case "At the airport":
|
||||||
|
return "Buenas tardes, pasajero. ¿Me puede mostrar su pasaporte y su boleto?"
|
||||||
|
case "Meeting someone new":
|
||||||
|
return "¡Hola! Me llamo Carlos. ¿Cómo te llamas? ¿De dónde eres?"
|
||||||
|
default:
|
||||||
|
return "¡Hola! ¿Cómo estás? Vamos a practicar español juntos."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func respond(to userMessage: String) async throws -> String {
|
||||||
|
guard let session else { return "Error: no session" }
|
||||||
|
isResponding = true
|
||||||
|
defer { isResponding = false }
|
||||||
|
|
||||||
|
let response = try await session.respond(to: userMessage)
|
||||||
|
return response.content
|
||||||
|
}
|
||||||
|
|
||||||
|
static var isAvailable: Bool {
|
||||||
|
SystemLanguageModel.default.availability == .available
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,27 @@ import SharedModels
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
actor DataLoader {
|
actor DataLoader {
|
||||||
|
static let courseDataVersion = 7
|
||||||
|
static let courseDataKey = "courseDataVersion"
|
||||||
|
|
||||||
|
static let textbookDataVersion = 12
|
||||||
|
static let textbookDataKey = "textbookDataVersion"
|
||||||
|
|
||||||
|
/// Quick check: does the DB need seeding or course data refresh?
|
||||||
|
static func needsSeeding(container: ModelContainer) async -> Bool {
|
||||||
|
let context = ModelContext(container)
|
||||||
|
let verbCount = (try? context.fetchCount(FetchDescriptor<Verb>())) ?? 0
|
||||||
|
if verbCount == 0 { return true }
|
||||||
|
|
||||||
|
let storedVersion = UserDefaults.standard.integer(forKey: courseDataKey)
|
||||||
|
if storedVersion < courseDataVersion { return true }
|
||||||
|
|
||||||
|
let textbookVersion = UserDefaults.standard.integer(forKey: textbookDataKey)
|
||||||
|
if textbookVersion < textbookDataVersion { return true }
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
static func seedIfNeeded(container: ModelContainer) async {
|
static func seedIfNeeded(container: ModelContainer) async {
|
||||||
let context = ModelContext(container)
|
let context = ModelContext(container)
|
||||||
|
|
||||||
@@ -118,30 +139,104 @@ actor DataLoader {
|
|||||||
|
|
||||||
// Seed course data (uses the same mainContext so @Query sees it)
|
// Seed course data (uses the same mainContext so @Query sees it)
|
||||||
seedCourseData(context: context)
|
seedCourseData(context: context)
|
||||||
|
|
||||||
|
// Seed textbook data — only bump the version key if the seed
|
||||||
|
// actually inserted rows, so a missing/unparseable bundle doesn't
|
||||||
|
// permanently lock us out of future re-seeds.
|
||||||
|
if seedTextbookData(context: context) {
|
||||||
|
UserDefaults.standard.set(textbookDataVersion, forKey: textbookDataKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-seed textbook data if the version has changed OR if the rows are
|
||||||
|
/// missing on disk. The row-count check exists because anything opening
|
||||||
|
/// this store with a subset schema (e.g. an out-of-date widget extension)
|
||||||
|
/// can destructively drop the rows without touching UserDefaults — so a
|
||||||
|
/// pure version-flag trigger would leave us permanently empty.
|
||||||
|
static func refreshTextbookDataIfNeeded(container: ModelContainer) async {
|
||||||
|
let shared = UserDefaults.standard
|
||||||
|
let context = ModelContext(container)
|
||||||
|
let existingCount = (try? context.fetchCount(FetchDescriptor<TextbookChapter>())) ?? 0
|
||||||
|
let versionCurrent = shared.integer(forKey: textbookDataKey) >= textbookDataVersion
|
||||||
|
|
||||||
|
if versionCurrent && existingCount > 0 { return }
|
||||||
|
|
||||||
|
if versionCurrent {
|
||||||
|
print("Textbook data version current but store has \(existingCount) chapters — re-seeding...")
|
||||||
|
} else {
|
||||||
|
print("Textbook data version outdated — re-seeding...")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch + delete individually instead of batch delete. SwiftData's
|
||||||
|
// context.delete(model:) hits the store directly and doesn't always
|
||||||
|
// clear the unique-constraint index before the reseed's save runs,
|
||||||
|
// so re-inserting rows with the same .unique id can throw.
|
||||||
|
let textbookCourseName = "Complete Spanish Step-by-Step"
|
||||||
|
if let existing = try? context.fetch(FetchDescriptor<TextbookChapter>()) {
|
||||||
|
for chapter in existing { context.delete(chapter) }
|
||||||
|
}
|
||||||
|
let deckDescriptor = FetchDescriptor<CourseDeck>(
|
||||||
|
predicate: #Predicate<CourseDeck> { $0.courseName == textbookCourseName }
|
||||||
|
)
|
||||||
|
if let decks = try? context.fetch(deckDescriptor) {
|
||||||
|
for deck in decks { context.delete(deck) }
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try context.save()
|
||||||
|
} catch {
|
||||||
|
print("[DataLoader] ERROR: textbook wipe save failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if seedTextbookData(context: context) {
|
||||||
|
shared.set(textbookDataVersion, forKey: textbookDataKey)
|
||||||
|
print("Textbook data re-seeded to version \(textbookDataVersion)")
|
||||||
|
} else {
|
||||||
|
print("Textbook re-seed failed — leaving version key untouched so next launch retries")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-seed course data if the version has changed (e.g. examples were added).
|
/// Re-seed course data if the version has changed (e.g. examples were added).
|
||||||
/// Call this on every launch — it checks a version key and only re-seeds when needed.
|
/// Call this on every launch — it checks a version key and only re-seeds when needed.
|
||||||
static func refreshCourseDataIfNeeded(container: ModelContainer) async {
|
static func refreshCourseDataIfNeeded(container: ModelContainer) async {
|
||||||
let currentVersion = 3 // Bump this whenever course_data.json changes
|
|
||||||
let key = "courseDataVersion"
|
|
||||||
let shared = UserDefaults.standard
|
let shared = UserDefaults.standard
|
||||||
|
|
||||||
if shared.integer(forKey: key) >= currentVersion { return }
|
if shared.integer(forKey: courseDataKey) >= courseDataVersion { return }
|
||||||
|
|
||||||
print("Course data version outdated — re-seeding...")
|
print("Course data version outdated — re-seeding...")
|
||||||
let context = ModelContext(container)
|
let context = ModelContext(container)
|
||||||
|
|
||||||
// Delete existing course data
|
// Delete existing course data + tense guides so they can be re-seeded
|
||||||
|
// with updated bodies from the bundled conjuga_data.json.
|
||||||
try? context.delete(model: VocabCard.self)
|
try? context.delete(model: VocabCard.self)
|
||||||
try? context.delete(model: CourseDeck.self)
|
try? context.delete(model: CourseDeck.self)
|
||||||
|
try? context.delete(model: TenseGuide.self)
|
||||||
try? context.save()
|
try? context.save()
|
||||||
|
|
||||||
// Re-seed
|
// Re-seed tense guides from the bundled JSON
|
||||||
|
if let url = Bundle.main.url(forResource: "conjuga_data", withExtension: "json"),
|
||||||
|
let data = try? Data(contentsOf: url),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let guides = json["tenseGuides"] as? [[String: Any]] {
|
||||||
|
for g in guides {
|
||||||
|
guard let tenseId = g["tenseId"] as? String,
|
||||||
|
let title = g["title"] as? String,
|
||||||
|
let body = g["body"] as? String else { continue }
|
||||||
|
context.insert(TenseGuide(tenseId: tenseId, title: title, body: body))
|
||||||
|
}
|
||||||
|
try? context.save()
|
||||||
|
print("Re-seeded \(guides.count) tense guides")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-seed course data
|
||||||
seedCourseData(context: context)
|
seedCourseData(context: context)
|
||||||
|
|
||||||
shared.set(currentVersion, forKey: key)
|
// Textbook's vocab decks/cards share the same CourseDeck/VocabCard
|
||||||
print("Course data re-seeded to version \(currentVersion)")
|
// 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)")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func migrateCourseProgressIfNeeded(
|
static func migrateCourseProgressIfNeeded(
|
||||||
@@ -255,14 +350,18 @@ actor DataLoader {
|
|||||||
// Parse example sentences
|
// Parse example sentences
|
||||||
var exES: [String] = []
|
var exES: [String] = []
|
||||||
var exEN: [String] = []
|
var exEN: [String] = []
|
||||||
|
var exBlanks: [String] = []
|
||||||
if let examples = cardDict["examples"] as? [[String: String]] {
|
if let examples = cardDict["examples"] as? [[String: String]] {
|
||||||
for ex in examples {
|
for ex in examples {
|
||||||
if let es = ex["es"] { exES.append(es) }
|
if let es = ex["es"] {
|
||||||
if let en = ex["en"] { exEN.append(en) }
|
exES.append(es)
|
||||||
|
exEN.append(ex["en"] ?? "")
|
||||||
|
exBlanks.append(ex["blank"] ?? "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let card = VocabCard(front: front, back: back, deckId: deckId, examplesES: exES, examplesEN: exEN)
|
let card = VocabCard(front: front, back: back, deckId: deckId, examplesES: exES, examplesEN: exEN, examplesBlanks: exBlanks)
|
||||||
card.deck = deck
|
card.deck = deck
|
||||||
context.insert(card)
|
context.insert(card)
|
||||||
cardCount += 1
|
cardCount += 1
|
||||||
@@ -302,4 +401,169 @@ actor DataLoader {
|
|||||||
context.insert(reviewCard)
|
context.insert(reviewCard)
|
||||||
return reviewCard
|
return reviewCard
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Textbook seeding
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private static func seedTextbookData(context: ModelContext) -> Bool {
|
||||||
|
let url = Bundle.main.url(forResource: "textbook_data", withExtension: "json")
|
||||||
|
?? Bundle.main.bundleURL.appendingPathComponent("textbook_data.json")
|
||||||
|
guard let data = try? Data(contentsOf: url) else {
|
||||||
|
print("[DataLoader] textbook_data.json not bundled — skipping textbook seed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
print("[DataLoader] ERROR: Could not parse textbook_data.json")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let courseName = (json["courseName"] as? String) ?? "Textbook"
|
||||||
|
guard let chapters = json["chapters"] as? [[String: Any]] else {
|
||||||
|
print("[DataLoader] ERROR: textbook_data.json missing chapters")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var inserted = 0
|
||||||
|
for ch in chapters {
|
||||||
|
guard let id = ch["id"] as? String,
|
||||||
|
let number = ch["number"] as? Int,
|
||||||
|
let title = ch["title"] as? String,
|
||||||
|
let blocksRaw = ch["blocks"] as? [[String: Any]] else { continue }
|
||||||
|
|
||||||
|
let part = (ch["part"] as? Int) ?? 0
|
||||||
|
|
||||||
|
// Normalize each block to canonical keys expected by TextbookBlock decoder.
|
||||||
|
var normalized: [[String: Any]] = []
|
||||||
|
var exerciseCount = 0
|
||||||
|
var vocabTableCount = 0
|
||||||
|
for (i, b) in blocksRaw.enumerated() {
|
||||||
|
var out: [String: Any] = [:]
|
||||||
|
out["index"] = i
|
||||||
|
let kind = (b["kind"] as? String) ?? ""
|
||||||
|
out["kind"] = kind
|
||||||
|
switch kind {
|
||||||
|
case "heading":
|
||||||
|
if let level = b["level"] { out["level"] = level }
|
||||||
|
if let text = b["text"] { out["text"] = text }
|
||||||
|
case "paragraph":
|
||||||
|
if let text = b["text"] { out["text"] = text }
|
||||||
|
case "key_vocab_header":
|
||||||
|
break
|
||||||
|
case "vocab_table":
|
||||||
|
vocabTableCount += 1
|
||||||
|
if let src = b["sourceImage"] { out["sourceImage"] = src }
|
||||||
|
if let lines = b["ocrLines"] { out["ocrLines"] = lines }
|
||||||
|
if let conf = b["ocrConfidence"] { out["ocrConfidence"] = conf }
|
||||||
|
// Paired 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
268
Conjuga/Conjuga/Services/DictionaryService.swift
Normal file
268
Conjuga/Conjuga/Services/DictionaryService.swift
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import Foundation
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class DictionaryService {
|
||||||
|
|
||||||
|
struct Entry {
|
||||||
|
let word: String
|
||||||
|
let baseForm: String
|
||||||
|
let english: String
|
||||||
|
let partOfSpeech: String
|
||||||
|
let tenseId: String?
|
||||||
|
let person: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private var verbIndex: [String: Entry] = [:]
|
||||||
|
private var nonVerbIndex: [String: Entry] = [:]
|
||||||
|
private var isBuilt = false
|
||||||
|
|
||||||
|
/// Build the reverse index from existing verb data + bundled non-verb dictionary.
|
||||||
|
/// Loads from disk cache if available, otherwise builds from DB and caches.
|
||||||
|
func buildIfNeeded(context: ModelContext) {
|
||||||
|
guard !isBuilt else { return }
|
||||||
|
|
||||||
|
loadNonVerbDictionary()
|
||||||
|
|
||||||
|
if loadCachedIndex() {
|
||||||
|
isBuilt = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cache — build from DB
|
||||||
|
let verbDescriptor = FetchDescriptor<Verb>()
|
||||||
|
let verbs = (try? context.fetch(verbDescriptor)) ?? []
|
||||||
|
let verbMap = Dictionary(uniqueKeysWithValues: verbs.map { ($0.id, $0) })
|
||||||
|
|
||||||
|
let formDescriptor = FetchDescriptor<VerbForm>()
|
||||||
|
let forms = (try? context.fetch(formDescriptor)) ?? []
|
||||||
|
|
||||||
|
let persons = TenseInfo.persons
|
||||||
|
for form in forms {
|
||||||
|
guard let verb = verbMap[form.verbId] else { continue }
|
||||||
|
let key = form.form.lowercased()
|
||||||
|
if verbIndex[key] != nil { continue }
|
||||||
|
|
||||||
|
let person = form.personIndex < persons.count ? persons[form.personIndex] : nil
|
||||||
|
verbIndex[key] = Entry(
|
||||||
|
word: form.form,
|
||||||
|
baseForm: verb.infinitive,
|
||||||
|
english: verb.english,
|
||||||
|
partOfSpeech: "verb",
|
||||||
|
tenseId: form.tenseId,
|
||||||
|
person: person
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for verb in verbs {
|
||||||
|
let key = verb.infinitive.lowercased()
|
||||||
|
if verbIndex[key] == nil {
|
||||||
|
verbIndex[key] = Entry(
|
||||||
|
word: verb.infinitive,
|
||||||
|
baseForm: verb.infinitive,
|
||||||
|
english: verb.english,
|
||||||
|
partOfSpeech: "verb",
|
||||||
|
tenseId: nil,
|
||||||
|
person: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isBuilt = true
|
||||||
|
saveCachedIndex()
|
||||||
|
print("[Dictionary] Built index from DB: \(verbIndex.count) verb forms")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Disk Cache
|
||||||
|
|
||||||
|
private static var cacheURL: URL {
|
||||||
|
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||||
|
.appendingPathComponent("dictionary_index.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CachedEntry: Codable {
|
||||||
|
let word: String
|
||||||
|
let baseForm: String
|
||||||
|
let english: String
|
||||||
|
let partOfSpeech: String
|
||||||
|
let tenseId: String?
|
||||||
|
let person: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveCachedIndex() {
|
||||||
|
let entries = verbIndex.map { (key: $0.key, value: CachedEntry(
|
||||||
|
word: $0.value.word, baseForm: $0.value.baseForm,
|
||||||
|
english: $0.value.english, partOfSpeech: $0.value.partOfSpeech,
|
||||||
|
tenseId: $0.value.tenseId, person: $0.value.person
|
||||||
|
))}
|
||||||
|
let dict = Dictionary(uniqueKeysWithValues: entries)
|
||||||
|
if let data = try? JSONEncoder().encode(dict) {
|
||||||
|
try? data.write(to: Self.cacheURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadCachedIndex() -> Bool {
|
||||||
|
guard let data = try? Data(contentsOf: Self.cacheURL),
|
||||||
|
let dict = try? JSONDecoder().decode([String: CachedEntry].self, from: data) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
verbIndex = dict.mapValues { Entry(
|
||||||
|
word: $0.word, baseForm: $0.baseForm,
|
||||||
|
english: $0.english, partOfSpeech: $0.partOfSpeech,
|
||||||
|
tenseId: $0.tenseId, person: $0.person
|
||||||
|
)}
|
||||||
|
print("[Dictionary] Loaded cached index: \(verbIndex.count) verb forms")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookup(_ word: String) -> Entry? {
|
||||||
|
let cleaned = word.lowercased()
|
||||||
|
.trimmingCharacters(in: .punctuationCharacters)
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
return verbIndex[cleaned] ?? nonVerbIndex[cleaned]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadNonVerbDictionary() {
|
||||||
|
// Common non-verb Spanish words — articles, prepositions, pronouns, adjectives, nouns, adverbs, conjunctions
|
||||||
|
let words: [(String, String, String)] = [
|
||||||
|
// Articles
|
||||||
|
("el", "the (masc.)", "article"), ("la", "the (fem.)", "article"),
|
||||||
|
("los", "the (masc. pl.)", "article"), ("las", "the (fem. pl.)", "article"),
|
||||||
|
("un", "a, an (masc.)", "article"), ("una", "a, an (fem.)", "article"),
|
||||||
|
("unos", "some (masc.)", "article"), ("unas", "some (fem.)", "article"),
|
||||||
|
|
||||||
|
// Pronouns
|
||||||
|
("yo", "I", "pronoun"), ("tú", "you (informal)", "pronoun"),
|
||||||
|
("él", "he", "pronoun"), ("ella", "she", "pronoun"),
|
||||||
|
("nosotros", "we (masc.)", "pronoun"), ("nosotras", "we (fem.)", "pronoun"),
|
||||||
|
("ellos", "they (masc.)", "pronoun"), ("ellas", "they (fem.)", "pronoun"),
|
||||||
|
("usted", "you (formal)", "pronoun"), ("ustedes", "you all (formal)", "pronoun"),
|
||||||
|
("me", "me", "pronoun"), ("te", "you (obj.)", "pronoun"),
|
||||||
|
("nos", "us", "pronoun"), ("le", "him/her/you (obj.)", "pronoun"),
|
||||||
|
("les", "them/you all (obj.)", "pronoun"), ("lo", "it/him (obj.)", "pronoun"),
|
||||||
|
("se", "self/each other", "pronoun"), ("mi", "my", "pronoun"),
|
||||||
|
("tu", "your (informal)", "pronoun"), ("su", "his/her/your/their", "pronoun"),
|
||||||
|
("nuestro", "our (masc.)", "pronoun"), ("nuestra", "our (fem.)", "pronoun"),
|
||||||
|
("esto", "this", "pronoun"), ("eso", "that", "pronoun"),
|
||||||
|
("algo", "something", "pronoun"), ("nada", "nothing", "pronoun"),
|
||||||
|
("alguien", "someone", "pronoun"), ("nadie", "nobody", "pronoun"),
|
||||||
|
("todo", "everything, all", "pronoun"), ("cada", "each", "pronoun"),
|
||||||
|
("otro", "other, another", "pronoun"), ("otra", "other, another (fem.)", "pronoun"),
|
||||||
|
("mismo", "same, self", "pronoun"), ("misma", "same, self (fem.)", "pronoun"),
|
||||||
|
|
||||||
|
// Prepositions
|
||||||
|
("a", "to, at", "preposition"), ("de", "of, from", "preposition"),
|
||||||
|
("en", "in, on, at", "preposition"), ("con", "with", "preposition"),
|
||||||
|
("por", "for, by, through", "preposition"), ("para", "for, in order to", "preposition"),
|
||||||
|
("sin", "without", "preposition"), ("sobre", "on, about", "preposition"),
|
||||||
|
("entre", "between, among", "preposition"), ("hasta", "until, up to", "preposition"),
|
||||||
|
("desde", "from, since", "preposition"), ("hacia", "toward", "preposition"),
|
||||||
|
("durante", "during", "preposition"), ("según", "according to", "preposition"),
|
||||||
|
("tras", "after, behind", "preposition"), ("contra", "against", "preposition"),
|
||||||
|
|
||||||
|
// Conjunctions
|
||||||
|
("y", "and", "conjunction"), ("e", "and (before i/hi)", "conjunction"),
|
||||||
|
("o", "or", "conjunction"), ("u", "or (before o/ho)", "conjunction"),
|
||||||
|
("pero", "but", "conjunction"), ("sino", "but rather", "conjunction"),
|
||||||
|
("porque", "because", "conjunction"), ("que", "that, which", "conjunction"),
|
||||||
|
("si", "if", "conjunction"), ("cuando", "when", "conjunction"),
|
||||||
|
("como", "as, like, how", "conjunction"), ("donde", "where", "conjunction"),
|
||||||
|
("aunque", "although", "conjunction"), ("mientras", "while", "conjunction"),
|
||||||
|
("ni", "neither, nor", "conjunction"), ("pues", "well, since", "conjunction"),
|
||||||
|
|
||||||
|
// Common adverbs
|
||||||
|
("no", "no, not", "adverb"), ("sí", "yes", "adverb"),
|
||||||
|
("muy", "very", "adverb"), ("más", "more, most", "adverb"),
|
||||||
|
("menos", "less, fewer", "adverb"), ("bien", "well", "adverb"),
|
||||||
|
("mal", "badly", "adverb"), ("ya", "already, now", "adverb"),
|
||||||
|
("también", "also, too", "adverb"), ("tampoco", "neither, either", "adverb"),
|
||||||
|
("aquí", "here", "adverb"), ("ahí", "there", "adverb"),
|
||||||
|
("allí", "over there", "adverb"), ("siempre", "always", "adverb"),
|
||||||
|
("nunca", "never", "adverb"), ("hoy", "today", "adverb"),
|
||||||
|
("ayer", "yesterday", "adverb"), ("mañana", "tomorrow", "adverb"),
|
||||||
|
("ahora", "now", "adverb"), ("después", "after, later", "adverb"),
|
||||||
|
("antes", "before", "adverb"), ("luego", "then, later", "adverb"),
|
||||||
|
("todavía", "still, yet", "adverb"), ("casi", "almost", "adverb"),
|
||||||
|
("solo", "only, alone", "adverb"), ("tan", "so, as", "adverb"),
|
||||||
|
("mucho", "a lot, much", "adverb"), ("poco", "little, few", "adverb"),
|
||||||
|
("bastante", "quite, enough", "adverb"), ("demasiado", "too much", "adverb"),
|
||||||
|
|
||||||
|
// Question words
|
||||||
|
("qué", "what", "interrogative"), ("quién", "who", "interrogative"),
|
||||||
|
("cómo", "how", "interrogative"), ("dónde", "where", "interrogative"),
|
||||||
|
("cuándo", "when", "interrogative"), ("cuánto", "how much", "interrogative"),
|
||||||
|
("cuál", "which", "interrogative"), ("por qué", "why", "interrogative"),
|
||||||
|
|
||||||
|
// Common nouns
|
||||||
|
("casa", "house", "noun"), ("hombre", "man", "noun"),
|
||||||
|
("mujer", "woman", "noun"), ("niño", "boy, child", "noun"),
|
||||||
|
("niña", "girl", "noun"), ("familia", "family", "noun"),
|
||||||
|
("amigo", "friend (masc.)", "noun"), ("amiga", "friend (fem.)", "noun"),
|
||||||
|
("tiempo", "time, weather", "noun"), ("día", "day", "noun"),
|
||||||
|
("noche", "night", "noun"), ("año", "year", "noun"),
|
||||||
|
("vida", "life", "noun"), ("mundo", "world", "noun"),
|
||||||
|
("país", "country", "noun"), ("ciudad", "city", "noun"),
|
||||||
|
("agua", "water", "noun"), ("comida", "food", "noun"),
|
||||||
|
("trabajo", "work, job", "noun"), ("escuela", "school", "noun"),
|
||||||
|
("libro", "book", "noun"), ("calle", "street", "noun"),
|
||||||
|
("dinero", "money", "noun"), ("mano", "hand", "noun"),
|
||||||
|
("padre", "father", "noun"), ("madre", "mother", "noun"),
|
||||||
|
("hijo", "son", "noun"), ("hija", "daughter", "noun"),
|
||||||
|
("hermano", "brother", "noun"), ("hermana", "sister", "noun"),
|
||||||
|
("persona", "person", "noun"), ("gente", "people", "noun"),
|
||||||
|
("cosa", "thing", "noun"), ("lugar", "place", "noun"),
|
||||||
|
("parte", "part", "noun"), ("nombre", "name", "noun"),
|
||||||
|
("momento", "moment", "noun"), ("problema", "problem", "noun"),
|
||||||
|
("mesa", "table", "noun"), ("puerta", "door", "noun"),
|
||||||
|
("coche", "car", "noun"), ("perro", "dog", "noun"),
|
||||||
|
("gato", "cat", "noun"), ("sol", "sun", "noun"),
|
||||||
|
("mar", "sea", "noun"), ("playa", "beach", "noun"),
|
||||||
|
("montaña", "mountain", "noun"), ("tienda", "store", "noun"),
|
||||||
|
("restaurante", "restaurant", "noun"), ("hotel", "hotel", "noun"),
|
||||||
|
("cuerpo", "body", "noun"), ("cabeza", "head", "noun"),
|
||||||
|
("corazón", "heart", "noun"), ("ojo", "eye", "noun"),
|
||||||
|
|
||||||
|
// Common adjectives
|
||||||
|
("bueno", "good", "adjective"), ("buena", "good (fem.)", "adjective"),
|
||||||
|
("malo", "bad", "adjective"), ("mala", "bad (fem.)", "adjective"),
|
||||||
|
("grande", "big, great", "adjective"), ("pequeño", "small", "adjective"),
|
||||||
|
("nuevo", "new", "adjective"), ("viejo", "old", "adjective"),
|
||||||
|
("joven", "young", "adjective"), ("largo", "long", "adjective"),
|
||||||
|
("corto", "short", "adjective"), ("alto", "tall, high", "adjective"),
|
||||||
|
("bajo", "short, low", "adjective"), ("bonito", "pretty", "adjective"),
|
||||||
|
("hermoso", "beautiful", "adjective"), ("feo", "ugly", "adjective"),
|
||||||
|
("feliz", "happy", "adjective"), ("triste", "sad", "adjective"),
|
||||||
|
("fácil", "easy", "adjective"), ("difícil", "difficult", "adjective"),
|
||||||
|
("importante", "important", "adjective"), ("posible", "possible", "adjective"),
|
||||||
|
("mejor", "better, best", "adjective"), ("peor", "worse, worst", "adjective"),
|
||||||
|
("primero", "first", "adjective"), ("último", "last", "adjective"),
|
||||||
|
("mismo", "same", "adjective"), ("otro", "other", "adjective"),
|
||||||
|
("cada", "each, every", "adjective"), ("todo", "all, every", "adjective"),
|
||||||
|
("mucho", "much, many", "adjective"), ("poco", "little, few", "adjective"),
|
||||||
|
|
||||||
|
// Numbers
|
||||||
|
("uno", "one", "number"), ("dos", "two", "number"),
|
||||||
|
("tres", "three", "number"), ("cuatro", "four", "number"),
|
||||||
|
("cinco", "five", "number"), ("seis", "six", "number"),
|
||||||
|
("siete", "seven", "number"), ("ocho", "eight", "number"),
|
||||||
|
("nueve", "nine", "number"), ("diez", "ten", "number"),
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
("del", "of the (de + el)", "contraction"), ("al", "to the (a + el)", "contraction"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (word, english, pos) in words {
|
||||||
|
nonVerbIndex[word.lowercased()] = Entry(
|
||||||
|
word: word,
|
||||||
|
baseForm: word,
|
||||||
|
english: english,
|
||||||
|
partOfSpeech: pos,
|
||||||
|
tenseId: nil,
|
||||||
|
person: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
Conjuga/Conjuga/Services/LyricsSearchService.swift
Normal file
121
Conjuga/Conjuga/Services/LyricsSearchService.swift
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct LyricsSearchResult: Sendable {
|
||||||
|
let title: String
|
||||||
|
let artist: String
|
||||||
|
let lyricsES: String
|
||||||
|
let albumArtURL: String?
|
||||||
|
let appleMusicURL: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
actor LyricsSearchService {
|
||||||
|
|
||||||
|
// MARK: - Public
|
||||||
|
|
||||||
|
func searchLyrics(artist: String, title: String) async throws -> [LyricsSearchResult] {
|
||||||
|
async let lrcResults = searchLRCLIB(artist: artist, title: title)
|
||||||
|
async let itunesResults = searchITunes(artist: artist, title: title)
|
||||||
|
|
||||||
|
let lyrics = try await lrcResults
|
||||||
|
let metadata = try? await itunesResults
|
||||||
|
|
||||||
|
return lyrics.map { lrc in
|
||||||
|
let match = metadata?.bestMatch(artist: lrc.artistName, title: lrc.trackName)
|
||||||
|
return LyricsSearchResult(
|
||||||
|
title: lrc.trackName,
|
||||||
|
artist: lrc.artistName,
|
||||||
|
lyricsES: lrc.plainLyrics,
|
||||||
|
albumArtURL: match?.artworkURL600,
|
||||||
|
appleMusicURL: match?.trackViewURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - LRCLIB
|
||||||
|
|
||||||
|
private struct LRCLIBResult: Decodable, Sendable {
|
||||||
|
let trackName: String
|
||||||
|
let artistName: String
|
||||||
|
let plainLyrics: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case trackName, artistName, plainLyrics
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
trackName = try c.decode(String.self, forKey: .trackName)
|
||||||
|
artistName = try c.decode(String.self, forKey: .artistName)
|
||||||
|
plainLyrics = (try? c.decode(String.self, forKey: .plainLyrics)) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func searchLRCLIB(artist: String, title: String) async throws -> [LRCLIBResult] {
|
||||||
|
var components = URLComponents(string: "https://lrclib.net/api/search")!
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "track_name", value: title),
|
||||||
|
URLQueryItem(name: "artist_name", value: artist),
|
||||||
|
]
|
||||||
|
guard let url = components.url else { return [] }
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue("Conjuga/1.0", forHTTPHeaderField: "User-Agent")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return [] }
|
||||||
|
|
||||||
|
let results = try JSONDecoder().decode([LRCLIBResult].self, from: data)
|
||||||
|
return results.filter { !$0.plainLyrics.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iTunes Search
|
||||||
|
|
||||||
|
private struct ITunesResponse: Decodable {
|
||||||
|
let results: [ITunesTrack]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ITunesTrack: Decodable {
|
||||||
|
let trackName: String?
|
||||||
|
let artistName: String?
|
||||||
|
let artworkUrl100: String?
|
||||||
|
let trackViewUrl: String?
|
||||||
|
|
||||||
|
var artworkURL600: String? {
|
||||||
|
artworkUrl100?.replacingOccurrences(of: "100x100", with: "600x600")
|
||||||
|
}
|
||||||
|
|
||||||
|
var trackViewURL: String? { trackViewUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ITunesMetadata: Sendable {
|
||||||
|
let tracks: [ITunesTrack]
|
||||||
|
|
||||||
|
func bestMatch(artist: String, title: String) -> ITunesTrack? {
|
||||||
|
let normalizedArtist = artist.lowercased()
|
||||||
|
let normalizedTitle = title.lowercased()
|
||||||
|
|
||||||
|
// Prefer exact title+artist match, then just title
|
||||||
|
return tracks.first {
|
||||||
|
($0.trackName ?? "").lowercased().contains(normalizedTitle) &&
|
||||||
|
($0.artistName ?? "").lowercased().contains(normalizedArtist)
|
||||||
|
} ?? tracks.first {
|
||||||
|
($0.trackName ?? "").lowercased().contains(normalizedTitle)
|
||||||
|
} ?? tracks.first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func searchITunes(artist: String, title: String) async throws -> ITunesMetadata {
|
||||||
|
let query = "\(artist) \(title)"
|
||||||
|
var components = URLComponents(string: "https://itunes.apple.com/search")!
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "term", value: query),
|
||||||
|
URLQueryItem(name: "media", value: "music"),
|
||||||
|
URLQueryItem(name: "limit", value: "5"),
|
||||||
|
]
|
||||||
|
guard let url = components.url else { return ITunesMetadata(tracks: []) }
|
||||||
|
|
||||||
|
let (data, _) = try await URLSession.shared.data(from: url)
|
||||||
|
let response = try JSONDecoder().decode(ITunesResponse.self, from: data)
|
||||||
|
return ITunesMetadata(tracks: response.results)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,23 @@ import SwiftData
|
|||||||
|
|
||||||
struct PracticeSettings: Sendable {
|
struct PracticeSettings: Sendable {
|
||||||
let selectedLevel: String
|
let selectedLevel: String
|
||||||
|
let selectedLevels: Set<String>
|
||||||
let enabledTenses: Set<String>
|
let enabledTenses: Set<String>
|
||||||
|
let enabledIrregularCategories: Set<IrregularSpan.SpanCategory>
|
||||||
let showVosotros: Bool
|
let showVosotros: Bool
|
||||||
|
let showReflexiveVerbsOnly: Bool
|
||||||
|
let reflexiveBaseInfinitives: Set<String>
|
||||||
|
|
||||||
init(progress: UserProgress?) {
|
init(progress: UserProgress?, reflexiveBaseInfinitives: Set<String> = []) {
|
||||||
let resolved = progress?.enabledTenseIDs ?? []
|
let resolvedTenses = progress?.enabledTenseIDs ?? []
|
||||||
|
let resolvedLevels = progress?.selectedVerbLevels ?? []
|
||||||
self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue
|
self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue
|
||||||
self.enabledTenses = Set(resolved)
|
self.selectedLevels = Set(resolvedLevels.map(\.rawValue))
|
||||||
|
self.enabledTenses = Set(resolvedTenses)
|
||||||
|
self.enabledIrregularCategories = progress?.enabledIrregularCategories ?? []
|
||||||
self.showVosotros = progress?.showVosotros ?? true
|
self.showVosotros = progress?.showVosotros ?? true
|
||||||
|
self.showReflexiveVerbsOnly = progress?.showReflexiveVerbsOnly ?? false
|
||||||
|
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectionTenseIDs: [String] {
|
var selectionTenseIDs: [String] {
|
||||||
@@ -36,19 +45,28 @@ struct FullTablePrompt {
|
|||||||
struct PracticeSessionService {
|
struct PracticeSessionService {
|
||||||
let localContext: ModelContext
|
let localContext: ModelContext
|
||||||
let cloudContext: ModelContext
|
let cloudContext: ModelContext
|
||||||
|
let reflexiveBaseInfinitives: Set<String>
|
||||||
private let referenceStore: ReferenceStore
|
private let referenceStore: ReferenceStore
|
||||||
|
|
||||||
init(localContext: ModelContext, cloudContext: ModelContext) {
|
init(
|
||||||
|
localContext: ModelContext,
|
||||||
|
cloudContext: ModelContext,
|
||||||
|
reflexiveBaseInfinitives: Set<String> = []
|
||||||
|
) {
|
||||||
self.localContext = localContext
|
self.localContext = localContext
|
||||||
self.cloudContext = cloudContext
|
self.cloudContext = cloudContext
|
||||||
|
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
|
||||||
self.referenceStore = ReferenceStore(context: localContext)
|
self.referenceStore = ReferenceStore(context: localContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func settings() -> PracticeSettings {
|
func settings() -> PracticeSettings {
|
||||||
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext))
|
PracticeSettings(
|
||||||
|
progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext),
|
||||||
|
reflexiveBaseInfinitives: reflexiveBaseInfinitives
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func nextCard(for focusMode: FocusMode) -> PracticeCardLoad? {
|
func nextCard(for focusMode: FocusMode, excludingVerbId lastVerbId: Int? = nil) -> PracticeCardLoad? {
|
||||||
switch focusMode {
|
switch focusMode {
|
||||||
case .weakVerbs:
|
case .weakVerbs:
|
||||||
if let form = pickWeakForm() {
|
if let form = pickWeakForm() {
|
||||||
@@ -58,11 +76,15 @@ struct PracticeSessionService {
|
|||||||
if let form = pickIrregularForm(filter: filter) {
|
if let form = pickIrregularForm(filter: filter) {
|
||||||
return loadCard(from: form)
|
return loadCard(from: form)
|
||||||
}
|
}
|
||||||
|
case .commonTenses:
|
||||||
|
if let form = pickCommonTenseForm() {
|
||||||
|
return loadCard(from: form)
|
||||||
|
}
|
||||||
case .none:
|
case .none:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if let dueCard = fetchDueCard() {
|
if let dueCard = fetchDueCard(excluding: lastVerbId) {
|
||||||
return loadCard(from: dueCard)
|
return loadCard(from: dueCard)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +97,12 @@ struct PracticeSessionService {
|
|||||||
|
|
||||||
func randomFullTablePrompt() -> FullTablePrompt? {
|
func randomFullTablePrompt() -> FullTablePrompt? {
|
||||||
let settings = settings()
|
let settings = settings()
|
||||||
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
|
// Full Table practice is regular-only, so the irregular-category setting is
|
||||||
|
// deliberately ignored here (applying it would empty the pool).
|
||||||
|
let verbs = applyReflexiveFilter(
|
||||||
|
to: referenceStore.fetchVerbs(selectedLevels: settings.selectedLevels),
|
||||||
|
settings: settings
|
||||||
|
)
|
||||||
guard !verbs.isEmpty else { return nil }
|
guard !verbs.isEmpty else { return nil }
|
||||||
|
|
||||||
for _ in 0..<40 {
|
for _ in 0..<40 {
|
||||||
@@ -85,6 +112,11 @@ struct PracticeSessionService {
|
|||||||
|
|
||||||
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId)
|
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId)
|
||||||
if forms.isEmpty { continue }
|
if forms.isEmpty { continue }
|
||||||
|
|
||||||
|
// Full Table practice is for regular patterns only — skip combos
|
||||||
|
// where any form in this (verb, tense) is irregular.
|
||||||
|
if forms.contains(where: { $0.regularity != "regular" }) { continue }
|
||||||
|
|
||||||
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: forms)
|
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: forms)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +159,27 @@ struct PracticeSessionService {
|
|||||||
return buildCardLoad(verb: verb, form: form)
|
return buildCardLoad(verb: verb, form: form)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When the user has "Reflexive verbs only" enabled, restrict the allowed
|
||||||
|
/// verb-id set to IDs whose infinitive is in the curated list.
|
||||||
|
/// No-op otherwise.
|
||||||
|
private func applyReflexiveFilter(to ids: Set<Int>, settings: PracticeSettings) -> Set<Int> {
|
||||||
|
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
let matching = ids.filter { id in
|
||||||
|
guard let verb = referenceStore.fetchVerb(id: id) else { return false }
|
||||||
|
return settings.reflexiveBaseInfinitives.contains(verb.infinitive.lowercased())
|
||||||
|
}
|
||||||
|
return matching
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyReflexiveFilter(to verbs: [Verb], settings: PracticeSettings) -> [Verb] {
|
||||||
|
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
|
||||||
|
return verbs
|
||||||
|
}
|
||||||
|
return verbs.filter { settings.reflexiveBaseInfinitives.contains($0.infinitive.lowercased()) }
|
||||||
|
}
|
||||||
|
|
||||||
private func buildCardLoad(verb: Verb, form: VerbForm) -> PracticeCardLoad {
|
private func buildCardLoad(verb: Verb, form: VerbForm) -> PracticeCardLoad {
|
||||||
let spans = referenceStore.fetchSpans(
|
let spans = referenceStore.fetchSpans(
|
||||||
verbId: form.verbId,
|
verbId: form.verbId,
|
||||||
@@ -146,9 +199,15 @@ struct PracticeSessionService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchDueCard() -> ReviewCard? {
|
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
|
||||||
let settings = settings()
|
let settings = settings()
|
||||||
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
|
let allowedVerbIds = applyReflexiveFilter(
|
||||||
|
to: referenceStore.allowedVerbIDs(
|
||||||
|
selectedLevels: settings.selectedLevels,
|
||||||
|
irregularCategories: settings.enabledIrregularCategories
|
||||||
|
),
|
||||||
|
settings: settings
|
||||||
|
)
|
||||||
let now = Date()
|
let now = Date()
|
||||||
var descriptor = FetchDescriptor<ReviewCard>(
|
var descriptor = FetchDescriptor<ReviewCard>(
|
||||||
predicate: #Predicate<ReviewCard> { $0.dueDate <= now },
|
predicate: #Predicate<ReviewCard> { $0.dueDate <= now },
|
||||||
@@ -157,16 +216,31 @@ struct PracticeSessionService {
|
|||||||
descriptor.fetchLimit = settings.enabledTenses.isEmpty ? 10 : 50
|
descriptor.fetchLimit = settings.enabledTenses.isEmpty ? 10 : 50
|
||||||
let cards = (try? cloudContext.fetch(descriptor)) ?? []
|
let cards = (try? cloudContext.fetch(descriptor)) ?? []
|
||||||
|
|
||||||
return cards.first { card in
|
let eligible = cards.filter { card in
|
||||||
allowedVerbIds.contains(card.verbId) &&
|
allowedVerbIds.contains(card.verbId) &&
|
||||||
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) &&
|
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) &&
|
||||||
(settings.showVosotros || card.personIndex != 4)
|
(settings.showVosotros || card.personIndex != 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer a card from a different verb than the last one shown.
|
||||||
|
// Fall back to the same verb only if it's the sole due card.
|
||||||
|
if let lastVerbId {
|
||||||
|
if let different = eligible.first(where: { $0.verbId != lastVerbId }) {
|
||||||
|
return different
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eligible.first
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pickWeakForm() -> VerbForm? {
|
private func pickWeakForm() -> VerbForm? {
|
||||||
let settings = settings()
|
let settings = settings()
|
||||||
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
|
let allowedVerbIds = applyReflexiveFilter(
|
||||||
|
to: referenceStore.allowedVerbIDs(
|
||||||
|
selectedLevels: settings.selectedLevels,
|
||||||
|
irregularCategories: settings.enabledIrregularCategories
|
||||||
|
),
|
||||||
|
settings: settings
|
||||||
|
)
|
||||||
|
|
||||||
let descriptor = FetchDescriptor<ReviewCard>(
|
let descriptor = FetchDescriptor<ReviewCard>(
|
||||||
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
|
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
|
||||||
@@ -188,7 +262,15 @@ struct PracticeSessionService {
|
|||||||
|
|
||||||
private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? {
|
private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? {
|
||||||
let settings = settings()
|
let settings = settings()
|
||||||
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
|
// Focus mode explicitly selects one irregular category, so the user's
|
||||||
|
// settings-level irregular filter is deliberately skipped here.
|
||||||
|
let allowedVerbIds = applyReflexiveFilter(
|
||||||
|
to: referenceStore.allowedVerbIDs(
|
||||||
|
selectedLevels: settings.selectedLevels,
|
||||||
|
irregularCategories: []
|
||||||
|
),
|
||||||
|
settings: settings
|
||||||
|
)
|
||||||
let typeRange: ClosedRange<Int>
|
let typeRange: ClosedRange<Int>
|
||||||
|
|
||||||
switch filter {
|
switch filter {
|
||||||
@@ -222,9 +304,35 @@ struct PracticeSessionService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func pickCommonTenseForm() -> VerbForm? {
|
||||||
|
let settings = settings()
|
||||||
|
let coreTenseIDs = TenseID.coreTenseIDs
|
||||||
|
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
|
||||||
|
coreTenseIDs.contains(form.tenseId) &&
|
||||||
|
(settings.showVosotros || form.personIndex != 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
return forms.randomElement()
|
||||||
|
}
|
||||||
|
|
||||||
private func pickRandomForm() -> VerbForm? {
|
private func pickRandomForm() -> VerbForm? {
|
||||||
let settings = settings()
|
let settings = settings()
|
||||||
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
|
let verbs = applyReflexiveFilter(
|
||||||
|
to: referenceStore.fetchVerbs(
|
||||||
|
selectedLevels: settings.selectedLevels,
|
||||||
|
irregularCategories: settings.enabledIrregularCategories
|
||||||
|
),
|
||||||
|
settings: settings
|
||||||
|
)
|
||||||
guard let verb = verbs.randomElement() else { return nil }
|
guard let verb = verbs.randomElement() else { return nil }
|
||||||
|
|
||||||
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
|
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
|
||||||
|
|||||||
153
Conjuga/Conjuga/Services/PronunciationService.swift
Normal file
153
Conjuga/Conjuga/Services/PronunciationService.swift
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import Foundation
|
||||||
|
import Speech
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class PronunciationService {
|
||||||
|
var isRecording = false
|
||||||
|
var transcript = ""
|
||||||
|
var isAuthorized = false
|
||||||
|
|
||||||
|
private var recognizer: SFSpeechRecognizer?
|
||||||
|
private var audioEngine: AVAudioEngine?
|
||||||
|
private var request: SFSpeechAudioBufferRecognitionRequest?
|
||||||
|
private var task: SFSpeechRecognitionTask?
|
||||||
|
private var recognizerResolved = false
|
||||||
|
|
||||||
|
func requestAuthorization() {
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
print("[PronunciationService] skipping speech auth on simulator")
|
||||||
|
return
|
||||||
|
#else
|
||||||
|
// Check current status first to avoid unnecessary prompt
|
||||||
|
let currentStatus = SFSpeechRecognizer.authorizationStatus()
|
||||||
|
if currentStatus == .authorized {
|
||||||
|
isAuthorized = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if currentStatus == .denied || currentStatus == .restricted {
|
||||||
|
isAuthorized = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only request if not determined yet — do it on a background queue
|
||||||
|
// to avoid blocking main thread, then update state on main
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
SFSpeechRecognizer.requestAuthorization { status in
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.isAuthorized = (status == .authorized)
|
||||||
|
print("[PronunciationService] authorization status: \(status.rawValue)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveRecognizerIfNeeded() {
|
||||||
|
guard !recognizerResolved else { return }
|
||||||
|
recognizerResolved = true
|
||||||
|
recognizer = SFSpeechRecognizer(locale: Locale(identifier: "es-ES"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func startRecording() {
|
||||||
|
guard isAuthorized else {
|
||||||
|
print("[PronunciationService] not authorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolveRecognizerIfNeeded()
|
||||||
|
guard let recognizer, recognizer.isAvailable else {
|
||||||
|
print("[PronunciationService] recognizer unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stopRecording()
|
||||||
|
|
||||||
|
do {
|
||||||
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
|
try audioSession.setCategory(.record, mode: .measurement, options: [.duckOthers])
|
||||||
|
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
audioEngine.prepare()
|
||||||
|
try audioEngine.start()
|
||||||
|
|
||||||
|
transcript = ""
|
||||||
|
isRecording = true
|
||||||
|
|
||||||
|
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let result {
|
||||||
|
self?.transcript = result.bestTranscription.formattedString
|
||||||
|
}
|
||||||
|
if error != nil || (result?.isFinal == true) {
|
||||||
|
self?.stopRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("[PronunciationService] startRecording failed: \(error)")
|
||||||
|
stopRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRecording() {
|
||||||
|
audioEngine?.stop()
|
||||||
|
audioEngine?.inputNode.removeTap(onBus: 0)
|
||||||
|
request?.endAudio()
|
||||||
|
task?.cancel()
|
||||||
|
task = nil
|
||||||
|
request = nil
|
||||||
|
audioEngine = nil
|
||||||
|
isRecording = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare spoken transcript against expected text, returns matched word ratio (0.0-1.0).
|
||||||
|
static func scoreMatch(expected: String, spoken: String) -> (score: Double, matches: [WordMatch]) {
|
||||||
|
let expectedWords = expected.lowercased()
|
||||||
|
.components(separatedBy: .whitespacesAndNewlines)
|
||||||
|
.map { $0.trimmingCharacters(in: .punctuationCharacters) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
|
||||||
|
let spokenWords = spoken.lowercased()
|
||||||
|
.components(separatedBy: .whitespacesAndNewlines)
|
||||||
|
.map { $0.trimmingCharacters(in: .punctuationCharacters) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
|
||||||
|
let spokenSet = Set(spokenWords)
|
||||||
|
var matches: [WordMatch] = []
|
||||||
|
|
||||||
|
for word in expectedWords {
|
||||||
|
matches.append(WordMatch(word: word, matched: spokenSet.contains(word)))
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchCount = matches.filter(\.matched).count
|
||||||
|
let score = expectedWords.isEmpty ? 0 : Double(matchCount) / Double(expectedWords.count)
|
||||||
|
return (score, matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WordMatch: Identifiable {
|
||||||
|
let word: String
|
||||||
|
let matched: Bool
|
||||||
|
var id: String { word }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,50 @@ struct ReferenceStore {
|
|||||||
Set(fetchVerbs(selectedLevel: selectedLevel).map(\.id))
|
Set(fetchVerbs(selectedLevel: selectedLevel).map(\.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Union of data-levels for all selected user-facing levels.
|
||||||
|
/// Empty input produces an empty result — callers decide how to handle that.
|
||||||
|
func fetchVerbs(selectedLevels: Set<String>) -> [Verb] {
|
||||||
|
guard !selectedLevels.isEmpty else { return [] }
|
||||||
|
let ids = PracticeFilter.verbIDs(
|
||||||
|
matchingLevels: selectedLevels,
|
||||||
|
in: fetchVerbs().map { .init(id: $0.id, level: $0.level) }
|
||||||
|
)
|
||||||
|
return fetchVerbs().filter { ids.contains($0.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Practice verb pool intersecting selected levels with selected irregular-span categories.
|
||||||
|
/// Delegates to `PracticeFilter` so the intersection logic is unit-tested
|
||||||
|
/// in SharedModels without a ModelContainer (Issue #26).
|
||||||
|
func allowedVerbIDs(
|
||||||
|
selectedLevels: Set<String>,
|
||||||
|
irregularCategories: Set<IrregularSpan.SpanCategory>
|
||||||
|
) -> Set<Int> {
|
||||||
|
PracticeFilter.allowedVerbIDs(
|
||||||
|
verbs: fetchVerbs().map { .init(id: $0.id, level: $0.level) },
|
||||||
|
spans: allIrregularSlots(),
|
||||||
|
selectedLevels: selectedLevels,
|
||||||
|
irregularCategories: irregularCategories
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: full Verb objects passing both filters.
|
||||||
|
func fetchVerbs(
|
||||||
|
selectedLevels: Set<String>,
|
||||||
|
irregularCategories: Set<IrregularSpan.SpanCategory>
|
||||||
|
) -> [Verb] {
|
||||||
|
let ids = allowedVerbIDs(
|
||||||
|
selectedLevels: selectedLevels,
|
||||||
|
irregularCategories: irregularCategories
|
||||||
|
)
|
||||||
|
return fetchVerbs().filter { ids.contains($0.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func allIrregularSlots() -> [PracticeFilter.IrregularSlot] {
|
||||||
|
let descriptor = FetchDescriptor<IrregularSpan>()
|
||||||
|
let spans = (try? context.fetch(descriptor)) ?? []
|
||||||
|
return spans.map { .init(verbId: $0.verbId, category: $0.category) }
|
||||||
|
}
|
||||||
|
|
||||||
func fetchVerb(id: Int) -> Verb? {
|
func fetchVerb(id: Int) -> Verb? {
|
||||||
let descriptor = FetchDescriptor<Verb>(predicate: #Predicate<Verb> { $0.id == id })
|
let descriptor = FetchDescriptor<Verb>(predicate: #Predicate<Verb> { $0.id == id })
|
||||||
return (try? context.fetch(descriptor))?.first
|
return (try? context.fetch(descriptor))?.first
|
||||||
|
|||||||
59
Conjuga/Conjuga/Services/ReflexiveVerbStore.swift
Normal file
59
Conjuga/Conjuga/Services/ReflexiveVerbStore.swift
Normal file
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,20 @@ import AVFoundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class SpeechService {
|
final class SpeechService {
|
||||||
private let synthesizer = AVSpeechSynthesizer()
|
private let synthesizer = AVSpeechSynthesizer()
|
||||||
private let spanishVoice: AVSpeechSynthesisVoice?
|
private var spanishVoice: AVSpeechSynthesisVoice?
|
||||||
|
private var voiceResolved = false
|
||||||
private var audioSessionConfigured = false
|
private var audioSessionConfigured = false
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
spanishVoice = AVSpeechSynthesisVoice(language: "es-ES")
|
// AVSpeechSynthesisVoice can trigger a malloc double-free on
|
||||||
|
// iOS 26 simulators when deserializing voice metadata. Defer
|
||||||
|
// voice resolution to first use so the crash doesn't happen
|
||||||
|
// during app launch.
|
||||||
|
spanishVoice = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func speak(_ text: String) {
|
func speak(_ text: String) {
|
||||||
|
resolveVoiceIfNeeded()
|
||||||
configureAudioSession()
|
configureAudioSession()
|
||||||
synthesizer.stopSpeaking(at: .immediate)
|
synthesizer.stopSpeaking(at: .immediate)
|
||||||
let utterance = AVSpeechUtterance(string: text)
|
let utterance = AVSpeechUtterance(string: text)
|
||||||
@@ -27,6 +33,12 @@ final class SpeechService {
|
|||||||
synthesizer.stopSpeaking(at: .immediate)
|
synthesizer.stopSpeaking(at: .immediate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func resolveVoiceIfNeeded() {
|
||||||
|
guard !voiceResolved else { return }
|
||||||
|
voiceResolved = true
|
||||||
|
spanishVoice = AVSpeechSynthesisVoice(language: "es-ES")
|
||||||
|
}
|
||||||
|
|
||||||
private func configureAudioSession() {
|
private func configureAudioSession() {
|
||||||
guard !audioSessionConfigured else { return }
|
guard !audioSessionConfigured else { return }
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ enum StartupCoordinator {
|
|||||||
static func bootstrap(localContainer: ModelContainer) async {
|
static func bootstrap(localContainer: ModelContainer) async {
|
||||||
await DataLoader.seedIfNeeded(container: localContainer)
|
await DataLoader.seedIfNeeded(container: localContainer)
|
||||||
await DataLoader.refreshCourseDataIfNeeded(container: localContainer)
|
await DataLoader.refreshCourseDataIfNeeded(container: localContainer)
|
||||||
|
await DataLoader.refreshTextbookDataIfNeeded(container: localContainer)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recurring maintenance: legacy migrations, identity repair, cloud dedup.
|
/// Recurring maintenance: legacy migrations, identity repair, cloud dedup.
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ enum StoreInspector {
|
|||||||
let hasZVERBFORM = tables.contains("ZVERBFORM")
|
let hasZVERBFORM = tables.contains("ZVERBFORM")
|
||||||
let hasZTENSEGUIDE = tables.contains("ZTENSEGUIDE")
|
let hasZTENSEGUIDE = tables.contains("ZTENSEGUIDE")
|
||||||
let hasZVOCABCARD = tables.contains("ZVOCABCARD")
|
let hasZVOCABCARD = tables.contains("ZVOCABCARD")
|
||||||
|
let hasZTEXTBOOKCHAPTER = tables.contains("ZTEXTBOOKCHAPTER")
|
||||||
|
|
||||||
var summary = "[StoreInspector:\(label)] \(tables.count) tables"
|
var summary = "[StoreInspector:\(label)] \(tables.count) tables"
|
||||||
summary += " | ZVERB=\(hasZVERB ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERB") : -1)"
|
summary += " | ZVERB=\(hasZVERB ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERB") : -1)"
|
||||||
summary += " ZVERBFORM=\(hasZVERBFORM ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERBFORM") : -1)"
|
summary += " ZVERBFORM=\(hasZVERBFORM ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERBFORM") : -1)"
|
||||||
summary += " ZTENSEGUIDE=\(hasZTENSEGUIDE ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTENSEGUIDE") : -1)"
|
summary += " ZTENSEGUIDE=\(hasZTENSEGUIDE ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTENSEGUIDE") : -1)"
|
||||||
summary += " ZVOCABCARD=\(hasZVOCABCARD ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVOCABCARD") : -1)"
|
summary += " ZVOCABCARD=\(hasZVOCABCARD ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVOCABCARD") : -1)"
|
||||||
|
summary += " ZTEXTBOOKCHAPTER=\(hasZTEXTBOOKCHAPTER ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTEXTBOOKCHAPTER") : -1)"
|
||||||
print(summary)
|
print(summary)
|
||||||
|
|
||||||
// Log all Z-tables (SwiftData entity tables start with Z, minus Core Data system tables)
|
// Log all Z-tables (SwiftData entity tables start with Z, minus Core Data system tables)
|
||||||
|
|||||||
97
Conjuga/Conjuga/Services/StoryGenerator.swift
Normal file
97
Conjuga/Conjuga/Services/StoryGenerator.swift
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct StoryGenerator {
|
||||||
|
|
||||||
|
// MARK: - Generable Types
|
||||||
|
|
||||||
|
@Generable
|
||||||
|
struct GeneratedStory {
|
||||||
|
@Guide(description: "A short creative title for the story in Spanish, 3-6 words")
|
||||||
|
var title: String
|
||||||
|
|
||||||
|
@Guide(description: "A one-paragraph story in Spanish, 5-8 sentences long, using vocabulary and grammar appropriate for the student level")
|
||||||
|
var bodyES: String
|
||||||
|
|
||||||
|
@Guide(description: "An accurate English translation of bodyES")
|
||||||
|
var bodyEN: String
|
||||||
|
|
||||||
|
@Guide(description: "Every word from the story annotated with its base form, English meaning, and part of speech. Include articles, prepositions, and all other words.")
|
||||||
|
var words: [GeneratedAnnotation]
|
||||||
|
|
||||||
|
@Guide(description: "3 reading comprehension questions about the story, each with 4 answer options in Spanish", .count(3))
|
||||||
|
var questions: [GeneratedQuestion]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Generable
|
||||||
|
struct GeneratedAnnotation {
|
||||||
|
@Guide(description: "The exact word as it appears in the story")
|
||||||
|
var word: String
|
||||||
|
@Guide(description: "The dictionary base form (infinitive for verbs, singular for nouns)")
|
||||||
|
var baseForm: String
|
||||||
|
@Guide(description: "English translation of the word")
|
||||||
|
var english: String
|
||||||
|
@Guide(description: "Part of speech: verb, noun, adjective, adverb, preposition, or other")
|
||||||
|
var partOfSpeech: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@Generable
|
||||||
|
struct GeneratedQuestion {
|
||||||
|
@Guide(description: "A comprehension question about the story in Spanish")
|
||||||
|
var question: String
|
||||||
|
@Guide(description: "4 answer options in Spanish", .count(4))
|
||||||
|
var options: [String]
|
||||||
|
@Guide(description: "Index of the correct answer (0-3)", .range(0...3))
|
||||||
|
var correctIndex: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generation
|
||||||
|
|
||||||
|
static func generate(level: String, tenses: [String]) async throws -> Story {
|
||||||
|
let tenseNames = tenses.isEmpty
|
||||||
|
? "present, preterite, imperfect, and future"
|
||||||
|
: tenses.joined(separator: ", ")
|
||||||
|
|
||||||
|
let session = LanguageModelSession(instructions: """
|
||||||
|
You are a Spanish language teacher creating a short reading exercise.
|
||||||
|
The student's level is: \(level).
|
||||||
|
Focus on these verb tenses: \(tenseNames).
|
||||||
|
Write naturally but keep vocabulary appropriate for the level.
|
||||||
|
Use common, everyday scenarios (shopping, travel, family, school, work, food).
|
||||||
|
The story should be exactly one paragraph of 5-8 sentences.
|
||||||
|
""")
|
||||||
|
|
||||||
|
let response = try await session.respond(
|
||||||
|
to: "Create a short Spanish story for reading practice.",
|
||||||
|
generating: GeneratedStory.self
|
||||||
|
)
|
||||||
|
|
||||||
|
let story = response.content
|
||||||
|
|
||||||
|
let annotations = story.words.map {
|
||||||
|
WordAnnotation(word: $0.word, baseForm: $0.baseForm, english: $0.english, partOfSpeech: $0.partOfSpeech)
|
||||||
|
}
|
||||||
|
let questions = story.questions.map {
|
||||||
|
QuizQuestion(question: $0.question, options: $0.options, correctIndex: $0.correctIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
let annotationsJSON = (try? String(data: JSONEncoder().encode(annotations), encoding: .utf8)) ?? "[]"
|
||||||
|
let questionsJSON = (try? String(data: JSONEncoder().encode(questions), encoding: .utf8)) ?? "[]"
|
||||||
|
|
||||||
|
return Story(
|
||||||
|
title: story.title,
|
||||||
|
bodyES: story.bodyES,
|
||||||
|
bodyEN: story.bodyEN,
|
||||||
|
level: level,
|
||||||
|
wordAnnotations: annotationsJSON,
|
||||||
|
quizQuestions: questionsJSON
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var isAvailable: Bool {
|
||||||
|
SystemLanguageModel.default.availability == .available
|
||||||
|
}
|
||||||
|
}
|
||||||
46
Conjuga/Conjuga/Services/StudyTimerService.swift
Normal file
46
Conjuga/Conjuga/Services/StudyTimerService.swift
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class StudyTimerService {
|
||||||
|
private(set) var sessionStart: Date?
|
||||||
|
private(set) var tick: Int = 0
|
||||||
|
private var timer: Timer?
|
||||||
|
|
||||||
|
var isRunning: Bool { sessionStart != nil }
|
||||||
|
|
||||||
|
/// Seconds elapsed in the current live session.
|
||||||
|
var currentSessionSeconds: Int {
|
||||||
|
// Access `tick` so SwiftUI re-evaluates each second.
|
||||||
|
_ = tick
|
||||||
|
guard let start = sessionStart else { return 0 }
|
||||||
|
return max(0, Int(Date().timeIntervalSince(start)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard sessionStart == nil else { return }
|
||||||
|
sessionStart = Date()
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.tick += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop(context: ModelContext) {
|
||||||
|
guard let start = sessionStart else { return }
|
||||||
|
let elapsed = max(0, Int(Date().timeIntervalSince(start)))
|
||||||
|
sessionStart = nil
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
|
||||||
|
guard elapsed > 0 else { return }
|
||||||
|
|
||||||
|
let todayString = DailyLog.todayString()
|
||||||
|
let log = ReviewStore.fetchOrCreateDailyLog(dateString: todayString, context: context)
|
||||||
|
log.studySeconds += elapsed
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
65
Conjuga/Conjuga/Services/VerbExampleCache.swift
Normal file
65
Conjuga/Conjuga/Services/VerbExampleCache.swift
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
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([String: [VerbExample]].self, from: data)
|
||||||
|
else { 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 {
|
||||||
|
if let id = Int(key) {
|
||||||
|
rebuilt[id] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store = rebuilt
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let serialized = Dictionary(uniqueKeysWithValues: store.map { (String($0.key), $0.value) })
|
||||||
|
guard let data = try? JSONEncoder().encode(serialized) else { return }
|
||||||
|
try? data.write(to: Self.cacheURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
78
Conjuga/Conjuga/Services/VerbExampleGenerator.swift
Normal file
78
Conjuga/Conjuga/Services/VerbExampleGenerator.swift
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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.
|
||||||
|
@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 example per tense in `tenseIds`. Returns the examples in the
|
||||||
|
/// same order as `tenseIds`, filling in placeholders for any the model skipped.
|
||||||
|
static func generate(
|
||||||
|
verbInfinitive: String,
|
||||||
|
verbEnglish: String,
|
||||||
|
tenseIds: [String]
|
||||||
|
) async throws -> [VerbExample] {
|
||||||
|
let tenseList = tenseIds
|
||||||
|
.compactMap { id in TenseInfo.find(id).map { "\(id) (\($0.english))" } }
|
||||||
|
.joined(separator: ", ")
|
||||||
|
|
||||||
|
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)).
|
||||||
|
Write one sentence per requested tense. Each sentence must:
|
||||||
|
- Actually conjugate "\(verbInfinitive)" in that tense (not just mention it).
|
||||||
|
- 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 example sentences for "\(verbInfinitive)" in these tenses, in this order:
|
||||||
|
\(tenseList)
|
||||||
|
|
||||||
|
Return one GeneratedExample per tense with the matching tenseId, spanish, and english.
|
||||||
|
"""
|
||||||
|
|
||||||
|
let response = try await session.respond(to: prompt, generating: GeneratedExampleSet.self)
|
||||||
|
|
||||||
|
// Map by tenseId and return in the caller's requested order so the UI
|
||||||
|
// renders a predictable sequence even if the model shuffles its output.
|
||||||
|
let byTense = Dictionary(uniqueKeysWithValues: response.content.examples.map {
|
||||||
|
($0.tenseId, VerbExample(tenseId: $0.tenseId, spanish: $0.spanish, english: $0.english))
|
||||||
|
})
|
||||||
|
return tenseIds.compactMap { byTense[$0] }
|
||||||
|
}
|
||||||
|
|
||||||
|
static var isAvailable: Bool {
|
||||||
|
SystemLanguageModel.default.availability == .available
|
||||||
|
}
|
||||||
|
}
|
||||||
201
Conjuga/Conjuga/Services/VideoDownloadService.swift
Normal file
201
Conjuga/Conjuga/Services/VideoDownloadService.swift
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
import SharedModels
|
||||||
|
import YouTubeKit
|
||||||
|
|
||||||
|
/// Downloads YouTube videos for offline viewing (Issue #21, phase 3).
|
||||||
|
///
|
||||||
|
/// Uses YouTubeKit to resolve stream URLs, then a `URLSession` download task
|
||||||
|
/// to persist the MP4 under the app's documents directory. Metadata is
|
||||||
|
/// recorded in SwiftData via `DownloadedVideo` so the app knows what's on
|
||||||
|
/// disk across launches.
|
||||||
|
///
|
||||||
|
/// **Known fragility**: YouTubeKit scrapes YouTube's private stream API and
|
||||||
|
/// will break when YouTube changes their internal format. When it does, the
|
||||||
|
/// service throws `DownloadError.extractionFailed` and the UI should fall
|
||||||
|
/// back to streaming (phase 2) which remains available.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class VideoDownloadService {
|
||||||
|
|
||||||
|
enum DownloadError: Error, LocalizedError {
|
||||||
|
case extractionFailed(String)
|
||||||
|
case noSuitableStream
|
||||||
|
case downloadFailed(String)
|
||||||
|
case fileWriteFailed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .extractionFailed(let why): "Could not extract video: \(why)"
|
||||||
|
case .noSuitableStream: "No downloadable stream found for this video."
|
||||||
|
case .downloadFailed(let why): "Download failed: \(why)"
|
||||||
|
case .fileWriteFailed(let why): "Could not save video: \(why)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-flight downloads by videoId. Progress is Double in [0, 1].
|
||||||
|
var activeDownloads: [String: Double] = [:]
|
||||||
|
|
||||||
|
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 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if a downloaded MP4 exists for this videoId.
|
||||||
|
static func isDownloaded(videoId: String) -> Bool {
|
||||||
|
FileManager.default.fileExists(atPath: fileURL(for: videoId).path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Download
|
||||||
|
|
||||||
|
/// Downloads a YouTube video to local storage and records it in SwiftData.
|
||||||
|
/// Throws on any failure. Caller is responsible for showing errors.
|
||||||
|
func download(
|
||||||
|
videoId: String,
|
||||||
|
title: String,
|
||||||
|
into modelContext: ModelContext
|
||||||
|
) async throws {
|
||||||
|
guard !activeDownloads.keys.contains(videoId) else { return }
|
||||||
|
activeDownloads[videoId] = 0
|
||||||
|
|
||||||
|
defer { activeDownloads.removeValue(forKey: videoId) }
|
||||||
|
|
||||||
|
try Self.ensureDirectory()
|
||||||
|
|
||||||
|
// 1. Resolve stream URL via YouTubeKit. Run off the main actor because
|
||||||
|
// YouTubeKit.YouTube isn't Sendable and does synchronous work we don't
|
||||||
|
// want blocking UI.
|
||||||
|
let streamURL: URL
|
||||||
|
do {
|
||||||
|
streamURL = try await Self.resolveStreamURL(videoId: videoId)
|
||||||
|
} catch let e as DownloadError {
|
||||||
|
throw e
|
||||||
|
} catch {
|
||||||
|
throw DownloadError.extractionFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Download the stream to disk with progress tracking.
|
||||||
|
let destURL = Self.fileURL(for: videoId)
|
||||||
|
do {
|
||||||
|
let (tempURL, response) = try await URLSession.shared.download(
|
||||||
|
for: URLRequest(url: streamURL),
|
||||||
|
delegate: DownloadProgressDelegate { [weak self] progress in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.activeDownloads[videoId] = progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_ = response
|
||||||
|
|
||||||
|
// Move the temp file to our persistent location (atomic).
|
||||||
|
if FileManager.default.fileExists(atPath: destURL.path) {
|
||||||
|
try FileManager.default.removeItem(at: destURL)
|
||||||
|
}
|
||||||
|
try FileManager.default.moveItem(at: tempURL, to: destURL)
|
||||||
|
} catch let e as DownloadError {
|
||||||
|
throw e
|
||||||
|
} catch {
|
||||||
|
throw DownloadError.downloadFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the best progressive-MP4 stream URL for a YouTube videoId.
|
||||||
|
/// Runs off the main actor because `YouTube` isn't Sendable.
|
||||||
|
nonisolated private static func resolveStreamURL(videoId: String) async throws -> URL {
|
||||||
|
let youtube = YouTube(videoID: videoId)
|
||||||
|
let streams = try await youtube.streams
|
||||||
|
let candidate = streams
|
||||||
|
.filter { $0.isProgressive && $0.subtype == "mp4" }
|
||||||
|
.sorted { ($0.bitrate ?? 0) > ($1.bitrate ?? 0) }
|
||||||
|
.first
|
||||||
|
?? streams.filter({ $0.subtype == "mp4" }).first
|
||||||
|
guard let stream = candidate else { throw DownloadError.noSuitableStream }
|
||||||
|
return stream.url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: - URLSession progress delegate
|
||||||
|
|
||||||
|
private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate {
|
||||||
|
let onProgress: (Double) -> Void
|
||||||
|
|
||||||
|
init(onProgress: @escaping (Double) -> Void) {
|
||||||
|
self.onProgress = onProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
downloadTask: URLSessionDownloadTask,
|
||||||
|
didWriteData bytesWritten: Int64,
|
||||||
|
totalBytesWritten: Int64,
|
||||||
|
totalBytesExpectedToWrite: Int64
|
||||||
|
) {
|
||||||
|
guard totalBytesExpectedToWrite > 0 else { return }
|
||||||
|
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
||||||
|
onProgress(progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
downloadTask: URLSessionDownloadTask,
|
||||||
|
didFinishDownloadingTo location: URL
|
||||||
|
) {
|
||||||
|
// Not used — `URLSession.download(for:delegate:)` already returns the temp URL.
|
||||||
|
}
|
||||||
|
}
|
||||||
61
Conjuga/Conjuga/Services/YouTubeVideoStore.swift
Normal file
61
Conjuga/Conjuga/Services/YouTubeVideoStore.swift
Normal file
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ enum FocusMode: Sendable {
|
|||||||
case none
|
case none
|
||||||
case weakVerbs
|
case weakVerbs
|
||||||
case irregularity(IrregularityFilter)
|
case irregularity(IrregularityFilter)
|
||||||
|
case commonTenses
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -95,8 +96,12 @@ final class PracticeViewModel {
|
|||||||
currentSpans = []
|
currentSpans = []
|
||||||
hasCards = true
|
hasCards = true
|
||||||
isLoading = true
|
isLoading = true
|
||||||
let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext)
|
let service = PracticeSessionService(
|
||||||
guard let cardLoad = service.nextCard(for: focusMode) else {
|
localContext: localContext,
|
||||||
|
cloudContext: cloudContext,
|
||||||
|
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
|
||||||
|
)
|
||||||
|
guard let cardLoad = service.nextCard(for: focusMode, excludingVerbId: currentVerb?.id) else {
|
||||||
clearCurrentCard()
|
clearCurrentCard()
|
||||||
hasCards = false
|
hasCards = false
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|||||||
224
Conjuga/Conjuga/Views/Course/CheckpointExamView.swift
Normal file
224
Conjuga/Conjuga/Views/Course/CheckpointExamView.swift
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct CheckpointExamView: View {
|
||||||
|
let courseName: String
|
||||||
|
let throughWeek: Int
|
||||||
|
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
@Query private var allDecks: [CourseDeck]
|
||||||
|
|
||||||
|
@State private var cardsByWeek: [Int: [VocabCard]] = [:]
|
||||||
|
@State private var checkpointResults: [TestResult] = []
|
||||||
|
@State private var selectedCount = 25
|
||||||
|
|
||||||
|
private let questionCounts = [25, 50, 100]
|
||||||
|
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
|
private var totalAvailable: Int {
|
||||||
|
cardsByWeek.values.reduce(0) { $0 + $1.count }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sample evenly across weeks, then fill remainder round-robin.
|
||||||
|
private var sampledCards: [VocabCard] {
|
||||||
|
let weekNumbers = cardsByWeek.keys.sorted()
|
||||||
|
guard !weekNumbers.isEmpty else { return [] }
|
||||||
|
|
||||||
|
let target = min(selectedCount, totalAvailable)
|
||||||
|
let perWeek = target / weekNumbers.count
|
||||||
|
var remainder = target - (perWeek * weekNumbers.count)
|
||||||
|
|
||||||
|
var result: [VocabCard] = []
|
||||||
|
for week in weekNumbers {
|
||||||
|
guard let pool = cardsByWeek[week] else { continue }
|
||||||
|
let shuffled = pool.shuffled()
|
||||||
|
var take = min(perWeek, shuffled.count)
|
||||||
|
// Distribute remainder one extra card per week until exhausted
|
||||||
|
if remainder > 0 && take < shuffled.count {
|
||||||
|
take += 1
|
||||||
|
remainder -= 1
|
||||||
|
}
|
||||||
|
result.append(contentsOf: shuffled.prefix(take))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If some weeks had fewer cards than perWeek, fill from weeks with surplus
|
||||||
|
if result.count < target {
|
||||||
|
let used = Set(result.map { ObjectIdentifier($0) })
|
||||||
|
var extras: [VocabCard] = []
|
||||||
|
for week in weekNumbers {
|
||||||
|
guard let pool = cardsByWeek[week] else { continue }
|
||||||
|
extras.append(contentsOf: pool.filter { !used.contains(ObjectIdentifier($0)) })
|
||||||
|
}
|
||||||
|
extras.shuffle()
|
||||||
|
result.append(contentsOf: extras.prefix(target - result.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.shuffled()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: "checkmark.seal")
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text("Checkpoint Exam")
|
||||||
|
.font(.largeTitle.weight(.bold))
|
||||||
|
Text("Weeks 1–\(throughWeek)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
// Question count picker
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("Questions")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(questionCounts, id: \.self) { count in
|
||||||
|
let available = count <= totalAvailable
|
||||||
|
Button {
|
||||||
|
withAnimation { selectedCount = count }
|
||||||
|
} label: {
|
||||||
|
Text("\(count)")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(selectedCount == count ? .blue : .secondary)
|
||||||
|
.disabled(!available)
|
||||||
|
.opacity(available ? 1 : 0.4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Label("Multiple choice", systemImage: "list.bullet")
|
||||||
|
Label("Cumulative vocabulary", systemImage: "books.vertical")
|
||||||
|
Label("\(totalAvailable) words available", systemImage: "character.book.closed")
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if cardsByWeek.isEmpty {
|
||||||
|
ProgressView("Loading vocabulary...")
|
||||||
|
.padding(.top, 20)
|
||||||
|
} else {
|
||||||
|
NavigationLink {
|
||||||
|
CourseQuizView(
|
||||||
|
cards: sampledCards,
|
||||||
|
quizType: .checkpoint,
|
||||||
|
courseName: courseName,
|
||||||
|
weekNumber: throughWeek,
|
||||||
|
isFocusMode: false
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
Text("Begin Exam")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.blue)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score History
|
||||||
|
if !checkpointResults.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Score History")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(Array(checkpointResults.prefix(10).enumerated()), id: \.offset) { _, result in
|
||||||
|
HStack {
|
||||||
|
Text(result.dateTaken.formatted(date: .abbreviated, time: .shortened))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("\(result.scorePercent)%")
|
||||||
|
.font(.title3.weight(.bold))
|
||||||
|
.foregroundStyle(scoreColor(result.scorePercent))
|
||||||
|
Text("\(result.correctCount)/\(result.totalQuestions)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
Divider().padding(.leading, 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
.adaptiveContainer()
|
||||||
|
}
|
||||||
|
.navigationTitle("Checkpoint")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear {
|
||||||
|
loadCumulativeCards()
|
||||||
|
loadResults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadCumulativeCards() {
|
||||||
|
let course = courseName
|
||||||
|
let maxWeek = throughWeek
|
||||||
|
let weekDecks = allDecks.filter {
|
||||||
|
$0.courseName == course && $0.weekNumber <= maxWeek && !$0.isReversed
|
||||||
|
}
|
||||||
|
var grouped: [Int: [VocabCard]] = [:]
|
||||||
|
for deck in weekDecks {
|
||||||
|
let deckId = deck.id
|
||||||
|
let descriptor = FetchDescriptor<VocabCard>(
|
||||||
|
predicate: #Predicate<VocabCard> { $0.deckId == deckId }
|
||||||
|
)
|
||||||
|
if let fetched = try? modelContext.fetch(descriptor) {
|
||||||
|
grouped[deck.weekNumber, default: []].append(contentsOf: fetched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cardsByWeek = grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadResults() {
|
||||||
|
let course = courseName
|
||||||
|
let week = throughWeek
|
||||||
|
let checkpointType = QuizType.checkpoint.rawValue
|
||||||
|
let descriptor = FetchDescriptor<TestResult>(
|
||||||
|
predicate: #Predicate<TestResult> {
|
||||||
|
$0.courseName == course && $0.weekNumber == week && $0.quizType == checkpointType
|
||||||
|
},
|
||||||
|
sortBy: [SortDescriptor(\TestResult.dateTaken, order: .reverse)]
|
||||||
|
)
|
||||||
|
checkpointResults = (try? cloudModelContext.fetch(descriptor)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scoreColor(_ percent: Int) -> Color {
|
||||||
|
if percent >= 90 { return .green }
|
||||||
|
if percent >= 70 { return .orange }
|
||||||
|
return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
CheckpointExamView(courseName: "LanGo Spanish | Beginner I", throughWeek: 3)
|
||||||
|
}
|
||||||
|
.modelContainer(for: [TestResult.self, CourseDeck.self, VocabCard.self], inMemory: true)
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ struct CourseQuizView: View {
|
|||||||
@State private var correctCount = 0
|
@State private var correctCount = 0
|
||||||
@State private var missedItems: [MissedCourseItem] = []
|
@State private var missedItems: [MissedCourseItem] = []
|
||||||
@State private var isAdvancing = false
|
@State private var isAdvancing = false
|
||||||
|
@State private var sentenceQuestion: SentenceQuizEngine.Question?
|
||||||
|
|
||||||
// Per-question state
|
// Per-question state
|
||||||
@State private var userAnswer = ""
|
@State private var userAnswer = ""
|
||||||
@@ -61,25 +62,29 @@ struct CourseQuizView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
// Prompt
|
// Prompt
|
||||||
VStack(spacing: 8) {
|
if quizType.isCompleteSentence, let question = sentenceQuestion {
|
||||||
Text(quizType.promptLanguage)
|
sentencePrompt(question: question)
|
||||||
.font(.caption.weight(.medium))
|
} else {
|
||||||
.foregroundStyle(.secondary)
|
VStack(spacing: 8) {
|
||||||
.textCase(.uppercase)
|
Text(quizType.promptLanguage)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
Text(quizType.prompt(for: card))
|
Text(quizType.prompt(for: card))
|
||||||
.font(.title.weight(.bold))
|
.font(.title.weight(.bold))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
if quizType.promptLanguage == "Spanish" {
|
if quizType.promptLanguage == "Spanish" {
|
||||||
Button { speechService.speak(card.front) } label: {
|
Button { speechService.speak(card.front) } label: {
|
||||||
Image(systemName: "speaker.wave.2")
|
Image(systemName: "speaker.wave.2")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
|
}
|
||||||
|
.tint(.secondary)
|
||||||
}
|
}
|
||||||
.tint(.secondary)
|
|
||||||
}
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
|
||||||
|
|
||||||
// Answer area
|
// Answer area
|
||||||
if quizType.isMultipleChoice {
|
if quizType.isMultipleChoice {
|
||||||
@@ -99,7 +104,7 @@ struct CourseQuizView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.adaptiveContainer()
|
.adaptiveContainer()
|
||||||
}
|
}
|
||||||
.navigationTitle(isFocusMode ? "Focus Area" : "Week \(weekNumber) Test")
|
.navigationTitle(isFocusMode ? "Focus Area" : quizType == .checkpoint ? "Checkpoint" : "Week \(weekNumber) Test")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
@@ -112,15 +117,48 @@ struct CourseQuizView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
shuffledCards = cards.shuffled()
|
let pool: [VocabCard]
|
||||||
|
if quizType.isCompleteSentence {
|
||||||
|
pool = cards.filter { SentenceQuizEngine.hasValidSentence(for: $0) }
|
||||||
|
} else {
|
||||||
|
pool = cards
|
||||||
|
}
|
||||||
|
shuffledCards = pool.shuffled()
|
||||||
prepareQuestion()
|
prepareQuestion()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Complete the Sentence
|
||||||
|
|
||||||
|
private func sentencePrompt(question: SentenceQuizEngine.Question) -> some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("Complete the Sentence")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
Text(question.displayTemplate)
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
if !question.sentenceEN.isEmpty {
|
||||||
|
Text(question.sentenceEN)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Multiple Choice
|
// MARK: - Multiple Choice
|
||||||
|
|
||||||
private func multipleChoiceArea(card: VocabCard) -> some View {
|
private func multipleChoiceArea(card: VocabCard) -> some View {
|
||||||
VStack(spacing: 10) {
|
let correct = correctAnswer(for: card)
|
||||||
|
return VStack(spacing: 10) {
|
||||||
ForEach(Array(options.enumerated()), id: \.offset) { index, option in
|
ForEach(Array(options.enumerated()), id: \.offset) { index, option in
|
||||||
Button {
|
Button {
|
||||||
guard !isAnswered else { return }
|
guard !isAnswered else { return }
|
||||||
@@ -132,7 +170,7 @@ struct CourseQuizView: View {
|
|||||||
.font(.body.weight(.medium))
|
.font(.body.weight(.medium))
|
||||||
Spacer()
|
Spacer()
|
||||||
if isAnswered {
|
if isAnswered {
|
||||||
if option == quizType.answer(for: card) {
|
if option.caseInsensitiveCompare(correct) == .orderedSame {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
} else if index == selectedOption {
|
} else if index == selectedOption {
|
||||||
@@ -147,7 +185,7 @@ struct CourseQuizView: View {
|
|||||||
}
|
}
|
||||||
.tint(mcTint(index: index, option: option, card: card))
|
.tint(mcTint(index: index, option: option, card: card))
|
||||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
.opacity(isAnswered && option != quizType.answer(for: card) && index != selectedOption ? 0.4 : 1)
|
.opacity(isAnswered && option.caseInsensitiveCompare(correct) != .orderedSame && index != selectedOption ? 0.4 : 1)
|
||||||
.disabled(isAnswered)
|
.disabled(isAnswered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,11 +194,18 @@ struct CourseQuizView: View {
|
|||||||
|
|
||||||
private func mcTint(index: Int, option: String, card: VocabCard) -> Color {
|
private func mcTint(index: Int, option: String, card: VocabCard) -> Color {
|
||||||
guard isAnswered else { return .primary }
|
guard isAnswered else { return .primary }
|
||||||
if option == quizType.answer(for: card) { return .green }
|
if option.caseInsensitiveCompare(correctAnswer(for: card)) == .orderedSame { return .green }
|
||||||
if index == selectedOption { return .red }
|
if index == selectedOption { return .red }
|
||||||
return .secondary
|
return .secondary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func correctAnswer(for card: VocabCard) -> String {
|
||||||
|
if quizType.isCompleteSentence, let blank = sentenceQuestion?.blankWord {
|
||||||
|
return blank
|
||||||
|
}
|
||||||
|
return quizType.answer(for: card)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Handwriting
|
// MARK: - Handwriting
|
||||||
|
|
||||||
private func handwritingArea(card: VocabCard) -> some View {
|
private func handwritingArea(card: VocabCard) -> some View {
|
||||||
@@ -418,6 +463,12 @@ struct CourseQuizView: View {
|
|||||||
selectedOption = nil
|
selectedOption = nil
|
||||||
userAnswer = ""
|
userAnswer = ""
|
||||||
|
|
||||||
|
if quizType.isCompleteSentence {
|
||||||
|
sentenceQuestion = SentenceQuizEngine.buildQuestion(for: card)
|
||||||
|
} else {
|
||||||
|
sentenceQuestion = nil
|
||||||
|
}
|
||||||
|
|
||||||
if quizType.isMultipleChoice {
|
if quizType.isMultipleChoice {
|
||||||
options = generateOptions(for: card)
|
options = generateOptions(for: card)
|
||||||
} else {
|
} else {
|
||||||
@@ -436,6 +487,7 @@ struct CourseQuizView: View {
|
|||||||
hwDrawing = PKDrawing()
|
hwDrawing = PKDrawing()
|
||||||
hwRecognizedText = ""
|
hwRecognizedText = ""
|
||||||
isRecognizing = false
|
isRecognizing = false
|
||||||
|
sentenceQuestion = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func submitHandwriting(card: VocabCard) {
|
private func submitHandwriting(card: VocabCard) {
|
||||||
@@ -453,11 +505,11 @@ struct CourseQuizView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func generateOptions(for card: VocabCard) -> [String] {
|
private func generateOptions(for card: VocabCard) -> [String] {
|
||||||
let correct = quizType.answer(for: card)
|
let correct = correctAnswer(for: card)
|
||||||
var distractors: [String] = []
|
var distractors: [String] = []
|
||||||
var seen: Set<String> = [correct.lowercased()]
|
var seen: Set<String> = [correct.lowercased()]
|
||||||
|
|
||||||
// Pull distractors from all cards in the set
|
// Pull distractors from all cards in the set using each card's own front
|
||||||
for other in shuffledCards.shuffled() {
|
for other in shuffledCards.shuffled() {
|
||||||
let ans = quizType.answer(for: other)
|
let ans = quizType.answer(for: other)
|
||||||
let lower = ans.lowercased()
|
let lower = ans.lowercased()
|
||||||
@@ -474,7 +526,7 @@ struct CourseQuizView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func checkMCAnswer(_ selected: String, card: VocabCard) {
|
private func checkMCAnswer(_ selected: String, card: VocabCard) {
|
||||||
let correct = quizType.answer(for: card)
|
let correct = correctAnswer(for: card)
|
||||||
isCorrect = selected.compare(correct, options: .caseInsensitive, locale: Locale(identifier: "es")) == .orderedSame
|
isCorrect = selected.compare(correct, options: .caseInsensitive, locale: Locale(identifier: "es")) == .orderedSame
|
||||||
recordAnswer(card: card)
|
recordAnswer(card: card)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ import SwiftData
|
|||||||
struct CourseView: View {
|
struct CourseView: View {
|
||||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
@Query(sort: \CourseDeck.weekNumber) private var decks: [CourseDeck]
|
@Query(sort: \CourseDeck.weekNumber) private var decks: [CourseDeck]
|
||||||
|
@Query(sort: \TextbookChapter.number) private var textbookChapters: [TextbookChapter]
|
||||||
@AppStorage("selectedCourse") private var selectedCourse: String?
|
@AppStorage("selectedCourse") private var selectedCourse: String?
|
||||||
@State private var testResults: [TestResult] = []
|
@State private var testResults: [TestResult] = []
|
||||||
|
|
||||||
|
private var textbookCourses: [String] {
|
||||||
|
Array(Set(textbookChapters.map(\.courseName))).sorted()
|
||||||
|
}
|
||||||
|
|
||||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
private var courseNames: [String] {
|
private var courseNames: [String] {
|
||||||
@@ -33,11 +38,20 @@ struct CourseView: View {
|
|||||||
private func bestScore(for week: Int) -> Int? {
|
private func bestScore(for week: Int) -> Int? {
|
||||||
let results = testResults.filter {
|
let results = testResults.filter {
|
||||||
$0.courseName == activeCourse && $0.weekNumber == week
|
$0.courseName == activeCourse && $0.weekNumber == week
|
||||||
|
&& $0.quizType != QuizType.checkpoint.rawValue
|
||||||
}
|
}
|
||||||
guard !results.isEmpty else { return nil }
|
guard !results.isEmpty else { return nil }
|
||||||
return results.map(\.scorePercent).max()
|
return results.map(\.scorePercent).max()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func bestCheckpointScore(for week: Int) -> Int? {
|
||||||
|
let results = testResults.filter {
|
||||||
|
$0.courseName == activeCourse && $0.weekNumber == week
|
||||||
|
&& $0.quizType == QuizType.checkpoint.rawValue
|
||||||
|
}
|
||||||
|
return results.map(\.scorePercent).max()
|
||||||
|
}
|
||||||
|
|
||||||
private func shortName(_ full: String) -> String {
|
private func shortName(_ full: String) -> String {
|
||||||
full.replacingOccurrences(of: "LanGo Spanish | ", with: "")
|
full.replacingOccurrences(of: "LanGo Spanish | ", with: "")
|
||||||
.replacingOccurrences(of: "LanGo Spanish ", with: "")
|
.replacingOccurrences(of: "LanGo Spanish ", with: "")
|
||||||
@@ -53,6 +67,32 @@ struct CourseView: View {
|
|||||||
description: Text("Course data is loading...")
|
description: Text("Course data is loading...")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
// Textbook entry (shown above course picker when available)
|
||||||
|
if !textbookCourses.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(textbookCourses, id: \.self) { name in
|
||||||
|
NavigationLink(value: TextbookDestination(courseName: name)) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "book.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.indigo)
|
||||||
|
.frame(width: 32)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(name)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text("Read chapters, do exercises")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Textbook")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Course picker
|
// Course picker
|
||||||
if courseNames.count > 1 {
|
if courseNames.count > 1 {
|
||||||
Section {
|
Section {
|
||||||
@@ -103,6 +143,32 @@ struct CourseView: View {
|
|||||||
DeckRowView(deck: deck)
|
DeckRowView(deck: deck)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checkpoint exam
|
||||||
|
NavigationLink(value: CheckpointDestination(courseName: activeCourse, throughWeek: week)) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "checkmark.seal")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.frame(width: 32)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Checkpoint Exam")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text("Cumulative review: Weeks 1–\(week)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let best = bestCheckpointScore(for: week) {
|
||||||
|
Text("\(best)%")
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.foregroundStyle(best >= 90 ? .green : best >= 70 ? .orange : .red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Week \(week)")
|
Text("Week \(week)")
|
||||||
}
|
}
|
||||||
@@ -117,6 +183,27 @@ struct CourseView: View {
|
|||||||
.navigationDestination(for: WeekTestDestination.self) { dest in
|
.navigationDestination(for: WeekTestDestination.self) { dest in
|
||||||
WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber)
|
WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber)
|
||||||
}
|
}
|
||||||
|
.navigationDestination(for: CheckpointDestination.self) { dest in
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +219,15 @@ struct WeekTestDestination: Hashable {
|
|||||||
let weekNumber: Int
|
let weekNumber: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CheckpointDestination: Hashable {
|
||||||
|
let courseName: String
|
||||||
|
let throughWeek: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TextbookDestination: Hashable {
|
||||||
|
let courseName: String
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Deck Row
|
// MARK: - Deck Row
|
||||||
|
|
||||||
private struct DeckRowView: View {
|
private struct DeckRowView: View {
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ struct DeckStudyView: View {
|
|||||||
@State private var isStudying = false
|
@State private var isStudying = false
|
||||||
@State private var speechService = SpeechService()
|
@State private var speechService = SpeechService()
|
||||||
@State private var deckCards: [VocabCard] = []
|
@State private var deckCards: [VocabCard] = []
|
||||||
|
@State private var expandedConjugations: Set<String> = []
|
||||||
|
|
||||||
|
private var isStemChangingDeck: Bool {
|
||||||
|
deck.title.localizedCaseInsensitiveContains("stem changing")
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
cardListView
|
cardListView
|
||||||
@@ -19,7 +24,8 @@ struct DeckStudyView: View {
|
|||||||
VocabFlashcardView(
|
VocabFlashcardView(
|
||||||
cards: deckCards.shuffled(),
|
cards: deckCards.shuffled(),
|
||||||
speechService: speechService,
|
speechService: speechService,
|
||||||
onDone: { isStudying = false }
|
onDone: { isStudying = false },
|
||||||
|
deckTitle: deck.title
|
||||||
)
|
)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
@@ -30,6 +36,24 @@ struct DeckStudyView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reversed stem-change decks have `front` as English, so prefer the
|
||||||
|
/// Spanish side when the card is stored that way. Strip parenthetical
|
||||||
|
/// notes and the reflexive `-se` ending for verb-table lookup.
|
||||||
|
private func inferInfinitive(card: VocabCard) -> String {
|
||||||
|
let raw: String
|
||||||
|
if deck.isReversed {
|
||||||
|
raw = card.back
|
||||||
|
} else {
|
||||||
|
raw = card.front
|
||||||
|
}
|
||||||
|
var t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let paren = t.firstIndex(of: "(") {
|
||||||
|
t = String(t[..<paren]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
if t.hasSuffix("se") && t.count > 4 { t = String(t.dropLast(2)) }
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
private func loadCards() {
|
private func loadCards() {
|
||||||
let deckId = deck.id
|
let deckId = deck.id
|
||||||
let descriptor = FetchDescriptor<VocabCard>(
|
let descriptor = FetchDescriptor<VocabCard>(
|
||||||
@@ -107,6 +131,36 @@ struct DeckStudyView: View {
|
|||||||
.multilineTextAlignment(.trailing)
|
.multilineTextAlignment(.trailing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stem-change conjugation toggle
|
||||||
|
if isStemChangingDeck {
|
||||||
|
let verb = inferInfinitive(card: card)
|
||||||
|
let isOpen = expandedConjugations.contains(verb)
|
||||||
|
Button {
|
||||||
|
withAnimation(.smooth) {
|
||||||
|
if isOpen {
|
||||||
|
expandedConjugations.remove(verb)
|
||||||
|
} else {
|
||||||
|
expandedConjugations.insert(verb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
isOpen ? "Hide conjugation" : "Show conjugation",
|
||||||
|
systemImage: isOpen ? "chevron.up" : "chevron.down"
|
||||||
|
)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.tint(.blue)
|
||||||
|
.padding(.leading, 42)
|
||||||
|
|
||||||
|
if isOpen {
|
||||||
|
StemChangeConjugationView(infinitive: verb)
|
||||||
|
.padding(.leading, 42)
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Example sentences
|
// Example sentences
|
||||||
if !card.examplesES.isEmpty {
|
if !card.examplesES.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
|||||||
97
Conjuga/Conjuga/Views/Course/StemChangeConjugationView.swift
Normal file
97
Conjuga/Conjuga/Views/Course/StemChangeConjugationView.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
121
Conjuga/Conjuga/Views/Course/TextbookChapterListView.swift
Normal file
121
Conjuga/Conjuga/Views/Course/TextbookChapterListView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
209
Conjuga/Conjuga/Views/Course/TextbookChapterView.swift
Normal file
209
Conjuga/Conjuga/Views/Course/TextbookChapterView.swift
Normal file
@@ -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
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
360
Conjuga/Conjuga/Views/Course/TextbookExerciseView.swift
Normal file
360
Conjuga/Conjuga/Views/Course/TextbookExerciseView.swift
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
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
|
||||||
|
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,19 @@ struct VocabFlashcardView: View {
|
|||||||
let cards: [VocabCard]
|
let cards: [VocabCard]
|
||||||
let speechService: SpeechService
|
let speechService: SpeechService
|
||||||
let onDone: () -> Void
|
let onDone: () -> Void
|
||||||
|
/// Optional deck context — when present and the title indicates a stem-
|
||||||
|
/// changing deck, each card gets an inline conjugation toggle.
|
||||||
|
var deckTitle: String? = nil
|
||||||
|
|
||||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
@State private var currentIndex = 0
|
@State private var currentIndex = 0
|
||||||
@State private var isRevealed = false
|
@State private var isRevealed = false
|
||||||
@State private var sessionCorrect = 0
|
@State private var sessionCorrect = 0
|
||||||
|
@State private var showConjugation = false
|
||||||
|
|
||||||
|
private var isStemChangingDeck: Bool {
|
||||||
|
(deckTitle ?? "").localizedCaseInsensitiveContains("stem changing")
|
||||||
|
}
|
||||||
|
|
||||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
@@ -61,6 +69,25 @@ struct VocabFlashcardView: View {
|
|||||||
.padding(12)
|
.padding(12)
|
||||||
}
|
}
|
||||||
.glassEffect(in: .circle)
|
.glassEffect(in: .circle)
|
||||||
|
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.transition(.blurReplace)
|
.transition(.blurReplace)
|
||||||
} else {
|
} else {
|
||||||
@@ -111,6 +138,7 @@ struct VocabFlashcardView: View {
|
|||||||
guard currentIndex > 0 else { return }
|
guard currentIndex > 0 else { return }
|
||||||
withAnimation(.smooth) {
|
withAnimation(.smooth) {
|
||||||
isRevealed = false
|
isRevealed = false
|
||||||
|
showConjugation = false
|
||||||
currentIndex -= 1
|
currentIndex -= 1
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
@@ -125,6 +153,7 @@ struct VocabFlashcardView: View {
|
|||||||
Button {
|
Button {
|
||||||
withAnimation(.smooth) {
|
withAnimation(.smooth) {
|
||||||
isRevealed = false
|
isRevealed = false
|
||||||
|
showConjugation = false
|
||||||
currentIndex += 1
|
currentIndex += 1
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
@@ -189,9 +218,25 @@ struct VocabFlashcardView: View {
|
|||||||
// Next card
|
// Next card
|
||||||
withAnimation(.smooth) {
|
withAnimation(.smooth) {
|
||||||
isRevealed = false
|
isRevealed = false
|
||||||
|
showConjugation = false
|
||||||
currentIndex += 1
|
currentIndex += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Card fronts may be plain infinitives ("cerrar") or, in reversed decks,
|
||||||
|
/// stored as English. Strip any reflexive-se suffix or parenthetical notes
|
||||||
|
/// to improve the verb lookup hit rate.
|
||||||
|
private func stripToInfinitive(_ s: String) -> String {
|
||||||
|
var t = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let paren = t.firstIndex(of: "(") {
|
||||||
|
t = String(t[..<paren]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
if t.hasSuffix("se") && t.count > 4 {
|
||||||
|
// "acostarse" → "acostar" for verb lookup
|
||||||
|
t = String(t.dropLast(2))
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ struct WeekTestView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
ForEach(QuizType.allCases) { type in
|
ForEach(QuizType.weeklyQuizTypes, id: \.self) { type in
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
CourseQuizView(
|
CourseQuizView(
|
||||||
cards: weekCards,
|
cards: weekCards,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Charts
|
|||||||
|
|
||||||
struct DashboardView: View {
|
struct DashboardView: View {
|
||||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
@Environment(StudyTimerService.self) private var studyTimer
|
||||||
@State private var userProgress: UserProgress?
|
@State private var userProgress: UserProgress?
|
||||||
@State private var dailyLogs: [DailyLog] = []
|
@State private var dailyLogs: [DailyLog] = []
|
||||||
@State private var testResults: [TestResult] = []
|
@State private var testResults: [TestResult] = []
|
||||||
@@ -19,8 +20,17 @@ struct DashboardView: View {
|
|||||||
// Summary stats
|
// Summary stats
|
||||||
statsGrid
|
statsGrid
|
||||||
|
|
||||||
// Streak calendar
|
// Study time + Activity — side by side on iPad, stacked on iPhone
|
||||||
streakCalendar
|
ViewThatFits(in: .horizontal) {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
studyTimeCard
|
||||||
|
streakCalendar
|
||||||
|
}
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
studyTimeCard
|
||||||
|
streakCalendar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Accuracy chart
|
// Accuracy chart
|
||||||
accuracyChart
|
accuracyChart
|
||||||
@@ -71,6 +81,57 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Study Time Card
|
||||||
|
|
||||||
|
private var studyTimeCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Study Time")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
let todaySeconds = todayStudySeconds + studyTimer.currentSessionSeconds
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(formatStudyTime(todaySeconds))
|
||||||
|
.font(.title3.bold().monospacedDigit())
|
||||||
|
Text("Today")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(formatStudyTime(totalStudySeconds))
|
||||||
|
.font(.title3.bold().monospacedDigit())
|
||||||
|
Text("Total")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
if weeklyStudyData.allSatisfy({ $0.minutes == 0 }) {
|
||||||
|
Text("Start studying to see your time")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 80)
|
||||||
|
} else {
|
||||||
|
Chart(weeklyStudyData) { day in
|
||||||
|
BarMark(
|
||||||
|
x: .value("Day", day.label),
|
||||||
|
y: .value("Minutes", day.minutes)
|
||||||
|
)
|
||||||
|
.foregroundStyle(.mint.gradient)
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
.chartYAxis(.hidden)
|
||||||
|
.frame(height: 80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Streak Calendar
|
// MARK: - Streak Calendar
|
||||||
|
|
||||||
private var streakCalendar: some View {
|
private var streakCalendar: some View {
|
||||||
@@ -81,6 +142,7 @@ struct DashboardView: View {
|
|||||||
StreakCalendarView(dailyLogs: dailyLogs)
|
StreakCalendarView(dailyLogs: dailyLogs)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +229,48 @@ struct DashboardView: View {
|
|||||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Study Time Computed
|
||||||
|
|
||||||
|
private var todayStudySeconds: Int {
|
||||||
|
let today = DailyLog.todayString()
|
||||||
|
return dailyLogs.first { $0.dateString == today }?.studySeconds ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalStudySeconds: Int {
|
||||||
|
dailyLogs.reduce(0) { $0 + $1.studySeconds } + studyTimer.currentSessionSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weeklyStudySeconds: Int {
|
||||||
|
weeklyStudyData.reduce(0) { $0 + $1.minutes } * 60
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weeklyStudyData: [StudyDay] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
return (0..<7).reversed().map { daysAgo in
|
||||||
|
let date = calendar.date(byAdding: .day, value: -daysAgo, to: today)!
|
||||||
|
let dateStr = DailyLog.dateString(from: date)
|
||||||
|
let seconds = dailyLogs.first { $0.dateString == dateStr }?.studySeconds ?? 0
|
||||||
|
let dayLabel = daysAgo == 0 ? "Today" : Self.shortDayFormatter.string(from: date)
|
||||||
|
return StudyDay(label: dayLabel, minutes: seconds / 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let shortDayFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "EEE"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func formatStudyTime(_ totalSeconds: Int) -> String {
|
||||||
|
let hours = totalSeconds / 3600
|
||||||
|
let minutes = (totalSeconds % 3600) / 60
|
||||||
|
if hours > 0 {
|
||||||
|
return "\(hours)h \(minutes)m"
|
||||||
|
}
|
||||||
|
return "\(minutes)m"
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Computed
|
// MARK: - Computed
|
||||||
|
|
||||||
private var recentLogs: [DailyLog] {
|
private var recentLogs: [DailyLog] {
|
||||||
@@ -222,7 +326,14 @@ private struct StatCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct StudyDay: Identifiable {
|
||||||
|
let label: String
|
||||||
|
let minutes: Int
|
||||||
|
var id: String { label }
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
DashboardView()
|
DashboardView()
|
||||||
|
.environment(StudyTimerService())
|
||||||
.modelContainer(for: [UserProgress.self, DailyLog.self, TestResult.self, ReviewCard.self], inMemory: true)
|
.modelContainer(for: [UserProgress.self, DailyLog.self, TestResult.self, ReviewCard.self], inMemory: true)
|
||||||
}
|
}
|
||||||
|
|||||||
164
Conjuga/Conjuga/Views/Guide/GrammarExerciseView.swift
Normal file
164
Conjuga/Conjuga/Views/Guide/GrammarExerciseView.swift
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GrammarExerciseView: View {
|
||||||
|
let noteId: String
|
||||||
|
let noteTitle: String
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var exercises: [GrammarExercise] = []
|
||||||
|
@State private var currentIndex = 0
|
||||||
|
@State private var selectedOption: Int?
|
||||||
|
@State private var correctCount = 0
|
||||||
|
@State private var isFinished = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
if isFinished {
|
||||||
|
finishedView
|
||||||
|
} else if let ex = exercises[safe: currentIndex] {
|
||||||
|
exerciseView(ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer(maxWidth: 600)
|
||||||
|
.navigationTitle("Practice: \(noteTitle)")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear { exercises = Array(GrammarExercise.exercises(for: noteId).shuffled().prefix(10)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func exerciseView(_ ex: GrammarExercise) -> some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Text("\(currentIndex + 1) / \(exercises.count)")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
ProgressView(value: Double(currentIndex), total: Double(exercises.count))
|
||||||
|
.tint(.purple)
|
||||||
|
|
||||||
|
// Prompt
|
||||||
|
Text(ex.prompt)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
// Sentence
|
||||||
|
Text(highlightBlank(ex.sentence))
|
||||||
|
.font(.title3)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
|
// Options
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(Array(ex.options.enumerated()), id: \.offset) { index, option in
|
||||||
|
Button {
|
||||||
|
guard selectedOption == nil else { return }
|
||||||
|
selectedOption = index
|
||||||
|
if option == ex.correctAnswer { correctCount += 1 }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(option)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
Spacer()
|
||||||
|
if let selected = selectedOption {
|
||||||
|
if option == ex.correctAnswer {
|
||||||
|
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||||
|
} else if index == selected {
|
||||||
|
Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(optionBG(index: index, correct: ex.correctAnswer, options: ex.options), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explanation after answer
|
||||||
|
if selectedOption != nil {
|
||||||
|
Text(ex.explanation)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if selectedOption != nil {
|
||||||
|
Button {
|
||||||
|
if currentIndex + 1 < exercises.count {
|
||||||
|
currentIndex += 1
|
||||||
|
selectedOption = nil
|
||||||
|
} else {
|
||||||
|
withAnimation { isFinished = true }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(currentIndex + 1 < exercises.count ? "Next" : "See Results")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.purple)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var finishedView: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: correctCount == exercises.count ? "star.fill" : "checkmark.circle")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(correctCount == exercises.count ? .yellow : .purple)
|
||||||
|
|
||||||
|
Text("\(correctCount) / \(exercises.count)")
|
||||||
|
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
||||||
|
|
||||||
|
Text(correctCount == exercises.count ? "Perfect!" : "Keep reviewing this topic.")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Text("Done")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.purple)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func highlightBlank(_ text: String) -> AttributedString {
|
||||||
|
var result = AttributedString(text)
|
||||||
|
if let range = result.range(of: "_____") {
|
||||||
|
result[range].foregroundColor = .purple
|
||||||
|
result[range].font = .title3.bold()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func optionBG(index: Int, correct: String, options: [String]) -> some ShapeStyle {
|
||||||
|
guard let selected = selectedOption else { return AnyShapeStyle(.fill.quaternary) }
|
||||||
|
if options[index] == correct { return AnyShapeStyle(.green.opacity(0.15)) }
|
||||||
|
if index == selected { return AnyShapeStyle(.red.opacity(0.15)) }
|
||||||
|
return AnyShapeStyle(.fill.quaternary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Collection {
|
||||||
|
subscript(safe index: Index) -> Element? {
|
||||||
|
indices.contains(index) ? self[index] : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,9 +19,9 @@ struct GrammarNotesListView: View {
|
|||||||
@Binding var selectedNote: GrammarNote?
|
@Binding var selectedNote: GrammarNote?
|
||||||
|
|
||||||
private var groupedNotes: [(String, [GrammarNote])] {
|
private var groupedNotes: [(String, [GrammarNote])] {
|
||||||
let grouped = Dictionary(grouping: GrammarNote.allNotes, by: \.category)
|
let grouped = Dictionary(grouping: GrammarNote.allNotesIncludingGenerated, by: \.category)
|
||||||
var seen: [String] = []
|
var seen: [String] = []
|
||||||
for note in GrammarNote.allNotes {
|
for note in GrammarNote.allNotesIncludingGenerated {
|
||||||
if !seen.contains(note.category) {
|
if !seen.contains(note.category) {
|
||||||
seen.append(note.category)
|
seen.append(note.category)
|
||||||
}
|
}
|
||||||
@@ -67,6 +67,11 @@ private struct GrammarNoteRow: View {
|
|||||||
|
|
||||||
struct GrammarNoteDetailView: View {
|
struct GrammarNoteDetailView: View {
|
||||||
let note: GrammarNote
|
let note: GrammarNote
|
||||||
|
@Environment(YouTubeVideoStore.self) private var videoStore
|
||||||
|
|
||||||
|
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
|
||||||
|
videoStore.video(forGrammarNoteId: note.id)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -83,10 +88,26 @@ struct GrammarNoteDetailView: View {
|
|||||||
.background(.fill.tertiary, in: Capsule())
|
.background(.fill.tertiary, in: Capsule())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
videoSection
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// Parsed body
|
// Parsed body
|
||||||
FormattedGrammarBody(content: note.body)
|
FormattedGrammarBody(content: note.body)
|
||||||
|
|
||||||
|
// Practice button (if exercises exist for this note)
|
||||||
|
if !GrammarExercise.exercises(for: note.id).isEmpty {
|
||||||
|
NavigationLink {
|
||||||
|
GrammarExerciseView(noteId: note.id, noteTitle: note.title)
|
||||||
|
} label: {
|
||||||
|
Label("Practice This", systemImage: "pencil.and.list.clipboard")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.purple)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.adaptiveContainer(maxWidth: 800)
|
.adaptiveContainer(maxWidth: 800)
|
||||||
@@ -94,6 +115,20 @@ struct GrammarNoteDetailView: View {
|
|||||||
.navigationTitle(note.title)
|
.navigationTitle(note.title)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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: - Formatted Body
|
// MARK: - Formatted Body
|
||||||
@@ -101,40 +136,148 @@ struct GrammarNoteDetailView: View {
|
|||||||
private struct FormattedGrammarBody: View {
|
private struct FormattedGrammarBody: View {
|
||||||
let content: String
|
let content: String
|
||||||
|
|
||||||
private var lines: [GrammarLine] {
|
private var sections: [GrammarSection] {
|
||||||
GrammarLine.parse(content)
|
GrammarSection.group(GrammarLine.parse(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
ForEach(lines) { line in
|
ForEach(sections) { section in
|
||||||
switch line.kind {
|
if section.heading == nil {
|
||||||
case .paragraph(let text):
|
// Intro content before the first heading — no card
|
||||||
renderParagraph(text)
|
renderLines(section.lines)
|
||||||
case .spanishExample(let spanish):
|
} else {
|
||||||
Text(spanish)
|
// Headed section in a card
|
||||||
.font(.body.weight(.medium))
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
.italic()
|
Text(section.heading!)
|
||||||
.padding(.leading, 12)
|
.font(.headline)
|
||||||
case .examplePair(let spanish, let english):
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
renderLines(section.lines)
|
||||||
Text(spanish)
|
|
||||||
.font(.body.weight(.medium))
|
|
||||||
.italic()
|
|
||||||
Text(english)
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
.padding(.leading, 12)
|
.padding()
|
||||||
case .heading(let text):
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
Text(text)
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
.font(.headline)
|
|
||||||
.padding(.top, 6)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func renderLines(_ lines: [GrammarLine]) -> some View {
|
||||||
|
let blocks = ContentBlock.group(lines)
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
ForEach(blocks) { block in
|
||||||
|
switch block.kind {
|
||||||
|
case .standalone(let line):
|
||||||
|
renderSingleLine(line)
|
||||||
|
case .exampleCard(let header, let examples):
|
||||||
|
renderExampleCard(header: header, examples: examples)
|
||||||
|
case .suffixCard(let suffix, let description, let examples):
|
||||||
|
renderSuffixCard(suffix: suffix, description: description, examples: examples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func renderSingleLine(_ line: GrammarLine) -> some View {
|
||||||
|
switch line.kind {
|
||||||
|
case .paragraph(let text):
|
||||||
|
renderParagraph(text)
|
||||||
|
case .spanishExample(let spanish):
|
||||||
|
Text(spanish)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.italic()
|
||||||
|
.padding(.leading, 12)
|
||||||
|
case .examplePair(let spanish, let english):
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(spanish)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.italic()
|
||||||
|
Text(english)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.leading, 12)
|
||||||
|
case .heading, .suffixDef:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func renderExampleCard(header: GrammarLine?, examples: [GrammarLine]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if let header, case .paragraph(let text) = header.kind {
|
||||||
|
renderParagraph(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
ForEach(examples) { ex in
|
||||||
|
if case .examplePair(let spanish, let english) = ex.kind {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(spanish)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.italic()
|
||||||
|
Text(english)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else if case .spanishExample(let spanish) = ex.kind {
|
||||||
|
Text(spanish)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.italic()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func renderSuffixCard(suffix: String, description: String, examples: [GrammarLine]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
Text(suffix)
|
||||||
|
.font(.subheadline.weight(.bold).monospaced())
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(.tint.opacity(0.12), in: Capsule())
|
||||||
|
|
||||||
|
Text(description)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !examples.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(examples) { ex in
|
||||||
|
if case .examplePair(let spanish, let english) = ex.kind {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text(spanish)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.italic()
|
||||||
|
Text(" ")
|
||||||
|
Text(english)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else if case .spanishExample(let spanish) = ex.kind {
|
||||||
|
Text(spanish)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.italic()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func renderParagraph(_ text: String) -> some View {
|
private func renderParagraph(_ text: String) -> some View {
|
||||||
Text(parseInlineFormatting(text))
|
Text(parseInlineFormatting(text))
|
||||||
@@ -200,11 +343,20 @@ private struct GrammarLine: Identifiable {
|
|||||||
let id: Int
|
let id: Int
|
||||||
let kind: Kind
|
let kind: Kind
|
||||||
|
|
||||||
|
var isExample: Bool {
|
||||||
|
switch kind {
|
||||||
|
case .examplePair, .spanishExample: return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum Kind {
|
enum Kind {
|
||||||
case paragraph(String)
|
case paragraph(String)
|
||||||
case heading(String)
|
case heading(String)
|
||||||
case spanishExample(String)
|
case spanishExample(String)
|
||||||
case examplePair(spanish: String, english: String)
|
case examplePair(spanish: String, english: String)
|
||||||
|
/// A suffix definition line like `*-ito / -ita* — description`
|
||||||
|
case suffixDef(suffix: String, description: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func parse(_ body: String) -> [GrammarLine] {
|
static func parse(_ body: String) -> [GrammarLine] {
|
||||||
@@ -255,7 +407,13 @@ private struct GrammarLine: Identifiable {
|
|||||||
let englishPart = String(line[dashRange.upperBound...])
|
let englishPart = String(line[dashRange.upperBound...])
|
||||||
.replacingOccurrences(of: "*", with: "")
|
.replacingOccurrences(of: "*", with: "")
|
||||||
.trimmingCharacters(in: .whitespaces)
|
.trimmingCharacters(in: .whitespaces)
|
||||||
result.append(GrammarLine(id: result.count, kind: .examplePair(spanish: spanishPart, english: englishPart)))
|
|
||||||
|
// Detect suffix definitions: spanish part starts with "-"
|
||||||
|
if spanishPart.hasPrefix("-") {
|
||||||
|
result.append(GrammarLine(id: result.count, kind: .suffixDef(suffix: spanishPart, description: englishPart)))
|
||||||
|
} else {
|
||||||
|
result.append(GrammarLine(id: result.count, kind: .examplePair(spanish: spanishPart, english: englishPart)))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Just a Spanish example without translation
|
// Just a Spanish example without translation
|
||||||
let spanish = line.replacingOccurrences(of: "*", with: "")
|
let spanish = line.replacingOccurrences(of: "*", with: "")
|
||||||
@@ -279,6 +437,126 @@ private struct GrammarLine: Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Content Block Grouping
|
||||||
|
|
||||||
|
/// Groups paragraphs with their trailing examples into visual cards.
|
||||||
|
/// Also handles suffix definitions as a special card type.
|
||||||
|
private struct ContentBlock: Identifiable {
|
||||||
|
let id: Int
|
||||||
|
|
||||||
|
enum Kind {
|
||||||
|
/// A standalone line (paragraph with no examples, or orphaned example)
|
||||||
|
case standalone(GrammarLine)
|
||||||
|
/// A paragraph header followed by example pairs — rendered as a card
|
||||||
|
case exampleCard(header: GrammarLine?, examples: [GrammarLine])
|
||||||
|
/// A suffix definition with its examples — rendered as a pill card
|
||||||
|
case suffixCard(suffix: String, description: String, examples: [GrammarLine])
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind: Kind
|
||||||
|
|
||||||
|
static func group(_ lines: [GrammarLine]) -> [ContentBlock] {
|
||||||
|
var result: [ContentBlock] = []
|
||||||
|
var i = 0
|
||||||
|
|
||||||
|
while i < lines.count {
|
||||||
|
let line = lines[i]
|
||||||
|
|
||||||
|
// Suffix definition: collect trailing examples
|
||||||
|
if case .suffixDef(let suffix, let desc) = line.kind {
|
||||||
|
var examples: [GrammarLine] = []
|
||||||
|
var j = i + 1
|
||||||
|
while j < lines.count, lines[j].isExample {
|
||||||
|
examples.append(lines[j])
|
||||||
|
j += 1
|
||||||
|
}
|
||||||
|
result.append(ContentBlock(id: result.count, kind: .suffixCard(suffix: suffix, description: desc, examples: examples)))
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph followed by examples: group into a card
|
||||||
|
if case .paragraph = line.kind {
|
||||||
|
var j = i + 1
|
||||||
|
// Check if examples follow
|
||||||
|
if j < lines.count, lines[j].isExample {
|
||||||
|
var examples: [GrammarLine] = []
|
||||||
|
while j < lines.count, lines[j].isExample {
|
||||||
|
examples.append(lines[j])
|
||||||
|
j += 1
|
||||||
|
}
|
||||||
|
result.append(ContentBlock(id: result.count, kind: .exampleCard(header: line, examples: examples)))
|
||||||
|
i = j
|
||||||
|
} else {
|
||||||
|
// Standalone paragraph
|
||||||
|
result.append(ContentBlock(id: result.count, kind: .standalone(line)))
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orphaned examples (no preceding paragraph) — group into a card
|
||||||
|
if line.isExample {
|
||||||
|
var examples: [GrammarLine] = []
|
||||||
|
var j = i
|
||||||
|
while j < lines.count, lines[j].isExample {
|
||||||
|
examples.append(lines[j])
|
||||||
|
j += 1
|
||||||
|
}
|
||||||
|
result.append(ContentBlock(id: result.count, kind: .exampleCard(header: nil, examples: examples)))
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip headings (handled at section level)
|
||||||
|
if case .heading = line.kind {
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.append(ContentBlock(id: result.count, kind: .standalone(line)))
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Grammar Section Grouping
|
||||||
|
|
||||||
|
private struct GrammarSection: Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let heading: String?
|
||||||
|
let lines: [GrammarLine]
|
||||||
|
|
||||||
|
static func group(_ lines: [GrammarLine]) -> [GrammarSection] {
|
||||||
|
var sections: [GrammarSection] = []
|
||||||
|
var currentHeading: String? = nil
|
||||||
|
var currentLines: [GrammarLine] = []
|
||||||
|
var sectionIndex = 0
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
if case .heading(let text) = line.kind {
|
||||||
|
// Flush the previous section
|
||||||
|
if !currentLines.isEmpty || currentHeading != nil {
|
||||||
|
sections.append(GrammarSection(id: sectionIndex, heading: currentHeading, lines: currentLines))
|
||||||
|
sectionIndex += 1
|
||||||
|
currentLines = []
|
||||||
|
}
|
||||||
|
currentHeading = text
|
||||||
|
} else {
|
||||||
|
currentLines.append(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush final section
|
||||||
|
if !currentLines.isEmpty || currentHeading != nil {
|
||||||
|
sections.append(GrammarSection(id: sectionIndex, heading: currentHeading, lines: currentLines))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Hashable/Equatable conformance for NavigationLink
|
// MARK: - Hashable/Equatable conformance for NavigationLink
|
||||||
|
|
||||||
extension GrammarNote: Hashable, Equatable {
|
extension GrammarNote: Hashable, Equatable {
|
||||||
|
|||||||
@@ -100,12 +100,25 @@ private struct TenseRowView: View {
|
|||||||
let tense: TenseInfo
|
let tense: TenseInfo
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
HStack {
|
||||||
Text(tense.english)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
.font(.headline)
|
Text(tense.english)
|
||||||
Text(tense.spanish)
|
.font(.headline)
|
||||||
.font(.subheadline)
|
Text(tense.spanish)
|
||||||
.foregroundStyle(.secondary)
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if tense.isCore {
|
||||||
|
Text("Essential")
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(.orange.opacity(0.12), in: Capsule())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,11 +127,16 @@ private struct TenseRowView: View {
|
|||||||
|
|
||||||
struct GuideDetailView: View {
|
struct GuideDetailView: View {
|
||||||
let guide: TenseGuide
|
let guide: TenseGuide
|
||||||
|
@Environment(YouTubeVideoStore.self) private var videoStore
|
||||||
|
|
||||||
private var tenseInfo: TenseInfo? {
|
private var tenseInfo: TenseInfo? {
|
||||||
TenseInfo.find(guide.tenseId)
|
TenseInfo.find(guide.tenseId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
|
||||||
|
videoStore.video(forTenseId: guide.tenseId)
|
||||||
|
}
|
||||||
|
|
||||||
private var endingTable: TenseEndingTable? {
|
private var endingTable: TenseEndingTable? {
|
||||||
TenseEndingTable.find(guide.tenseId)
|
TenseEndingTable.find(guide.tenseId)
|
||||||
}
|
}
|
||||||
@@ -133,6 +151,9 @@ struct GuideDetailView: View {
|
|||||||
// Header
|
// Header
|
||||||
headerSection
|
headerSection
|
||||||
|
|
||||||
|
// Video section (Issue #21)
|
||||||
|
videoSection
|
||||||
|
|
||||||
// Conjugation ending table
|
// Conjugation ending table
|
||||||
if let table = endingTable {
|
if let table = endingTable {
|
||||||
conjugationTableSection(table)
|
conjugationTableSection(table)
|
||||||
@@ -167,6 +188,22 @@ struct GuideDetailView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Video (Issue #21)
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var videoSection: some View {
|
||||||
|
if let video = curatedVideo {
|
||||||
|
VideoActionsButtonRow(video: video)
|
||||||
|
} else {
|
||||||
|
Label("No video yet", systemImage: "play.slash")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.background(.fill.quinary, in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
|
|
||||||
private var headerSection: some View {
|
private var headerSection: some View {
|
||||||
@@ -437,14 +474,17 @@ struct GuideContent {
|
|||||||
var spanishLine: String?
|
var spanishLine: String?
|
||||||
|
|
||||||
func flushUsage() {
|
func flushUsage() {
|
||||||
if currentUsageNumber > 0 {
|
// Only emit a usage if it has at least one example. This suppresses
|
||||||
|
// the implicit "Usage 1" seeded when we enter an unnumbered
|
||||||
|
// *Usages* block but the body actually has numbered headers below.
|
||||||
|
if currentUsageNumber > 0 && !currentExamples.isEmpty {
|
||||||
usages.append(GuideUsage(
|
usages.append(GuideUsage(
|
||||||
number: currentUsageNumber,
|
number: currentUsageNumber,
|
||||||
title: currentUsageTitle,
|
title: currentUsageTitle,
|
||||||
examples: currentExamples
|
examples: currentExamples
|
||||||
))
|
))
|
||||||
currentExamples = []
|
|
||||||
}
|
}
|
||||||
|
currentExamples = []
|
||||||
}
|
}
|
||||||
|
|
||||||
for line in lines {
|
for line in lines {
|
||||||
@@ -478,6 +518,11 @@ struct GuideContent {
|
|||||||
let title = String(match.1).replacingOccurrences(of: "*", with: "")
|
let title = String(match.1).replacingOccurrences(of: "*", with: "")
|
||||||
if title.lowercased().contains("usage") {
|
if title.lowercased().contains("usage") {
|
||||||
inUsages = true
|
inUsages = true
|
||||||
|
// Seed an implicit Usage 1 so content that follows without a
|
||||||
|
// numbered "*1 Title*" header still gets captured. Any numbered
|
||||||
|
// header below will replace this via flushUsage().
|
||||||
|
currentUsageNumber = 1
|
||||||
|
currentUsageTitle = "Usage"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -550,4 +595,5 @@ struct GuideExample: Identifiable {
|
|||||||
#Preview {
|
#Preview {
|
||||||
GuideView()
|
GuideView()
|
||||||
.modelContainer(for: TenseGuide.self, inMemory: true)
|
.modelContainer(for: TenseGuide.self, inMemory: true)
|
||||||
|
.environment(YouTubeVideoStore())
|
||||||
}
|
}
|
||||||
|
|||||||
188
Conjuga/Conjuga/Views/Guide/VideoActionsView.swift
Normal file
188
Conjuga/Conjuga/Views/Guide/VideoActionsView.swift
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
@State private var downloadService = VideoDownloadService.shared
|
||||||
|
@State private var isDownloaded: Bool
|
||||||
|
@State private var playerVideoId: String?
|
||||||
|
@State private var downloadError: String?
|
||||||
|
|
||||||
|
init(video: YouTubeVideoStore.VideoEntry) {
|
||||||
|
self.video = video
|
||||||
|
self._isDownloaded = State(initialValue: VideoDownloadService.isDownloaded(videoId: video.videoId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeProgress: Double? {
|
||||||
|
downloadService.activeDownloads[video.videoId]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isDownloading: Bool {
|
||||||
|
activeProgress != 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 ?? "")
|
||||||
|
}
|
||||||
|
.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)") {
|
||||||
|
openURL(url)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Stream", systemImage: "play.rectangle.fill")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.red)
|
||||||
|
.controlSize(.large)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var downloadButton: some View {
|
||||||
|
Button {
|
||||||
|
Task { await startDownload() }
|
||||||
|
} label: {
|
||||||
|
Group {
|
||||||
|
if let progress = activeProgress {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ProgressView(value: progress)
|
||||||
|
.frame(width: 40)
|
||||||
|
Text("\(Int(progress * 100))%")
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
}
|
||||||
|
} else if isDownloaded {
|
||||||
|
Label("Downloaded", systemImage: "checkmark.circle.fill")
|
||||||
|
} else {
|
||||||
|
Label("Download", systemImage: "arrow.down.to.line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.blue)
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(isDownloaded || isDownloading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var playButton: some View {
|
||||||
|
Button {
|
||||||
|
playerVideoId = video.videoId
|
||||||
|
} label: {
|
||||||
|
Label("Play", systemImage: "play.fill")
|
||||||
|
.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
|
||||||
|
} 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Local playback sheet
|
||||||
|
|
||||||
|
struct LocalVideoPlayerSheet: View {
|
||||||
|
let videoId: String
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var player: AVPlayer
|
||||||
|
|
||||||
|
init(videoId: String, title: String) {
|
||||||
|
self.videoId = videoId
|
||||||
|
self.title = title
|
||||||
|
self._player = State(initialValue: AVPlayer(url: VideoDownloadService.fileURL(for: videoId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ZStack {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
VideoPlayer(player: player)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onAppear { player.play() }
|
||||||
|
.onDisappear { player.pause() }
|
||||||
|
}
|
||||||
|
.navigationTitle(title)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbarBackground(.black, for: .navigationBar)
|
||||||
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,7 +128,7 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
private func completeOnboarding() {
|
private func completeOnboarding() {
|
||||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||||
progress.selectedVerbLevel = selectedLevel
|
progress.selectedVerbLevels = [selectedLevel]
|
||||||
if progress.enabledTenseIDs.isEmpty {
|
if progress.enabledTenseIDs.isEmpty {
|
||||||
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
|
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
|
||||||
}
|
}
|
||||||
|
|||||||
126
Conjuga/Conjuga/Views/Practice/Chat/ChatLibraryView.swift
Normal file
126
Conjuga/Conjuga/Views/Practice/Chat/ChatLibraryView.swift
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct ChatLibraryView: View {
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
@State private var conversations: [Conversation] = []
|
||||||
|
@State private var showingScenarioPicker = false
|
||||||
|
|
||||||
|
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if conversations.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Conversations Yet",
|
||||||
|
systemImage: "bubble.left.and.bubble.right",
|
||||||
|
description: Text("Tap + to start a Spanish conversation.")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(conversations) { conv in
|
||||||
|
NavigationLink {
|
||||||
|
ChatView(conversation: conv)
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(conv.scenario)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(conv.level.capitalized)
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(.green.opacity(0.12), in: Capsule())
|
||||||
|
let msgCount = conv.decodedMessages.count
|
||||||
|
Text("\(msgCount) message\(msgCount == 1 ? "" : "s")")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete(perform: deleteConversations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Conversations")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showingScenarioPicker = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.disabled(!ConversationService.isAvailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingScenarioPicker) {
|
||||||
|
ScenarioPickerView { scenario in
|
||||||
|
showingScenarioPicker = false
|
||||||
|
createConversation(scenario: scenario)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear(perform: loadConversations)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadConversations() {
|
||||||
|
let descriptor = FetchDescriptor<Conversation>(
|
||||||
|
sortBy: [SortDescriptor(\Conversation.createdDate, order: .reverse)]
|
||||||
|
)
|
||||||
|
conversations = (try? cloudContext.fetch(descriptor)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createConversation(scenario: String) {
|
||||||
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||||
|
let conv = Conversation(scenario: scenario, level: progress.selectedLevel)
|
||||||
|
cloudContext.insert(conv)
|
||||||
|
try? cloudContext.save()
|
||||||
|
loadConversations()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteConversations(at offsets: IndexSet) {
|
||||||
|
for index in offsets { cloudContext.delete(conversations[index]) }
|
||||||
|
try? cloudContext.save()
|
||||||
|
loadConversations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scenario Picker
|
||||||
|
|
||||||
|
struct ScenarioPickerView: View {
|
||||||
|
let onPick: (String) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(ConversationService.scenarios, id: \.self) { scenario in
|
||||||
|
Button {
|
||||||
|
onPick(scenario)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(scenario)
|
||||||
|
.font(.body)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Choose a Scenario")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
349
Conjuga/Conjuga/Views/Practice/Chat/ChatView.swift
Normal file
349
Conjuga/Conjuga/Views/Practice/Chat/ChatView.swift
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
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() }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Messages
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 12) {
|
||||||
|
ForEach(messages) { message in
|
||||||
|
ChatBubble(message: message, dictionary: dictionary, lookupCache: $lookupCache) { word in
|
||||||
|
selectedWord = word
|
||||||
|
}
|
||||||
|
.id(message.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if service.isResponding {
|
||||||
|
HStack {
|
||||||
|
ProgressView()
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
}
|
||||||
|
.onChange(of: messages.count) {
|
||||||
|
if let last = messages.last {
|
||||||
|
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Input
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("Type in Spanish...", text: $inputText)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.sentences)
|
||||||
|
.onSubmit { sendMessage() }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
sendMessage()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
.disabled(inputText.trimmingCharacters(in: .whitespaces).isEmpty || service.isResponding)
|
||||||
|
.tint(.green)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.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 } }
|
||||||
|
)) {
|
||||||
|
Button("OK") { errorMessage = nil }
|
||||||
|
} message: {
|
||||||
|
Text(errorMessage ?? "")
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
messages = conversation.decodedMessages
|
||||||
|
if !hasStarted && messages.isEmpty {
|
||||||
|
startConversation()
|
||||||
|
}
|
||||||
|
hasStarted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startConversation() {
|
||||||
|
let opening = service.startConversation(scenario: conversation.scenario, level: conversation.level)
|
||||||
|
let msg = ChatMessage(role: "assistant", content: opening)
|
||||||
|
conversation.appendMessage(msg)
|
||||||
|
messages = conversation.decodedMessages
|
||||||
|
try? cloudContext.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendMessage() {
|
||||||
|
let text = inputText.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
|
||||||
|
let userMsg = ChatMessage(role: "user", content: text)
|
||||||
|
conversation.appendMessage(userMsg)
|
||||||
|
messages = conversation.decodedMessages
|
||||||
|
inputText = ""
|
||||||
|
try? cloudContext.save()
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let response = try await service.respond(to: text)
|
||||||
|
let assistantMsg = ChatMessage(role: "assistant", content: response)
|
||||||
|
conversation.appendMessage(assistantMsg)
|
||||||
|
messages = conversation.decodedMessages
|
||||||
|
try? cloudContext.save()
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Failed to get response: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chat Bubble
|
||||||
|
|
||||||
|
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" }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
if isUser { Spacer(minLength: 60) }
|
||||||
|
|
||||||
|
VStack(alignment: isUser ? .trailing : .leading, spacing: 4) {
|
||||||
|
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)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isUser { Spacer(minLength: 60) }
|
||||||
|
}
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
212
Conjuga/Conjuga/Views/Practice/ClozeView.swift
Normal file
212
Conjuga/Conjuga/Views/Practice/ClozeView.swift
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct ClozeView: View {
|
||||||
|
@Environment(\.modelContext) private var localContext
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
|
||||||
|
@State private var questions: [ClozeQuestion] = []
|
||||||
|
@State private var currentIndex = 0
|
||||||
|
@State private var selectedOption: Int?
|
||||||
|
@State private var correctCount = 0
|
||||||
|
@State private var isFinished = false
|
||||||
|
@State private var isLoading = true
|
||||||
|
|
||||||
|
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView("Loading questions...")
|
||||||
|
} else if isFinished || questions.isEmpty {
|
||||||
|
finishedView
|
||||||
|
} else if let q = questions[safe: currentIndex] {
|
||||||
|
questionView(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer(maxWidth: 600)
|
||||||
|
.navigationTitle("Cloze Practice")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear(perform: loadQuestions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Question View
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func questionView(_ q: ClozeQuestion) -> some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Text("\(currentIndex + 1) / \(questions.count)")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
ProgressView(value: Double(currentIndex), total: Double(questions.count))
|
||||||
|
.tint(.indigo)
|
||||||
|
|
||||||
|
// Sentence with blank
|
||||||
|
Text(highlightedTemplate(q.displayTemplate))
|
||||||
|
.font(.title3)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
|
// English hint
|
||||||
|
if !q.sentenceEN.isEmpty {
|
||||||
|
Text(q.sentenceEN)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(Array(q.options.enumerated()), id: \.offset) { index, option in
|
||||||
|
Button {
|
||||||
|
guard selectedOption == nil else { return }
|
||||||
|
selectedOption = index
|
||||||
|
if option.lowercased() == q.answer.lowercased() {
|
||||||
|
correctCount += 1
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(option)
|
||||||
|
.font(.body)
|
||||||
|
Spacer()
|
||||||
|
if let selected = selectedOption {
|
||||||
|
if option.lowercased() == q.answer.lowercased() {
|
||||||
|
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||||
|
} else if index == selected {
|
||||||
|
Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(optionBG(index: index, answer: q.answer, options: q.options), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if selectedOption != nil {
|
||||||
|
Button {
|
||||||
|
if currentIndex + 1 < questions.count {
|
||||||
|
currentIndex += 1
|
||||||
|
selectedOption = nil
|
||||||
|
} else {
|
||||||
|
withAnimation { isFinished = true }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(currentIndex + 1 < questions.count ? "Next" : "See Results")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.indigo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Finished
|
||||||
|
|
||||||
|
private var finishedView: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: questions.isEmpty ? "text.badge.xmark" : "checkmark.circle")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(questions.isEmpty ? Color.secondary : Color.indigo)
|
||||||
|
|
||||||
|
if questions.isEmpty {
|
||||||
|
Text("No cloze questions available")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Complete some course decks first to unlock cloze practice.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
} else {
|
||||||
|
Text("\(correctCount) / \(questions.count)")
|
||||||
|
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
||||||
|
Text(correctCount == questions.count ? "Perfect!" : "Keep practicing!")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func highlightedTemplate(_ template: String) -> AttributedString {
|
||||||
|
var result = AttributedString(template)
|
||||||
|
if let range = result.range(of: SentenceQuizEngine.blankMarker) {
|
||||||
|
result[range].foregroundColor = .indigo
|
||||||
|
result[range].font = .title3.bold()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func optionBG(index: Int, answer: String, options: [String]) -> some ShapeStyle {
|
||||||
|
guard let selected = selectedOption else { return AnyShapeStyle(.fill.quaternary) }
|
||||||
|
if options[index].lowercased() == answer.lowercased() { return AnyShapeStyle(.green.opacity(0.15)) }
|
||||||
|
if index == selected { return AnyShapeStyle(.red.opacity(0.15)) }
|
||||||
|
return AnyShapeStyle(.fill.quaternary)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadQuestions() {
|
||||||
|
let descriptor = FetchDescriptor<VocabCard>()
|
||||||
|
let allCards = (try? localContext.fetch(descriptor)) ?? []
|
||||||
|
let eligible = allCards.filter { SentenceQuizEngine.hasValidSentence(for: $0) }
|
||||||
|
|
||||||
|
var result: [ClozeQuestion] = []
|
||||||
|
let pool = eligible.shuffled().prefix(20)
|
||||||
|
|
||||||
|
for card in pool {
|
||||||
|
guard let q = SentenceQuizEngine.buildQuestion(for: card) else { continue }
|
||||||
|
|
||||||
|
// Build distractors from other cards
|
||||||
|
var distractors = eligible
|
||||||
|
.filter { $0.front != card.front }
|
||||||
|
.shuffled()
|
||||||
|
.prefix(3)
|
||||||
|
.map(\.front)
|
||||||
|
|
||||||
|
while distractors.count < 3 {
|
||||||
|
distractors.append("---")
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = Array(distractors) + [q.blankWord]
|
||||||
|
options.shuffle()
|
||||||
|
|
||||||
|
result.append(ClozeQuestion(
|
||||||
|
displayTemplate: q.displayTemplate,
|
||||||
|
sentenceEN: q.sentenceEN,
|
||||||
|
answer: q.blankWord,
|
||||||
|
options: options
|
||||||
|
))
|
||||||
|
|
||||||
|
if result.count >= 10 { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
questions = result
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ClozeQuestion {
|
||||||
|
let displayTemplate: String
|
||||||
|
let sentenceEN: String
|
||||||
|
let answer: String
|
||||||
|
let options: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Collection {
|
||||||
|
subscript(safe index: Index) -> Element? {
|
||||||
|
indices.contains(index) ? self[index] : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -243,7 +243,11 @@ struct FullTableView: View {
|
|||||||
results = Array(repeating: nil, count: 6)
|
results = Array(repeating: nil, count: 6)
|
||||||
correctForms = []
|
correctForms = []
|
||||||
drawings = Array(repeating: PKDrawing(), count: 6)
|
drawings = Array(repeating: PKDrawing(), count: 6)
|
||||||
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext)
|
let service = PracticeSessionService(
|
||||||
|
localContext: modelContext,
|
||||||
|
cloudContext: cloudModelContext,
|
||||||
|
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
|
||||||
|
)
|
||||||
guard let prompt = service.randomFullTablePrompt() else {
|
guard let prompt = service.randomFullTablePrompt() else {
|
||||||
currentVerb = nil
|
currentVerb = nil
|
||||||
currentTense = nil
|
currentTense = nil
|
||||||
@@ -312,7 +316,11 @@ struct FullTableView: View {
|
|||||||
if allCorrect { sessionCorrect += 1 }
|
if allCorrect { sessionCorrect += 1 }
|
||||||
|
|
||||||
if let verb = currentVerb, let tense = currentTense {
|
if let verb = currentVerb, let tense = currentTense {
|
||||||
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext)
|
let service = PracticeSessionService(
|
||||||
|
localContext: modelContext,
|
||||||
|
cloudContext: cloudModelContext,
|
||||||
|
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
|
||||||
|
)
|
||||||
let reviewResults = Dictionary(uniqueKeysWithValues: personsToShow.map { ($0.index, results[$0.index] == true) })
|
let reviewResults = Dictionary(uniqueKeysWithValues: personsToShow.map { ($0.index, results[$0.index] == true) })
|
||||||
_ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults)
|
_ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults)
|
||||||
}
|
}
|
||||||
|
|||||||
319
Conjuga/Conjuga/Views/Practice/ListeningView.swift
Normal file
319
Conjuga/Conjuga/Views/Practice/ListeningView.swift
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct ListeningView: View {
|
||||||
|
@Environment(\.modelContext) private var localContext
|
||||||
|
@State private var pronunciation = PronunciationService()
|
||||||
|
@State private var speechService = SpeechService()
|
||||||
|
|
||||||
|
@State private var sentences: [(spanish: String, english: String)] = []
|
||||||
|
@State private var currentIndex = 0
|
||||||
|
@State private var userInput = ""
|
||||||
|
@State private var isRevealed = false
|
||||||
|
@State private var score: Double?
|
||||||
|
@State private var wordMatches: [PronunciationService.WordMatch] = []
|
||||||
|
@State private var mode: ListeningMode = .listenType
|
||||||
|
@State private var correctCount = 0
|
||||||
|
@State private var isFinished = false
|
||||||
|
|
||||||
|
enum ListeningMode: String, CaseIterable {
|
||||||
|
case listenType = "Listen & Type"
|
||||||
|
case speakCheck = "Pronunciation"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
if isFinished {
|
||||||
|
finishedView
|
||||||
|
} else if sentences.isEmpty {
|
||||||
|
ContentUnavailableView("No sentences available", systemImage: "waveform", description: Text("Complete some course decks first."))
|
||||||
|
} else {
|
||||||
|
exerciseView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer(maxWidth: 600)
|
||||||
|
.navigationTitle("Listening Practice")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear {
|
||||||
|
print("[ListeningView] onAppear — loading sentences")
|
||||||
|
loadSentences()
|
||||||
|
print("[ListeningView] loaded \(sentences.count) sentences, requesting auth")
|
||||||
|
Task {
|
||||||
|
pronunciation.requestAuthorization()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Exercise
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var exerciseView: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Mode picker
|
||||||
|
Picker("Mode", selection: $mode) {
|
||||||
|
ForEach(ListeningMode.allCases, id: \.self) { m in
|
||||||
|
Text(m.rawValue).tag(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
Text("\(currentIndex + 1) / \(sentences.count)")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if mode == .listenType {
|
||||||
|
listenAndTypeView
|
||||||
|
} else {
|
||||||
|
pronunciationCheckView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Listen & Type
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var listenAndTypeView: some View {
|
||||||
|
let sentence = sentences[currentIndex]
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Play button
|
||||||
|
Button {
|
||||||
|
speechService.speak(sentence.spanish)
|
||||||
|
} label: {
|
||||||
|
Label("Play", systemImage: "speaker.wave.2.fill")
|
||||||
|
.font(.headline)
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.blue)
|
||||||
|
|
||||||
|
// User types what they heard
|
||||||
|
TextField("Type what you hear...", text: $userInput)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
|
||||||
|
if isRevealed {
|
||||||
|
// Show correct answer
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Correct:")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(sentence.spanish)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
Text(sentence.english)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
|
||||||
|
Text("Score: \(Int(result.score * 100))%")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(result.score >= 0.8 ? .green : result.score >= 0.5 ? .orange : .red)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
|
nextButton
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
|
||||||
|
if result.score >= 0.7 { correctCount += 1 }
|
||||||
|
withAnimation { isRevealed = true }
|
||||||
|
} label: {
|
||||||
|
Text("Check")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.blue)
|
||||||
|
.disabled(userInput.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pronunciation Check
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var pronunciationCheckView: some View {
|
||||||
|
let sentence = sentences[currentIndex]
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Show the sentence to read
|
||||||
|
Text(sentence.spanish)
|
||||||
|
.font(.title3.weight(.medium))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
|
Text(sentence.english)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
// Mic button
|
||||||
|
Button {
|
||||||
|
if pronunciation.isRecording {
|
||||||
|
pronunciation.stopRecording()
|
||||||
|
// Score after stopping
|
||||||
|
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: pronunciation.transcript)
|
||||||
|
score = result.score
|
||||||
|
wordMatches = result.matches
|
||||||
|
if result.score >= 0.7 { correctCount += 1 }
|
||||||
|
withAnimation { isRevealed = true }
|
||||||
|
} else {
|
||||||
|
pronunciation.startRecording()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(pronunciation.isRecording ? "Stop" : "Start Speaking", systemImage: pronunciation.isRecording ? "stop.circle.fill" : "mic.circle.fill")
|
||||||
|
.font(.headline)
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(pronunciation.isRecording ? .red : .green)
|
||||||
|
.disabled(!pronunciation.isAuthorized)
|
||||||
|
|
||||||
|
if !pronunciation.isAuthorized {
|
||||||
|
Text("Microphone access required. Enable in Settings.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pronunciation.isRecording {
|
||||||
|
Text(pronunciation.transcript.isEmpty ? "Listening..." : pronunciation.transcript)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.italic()
|
||||||
|
}
|
||||||
|
|
||||||
|
if isRevealed, let score {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("\(Int(score * 100))% match")
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(score >= 0.8 ? .green : score >= 0.5 ? .orange : .red)
|
||||||
|
|
||||||
|
// Word-by-word feedback
|
||||||
|
FlowLayout(spacing: 4) {
|
||||||
|
ForEach(wordMatches) { match in
|
||||||
|
Text(match.word)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(match.matched ? .green : .red)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(match.matched ? .green.opacity(0.1) : .red.opacity(0.1), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
|
nextButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared
|
||||||
|
|
||||||
|
private var nextButton: some View {
|
||||||
|
Button {
|
||||||
|
if currentIndex + 1 < sentences.count {
|
||||||
|
currentIndex += 1
|
||||||
|
userInput = ""
|
||||||
|
isRevealed = false
|
||||||
|
score = nil
|
||||||
|
wordMatches = []
|
||||||
|
} else {
|
||||||
|
withAnimation { isFinished = true }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(currentIndex + 1 < sentences.count ? "Next" : "See Results")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var finishedView: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "ear.fill")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text("\(correctCount) / \(sentences.count)")
|
||||||
|
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
||||||
|
Text("Listening complete!")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSentences() {
|
||||||
|
print("[ListeningView] fetching VocabCards from localContext...")
|
||||||
|
print("[ListeningView] context: \(localContext)")
|
||||||
|
let descriptor = FetchDescriptor<VocabCard>()
|
||||||
|
let cards: [VocabCard]
|
||||||
|
do {
|
||||||
|
let count = try localContext.fetchCount(descriptor)
|
||||||
|
print("[ListeningView] fetchCount = \(count)")
|
||||||
|
cards = try localContext.fetch(descriptor)
|
||||||
|
print("[ListeningView] fetched \(cards.count) VocabCards")
|
||||||
|
} catch {
|
||||||
|
print("[ListeningView] ERROR fetching VocabCards: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var results: [(String, String)] = []
|
||||||
|
for card in cards.shuffled() {
|
||||||
|
for i in card.examplesES.indices {
|
||||||
|
let es = card.examplesES[i]
|
||||||
|
let en = i < card.examplesEN.count ? card.examplesEN[i] : ""
|
||||||
|
if es.split(separator: " ").count >= 4 {
|
||||||
|
results.append((es, en))
|
||||||
|
}
|
||||||
|
if results.count >= 10 { break }
|
||||||
|
}
|
||||||
|
if results.count >= 10 { break }
|
||||||
|
}
|
||||||
|
sentences = results
|
||||||
|
print("[ListeningView] selected \(sentences.count) sentences")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse FlowLayout from StoryReaderView — import not needed since it's in the same module
|
||||||
|
// but we need a local copy since it's private there
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
import Translation
|
||||||
|
|
||||||
|
struct LyricsConfirmationView: View {
|
||||||
|
let result: LyricsSearchResult
|
||||||
|
let onSave: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
|
@State private var translatedEN = ""
|
||||||
|
@State private var isTranslating = true
|
||||||
|
@State private var translationError = false
|
||||||
|
@State private var translationConfig: TranslationSession.Configuration?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
headerSection
|
||||||
|
lyricsPreview
|
||||||
|
actionButtons
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer()
|
||||||
|
}
|
||||||
|
.navigationTitle("Confirm Lyrics")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear {
|
||||||
|
translationConfig = .init(
|
||||||
|
source: Locale.Language(identifier: "es"),
|
||||||
|
target: Locale.Language(identifier: "en")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.translationTask(translationConfig) { session in
|
||||||
|
await translateLyrics(session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sections
|
||||||
|
|
||||||
|
private var headerSection: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
if let artURL = result.albumArtURL, let url = URL(string: artURL) {
|
||||||
|
AsyncImage(url: url) { image in
|
||||||
|
image.resizable().scaledToFill()
|
||||||
|
} placeholder: {
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(.fill.quaternary)
|
||||||
|
.overlay {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 200, height: 200)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(result.title)
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text(result.artist)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lyricsPreview: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("Spanish Lyrics")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text(result.lyricsES.prefix(500) + (result.lyricsES.count > 500 ? "\n..." : ""))
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("English Translation")
|
||||||
|
.font(.headline)
|
||||||
|
if isTranslating {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if translationError {
|
||||||
|
Label("Translation unavailable. You can still save with Spanish only.",
|
||||||
|
systemImage: "exclamationmark.triangle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
} else if translatedEN.isEmpty && isTranslating {
|
||||||
|
Text("Translating...")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text(translatedEN.prefix(500) + (translatedEN.count > 500 ? "\n..." : ""))
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var actionButtons: some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Button(role: .cancel) {
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Text("Cancel")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
saveSong()
|
||||||
|
} label: {
|
||||||
|
Text("Save")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.orange)
|
||||||
|
.disabled(isTranslating && translatedEN.isEmpty && !translationError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Logic
|
||||||
|
|
||||||
|
private func translateLyrics(session: sending TranslationSession) async {
|
||||||
|
await MainActor.run { isTranslating = true }
|
||||||
|
let text = result.lyricsES
|
||||||
|
let esLines = text.components(separatedBy: "\n")
|
||||||
|
print("[LyricsTranslation] Spanish line count: \(esLines.count)")
|
||||||
|
print("[LyricsTranslation] Spanish blank lines: \(esLines.filter { $0.trimmingCharacters(in: .whitespaces).isEmpty }.count)")
|
||||||
|
print("[LyricsTranslation] First 10 ES lines:")
|
||||||
|
for (i, line) in esLines.prefix(10).enumerated() {
|
||||||
|
print(" ES[\(i)]: \(line.isEmpty ? "(blank)" : line)")
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await session.translate(text)
|
||||||
|
let enLines = response.targetText.components(separatedBy: "\n")
|
||||||
|
print("[LyricsTranslation] English line count: \(enLines.count)")
|
||||||
|
print("[LyricsTranslation] English blank lines: \(enLines.filter { $0.trimmingCharacters(in: .whitespaces).isEmpty }.count)")
|
||||||
|
print("[LyricsTranslation] First 10 EN lines:")
|
||||||
|
for (i, line) in enLines.prefix(10).enumerated() {
|
||||||
|
print(" EN[\(i)]: \(line.isEmpty ? "(blank)" : line)")
|
||||||
|
}
|
||||||
|
// The Translation framework often inserts a blank line after every
|
||||||
|
// translated line. Collapse consecutive blank lines into single blanks,
|
||||||
|
// then trim leading/trailing blanks so the EN structure matches the ES.
|
||||||
|
let normalized = Self.normalizeTranslationLineBreaks(
|
||||||
|
translated: response.targetText,
|
||||||
|
originalES: text
|
||||||
|
)
|
||||||
|
let normalizedLines = normalized.components(separatedBy: "\n")
|
||||||
|
print("[LyricsTranslation] After normalization: EN lines \(enLines.count) → \(normalizedLines.count) (target: \(esLines.count))")
|
||||||
|
|
||||||
|
await MainActor.run { translatedEN = normalized }
|
||||||
|
} catch {
|
||||||
|
print("[LyricsTranslation] Translation error: \(error)")
|
||||||
|
await MainActor.run { translationError = true }
|
||||||
|
}
|
||||||
|
await MainActor.run { isTranslating = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-align translated line breaks to match the original Spanish structure.
|
||||||
|
/// The Translation framework often inserts a blank line after every translated
|
||||||
|
/// line. We strip all blanks from the EN output, then re-insert them at the
|
||||||
|
/// same positions where the original ES text has blank lines.
|
||||||
|
/// Re-align translated line breaks to match the original Spanish structure.
|
||||||
|
private static func normalizeTranslationLineBreaks(translated: String, originalES: String) -> String {
|
||||||
|
let esLines = originalES.components(separatedBy: "\n")
|
||||||
|
let enContentLines = translated.components(separatedBy: "\n")
|
||||||
|
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
|
|
||||||
|
// Walk ES lines. For each blank ES line, insert a blank in the result.
|
||||||
|
// For each content ES line, consume the next EN content line.
|
||||||
|
var result: [String] = []
|
||||||
|
var enIndex = 0
|
||||||
|
for esLine in esLines {
|
||||||
|
if esLine.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
|
result.append("")
|
||||||
|
} else if enIndex < enContentLines.count {
|
||||||
|
result.append(enContentLines[enIndex])
|
||||||
|
enIndex += 1
|
||||||
|
} else {
|
||||||
|
result.append("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[LyricsNormalize] ES lines: \(esLines.count), EN content: \(enContentLines.count), result: \(result.count), EN consumed: \(enIndex)")
|
||||||
|
return result.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveSong() {
|
||||||
|
let song = SavedSong(
|
||||||
|
title: result.title,
|
||||||
|
artist: result.artist,
|
||||||
|
lyricsES: result.lyricsES,
|
||||||
|
lyricsEN: translatedEN,
|
||||||
|
albumArtURL: result.albumArtURL ?? "",
|
||||||
|
appleMusicURL: result.appleMusicURL ?? ""
|
||||||
|
)
|
||||||
|
cloudModelContext.insert(song)
|
||||||
|
try? cloudModelContext.save()
|
||||||
|
onSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
107
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsLibraryView.swift
Normal file
107
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsLibraryView.swift
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct LyricsLibraryView: View {
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
@State private var songs: [SavedSong] = []
|
||||||
|
@State private var showingSearch = false
|
||||||
|
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if songs.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Songs Yet",
|
||||||
|
systemImage: "music.note.list",
|
||||||
|
description: Text("Tap + to search for Spanish song lyrics.")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(songs) { song in
|
||||||
|
NavigationLink {
|
||||||
|
LyricsReaderView(song: song)
|
||||||
|
} label: {
|
||||||
|
SongRowView(song: song)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete(perform: deleteSongs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Lyrics")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showingSearch = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingSearch) {
|
||||||
|
NavigationStack {
|
||||||
|
LyricsSearchView {
|
||||||
|
showingSearch = false
|
||||||
|
loadSongs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear(perform: loadSongs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSongs() {
|
||||||
|
let descriptor = FetchDescriptor<SavedSong>(
|
||||||
|
sortBy: [SortDescriptor(\SavedSong.savedDate, order: .reverse)]
|
||||||
|
)
|
||||||
|
songs = (try? cloudModelContext.fetch(descriptor)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteSongs(at offsets: IndexSet) {
|
||||||
|
for index in offsets {
|
||||||
|
cloudModelContext.delete(songs[index])
|
||||||
|
}
|
||||||
|
try? cloudModelContext.save()
|
||||||
|
loadSongs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Song Row
|
||||||
|
|
||||||
|
private struct SongRowView: View {
|
||||||
|
let song: SavedSong
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if !song.albumArtURL.isEmpty, let url = URL(string: song.albumArtURL) {
|
||||||
|
AsyncImage(url: url) { image in
|
||||||
|
image.resizable().scaledToFill()
|
||||||
|
} placeholder: {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(.fill.quaternary)
|
||||||
|
}
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "music.note")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(song.title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(song.artist)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
274
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift
Normal file
274
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
|
||||||
|
struct LyricsReaderView: View {
|
||||||
|
let song: SavedSong
|
||||||
|
|
||||||
|
@Environment(DictionaryService.self) private var dictionary
|
||||||
|
@State private var selectedWord: LyricsWordLookup?
|
||||||
|
@State private var lookupCache: [String: LyricsWordLookup] = [:]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
headerSection
|
||||||
|
lyricsBody
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer()
|
||||||
|
}
|
||||||
|
.navigationTitle(song.title)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.sheet(item: $selectedWord) { word in
|
||||||
|
LyricsWordDetailSheet(word: word)
|
||||||
|
.presentationDetents([.height(260)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var headerSection: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
if !song.albumArtURL.isEmpty, let url = URL(string: song.albumArtURL) {
|
||||||
|
AsyncImage(url: url) { image in
|
||||||
|
image.resizable().scaledToFill()
|
||||||
|
} placeholder: {
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(.fill.quaternary)
|
||||||
|
}
|
||||||
|
.frame(width: 160, height: 160)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(song.title)
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text(song.artist)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if !song.appleMusicURL.isEmpty, let url = URL(string: song.appleMusicURL) {
|
||||||
|
Link(destination: url) {
|
||||||
|
Label("Open in Apple Music", systemImage: "apple.logo")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
}
|
||||||
|
.tint(.pink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lyrics Body
|
||||||
|
|
||||||
|
private var lyricsBody: some View {
|
||||||
|
let spanishLines = song.lyricsES.components(separatedBy: "\n")
|
||||||
|
let englishLines = song.lyricsEN.components(separatedBy: "\n")
|
||||||
|
let lineCount = max(spanishLines.count, englishLines.count)
|
||||||
|
|
||||||
|
return VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(0..<lineCount, id: \.self) { index in
|
||||||
|
let es = index < spanishLines.count ? spanishLines[index] : ""
|
||||||
|
let en = index < englishLines.count ? englishLines[index] : ""
|
||||||
|
|
||||||
|
if es.trimmingCharacters(in: .whitespaces).isEmpty &&
|
||||||
|
en.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
|
// Blank line = section divider
|
||||||
|
Spacer().frame(height: 20)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
if !es.isEmpty {
|
||||||
|
spanishLine(es)
|
||||||
|
}
|
||||||
|
if !en.isEmpty {
|
||||||
|
Text(en)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
179
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsSearchView.swift
Normal file
179
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsSearchView.swift
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
import Translation
|
||||||
|
|
||||||
|
struct LyricsSearchView: View {
|
||||||
|
var onSaved: (() -> Void)?
|
||||||
|
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var artist = ""
|
||||||
|
@State private var songTitle = ""
|
||||||
|
@State private var isSearching = false
|
||||||
|
@State private var searchResults: [LyricsSearchResult] = []
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@State private var selectedResult: LyricsSearchResult?
|
||||||
|
|
||||||
|
private let service = LyricsSearchService()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
searchSection
|
||||||
|
if isSearching {
|
||||||
|
loadingSection
|
||||||
|
}
|
||||||
|
if let errorMessage {
|
||||||
|
errorSection(errorMessage)
|
||||||
|
}
|
||||||
|
if !searchResults.isEmpty {
|
||||||
|
resultsSection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Search Lyrics")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationDestination(item: $selectedResult) { result in
|
||||||
|
LyricsConfirmationView(result: result) {
|
||||||
|
if let onSaved {
|
||||||
|
onSaved()
|
||||||
|
} else {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sections
|
||||||
|
|
||||||
|
private var searchSection: some View {
|
||||||
|
Section {
|
||||||
|
TextField("Artist", text: $artist)
|
||||||
|
.textInputAutocapitalization(.words)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
|
TextField("Song Title", text: $songTitle)
|
||||||
|
.textInputAutocapitalization(.words)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
performSearch()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Label("Search", systemImage: "magnifyingglass")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(artist.trimmingCharacters(in: .whitespaces).isEmpty ||
|
||||||
|
songTitle.trimmingCharacters(in: .whitespaces).isEmpty ||
|
||||||
|
isSearching)
|
||||||
|
} header: {
|
||||||
|
Text("Find a Song")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadingSection: some View {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView("Searching...")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func errorSection(_ message: String) -> some View {
|
||||||
|
Section {
|
||||||
|
Label(message, systemImage: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var resultsSection: some View {
|
||||||
|
Section {
|
||||||
|
ForEach(Array(searchResults.prefix(5).enumerated()), id: \.offset) { _, result in
|
||||||
|
Button {
|
||||||
|
selectedResult = result
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if let artURL = result.albumArtURL, let url = URL(string: artURL) {
|
||||||
|
AsyncImage(url: url) { image in
|
||||||
|
image.resizable().scaledToFill()
|
||||||
|
} placeholder: {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(.fill.quaternary)
|
||||||
|
}
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "music.note")
|
||||||
|
.font(.title2)
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(result.title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text(result.artist)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func performSearch() {
|
||||||
|
isSearching = true
|
||||||
|
errorMessage = nil
|
||||||
|
searchResults = []
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let results = try await service.searchLyrics(artist: artist, title: songTitle)
|
||||||
|
searchResults = results
|
||||||
|
if results.isEmpty {
|
||||||
|
errorMessage = "No lyrics found. Try a different spelling."
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Search failed. Check your connection."
|
||||||
|
}
|
||||||
|
isSearching = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Identifiable conformance for navigation
|
||||||
|
|
||||||
|
extension LyricsSearchResult: Equatable {
|
||||||
|
static func == (lhs: LyricsSearchResult, rhs: LyricsSearchResult) -> Bool {
|
||||||
|
lhs.title == rhs.title && lhs.artist == rhs.artist && lhs.lyricsES == rhs.lyricsES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LyricsSearchResult: Hashable {
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(title)
|
||||||
|
hasher.combine(artist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LyricsSearchResult: Identifiable {
|
||||||
|
var id: String { "\(artist)—\(title)" }
|
||||||
|
}
|
||||||
@@ -98,12 +98,245 @@ struct PracticeView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Lyrics
|
||||||
|
NavigationLink {
|
||||||
|
LyricsLibraryView()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: "music.note.list")
|
||||||
|
.font(.title3)
|
||||||
|
.frame(width: 36)
|
||||||
|
.foregroundStyle(.pink)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Lyrics")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text("Read Spanish song lyrics with translations")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Conversation Practice
|
||||||
|
NavigationLink {
|
||||||
|
ChatLibraryView()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: "bubble.left.and.bubble.right.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.frame(width: 36)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Conversation")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text("Chat with AI in Spanish scenarios")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Listening Practice
|
||||||
|
NavigationLink {
|
||||||
|
ListeningView()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: "ear.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.frame(width: 36)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Listening")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text("Listen and type, or practice pronunciation")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Cloze Practice
|
||||||
|
NavigationLink {
|
||||||
|
ClozeView()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: "text.badge.minus")
|
||||||
|
.font(.title3)
|
||||||
|
.frame(width: 36)
|
||||||
|
.foregroundStyle(.indigo)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Cloze Practice")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text("Fill in the missing word in context")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Stories
|
||||||
|
NavigationLink {
|
||||||
|
StoryLibraryView()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: "book.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.frame(width: 36)
|
||||||
|
.foregroundStyle(.teal)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Stories")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text("Read AI-generated Spanish stories")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
// Quick Actions
|
// Quick Actions
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text("Quick Actions")
|
Text("Quick Actions")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.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
|
// Weak verbs focus
|
||||||
Button {
|
Button {
|
||||||
viewModel.practiceMode = .flashcard
|
viewModel.practiceMode = .flashcard
|
||||||
|
|||||||
134
Conjuga/Conjuga/Views/Practice/Stories/StoryLibraryView.swift
Normal file
134
Conjuga/Conjuga/Views/Practice/Stories/StoryLibraryView.swift
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct StoryLibraryView: View {
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
@State private var stories: [Story] = []
|
||||||
|
@State private var isGenerating = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if stories.isEmpty && !isGenerating {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Stories Yet",
|
||||||
|
systemImage: "book.closed",
|
||||||
|
description: Text("Tap + to generate a Spanish story with AI.")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
if isGenerating {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Generating story...")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(stories) { story in
|
||||||
|
NavigationLink {
|
||||||
|
StoryReaderView(story: story)
|
||||||
|
} label: {
|
||||||
|
StoryRowView(story: story)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete(perform: deleteStories)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Stories")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
generateStory()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.disabled(isGenerating || !StoryGenerator.isAvailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Error", isPresented: .init(
|
||||||
|
get: { errorMessage != nil },
|
||||||
|
set: { if !$0 { errorMessage = nil } }
|
||||||
|
)) {
|
||||||
|
Button("OK") { errorMessage = nil }
|
||||||
|
} message: {
|
||||||
|
Text(errorMessage ?? "")
|
||||||
|
}
|
||||||
|
.onAppear(perform: loadStories)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadStories() {
|
||||||
|
let descriptor = FetchDescriptor<Story>(
|
||||||
|
sortBy: [SortDescriptor(\Story.createdDate, order: .reverse)]
|
||||||
|
)
|
||||||
|
stories = (try? cloudModelContext.fetch(descriptor)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateStory() {
|
||||||
|
guard !isGenerating else { return }
|
||||||
|
|
||||||
|
guard StoryGenerator.isAvailable else {
|
||||||
|
errorMessage = "Apple Intelligence is not available on this device. Stories require an iPhone 15 Pro or later with Apple Intelligence enabled."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isGenerating = true
|
||||||
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||||
|
let level = progress.selectedLevel
|
||||||
|
let tenses = progress.enabledTenseIDs
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let story = try await StoryGenerator.generate(level: level, tenses: tenses)
|
||||||
|
cloudModelContext.insert(story)
|
||||||
|
try? cloudModelContext.save()
|
||||||
|
loadStories()
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Failed to generate story: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
isGenerating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteStories(at offsets: IndexSet) {
|
||||||
|
for index in offsets {
|
||||||
|
cloudModelContext.delete(stories[index])
|
||||||
|
}
|
||||||
|
try? cloudModelContext.save()
|
||||||
|
loadStories()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Story Row
|
||||||
|
|
||||||
|
private struct StoryRowView: View {
|
||||||
|
let story: Story
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(story.title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(story.level.capitalized)
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.foregroundStyle(.teal)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(.teal.opacity(0.12), in: Capsule())
|
||||||
|
|
||||||
|
Text(story.createdDate.formatted(date: .abbreviated, time: .omitted))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
152
Conjuga/Conjuga/Views/Practice/Stories/StoryQuizView.swift
Normal file
152
Conjuga/Conjuga/Views/Practice/Stories/StoryQuizView.swift
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
|
||||||
|
struct StoryQuizView: View {
|
||||||
|
let story: Story
|
||||||
|
|
||||||
|
@State private var currentIndex = 0
|
||||||
|
@State private var selectedOption: Int?
|
||||||
|
@State private var correctCount = 0
|
||||||
|
@State private var isFinished = false
|
||||||
|
|
||||||
|
private var questions: [QuizQuestion] { story.decodedQuestions }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
if isFinished {
|
||||||
|
finishedView
|
||||||
|
} else if let question = questions[safe: currentIndex] {
|
||||||
|
questionView(question)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer(maxWidth: 600)
|
||||||
|
.navigationTitle("Comprehension Quiz")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Question View
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func questionView(_ question: QuizQuestion) -> some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Progress
|
||||||
|
Text("Question \(currentIndex + 1) of \(questions.count)")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
// Question
|
||||||
|
Text(question.question)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
|
// Options
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(Array(question.options.enumerated()), id: \.offset) { index, option in
|
||||||
|
Button {
|
||||||
|
guard selectedOption == nil else { return }
|
||||||
|
selectedOption = index
|
||||||
|
if index == question.correctIndex {
|
||||||
|
correctCount += 1
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(option)
|
||||||
|
.font(.body)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
Spacer()
|
||||||
|
if let selected = selectedOption {
|
||||||
|
if index == question.correctIndex {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
} else if index == selected {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(optionBackground(index: index, correct: question.correctIndex), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Next button
|
||||||
|
if selectedOption != nil {
|
||||||
|
Button {
|
||||||
|
if currentIndex + 1 < questions.count {
|
||||||
|
currentIndex += 1
|
||||||
|
selectedOption = nil
|
||||||
|
} else {
|
||||||
|
withAnimation { isFinished = true }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(currentIndex + 1 < questions.count ? "Next Question" : "See Results")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.teal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Finished View
|
||||||
|
|
||||||
|
private var finishedView: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: correctCount == questions.count ? "star.fill" : "checkmark.circle")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(correctCount == questions.count ? .yellow : .teal)
|
||||||
|
|
||||||
|
Text("\(correctCount) / \(questions.count)")
|
||||||
|
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
||||||
|
|
||||||
|
Text(scoreMessage)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scoreMessage: String {
|
||||||
|
switch correctCount {
|
||||||
|
case questions.count: return "Perfect score!"
|
||||||
|
case _ where correctCount > questions.count / 2: return "Good job! Keep reading."
|
||||||
|
default: return "Try re-reading the story and quiz again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func optionBackground(index: Int, correct: Int) -> some ShapeStyle {
|
||||||
|
guard let selected = selectedOption else {
|
||||||
|
return AnyShapeStyle(.fill.quaternary)
|
||||||
|
}
|
||||||
|
if index == correct {
|
||||||
|
return AnyShapeStyle(.green.opacity(0.15))
|
||||||
|
}
|
||||||
|
if index == selected {
|
||||||
|
return AnyShapeStyle(.red.opacity(0.15))
|
||||||
|
}
|
||||||
|
return AnyShapeStyle(.fill.quaternary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Collection {
|
||||||
|
subscript(safe index: Index) -> Element? {
|
||||||
|
indices.contains(index) ? self[index] : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
333
Conjuga/Conjuga/Views/Practice/Stories/StoryReaderView.swift
Normal file
333
Conjuga/Conjuga/Views/Practice/Stories/StoryReaderView.swift
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
struct StoryReaderView: View {
|
||||||
|
let story: Story
|
||||||
|
|
||||||
|
@Environment(DictionaryService.self) private var dictionary
|
||||||
|
@State private var selectedWord: WordAnnotation?
|
||||||
|
@State private var showTranslation = false
|
||||||
|
@State private var lookupCache: [String: WordAnnotation] = [:]
|
||||||
|
|
||||||
|
private var annotations: [WordAnnotation] { story.decodedAnnotations }
|
||||||
|
private var annotationMap: [String: WordAnnotation] {
|
||||||
|
Dictionary(annotations.map { (cleanWord($0.word), $0) }, uniquingKeysWith: { first, _ in first })
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
// Title
|
||||||
|
Text(story.title)
|
||||||
|
.font(.title2.bold())
|
||||||
|
|
||||||
|
// Level badge
|
||||||
|
Text(story.level.capitalized)
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.foregroundStyle(.teal)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(.teal.opacity(0.12), in: Capsule())
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Tappable Spanish text
|
||||||
|
tappableText
|
||||||
|
.padding()
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
|
// Translation toggle
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Button {
|
||||||
|
withAnimation { showTranslation.toggle() }
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
showTranslation ? "Hide Translation" : "Show Translation",
|
||||||
|
systemImage: showTranslation ? "eye.slash" : "eye"
|
||||||
|
)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
}
|
||||||
|
.tint(.secondary)
|
||||||
|
|
||||||
|
if showTranslation {
|
||||||
|
Text(story.bodyEN)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quiz button
|
||||||
|
if !story.decodedQuestions.isEmpty {
|
||||||
|
NavigationLink {
|
||||||
|
StoryQuizView(story: story)
|
||||||
|
} label: {
|
||||||
|
Label("Take Comprehension Quiz", systemImage: "questionmark.circle")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.teal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer(maxWidth: 800)
|
||||||
|
}
|
||||||
|
.navigationTitle("Story")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.sheet(item: $selectedWord) { word in
|
||||||
|
WordDetailSheet(word: word)
|
||||||
|
.presentationDetents([.height(200)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tappable Text
|
||||||
|
|
||||||
|
private var tappableText: some View {
|
||||||
|
let words = story.bodyES.components(separatedBy: " ")
|
||||||
|
let map = annotationMap
|
||||||
|
let cache = lookupCache
|
||||||
|
let context = story.bodyES
|
||||||
|
|
||||||
|
return WrappingHStack(words: words) { word in
|
||||||
|
WordButton(word: word, map: map, cache: cache) { ann in
|
||||||
|
if ann.english.isEmpty {
|
||||||
|
lookupWord(ann.word, inContext: context)
|
||||||
|
} else {
|
||||||
|
selectedWord = ann
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lookupWord(_ word: String, inContext sentence: String) {
|
||||||
|
// Try offline 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
|
||||||
|
selectedWord = annotation
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to on-device AI lookup
|
||||||
|
selectedWord = WordAnnotation(word: word, baseForm: word, english: "Looking up...", partOfSpeech: "")
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let annotation = try await WordLookup.lookup(word: word, inContext: sentence)
|
||||||
|
lookupCache[word] = annotation
|
||||||
|
selectedWord = annotation
|
||||||
|
} catch {
|
||||||
|
selectedWord = WordAnnotation(word: word, baseForm: word, english: "Lookup unavailable", partOfSpeech: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanWord(_ word: String) -> String {
|
||||||
|
word.lowercased()
|
||||||
|
.trimmingCharacters(in: .punctuationCharacters)
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Word Button
|
||||||
|
|
||||||
|
private struct WordButton: View {
|
||||||
|
let word: String
|
||||||
|
let map: [String: WordAnnotation]
|
||||||
|
let cache: [String: WordAnnotation]
|
||||||
|
let onTap: (WordAnnotation) -> Void
|
||||||
|
|
||||||
|
private var cleaned: String {
|
||||||
|
word.lowercased()
|
||||||
|
.trimmingCharacters(in: .punctuationCharacters)
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var resolvedAnnotation: WordAnnotation {
|
||||||
|
map[cleaned] ?? cache[cleaned] ?? WordAnnotation(word: cleaned, baseForm: cleaned, english: "", partOfSpeech: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
onTap(resolvedAnnotation)
|
||||||
|
} label: {
|
||||||
|
Text(word + " ")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.underline(true, color: .teal.opacity(0.3))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wrapping HStack
|
||||||
|
|
||||||
|
private struct WrappingHStack<Content: View>: View {
|
||||||
|
let words: [String]
|
||||||
|
let content: (String) -> Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
FlowLayout(spacing: 0) {
|
||||||
|
ForEach(Array(words.enumerated()), id: \.offset) { _, word in
|
||||||
|
content(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - On-Demand Word Lookup
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
180
Conjuga/Conjuga/Views/Practice/VocabReviewView.swift
Normal file
180
Conjuga/Conjuga/Views/Practice/VocabReviewView.swift
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct VocabReviewView: View {
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
@Environment(\.modelContext) private var localContext
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var dueCards: [CourseReviewCard] = []
|
||||||
|
@State private var currentIndex = 0
|
||||||
|
@State private var isRevealed = false
|
||||||
|
@State private var sessionCorrect = 0
|
||||||
|
@State private var sessionTotal = 0
|
||||||
|
@State private var isFinished = false
|
||||||
|
|
||||||
|
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
if isFinished || dueCards.isEmpty {
|
||||||
|
finishedView
|
||||||
|
} else if let card = dueCards[safe: currentIndex] {
|
||||||
|
cardView(card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer(maxWidth: 600)
|
||||||
|
.navigationTitle("Vocab Review")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear(perform: loadDueCards)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Card View
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func cardView(_ card: CourseReviewCard) -> some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
// Progress
|
||||||
|
Text("\(currentIndex + 1) of \(dueCards.count)")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
ProgressView(value: Double(currentIndex), total: Double(dueCards.count))
|
||||||
|
.tint(.teal)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Front (Spanish)
|
||||||
|
Text(card.front)
|
||||||
|
.font(.largeTitle.bold())
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
if isRevealed {
|
||||||
|
// Back (English)
|
||||||
|
Text(card.back)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Rating buttons
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ratingButton("Again", color: .red, quality: .again)
|
||||||
|
ratingButton("Hard", color: .orange, quality: .hard)
|
||||||
|
ratingButton("Good", color: .green, quality: .good)
|
||||||
|
ratingButton("Easy", color: .blue, quality: .easy)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation { isRevealed = true }
|
||||||
|
} label: {
|
||||||
|
Text("Show Answer")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.teal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Finished View
|
||||||
|
|
||||||
|
private var finishedView: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: dueCards.isEmpty ? "checkmark.seal.fill" : "star.fill")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(dueCards.isEmpty ? .green : .yellow)
|
||||||
|
|
||||||
|
if dueCards.isEmpty {
|
||||||
|
Text("All caught up!")
|
||||||
|
.font(.title2.bold())
|
||||||
|
Text("No vocabulary cards are due for review.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("\(sessionCorrect) / \(sessionTotal)")
|
||||||
|
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
||||||
|
Text("Review complete!")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
|
||||||
|
Button {
|
||||||
|
rate(quality: quality)
|
||||||
|
} label: {
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rate(quality: ReviewQuality) {
|
||||||
|
guard let card = dueCards[safe: currentIndex] else { return }
|
||||||
|
|
||||||
|
let store = CourseReviewStore(context: cloudContext)
|
||||||
|
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? cloudContext.save()
|
||||||
|
|
||||||
|
sessionTotal += 1
|
||||||
|
if quality != .again { sessionCorrect += 1 }
|
||||||
|
|
||||||
|
isRevealed = false
|
||||||
|
if currentIndex + 1 < dueCards.count {
|
||||||
|
currentIndex += 1
|
||||||
|
} else {
|
||||||
|
withAnimation { isFinished = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadDueCards() {
|
||||||
|
let now = Date()
|
||||||
|
let descriptor = FetchDescriptor<CourseReviewCard>(
|
||||||
|
predicate: #Predicate<CourseReviewCard> { $0.dueDate <= now },
|
||||||
|
sortBy: [SortDescriptor(\CourseReviewCard.dueDate)]
|
||||||
|
)
|
||||||
|
dueCards = (try? cloudContext.fetch(descriptor)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dueCount(context: ModelContext) -> Int {
|
||||||
|
let now = Date()
|
||||||
|
let descriptor = FetchDescriptor<CourseReviewCard>(
|
||||||
|
predicate: #Predicate<CourseReviewCard> { $0.dueDate <= now }
|
||||||
|
)
|
||||||
|
return (try? context.fetchCount(descriptor)) ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Collection {
|
||||||
|
subscript(safe index: Index) -> Element? {
|
||||||
|
indices.contains(index) ? self[index] : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
99
Conjuga/Conjuga/Views/Settings/DownloadedVideosView.swift
Normal file
99
Conjuga/Conjuga/Views/Settings/DownloadedVideosView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
252
Conjuga/Conjuga/Views/Settings/FeatureReferenceView.swift
Normal file
252
Conjuga/Conjuga/Views/Settings/FeatureReferenceView.swift
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FeatureReferenceView: View {
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section("Verb Conjugation Practice") {
|
||||||
|
featureRow(
|
||||||
|
icon: "rectangle.stack", color: .blue,
|
||||||
|
title: "Flashcard / Typing / MC / Handwriting / Sentence Builder",
|
||||||
|
details: [
|
||||||
|
"Pulls from verb conjugation database (1,750 verbs)",
|
||||||
|
"Filtered by your Level setting",
|
||||||
|
"Filtered by your Enabled Tenses",
|
||||||
|
"Respects Include Vosotros setting",
|
||||||
|
"Due cards (SRS) shown first, then random",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
featureRow(
|
||||||
|
icon: "tablecells", color: .blue,
|
||||||
|
title: "Full Table",
|
||||||
|
details: [
|
||||||
|
"Shows all 6 person forms for one verb + tense",
|
||||||
|
"Random verb from your Level",
|
||||||
|
"Random tense from your Enabled Tenses",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Quick Actions") {
|
||||||
|
featureRow(
|
||||||
|
icon: "star.fill", color: .orange,
|
||||||
|
title: "Common Tenses",
|
||||||
|
details: [
|
||||||
|
"Restricts to 6 essential tenses: Present, Preterite, Imperfect, Future, Present Subjunctive, Imperative",
|
||||||
|
"Still filtered by your Level",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
featureRow(
|
||||||
|
icon: "exclamationmark.triangle", color: .red,
|
||||||
|
title: "Weak Verbs",
|
||||||
|
details: [
|
||||||
|
"Shows verbs you've struggled with (ease factor < 2.0)",
|
||||||
|
"Only includes verbs you've reviewed at least once",
|
||||||
|
"Weakest verbs shown first",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
featureRow(
|
||||||
|
icon: "wand.and.stars", color: .purple,
|
||||||
|
title: "Irregularity Drills",
|
||||||
|
details: [
|
||||||
|
"Spelling Changes: c->qu, g->gu, z->c patterns",
|
||||||
|
"Stem Changes: e->ie, o->ue, e->i patterns",
|
||||||
|
"Unique Irregulars: ser, ir, haber, etc.",
|
||||||
|
"Filtered by your Level and Enabled Tenses",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
featureRow(
|
||||||
|
icon: "rectangle.stack.fill", color: .teal,
|
||||||
|
title: "Vocab Review",
|
||||||
|
details: [
|
||||||
|
"Reviews 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",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Practice Activities") {
|
||||||
|
featureRow(
|
||||||
|
icon: "bubble.left.and.bubble.right.fill", color: .green,
|
||||||
|
title: "Conversation",
|
||||||
|
details: [
|
||||||
|
"AI chat partner using Apple Intelligence (on-device)",
|
||||||
|
"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",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
featureRow(
|
||||||
|
icon: "ear.fill", color: .blue,
|
||||||
|
title: "Listening",
|
||||||
|
details: [
|
||||||
|
"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",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
featureRow(
|
||||||
|
icon: "text.badge.minus", color: .indigo,
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
featureRow(
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Course") {
|
||||||
|
featureRow(
|
||||||
|
icon: "list.clipboard", color: .orange,
|
||||||
|
title: "Course Quizzes",
|
||||||
|
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: "checkmark.seal", color: .orange,
|
||||||
|
title: "Checkpoint Exams",
|
||||||
|
details: [
|
||||||
|
"Cumulative review across multiple weeks",
|
||||||
|
"Cards sampled evenly across all covered weeks",
|
||||||
|
"Choose 25, 50, or 100 questions",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
"7-day bar chart of daily study time",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "Daily Goal", affects: "Dashboard progress tracking only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("How Features Work")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Components
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func featureRow(icon: String, color: Color, title: String, details: [String]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.frame(width: 24)
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(details, id: \.self) { detail in
|
||||||
|
HStack(alignment: .top, spacing: 6) {
|
||||||
|
Text("•")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(detail)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, 34)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func settingRow(name: String, affects: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(name)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text(affects)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,11 @@ struct SettingsView: View {
|
|||||||
@State private var dailyGoal: Double = 50
|
@State private var dailyGoal: Double = 50
|
||||||
@State private var showVosotros: Bool = true
|
@State private var showVosotros: Bool = true
|
||||||
@State private var autoFillStem: Bool = false
|
@State private var autoFillStem: Bool = false
|
||||||
@State private var selectedLevel: VerbLevel = .basic
|
|
||||||
|
|
||||||
private let levels = VerbLevel.allCases
|
private let levels = VerbLevel.allCases
|
||||||
|
private let irregularCategories: [IrregularSpan.SpanCategory] = [
|
||||||
|
.spelling, .stemChange, .uniqueIrregular
|
||||||
|
]
|
||||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -40,19 +42,26 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Level") {
|
Section {
|
||||||
Picker("Current Level", selection: $selectedLevel) {
|
ForEach(levels, id: \.self) { level in
|
||||||
ForEach(levels, id: \.self) { level in
|
Toggle(level.displayName, isOn: Binding(
|
||||||
Text(level.displayName).tag(level)
|
get: {
|
||||||
}
|
progress?.selectedVerbLevels.contains(level) ?? false
|
||||||
}
|
},
|
||||||
.onChange(of: selectedLevel) { _, newValue in
|
set: { enabled in
|
||||||
progress?.selectedVerbLevel = newValue
|
guard let progress else { return }
|
||||||
saveProgress()
|
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("Tenses") {
|
Section {
|
||||||
ForEach(TenseInfo.all) { tense in
|
ForEach(TenseInfo.all) { tense in
|
||||||
Toggle(tense.english, isOn: Binding(
|
Toggle(tense.english, isOn: Binding(
|
||||||
get: {
|
get: {
|
||||||
@@ -65,6 +74,41 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Tenses")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach(irregularCategories, id: \.self) { category in
|
||||||
|
Toggle(category.rawValue, isOn: Binding(
|
||||||
|
get: {
|
||||||
|
progress?.enabledIrregularCategories.contains(category) ?? false
|
||||||
|
},
|
||||||
|
set: { enabled in
|
||||||
|
guard let progress else { return }
|
||||||
|
progress.setIrregularCategoryEnabled(category, enabled: enabled)
|
||||||
|
saveProgress()
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Irregular Types")
|
||||||
|
} footer: {
|
||||||
|
Text("Leave all off to include regular and irregular verbs. Enable any to restrict practice to those irregularity types.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("Reflexive verbs only", isOn: Binding(
|
||||||
|
get: { progress?.showReflexiveVerbsOnly ?? false },
|
||||||
|
set: { enabled in
|
||||||
|
progress?.showReflexiveVerbsOnly = enabled
|
||||||
|
saveProgress()
|
||||||
|
}
|
||||||
|
))
|
||||||
|
} header: {
|
||||||
|
Text("Reflexive")
|
||||||
|
} footer: {
|
||||||
|
Text("When on, practice pulls only from the curated list of common reflexive verbs.")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Stats") {
|
Section("Stats") {
|
||||||
@@ -75,6 +119,15 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Reference") {
|
||||||
|
NavigationLink("How Features Work") {
|
||||||
|
FeatureReferenceView()
|
||||||
|
}
|
||||||
|
NavigationLink("Downloaded Videos") {
|
||||||
|
DownloadedVideosView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section("About") {
|
Section("About") {
|
||||||
LabeledContent("Version", value: "1.0.0")
|
LabeledContent("Version", value: "1.0.0")
|
||||||
}
|
}
|
||||||
@@ -90,7 +143,6 @@ struct SettingsView: View {
|
|||||||
dailyGoal = Double(resolved.dailyGoal)
|
dailyGoal = Double(resolved.dailyGoal)
|
||||||
showVosotros = resolved.showVosotros
|
showVosotros = resolved.showVosotros
|
||||||
autoFillStem = resolved.autoFillStem
|
autoFillStem = resolved.autoFillStem
|
||||||
selectedLevel = resolved.selectedVerbLevel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveProgress() {
|
private func saveProgress() {
|
||||||
|
|||||||
@@ -4,14 +4,40 @@ import SwiftData
|
|||||||
|
|
||||||
struct VerbDetailView: View {
|
struct VerbDetailView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(VerbExampleCache.self) private var exampleCache
|
||||||
|
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
|
||||||
@State private var speechService = SpeechService()
|
@State private var speechService = SpeechService()
|
||||||
let verb: Verb
|
let verb: Verb
|
||||||
@State private var selectedTense: TenseInfo = TenseInfo.all[0]
|
@State private var selectedTense: TenseInfo = TenseInfo.all[0]
|
||||||
|
|
||||||
|
@State private var examples: [VerbExample] = []
|
||||||
|
@State private var examplesState: ExamplesState = .idle
|
||||||
|
|
||||||
|
private enum ExamplesState: Equatable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case loaded
|
||||||
|
case unavailable
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let exampleTenseIds: [String] = [
|
||||||
|
TenseID.ind_presente.rawValue,
|
||||||
|
TenseID.ind_preterito.rawValue,
|
||||||
|
TenseID.ind_imperfecto.rawValue,
|
||||||
|
TenseID.ind_futuro.rawValue,
|
||||||
|
TenseID.subj_presente.rawValue,
|
||||||
|
TenseID.imp_afirmativo.rawValue,
|
||||||
|
]
|
||||||
|
|
||||||
private var formsForTense: [VerbForm] {
|
private var formsForTense: [VerbForm] {
|
||||||
ReferenceStore(context: modelContext).fetchForms(verbId: verb.id, tenseId: selectedTense.id)
|
ReferenceStore(context: modelContext).fetchForms(verbId: verb.id, tenseId: selectedTense.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var reflexiveEntries: [ReflexiveVerb] {
|
||||||
|
reflexiveStore.entries(for: verb.infinitive)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
@@ -25,6 +51,10 @@ struct VerbDetailView: View {
|
|||||||
Text("Info")
|
Text("Info")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !reflexiveEntries.isEmpty {
|
||||||
|
reflexiveSection
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Picker("Tense", selection: $selectedTense) {
|
Picker("Tense", selection: $selectedTense) {
|
||||||
ForEach(TenseInfo.all) { tense in
|
ForEach(TenseInfo.all) { tense in
|
||||||
@@ -38,19 +68,36 @@ struct VerbDetailView: View {
|
|||||||
ContentUnavailableView("No forms loaded", systemImage: "exclamationmark.triangle")
|
ContentUnavailableView("No forms loaded", systemImage: "exclamationmark.triangle")
|
||||||
} else {
|
} else {
|
||||||
ForEach(formsForTense, id: \.personIndex) { form in
|
ForEach(formsForTense, id: \.personIndex) { form in
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(TenseInfo.persons[form.personIndex])
|
HStack {
|
||||||
.foregroundStyle(.secondary)
|
Text(TenseInfo.persons[form.personIndex])
|
||||||
.frame(minWidth: 100, alignment: .leading)
|
.foregroundStyle(.secondary)
|
||||||
Text(form.form)
|
.frame(minWidth: 100, alignment: .leading)
|
||||||
.fontWeight(form.regularity != "ordinary" ? .semibold : .regular)
|
Text(form.form)
|
||||||
.foregroundStyle(form.regularity != "ordinary" ? .red : .primary)
|
.fontWeight(form.regularity != "ordinary" ? .semibold : .regular)
|
||||||
|
.foregroundStyle(form.regularity != "ordinary" ? .red : .primary)
|
||||||
|
}
|
||||||
|
Text(EnglishConjugator.translate(
|
||||||
|
english: verb.english,
|
||||||
|
tenseId: selectedTense.id,
|
||||||
|
personIndex: form.personIndex
|
||||||
|
))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if formsForTense.contains(where: { $0.regularity != "ordinary" }) {
|
||||||
|
Label("Red indicates an irregular conjugation", systemImage: "info.circle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Conjugation")
|
Text("Conjugation")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
examplesSection
|
||||||
}
|
}
|
||||||
.navigationTitle(verb.infinitive)
|
.navigationTitle(verb.infinitive)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -63,6 +110,126 @@ struct VerbDetailView: View {
|
|||||||
.tint(.secondary)
|
.tint(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task(id: verb.id) {
|
||||||
|
await loadExamples()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reflexive
|
||||||
|
|
||||||
|
private var reflexiveSection: some View {
|
||||||
|
Section {
|
||||||
|
ForEach(Array(reflexiveEntries.enumerated()), id: \.offset) { _, entry in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
Text(entry.infinitive)
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
.italic()
|
||||||
|
if let hint = entry.usageHint, !hint.isEmpty {
|
||||||
|
Text(hint)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(entry.english)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Reflexive")
|
||||||
|
} footer: {
|
||||||
|
if reflexiveEntries.contains(where: { $0.usageHint != nil }) {
|
||||||
|
Text("Highlighted words are prepositions or phrases this verb commonly pairs with.")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Examples
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var examplesSection: some View {
|
||||||
|
Section {
|
||||||
|
switch examplesState {
|
||||||
|
case .idle, .loading:
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Generating examples…")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
case .unavailable:
|
||||||
|
Label("Examples require Apple Intelligence on this device.", systemImage: "sparkles")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
case .failed(let message):
|
||||||
|
Label(message, systemImage: "exclamationmark.triangle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
case .loaded:
|
||||||
|
if examples.isEmpty {
|
||||||
|
Label("No examples available.", systemImage: "text.quote")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(Array(examples.enumerated()), id: \.offset) { _, example in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
if let info = TenseInfo.find(example.tenseId) {
|
||||||
|
Text(info.english)
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
}
|
||||||
|
Text(example.spanish)
|
||||||
|
.font(.body)
|
||||||
|
.italic()
|
||||||
|
Text(example.english)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Examples")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadExamples() async {
|
||||||
|
// Reset state when navigating between verbs via NavigationSplitView.
|
||||||
|
examples = []
|
||||||
|
examplesState = .idle
|
||||||
|
|
||||||
|
if let cached = exampleCache.examples(for: verb.id), !cached.isEmpty {
|
||||||
|
examples = cached
|
||||||
|
examplesState = .loaded
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard VerbExampleGenerator.isAvailable else {
|
||||||
|
examplesState = .unavailable
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
examplesState = .loading
|
||||||
|
do {
|
||||||
|
let generated = try await VerbExampleGenerator.generate(
|
||||||
|
verbInfinitive: verb.infinitive,
|
||||||
|
verbEnglish: verb.english,
|
||||||
|
tenseIds: Self.exampleTenseIds
|
||||||
|
)
|
||||||
|
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.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,4 +238,6 @@ struct VerbDetailView: View {
|
|||||||
VerbDetailView(verb: Verb(id: 1, infinitive: "hablar", english: "to speak", rank: 1, ending: "ar", reflexive: 0, level: "basic"))
|
VerbDetailView(verb: Verb(id: 1, infinitive: "hablar", english: "to speak", rank: 1, ending: "ar", reflexive: 0, level: "basic"))
|
||||||
}
|
}
|
||||||
.modelContainer(for: [Verb.self, VerbForm.self], inMemory: true)
|
.modelContainer(for: [Verb.self, VerbForm.self], inMemory: true)
|
||||||
|
.environment(VerbExampleCache())
|
||||||
|
.environment(ReflexiveVerbStore())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,33 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
import SharedModels
|
import SharedModels
|
||||||
|
|
||||||
|
enum IrregularityCategory: String, CaseIterable, Identifiable {
|
||||||
|
case anyIrregular = "Any Irregular"
|
||||||
|
case spelling = "Spelling Change"
|
||||||
|
case stemChange = "Stem Change"
|
||||||
|
case uniqueIrregular = "Unique Irregular"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .anyIrregular: "asterisk"
|
||||||
|
case .spelling: "character.cursor.ibeam"
|
||||||
|
case .stemChange: "arrow.triangle.2.circlepath"
|
||||||
|
case .uniqueIrregular: "star"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct VerbListView: View {
|
struct VerbListView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
|
||||||
@State private var verbs: [Verb] = []
|
@State private var verbs: [Verb] = []
|
||||||
|
@State private var irregularityByVerbId: [Int: Set<IrregularityCategory>] = [:]
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var selectedLevel: String?
|
@State private var selectedLevel: String?
|
||||||
|
@State private var selectedIrregularity: IrregularityCategory?
|
||||||
|
@State private var reflexiveOnly: Bool = false
|
||||||
@State private var selectedVerb: Verb?
|
@State private var selectedVerb: Verb?
|
||||||
|
|
||||||
private var filteredVerbs: [Verb] {
|
private var filteredVerbs: [Verb] {
|
||||||
@@ -14,6 +36,15 @@ struct VerbListView: View {
|
|||||||
if let level = selectedLevel {
|
if let level = selectedLevel {
|
||||||
result = result.filter { VerbLevelGroup.matches($0.level, selectedLevel: level) }
|
result = result.filter { VerbLevelGroup.matches($0.level, selectedLevel: level) }
|
||||||
}
|
}
|
||||||
|
if let category = selectedIrregularity {
|
||||||
|
result = result.filter { verb in
|
||||||
|
guard let cats = irregularityByVerbId[verb.id] else { return false }
|
||||||
|
return category == .anyIrregular ? !cats.isEmpty : cats.contains(category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reflexiveOnly {
|
||||||
|
result = result.filter { reflexiveStore.isReflexive(baseInfinitive: $0.infinitive) }
|
||||||
|
}
|
||||||
if !searchText.isEmpty {
|
if !searchText.isEmpty {
|
||||||
let query = searchText.lowercased()
|
let query = searchText.lowercased()
|
||||||
result = result.filter {
|
result = result.filter {
|
||||||
@@ -30,20 +61,58 @@ struct VerbListView: View {
|
|||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
List(filteredVerbs, selection: $selectedVerb) { verb in
|
List(filteredVerbs, selection: $selectedVerb) { verb in
|
||||||
NavigationLink(value: verb) {
|
NavigationLink(value: verb) {
|
||||||
VerbRowView(verb: verb)
|
VerbRowView(verb: verb, irregularities: irregularityByVerbId[verb.id] ?? [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Verbs")
|
.navigationTitle("Verbs")
|
||||||
.searchable(text: $searchText, prompt: "Search verbs...")
|
.searchable(text: $searchText, prompt: "Search verbs...")
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
if hasActiveFilter {
|
||||||
|
activeFilterBar
|
||||||
|
}
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Menu {
|
Menu {
|
||||||
Button("All Levels") { selectedLevel = nil }
|
Section("Level") {
|
||||||
ForEach(levels, id: \.self) { level in
|
Button {
|
||||||
Button(level.capitalized) { selectedLevel = level }
|
selectedLevel = nil
|
||||||
|
} label: {
|
||||||
|
Label("All Levels", systemImage: selectedLevel == nil ? "checkmark" : "")
|
||||||
|
}
|
||||||
|
ForEach(levels, id: \.self) { level in
|
||||||
|
Button {
|
||||||
|
selectedLevel = level
|
||||||
|
} label: {
|
||||||
|
Label(level.capitalized, systemImage: selectedLevel == level ? "checkmark" : "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Irregularity") {
|
||||||
|
Button {
|
||||||
|
selectedIrregularity = nil
|
||||||
|
} label: {
|
||||||
|
Label("All Verbs", systemImage: selectedIrregularity == nil ? "checkmark" : "")
|
||||||
|
}
|
||||||
|
ForEach(IrregularityCategory.allCases) { category in
|
||||||
|
Button {
|
||||||
|
selectedIrregularity = category
|
||||||
|
} label: {
|
||||||
|
Label(category.rawValue, systemImage: selectedIrregularity == category ? "checkmark" : category.systemImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Reflexive") {
|
||||||
|
Button {
|
||||||
|
reflexiveOnly.toggle()
|
||||||
|
} label: {
|
||||||
|
Label("Reflexive verbs only", systemImage: reflexiveOnly ? "checkmark" : "arrow.triangle.2.circlepath")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Filter", systemImage: "line.3.horizontal.decrease.circle")
|
Label("Filter", systemImage: hasActiveFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,6 +127,56 @@ struct VerbListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var hasActiveFilter: Bool {
|
||||||
|
selectedLevel != nil || selectedIrregularity != nil || reflexiveOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var activeFilterBar: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if let level = selectedLevel {
|
||||||
|
filterChip(text: level.capitalized, systemImage: "graduationcap") {
|
||||||
|
selectedLevel = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let cat = selectedIrregularity {
|
||||||
|
filterChip(text: cat.rawValue, systemImage: cat.systemImage) {
|
||||||
|
selectedIrregularity = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reflexiveOnly {
|
||||||
|
filterChip(text: "Reflexive", systemImage: "arrow.triangle.2.circlepath") {
|
||||||
|
reflexiveOnly = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text("\(filteredVerbs.count)")
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filterChip(text: String, systemImage: String, onClear: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: onClear) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.caption2)
|
||||||
|
Text(text)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(.blue.opacity(0.15), in: Capsule())
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
private func loadVerbs() {
|
private func loadVerbs() {
|
||||||
// Hit the shared local container directly, bypassing @Environment.
|
// Hit the shared local container directly, bypassing @Environment.
|
||||||
guard let container = SharedStore.localContainer else {
|
guard let container = SharedStore.localContainer else {
|
||||||
@@ -69,12 +188,30 @@ struct VerbListView: View {
|
|||||||
}
|
}
|
||||||
let context = ModelContext(container)
|
let context = ModelContext(container)
|
||||||
verbs = ReferenceStore(context: context).fetchVerbs()
|
verbs = ReferenceStore(context: context).fetchVerbs()
|
||||||
print("[VerbListView] loaded \(verbs.count) verbs (container: \(ObjectIdentifier(container)))")
|
irregularityByVerbId = buildIrregularityIndex(context: context)
|
||||||
|
print("[VerbListView] loaded \(verbs.count) verbs, \(irregularityByVerbId.count) flagged irregular (container: \(ObjectIdentifier(container)))")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildIrregularityIndex(context: ModelContext) -> [Int: Set<IrregularityCategory>] {
|
||||||
|
let spans = (try? context.fetch(FetchDescriptor<IrregularSpan>())) ?? []
|
||||||
|
var index: [Int: Set<IrregularityCategory>] = [:]
|
||||||
|
for span in spans {
|
||||||
|
let category: IrregularityCategory
|
||||||
|
switch span.spanType {
|
||||||
|
case 100..<200: category = .spelling
|
||||||
|
case 200..<300: category = .stemChange
|
||||||
|
case 300..<400: category = .uniqueIrregular
|
||||||
|
default: continue
|
||||||
|
}
|
||||||
|
index[span.verbId, default: []].insert(category)
|
||||||
|
}
|
||||||
|
return index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VerbRowView: View {
|
struct VerbRowView: View {
|
||||||
let verb: Verb
|
let verb: Verb
|
||||||
|
var irregularities: Set<IrregularityCategory> = []
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -88,14 +225,39 @@ struct VerbRowView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Text(verb.level.prefix(3).uppercased())
|
HStack(spacing: 4) {
|
||||||
.font(.caption2)
|
ForEach(orderedIrregularities, id: \.self) { cat in
|
||||||
.fontWeight(.semibold)
|
Image(systemName: cat.systemImage)
|
||||||
.padding(.horizontal, 8)
|
.font(.caption2.weight(.semibold))
|
||||||
.padding(.vertical, 4)
|
.foregroundStyle(irregularityColor(cat))
|
||||||
.background(levelColor(verb.level).opacity(0.15))
|
.help(cat.rawValue)
|
||||||
.foregroundStyle(levelColor(verb.level))
|
.accessibilityLabel(cat.rawValue)
|
||||||
.clipShape(Capsule())
|
}
|
||||||
|
|
||||||
|
Text(verb.level.prefix(3).uppercased())
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(levelColor(verb.level).opacity(0.15))
|
||||||
|
.foregroundStyle(levelColor(verb.level))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var orderedIrregularities: [IrregularityCategory] {
|
||||||
|
// Order: unique > stem > spelling (most notable first)
|
||||||
|
let order: [IrregularityCategory] = [.uniqueIrregular, .stemChange, .spelling]
|
||||||
|
return order.filter { irregularities.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func irregularityColor(_ category: IrregularityCategory) -> Color {
|
||||||
|
switch category {
|
||||||
|
case .uniqueIrregular: return .purple
|
||||||
|
case .stemChange: return .orange
|
||||||
|
case .spelling: return .teal
|
||||||
|
case .anyIrregular: return .gray
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
104
Conjuga/Conjuga/reflexive_verbs.json
Normal file
104
Conjuga/Conjuga/reflexive_verbs.json
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
[
|
||||||
|
{"infinitive": "aburrirse", "baseInfinitive": "aburrir", "english": "to get bored"},
|
||||||
|
{"infinitive": "acercarse", "baseInfinitive": "acercar", "english": "to get close to", "usageHint": "a"},
|
||||||
|
{"infinitive": "acordarse", "baseInfinitive": "acordar", "english": "to remember", "usageHint": "de"},
|
||||||
|
{"infinitive": "acostarse", "baseInfinitive": "acostar", "english": "to lay down / to go to bed"},
|
||||||
|
{"infinitive": "acostumbrarse", "baseInfinitive": "acostumbrar", "english": "to get used to", "usageHint": "a"},
|
||||||
|
{"infinitive": "afeitarse", "baseInfinitive": "afeitar", "english": "to shave"},
|
||||||
|
{"infinitive": "alegrarse", "baseInfinitive": "alegrar", "english": "to be glad / happy / pleased"},
|
||||||
|
{"infinitive": "alejarse", "baseInfinitive": "alejar", "english": "to get away from", "usageHint": "de"},
|
||||||
|
{"infinitive": "animarse", "baseInfinitive": "animar", "english": "to cheer up / to dare to do something", "usageHint": "a"},
|
||||||
|
{"infinitive": "apurarse", "baseInfinitive": "apurar", "english": "to hurry"},
|
||||||
|
{"infinitive": "aprovecharse", "baseInfinitive": "aprovechar", "english": "to take advantage of", "usageHint": "de"},
|
||||||
|
{"infinitive": "asustarse", "baseInfinitive": "asustar", "english": "to get or become afraid"},
|
||||||
|
{"infinitive": "atreverse", "baseInfinitive": "atrever", "english": "to dare to", "usageHint": "a"},
|
||||||
|
{"infinitive": "bañarse", "baseInfinitive": "bañar", "english": "to take a bath / shower"},
|
||||||
|
{"infinitive": "burlarse", "baseInfinitive": "burlar", "english": "to make fun of", "usageHint": "de"},
|
||||||
|
{"infinitive": "caerse", "baseInfinitive": "caer", "english": "to fall down"},
|
||||||
|
{"infinitive": "calmarse", "baseInfinitive": "calmar", "english": "to calm down"},
|
||||||
|
{"infinitive": "cansarse", "baseInfinitive": "cansar", "english": "to get tired (of)", "usageHint": "(de)"},
|
||||||
|
{"infinitive": "casarse", "baseInfinitive": "casar", "english": "to marry", "usageHint": "con"},
|
||||||
|
{"infinitive": "cepillarse", "baseInfinitive": "cepillar", "english": "to brush (hair, teeth)"},
|
||||||
|
{"infinitive": "deprimirse", "baseInfinitive": "deprimir", "english": "to become depressed"},
|
||||||
|
{"infinitive": "conformarse", "baseInfinitive": "conformar", "english": "to resign oneself to", "usageHint": "con"},
|
||||||
|
{"infinitive": "volverse", "baseInfinitive": "volver", "english": "to become / to turn into / to return"},
|
||||||
|
{"infinitive": "darse", "baseInfinitive": "dar", "english": "to realize", "usageHint": "cuenta de"},
|
||||||
|
{"infinitive": "dedicarse", "baseInfinitive": "dedicar", "english": "to dedicate oneself to / to do for a living", "usageHint": "a"},
|
||||||
|
{"infinitive": "despedirse", "baseInfinitive": "despedir", "english": "to say goodbye", "usageHint": "(de)"},
|
||||||
|
{"infinitive": "despertarse", "baseInfinitive": "despertar", "english": "to wake up"},
|
||||||
|
{"infinitive": "desvestirse", "baseInfinitive": "desvestir", "english": "to undress"},
|
||||||
|
{"infinitive": "dirigirse", "baseInfinitive": "dirigir", "english": "to go to / make one's way toward / to address", "usageHint": "a"},
|
||||||
|
{"infinitive": "hacerse", "baseInfinitive": "hacer", "english": "to become / to pretend"},
|
||||||
|
{"infinitive": "divertirse", "baseInfinitive": "divertir", "english": "to have fun"},
|
||||||
|
{"infinitive": "dormirse", "baseInfinitive": "dormir", "english": "to fall asleep / to oversleep"},
|
||||||
|
{"infinitive": "ducharse", "baseInfinitive": "duchar", "english": "to shower"},
|
||||||
|
{"infinitive": "echarse", "baseInfinitive": "echar", "english": "to begin (usually suddenly) to do something / to break into", "usageHint": "a"},
|
||||||
|
{"infinitive": "enamorarse", "baseInfinitive": "enamorar", "english": "to fall in love with", "usageHint": "de"},
|
||||||
|
{"infinitive": "encargarse", "baseInfinitive": "encargar", "english": "to take charge of or be responsible for", "usageHint": "de"},
|
||||||
|
{"infinitive": "encogerse", "baseInfinitive": "encoger", "english": "to shrug (shoulders)", "usageHint": "(de hombros)"},
|
||||||
|
{"infinitive": "encontrarse", "baseInfinitive": "encontrar", "english": "to meet with / to run into someone", "usageHint": "(con)"},
|
||||||
|
{"infinitive": "enfermarse", "baseInfinitive": "enfermar", "english": "to get sick"},
|
||||||
|
{"infinitive": "enojarse", "baseInfinitive": "enojar", "english": "to get or become angry"},
|
||||||
|
{"infinitive": "enterarse", "baseInfinitive": "enterar", "english": "to find out, to realize", "usageHint": "de"},
|
||||||
|
{"infinitive": "exponerse", "baseInfinitive": "exponer", "english": "to expose oneself to or run the risk of", "usageHint": "a"},
|
||||||
|
{"infinitive": "fijarse", "baseInfinitive": "fijar", "english": "to pay attention to / to take a look"},
|
||||||
|
{"infinitive": "jugarse", "baseInfinitive": "jugar", "english": "to risk"},
|
||||||
|
{"infinitive": "lastimarse", "baseInfinitive": "lastimar", "english": "to get hurt or hurt oneself"},
|
||||||
|
{"infinitive": "lavarse", "baseInfinitive": "lavar", "english": "to wash (a body part)"},
|
||||||
|
{"infinitive": "levantarse", "baseInfinitive": "levantar", "english": "to get up"},
|
||||||
|
{"infinitive": "maquillarse", "baseInfinitive": "maquillar", "english": "to put makeup on"},
|
||||||
|
{"infinitive": "meterse", "baseInfinitive": "meter", "english": "to get into / to pick on / to pick a fight with", "usageHint": "en / con"},
|
||||||
|
{"infinitive": "motivarse", "baseInfinitive": "motivar", "english": "to become or get motivated to"},
|
||||||
|
{"infinitive": "moverse", "baseInfinitive": "mover", "english": "to move oneself"},
|
||||||
|
{"infinitive": "mudarse", "baseInfinitive": "mudar", "english": "to move (change residence)"},
|
||||||
|
{"infinitive": "negarse", "baseInfinitive": "negar", "english": "to refuse to", "usageHint": "a"},
|
||||||
|
{"infinitive": "obsesionarse", "baseInfinitive": "obsesionar", "english": "to be or get obsessed with", "usageHint": "con"},
|
||||||
|
{"infinitive": "ocuparse", "baseInfinitive": "ocupar", "english": "to look after", "usageHint": "de"},
|
||||||
|
{"infinitive": "olvidarse", "baseInfinitive": "olvidar", "english": "to forget", "usageHint": "de"},
|
||||||
|
{"infinitive": "parecerse", "baseInfinitive": "parecer", "english": "to look like someone or something", "usageHint": "a"},
|
||||||
|
{"infinitive": "peinarse", "baseInfinitive": "peinar", "english": "to comb your hair"},
|
||||||
|
{"infinitive": "ponerse", "baseInfinitive": "poner", "english": "to put on (clothing) / to get or become"},
|
||||||
|
{"infinitive": "ponerse", "baseInfinitive": "poner", "english": "to come to an agreement with someone", "usageHint": "de acuerdo"},
|
||||||
|
{"infinitive": "preocuparse", "baseInfinitive": "preocupar", "english": "to worry about", "usageHint": "por"},
|
||||||
|
{"infinitive": "prepararse", "baseInfinitive": "preparar", "english": "to prepare to"},
|
||||||
|
{"infinitive": "probarse", "baseInfinitive": "probar", "english": "to try on"},
|
||||||
|
{"infinitive": "quebrarse", "baseInfinitive": "quebrar", "english": "to break (an arm, leg, etc.)"},
|
||||||
|
{"infinitive": "quejarse", "baseInfinitive": "quejar", "english": "to complain about", "usageHint": "de"},
|
||||||
|
{"infinitive": "quedarse", "baseInfinitive": "quedar", "english": "to remain / to stay"},
|
||||||
|
{"infinitive": "quemarse", "baseInfinitive": "quemar", "english": "to burn oneself / one's body"},
|
||||||
|
{"infinitive": "quitarse", "baseInfinitive": "quitar", "english": "to take off (clothing, etc.)"},
|
||||||
|
{"infinitive": "reírse", "baseInfinitive": "reír", "english": "to laugh about", "usageHint": "de"},
|
||||||
|
{"infinitive": "resignarse", "baseInfinitive": "resignar", "english": "to resign oneself to", "usageHint": "a"},
|
||||||
|
{"infinitive": "romperse", "baseInfinitive": "romper", "english": "to break (an arm, leg, etc.)"},
|
||||||
|
{"infinitive": "secarse", "baseInfinitive": "secar", "english": "to dry (a body part)"},
|
||||||
|
{"infinitive": "sentarse", "baseInfinitive": "sentar", "english": "to sit down"},
|
||||||
|
{"infinitive": "sentirse", "baseInfinitive": "sentir", "english": "to feel"},
|
||||||
|
{"infinitive": "servirse", "baseInfinitive": "servir", "english": "to help oneself to (food)"},
|
||||||
|
{"infinitive": "suicidarse", "baseInfinitive": "suicidar", "english": "to commit suicide"},
|
||||||
|
{"infinitive": "tratarse", "baseInfinitive": "tratar", "english": "to be about", "usageHint": "de"},
|
||||||
|
{"infinitive": "vestirse", "baseInfinitive": "vestir", "english": "to get dressed"},
|
||||||
|
{"infinitive": "marearse", "baseInfinitive": "marear", "english": "to get sick, to get dizzy"},
|
||||||
|
{"infinitive": "irse", "baseInfinitive": "ir", "english": "to leave"},
|
||||||
|
{"infinitive": "imaginarse", "baseInfinitive": "imaginar", "english": "to imagine"},
|
||||||
|
{"infinitive": "preguntarse", "baseInfinitive": "preguntar", "english": "to wonder"},
|
||||||
|
{"infinitive": "llamarse", "baseInfinitive": "llamar", "english": "to be called"},
|
||||||
|
{"infinitive": "verse", "baseInfinitive": "ver", "english": "to look or appear"},
|
||||||
|
{"infinitive": "distraerse", "baseInfinitive": "distraer", "english": "to get distracted"},
|
||||||
|
{"infinitive": "concentrarse", "baseInfinitive": "concentrar", "english": "to focus"},
|
||||||
|
{"infinitive": "rendirse", "baseInfinitive": "rendir", "english": "to give up"},
|
||||||
|
{"infinitive": "relajarse", "baseInfinitive": "relajar", "english": "to relax"},
|
||||||
|
{"infinitive": "merecerse", "baseInfinitive": "merecer", "english": "to deserve"},
|
||||||
|
{"infinitive": "suponerse", "baseInfinitive": "suponer", "english": "to suppose"},
|
||||||
|
{"infinitive": "conectarse", "baseInfinitive": "conectar", "english": "to connect"},
|
||||||
|
{"infinitive": "destacarse", "baseInfinitive": "destacar", "english": "to stand out"},
|
||||||
|
{"infinitive": "recibirse", "baseInfinitive": "recibir", "english": "to graduate"},
|
||||||
|
{"infinitive": "graduarse", "baseInfinitive": "graduar", "english": "to graduate"},
|
||||||
|
{"infinitive": "perderse", "baseInfinitive": "perder", "english": "to get lost"},
|
||||||
|
{"infinitive": "cambiarse", "baseInfinitive": "cambiar", "english": "to change (clothing)", "usageHint": "(de ropa)"},
|
||||||
|
{"infinitive": "adaptarse", "baseInfinitive": "adaptar", "english": "to adapt, to adjust", "usageHint": "a"},
|
||||||
|
{"infinitive": "salirse", "baseInfinitive": "salir", "english": "to get away with", "usageHint": "con (la suya)"},
|
||||||
|
{"infinitive": "subirse", "baseInfinitive": "subir", "english": "to get on (the bus, etc.)", "usageHint": "a"},
|
||||||
|
{"infinitive": "tranquilizarse", "baseInfinitive": "tranquilizar", "english": "to relax"},
|
||||||
|
{"infinitive": "equivocarse", "baseInfinitive": "equivocar", "english": "to get something wrong / confused"},
|
||||||
|
{"infinitive": "confundirse", "baseInfinitive": "confundir", "english": "to get something wrong / confused"}
|
||||||
|
]
|
||||||
1
Conjuga/Conjuga/textbook_data.json
Normal file
1
Conjuga/Conjuga/textbook_data.json
Normal file
File diff suppressed because one or more lines are too long
25099
Conjuga/Conjuga/textbook_vocab.json
Normal file
25099
Conjuga/Conjuga/textbook_vocab.json
Normal file
File diff suppressed because it is too large
Load Diff
61
Conjuga/Conjuga/youtube_videos.json
Normal file
61
Conjuga/Conjuga/youtube_videos.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"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.",
|
||||||
|
"tenseGuides": {
|
||||||
|
"ind_presente": {"videoId": "8HWXJjxvOTE", "title": "Spanish Present Tense: Regular -AR -ER -IR verb conjugation"},
|
||||||
|
"ind_preterito": {"videoId": "R4SiKCStHuU", "title": "Preterite / pretérito in Spanish: how to form it (animated)"},
|
||||||
|
"ind_imperfecto": {"videoId": "hMg05drgI7w", "title": "Spanish Imperfect Tense Tutorial v2.0"},
|
||||||
|
"ind_futuro": {"videoId": "yjQGJFCUOog", "title": "Regular Future Tense Conjugation in Spanish (w/ Ser, Estar & Ir)"},
|
||||||
|
"ind_perfecto": {"videoId": "y_yeb6qkMbs", "title": "Forming the PRESENT PERFECT in Spanish (PRESENTE PERFECTO)"},
|
||||||
|
"ind_pluscuamperfecto": {"videoId": "5VpGDhJ8eNw", "title": "Past Perfect / Pluperfect / Pluscuamperfecto in Spanish"},
|
||||||
|
"ind_futuro_perfecto": {"videoId": "459J8Cy-9DU", "title": "FUTURE PERFECT: How to form verbs in the futuro perfecto in Spanish"},
|
||||||
|
"cond_presente": {"videoId": "9ctJ6I-4NJ8", "title": "03 Spanish Lesson - Conditional Tense"},
|
||||||
|
"cond_perfecto": {"videoId": "jTBATres2hw", "title": "How to form the CONDITIONAL PERFECT in Spanish (condicional perfecto)"},
|
||||||
|
"subj_presente": {"videoId": "CRvXpo45oHw", "title": "The Subjunctive in Spanish — The Language Tutor Lesson 58"},
|
||||||
|
"subj_imperfecto_1": {"videoId": "oqMCJORRdVs", "title": "Easily conquer the Spanish Imperfect Subjunctive"},
|
||||||
|
"subj_imperfecto_2": {"videoId": "oqMCJORRdVs", "title": "Easily conquer the Spanish Imperfect Subjunctive"},
|
||||||
|
"subj_perfecto": {"videoId": "gAgFFpt6-08", "title": "Present Perfect Subjunctive Spanish Guide: How to Use 'Haya'"},
|
||||||
|
"subj_pluscuamperfecto_1": {"videoId": "aAQCodqWhkU", "title": "The Past Perfect or Pluperfect Subjunctive in Spanish: Forms and Uses"},
|
||||||
|
"subj_pluscuamperfecto_2": {"videoId": "aAQCodqWhkU", "title": "The Past Perfect or Pluperfect Subjunctive in Spanish: Forms and Uses"},
|
||||||
|
"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": "uQi14msiaYg", "title": "Commands in Spanish: The Imperative Mood Explained"},
|
||||||
|
"imp_negativo": {"videoId": "wsLFs_OQOfM", "title": "The Negative Imperative in Spanish"}
|
||||||
|
},
|
||||||
|
"grammarNotes": {
|
||||||
|
"ser-vs-estar": {"videoId": "X-7k7R3Ca9U", "title": "SER vs. ESTAR — The COMPLETE guide | How to Use 'To Be' in Spanish"},
|
||||||
|
"por-vs-para": {"videoId": "PX6wnebioOA", "title": "Por vs Para — The definitive guide"},
|
||||||
|
"preterite-vs-imperfect": {"videoId": "DfrpSIAuUjg", "title": "Preterite vs Imperfect in Spanish: Never Confuse Them Again"},
|
||||||
|
"subjunctive-triggers": {"videoId": "OzGWFJTcrKc", "title": "Spanish Subjunctive Part 2/5: Wishes, Emotions & Doubt (WEIRDO Triggers)"},
|
||||||
|
"reflexive-verbs": {"videoId": "z2UXjjp3vnI", "title": "Spanish Reflexive Verbs: How-To, 20 Verbs & My 1 RULE"},
|
||||||
|
"object-pronouns": {"videoId": "vJD6AeHZ0j4", "title": "DIRECT & INDIRECT OBJECT PRONOUNS in Spanish: ALL you need to know"},
|
||||||
|
"gustar-like-verbs": {"videoId": "eCDWXZlDHUA", "title": "How Verbs Like Gustar Work: Never Confuse Them Again"},
|
||||||
|
"comparatives-superlatives": {"videoId": "OSxtLNHaRQg", "title": "Learn the COMPARATIVE and SUPERLATIVE in Spanish"},
|
||||||
|
"conditional-if-clauses": {"videoId": "thvW8qVsqkE", "title": "Si Clauses: The Spanish Hypothetical Explained"},
|
||||||
|
"commands-imperative": {"videoId": "uQi14msiaYg", "title": "Commands in Spanish: The Imperative Mood Explained"},
|
||||||
|
"saber-vs-conocer": {"videoId": "j87i7MVCvIE", "title": "Saber vs. Conocer: Right (and WRONG) Times to Use These Spanish Verbs"},
|
||||||
|
"double-negatives": {"videoId": "dmcLNMYxMFI", "title": "Learn Spanish Grammar: Double Negatives in Spanish"},
|
||||||
|
"adjective-placement": {"videoId": "JNh6nuZe_zo", "title": "SPANISH ADJECTIVES: BEFORE or AFTER NOUNS??"},
|
||||||
|
"tener-expressions": {"videoId": "uD1rcv_ZTNA", "title": "Idiomatic Expressions with TENER"},
|
||||||
|
"personal-a": {"videoId": "5QRZ13VZ2PE", "title": "Personal 'A' in Spanish: What is it & How to Use it"},
|
||||||
|
"relative-pronouns": {"videoId": "2YmFy5sJOj8", "title": "Master Spanish Relative Pronouns: donde, cuando, como, que, quien, cuyo"},
|
||||||
|
"future-vs-ir-a": {"videoId": "oGHz-O_m0tk", "title": "IR A + Infinitive VS. Future Tense: What's the difference in Spanish?"},
|
||||||
|
"accent-marks-stress": {"videoId": "iBWTR-a3pZc", "title": "LA TILDE | Word Stress and Accent Marks in Spanish"},
|
||||||
|
"se-constructions": {"videoId": "ndxsrGD7b-8", "title": "Understanding 'SE' in Spanish: Reflexive, Passive, and Impersonal Constructions"},
|
||||||
|
"spanish-suffixes": {"videoId": "2acPjFrmJCc", "title": "How to use Suffixes in Spanish - Basic Grammar"},
|
||||||
|
"common-irregular-verbs": {"videoId": "1CmeCwO0t5w", "title": "Master the 4 Most Important Irregular Verbs in Spanish (SER, ESTAR, TENER, IR)"},
|
||||||
|
"types-of-irregular-verbs": {"videoId": "tQuQcuwsIqw", "title": "Stem-Changing Verbs in Spanish: 90% of 'Irregular' Verbs Solved"},
|
||||||
|
"present-indicative-conjugation": {"videoId": "8HWXJjxvOTE", "title": "Spanish Present Tense: Regular -AR -ER -IR verb conjugation"},
|
||||||
|
"articles-and-gender": {"videoId": "h2b37zYtQuc", "title": "Definite Articles in Spanish: Rules and Examples"},
|
||||||
|
"possessive-adjectives": {"videoId": "zJQxR4mUj2Y", "title": "Possessive adjectives in Spanish for beginners"},
|
||||||
|
"demonstrative-adjectives": {"videoId": "jZJ0tE3WZlo", "title": "THIS & THAT in Spanish: How to use ESTE, ESE, AQUEL"},
|
||||||
|
"greetings-farewells": {"videoId": "AqfQQZVmTUw", "title": "Every Spanish Greeting You Need (Formal, Casual & Slang)"},
|
||||||
|
"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": "l29XtaZSSyY", "title": "PREPOSITIONAL PRONOUNS: How and when to use them in Spanish"},
|
||||||
|
"irregular-yo-verbs": {"videoId": "yRf6adUKSzQ", "title": "Spanish Irregular Yo Form Verbs — Go Go Verbs Song"},
|
||||||
|
"stem-changing-verbs": {"videoId": "tQuQcuwsIqw", "title": "Stem-Changing Verbs in Spanish: 90% of 'Irregular' Verbs Solved"},
|
||||||
|
"stressed-possessives": {"videoId": "epObIkGAPoU", "title": "Spanish Long Form Possessive Adjectives Grammar | Possessive Pronouns"},
|
||||||
|
"present-perfect-tense": {"videoId": "y_yeb6qkMbs", "title": "Forming the PRESENT PERFECT in Spanish (PRESENTE PERFECTO)"},
|
||||||
|
"future-perfect-tense": {"videoId": "459J8Cy-9DU", "title": "FUTURE PERFECT: How to form verbs in the futuro perfecto in Spanish"}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
Conjuga/ConjugaUITests/AllChaptersScreenshotTests.swift
Normal file
95
Conjuga/ConjugaUITests/AllChaptersScreenshotTests.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
66
Conjuga/ConjugaUITests/StemChangeToggleTests.swift
Normal file
66
Conjuga/ConjugaUITests/StemChangeToggleTests.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
Conjuga/ConjugaUITests/TextbookFlowUITests.swift
Normal file
80
Conjuga/ConjugaUITests/TextbookFlowUITests.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
Conjuga/ConjugaUITests/VocabGridTests.swift
Normal file
53
Conjuga/ConjugaUITests/VocabGridTests.swift
Normal file
@@ -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,14 +41,16 @@ struct CombinedProvider: TimelineProvider {
|
|||||||
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
|
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
|
||||||
guard let localURL = SharedStore.localStoreURL() else { return nil }
|
guard let localURL = SharedStore.localStoreURL() else { return nil }
|
||||||
|
|
||||||
// MUST declare all 6 local entities to match the main app's schema.
|
// MUST declare all 7 local entities to match the main app's schema.
|
||||||
// Declaring a subset would cause SwiftData to destructively migrate the store
|
// Declaring a subset would cause SwiftData to destructively migrate the
|
||||||
// on open, dropping the entities not listed here.
|
// store on open, dropping the entities not listed here (this is how we
|
||||||
|
// previously lost all TextbookChapter rows on every widget refresh).
|
||||||
let config = ModelConfiguration(
|
let config = ModelConfiguration(
|
||||||
"local",
|
"local",
|
||||||
schema: Schema([
|
schema: Schema([
|
||||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||||
|
TextbookChapter.self,
|
||||||
]),
|
]),
|
||||||
url: localURL,
|
url: localURL,
|
||||||
cloudKitDatabase: .none
|
cloudKitDatabase: .none
|
||||||
@@ -56,6 +58,7 @@ struct CombinedProvider: TimelineProvider {
|
|||||||
guard let container = try? ModelContainer(
|
guard let container = try? ModelContainer(
|
||||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||||
|
TextbookChapter.self,
|
||||||
configurations: config
|
configurations: config
|
||||||
) else { return nil }
|
) else { return nil }
|
||||||
|
|
||||||
|
|||||||
@@ -32,14 +32,16 @@ struct WordOfDayProvider: TimelineProvider {
|
|||||||
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
|
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
|
||||||
guard let localURL = SharedStore.localStoreURL() else { return nil }
|
guard let localURL = SharedStore.localStoreURL() else { return nil }
|
||||||
|
|
||||||
// MUST declare all 6 local entities to match the main app's schema.
|
// MUST declare all 7 local entities to match the main app's schema.
|
||||||
// Declaring a subset would cause SwiftData to destructively migrate the store
|
// Declaring a subset would cause SwiftData to destructively migrate the
|
||||||
// on open, dropping the entities not listed here.
|
// store on open, dropping the entities not listed here (this is how we
|
||||||
|
// previously lost all TextbookChapter rows on every widget refresh).
|
||||||
let config = ModelConfiguration(
|
let config = ModelConfiguration(
|
||||||
"local",
|
"local",
|
||||||
schema: Schema([
|
schema: Schema([
|
||||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||||
|
TextbookChapter.self,
|
||||||
]),
|
]),
|
||||||
url: localURL,
|
url: localURL,
|
||||||
cloudKitDatabase: .none
|
cloudKitDatabase: .none
|
||||||
@@ -47,6 +49,7 @@ struct WordOfDayProvider: TimelineProvider {
|
|||||||
guard let container = try? ModelContainer(
|
guard let container = try? ModelContainer(
|
||||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||||
|
TextbookChapter.self,
|
||||||
configurations: config
|
configurations: config
|
||||||
) else { return nil }
|
) else { return nil }
|
||||||
|
|
||||||
|
|||||||
@@ -8486,7 +8486,7 @@
|
|||||||
"cards": [
|
"cards": [
|
||||||
{
|
{
|
||||||
"front": "tener",
|
"front": "tener",
|
||||||
"back": "tengo",
|
"back": "tengo — I have",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "The Spanish Verb \"Tener\"",
|
"es": "The Spanish Verb \"Tener\"",
|
||||||
@@ -8504,7 +8504,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "venir",
|
"front": "venir",
|
||||||
"back": "vengo",
|
"back": "vengo — I come",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "Lo mejor está por venir.",
|
"es": "Lo mejor está por venir.",
|
||||||
@@ -8522,7 +8522,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "hacer",
|
"front": "hacer",
|
||||||
"back": "hago",
|
"back": "hago — I do, I make",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "Expressions with \"Hacer\"",
|
"es": "Expressions with \"Hacer\"",
|
||||||
@@ -8540,7 +8540,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "salir",
|
"front": "salir",
|
||||||
"back": "salgo",
|
"back": "salgo — I go out",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "Usa el ascensor para salir.",
|
"es": "Usa el ascensor para salir.",
|
||||||
@@ -8558,7 +8558,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "caer",
|
"front": "caer",
|
||||||
"back": "caigo",
|
"back": "caigo — I fall",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "¡Acabo de caer en que tengo una cita con el dentista en diez minutos!",
|
"es": "¡Acabo de caer en que tengo una cita con el dentista en diez minutos!",
|
||||||
@@ -8576,7 +8576,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "traer",
|
"front": "traer",
|
||||||
"back": "traigo",
|
"back": "traigo — I bring",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "Esto puede traer cambios profundos en bares, discotecas y restaurantes.",
|
"es": "Esto puede traer cambios profundos en bares, discotecas y restaurantes.",
|
||||||
@@ -8594,7 +8594,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "poner",
|
"front": "poner",
|
||||||
"back": "pongo",
|
"back": "pongo — I put",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "Después de bañar a la bebé, hay que ponerle ropa limpia.",
|
"es": "Después de bañar a la bebé, hay que ponerle ropa limpia.",
|
||||||
@@ -8612,7 +8612,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "decir",
|
"front": "decir",
|
||||||
"back": "digo",
|
"back": "digo — I say",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "¿Jura decir la verdad?",
|
"es": "¿Jura decir la verdad?",
|
||||||
@@ -8630,7 +8630,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "conducir",
|
"front": "conducir",
|
||||||
"back": "conduzco",
|
"back": "conduzco — I lead, I drive",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "conducir(kohn-doo-seer)",
|
"es": "conducir(kohn-doo-seer)",
|
||||||
@@ -8648,7 +8648,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "conocer",
|
"front": "conocer",
|
||||||
"back": "conozco",
|
"back": "conozco — I know, I meet",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "conocer(koh-noh-sehr)",
|
"es": "conocer(koh-noh-sehr)",
|
||||||
@@ -8666,7 +8666,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "agradecer",
|
"front": "agradecer",
|
||||||
"back": "agradezco",
|
"back": "agradezco — I thank",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "agradecer(ah-grah-deh-sehr)",
|
"es": "agradecer(ah-grah-deh-sehr)",
|
||||||
@@ -8684,7 +8684,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "parecer",
|
"front": "parecer",
|
||||||
"back": "parezco",
|
"back": "parezco — I seem",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "parecer(pah-reh-sehr)",
|
"es": "parecer(pah-reh-sehr)",
|
||||||
@@ -8702,7 +8702,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "crecer",
|
"front": "crecer",
|
||||||
"back": "crezco",
|
"back": "crezco — I grow",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "crecer(kreh-sehr)",
|
"es": "crecer(kreh-sehr)",
|
||||||
@@ -8720,7 +8720,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "producir",
|
"front": "producir",
|
||||||
"back": "produzco",
|
"back": "produzco — I produce",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "producir(proh-doo-seer)",
|
"es": "producir(proh-doo-seer)",
|
||||||
@@ -8738,7 +8738,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "traducir",
|
"front": "traducir",
|
||||||
"back": "traduzco",
|
"back": "traduzco — I translate",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "traducir(trah-doo-seer)",
|
"es": "traducir(trah-doo-seer)",
|
||||||
@@ -8756,7 +8756,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "establecer",
|
"front": "establecer",
|
||||||
"back": "establezco",
|
"back": "establezco — I establish",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "establecer(ehs-tah-bleh-sehr)",
|
"es": "establecer(ehs-tah-bleh-sehr)",
|
||||||
@@ -8774,7 +8774,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "elejir",
|
"front": "elejir",
|
||||||
"back": "elijo",
|
"back": "elijo — I choose",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "En realidad cada persona será libre de elejir su comida.",
|
"es": "En realidad cada persona será libre de elejir su comida.",
|
||||||
@@ -8792,7 +8792,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "proteger",
|
"front": "proteger",
|
||||||
"back": "protejo",
|
"back": "protejo — I protect",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "proteger(proh-teh-hehr)",
|
"es": "proteger(proh-teh-hehr)",
|
||||||
@@ -8810,7 +8810,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "dirigir",
|
"front": "dirigir",
|
||||||
"back": "dirijo",
|
"back": "dirijo — I manage, I direct",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "dirigir(dee-ree-heer)",
|
"es": "dirigir(dee-ree-heer)",
|
||||||
@@ -8828,7 +8828,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "fingir",
|
"front": "fingir",
|
||||||
"back": "finjo",
|
"back": "finjo — I pretend, I feign",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "fingir(feen-heer)",
|
"es": "fingir(feen-heer)",
|
||||||
@@ -8846,7 +8846,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "sumergir",
|
"front": "sumergir",
|
||||||
"back": "sumerjo",
|
"back": "sumerjo — I submerge",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "sumergir(soo-mehr-heer)",
|
"es": "sumergir(soo-mehr-heer)",
|
||||||
@@ -8864,7 +8864,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "ver",
|
"front": "ver",
|
||||||
"back": "veo",
|
"back": "veo — I see",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "¿Quieres ver mi carro nuevo?",
|
"es": "¿Quieres ver mi carro nuevo?",
|
||||||
@@ -8882,7 +8882,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "saber",
|
"front": "saber",
|
||||||
"back": "sé",
|
"back": "sé — I know, I taste",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "El saber popular se basa en creencias.",
|
"es": "El saber popular se basa en creencias.",
|
||||||
@@ -8900,7 +8900,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "distinguir",
|
"front": "distinguir",
|
||||||
"back": "distingo",
|
"back": "distingo — I distinguish",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "distinguir(dees-teeng-geer)",
|
"es": "distinguir(dees-teeng-geer)",
|
||||||
@@ -8918,7 +8918,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "oír",
|
"front": "oír",
|
||||||
"back": "oigo",
|
"back": "oigo — I hear",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "Me quejé a mucha gente, pero nadie quiso oírme.",
|
"es": "Me quejé a mucha gente, pero nadie quiso oírme.",
|
||||||
@@ -8943,7 +8943,7 @@
|
|||||||
"cards": [
|
"cards": [
|
||||||
{
|
{
|
||||||
"front": "tener",
|
"front": "tener",
|
||||||
"back": "tengo",
|
"back": "tengo — I have",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "The Spanish Verb \"Tener\"",
|
"es": "The Spanish Verb \"Tener\"",
|
||||||
@@ -8961,7 +8961,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "venir",
|
"front": "venir",
|
||||||
"back": "vengo",
|
"back": "vengo — I come",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "Lo mejor está por venir.",
|
"es": "Lo mejor está por venir.",
|
||||||
@@ -8979,7 +8979,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "hacer",
|
"front": "hacer",
|
||||||
"back": "hago",
|
"back": "hago — I do, I make",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "Expressions with \"Hacer\"",
|
"es": "Expressions with \"Hacer\"",
|
||||||
@@ -8997,7 +8997,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "salir",
|
"front": "salir",
|
||||||
"back": "salgo",
|
"back": "salgo — I go out",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "Usa el ascensor para salir.",
|
"es": "Usa el ascensor para salir.",
|
||||||
@@ -9015,7 +9015,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "caer",
|
"front": "caer",
|
||||||
"back": "caigo",
|
"back": "caigo — I fall",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "¡Acabo de caer en que tengo una cita con el dentista en diez minutos!",
|
"es": "¡Acabo de caer en que tengo una cita con el dentista en diez minutos!",
|
||||||
@@ -9033,7 +9033,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "traer",
|
"front": "traer",
|
||||||
"back": "traigo",
|
"back": "traigo — I bring",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "Esto puede traer cambios profundos en bares, discotecas y restaurantes.",
|
"es": "Esto puede traer cambios profundos en bares, discotecas y restaurantes.",
|
||||||
@@ -9051,7 +9051,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "poner",
|
"front": "poner",
|
||||||
"back": "pongo",
|
"back": "pongo — I put",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "Después de bañar a la bebé, hay que ponerle ropa limpia.",
|
"es": "Después de bañar a la bebé, hay que ponerle ropa limpia.",
|
||||||
@@ -9069,7 +9069,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "decir",
|
"front": "decir",
|
||||||
"back": "digo",
|
"back": "digo — I say",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "¿Jura decir la verdad?",
|
"es": "¿Jura decir la verdad?",
|
||||||
@@ -9087,7 +9087,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "conducir",
|
"front": "conducir",
|
||||||
"back": "conduzco",
|
"back": "conduzco — I lead, I drive",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "conducir(kohn-doo-seer)",
|
"es": "conducir(kohn-doo-seer)",
|
||||||
@@ -9105,7 +9105,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "conocer",
|
"front": "conocer",
|
||||||
"back": "conozco",
|
"back": "conozco — I know, I meet",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "conocer(koh-noh-sehr)",
|
"es": "conocer(koh-noh-sehr)",
|
||||||
@@ -9123,7 +9123,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "agradecer",
|
"front": "agradecer",
|
||||||
"back": "agradezco",
|
"back": "agradezco — I thank",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "agradecer(ah-grah-deh-sehr)",
|
"es": "agradecer(ah-grah-deh-sehr)",
|
||||||
@@ -9141,7 +9141,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "parecer",
|
"front": "parecer",
|
||||||
"back": "parezco",
|
"back": "parezco — I seem",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "parecer(pah-reh-sehr)",
|
"es": "parecer(pah-reh-sehr)",
|
||||||
@@ -9159,7 +9159,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "crecer",
|
"front": "crecer",
|
||||||
"back": "crezco",
|
"back": "crezco — I grow",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "crecer(kreh-sehr)",
|
"es": "crecer(kreh-sehr)",
|
||||||
@@ -9177,7 +9177,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "producir",
|
"front": "producir",
|
||||||
"back": "produzco",
|
"back": "produzco — I produce",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "producir(proh-doo-seer)",
|
"es": "producir(proh-doo-seer)",
|
||||||
@@ -9195,7 +9195,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "traducir",
|
"front": "traducir",
|
||||||
"back": "traduzco",
|
"back": "traduzco — I translate",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "traducir(trah-doo-seer)",
|
"es": "traducir(trah-doo-seer)",
|
||||||
@@ -9213,7 +9213,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "establecer",
|
"front": "establecer",
|
||||||
"back": "establezco",
|
"back": "establezco — I establish",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "establecer(ehs-tah-bleh-sehr)",
|
"es": "establecer(ehs-tah-bleh-sehr)",
|
||||||
@@ -9231,7 +9231,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "elejir",
|
"front": "elejir",
|
||||||
"back": "elijo",
|
"back": "elijo — I choose",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "En realidad cada persona será libre de elejir su comida.",
|
"es": "En realidad cada persona será libre de elejir su comida.",
|
||||||
@@ -9249,7 +9249,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "proteger",
|
"front": "proteger",
|
||||||
"back": "protejo",
|
"back": "protejo — I protect",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "proteger(proh-teh-hehr)",
|
"es": "proteger(proh-teh-hehr)",
|
||||||
@@ -9267,7 +9267,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "dirigir",
|
"front": "dirigir",
|
||||||
"back": "dirijo",
|
"back": "dirijo — I manage, I direct",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "dirigir(dee-ree-heer)",
|
"es": "dirigir(dee-ree-heer)",
|
||||||
@@ -9285,7 +9285,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "fingir",
|
"front": "fingir",
|
||||||
"back": "finjo",
|
"back": "finjo — I pretend, I feign",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "fingir(feen-heer)",
|
"es": "fingir(feen-heer)",
|
||||||
@@ -9303,7 +9303,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "sumergir",
|
"front": "sumergir",
|
||||||
"back": "sumerjo",
|
"back": "sumerjo — I submerge",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "sumergir(soo-mehr-heer)",
|
"es": "sumergir(soo-mehr-heer)",
|
||||||
@@ -9321,7 +9321,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "ver",
|
"front": "ver",
|
||||||
"back": "veo",
|
"back": "veo — I see",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "¿Quieres ver mi carro nuevo?",
|
"es": "¿Quieres ver mi carro nuevo?",
|
||||||
@@ -9339,7 +9339,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "saber",
|
"front": "saber",
|
||||||
"back": "sé",
|
"back": "sé — I know, I taste",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "El saber popular se basa en creencias.",
|
"es": "El saber popular se basa en creencias.",
|
||||||
@@ -9357,7 +9357,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "distinguir",
|
"front": "distinguir",
|
||||||
"back": "distingo",
|
"back": "distingo — I distinguish",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "distinguir(dees-teeng-geer)",
|
"es": "distinguir(dees-teeng-geer)",
|
||||||
@@ -9375,7 +9375,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"front": "oír",
|
"front": "oír",
|
||||||
"back": "oigo",
|
"back": "oigo — I hear",
|
||||||
"examples": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"es": "Me quejé a mucha gente, pero nadie quiso oírme.",
|
"es": "Me quejé a mucha gente, pero nadie quiso oírme.",
|
||||||
|
|||||||
374
Conjuga/Scripts/textbook/build_book.py
Normal file
374
Conjuga/Scripts/textbook/build_book.py
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Merge chapters.json + answers.json + ocr.json → book.json (single source).
|
||||||
|
|
||||||
|
Also emits vocab_cards.json: flashcards derived from vocab_image blocks where
|
||||||
|
OCR text parses as a clean two-column (Spanish ↔ English) table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
CHAPTERS_JSON = HERE / "chapters.json"
|
||||||
|
ANSWERS_JSON = HERE / "answers.json"
|
||||||
|
OCR_JSON = HERE / "ocr.json"
|
||||||
|
OUT_BOOK = HERE / "book.json"
|
||||||
|
OUT_VOCAB = HERE / "vocab_cards.json"
|
||||||
|
|
||||||
|
COURSE_NAME = "Complete Spanish Step-by-Step"
|
||||||
|
|
||||||
|
# Heuristic: parseable "Spanish | English" vocab rows.
|
||||||
|
# OCR usually produces "word — translation" or "word translation" separated
|
||||||
|
# by 2+ spaces. We detect rows that contain both Spanish and English words.
|
||||||
|
SPANISH_ACCENT_RE = re.compile(r"[áéíóúñüÁÉÍÓÚÑÜ¿¡]")
|
||||||
|
SPANISH_ARTICLES = {"el", "la", "los", "las", "un", "una", "unos", "unas"}
|
||||||
|
ENGLISH_STARTERS = {"the", "a", "an", "to", "my", "his", "her", "our", "their", "your", "some"}
|
||||||
|
# English-only words that would never appear as Spanish
|
||||||
|
ENGLISH_ONLY_WORDS = {"the", "he", "she", "it", "we", "they", "I", "is", "are", "was", "were",
|
||||||
|
"been", "have", "has", "had", "will", "would", "should", "could"}
|
||||||
|
SEP_RE = re.compile(r"[ \t]{2,}|\s[—–−-]\s")
|
||||||
|
|
||||||
|
|
||||||
|
def classify_line(line: str) -> str:
|
||||||
|
"""Return 'es', 'en', or 'unknown' for the dominant language of a vocab line."""
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
return "unknown"
|
||||||
|
# Accent = definitely Spanish
|
||||||
|
if SPANISH_ACCENT_RE.search(line):
|
||||||
|
return "es"
|
||||||
|
first = line.split()[0].lower().strip(",.;:")
|
||||||
|
if first in SPANISH_ARTICLES:
|
||||||
|
return "es"
|
||||||
|
if first in ENGLISH_STARTERS:
|
||||||
|
return "en"
|
||||||
|
# Check if the leading word is an English-only function word
|
||||||
|
if first in ENGLISH_ONLY_WORDS:
|
||||||
|
return "en"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def looks_english(word: str) -> bool:
|
||||||
|
"""Legacy helper — kept for try_split_row below."""
|
||||||
|
w = word.lower().strip()
|
||||||
|
if not w:
|
||||||
|
return False
|
||||||
|
if SPANISH_ACCENT_RE.search(w):
|
||||||
|
return False
|
||||||
|
if w in SPANISH_ARTICLES:
|
||||||
|
return False
|
||||||
|
if w in ENGLISH_STARTERS or w in ENGLISH_ONLY_WORDS:
|
||||||
|
return True
|
||||||
|
return bool(re.match(r"^[a-z][a-z\s'/()\-,.]*$", w))
|
||||||
|
|
||||||
|
|
||||||
|
def try_split_row(line: str) -> "tuple[str, str] | None":
|
||||||
|
"""Split a line into (spanish, english) if it looks like a vocab entry."""
|
||||||
|
line = line.strip()
|
||||||
|
if not line or len(line) < 3:
|
||||||
|
return None
|
||||||
|
# Try explicit separators first
|
||||||
|
parts = SEP_RE.split(line)
|
||||||
|
parts = [p.strip() for p in parts if p.strip()]
|
||||||
|
if len(parts) == 2:
|
||||||
|
spanish, english = parts
|
||||||
|
if looks_english(english) and not looks_english(spanish.split()[0]):
|
||||||
|
return (spanish, english)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load(p: Path) -> dict:
|
||||||
|
return json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def build_vocab_cards_for_block(block: dict, ocr_entry: dict, chapter: dict, context_title: str, idx: int) -> list:
|
||||||
|
"""Given a vocab_image block + its OCR lines, derive flashcards.
|
||||||
|
|
||||||
|
Vision OCR reads top-to-bottom, left-to-right; a two-column vocab table
|
||||||
|
produces Spanish lines first, then English lines. We split the list in
|
||||||
|
half when one side is predominantly Spanish and the other English.
|
||||||
|
Per-line '—' separators are also supported as a fallback.
|
||||||
|
"""
|
||||||
|
cards = []
|
||||||
|
if not ocr_entry:
|
||||||
|
return cards
|
||||||
|
lines = [l.strip() for l in ocr_entry.get("lines", []) if l.strip()]
|
||||||
|
if not lines:
|
||||||
|
return cards
|
||||||
|
|
||||||
|
def card(front: str, back: str) -> dict:
|
||||||
|
return {
|
||||||
|
"front": front,
|
||||||
|
"back": back,
|
||||||
|
"chapter": chapter["number"],
|
||||||
|
"chapterTitle": chapter["title"],
|
||||||
|
"section": context_title,
|
||||||
|
"sourceImage": block["src"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Attempt 1: explicit inline separator (e.g. "la casa — the house")
|
||||||
|
inline = []
|
||||||
|
all_inline = True
|
||||||
|
for line in lines:
|
||||||
|
pair = try_split_row(line)
|
||||||
|
if pair:
|
||||||
|
inline.append(pair)
|
||||||
|
else:
|
||||||
|
all_inline = False
|
||||||
|
break
|
||||||
|
if all_inline and inline:
|
||||||
|
for es, en in inline:
|
||||||
|
cards.append(card(es, en))
|
||||||
|
return cards
|
||||||
|
|
||||||
|
# Attempt 2: block-alternating layout.
|
||||||
|
# Vision OCR reads columns top-to-bottom, so a 2-col table rendered across
|
||||||
|
# 2 visual columns produces runs like: [ES...ES][EN...EN][ES...ES][EN...EN]
|
||||||
|
# We classify each line, smooth "unknown" using neighbors, then pair
|
||||||
|
# same-sized consecutive ES/EN blocks.
|
||||||
|
classes = [classify_line(l) for l in lines]
|
||||||
|
|
||||||
|
# Pass 1: fill unknowns using nearest non-unknown neighbor (forward)
|
||||||
|
last_known = "unknown"
|
||||||
|
forward = []
|
||||||
|
for c in classes:
|
||||||
|
if c != "unknown":
|
||||||
|
last_known = c
|
||||||
|
forward.append(last_known)
|
||||||
|
# Pass 2: backfill leading unknowns (backward)
|
||||||
|
last_known = "unknown"
|
||||||
|
backward = [""] * len(classes)
|
||||||
|
for i in range(len(classes) - 1, -1, -1):
|
||||||
|
if classes[i] != "unknown":
|
||||||
|
last_known = classes[i]
|
||||||
|
backward[i] = last_known
|
||||||
|
# Merge: prefer forward unless still unknown
|
||||||
|
resolved = []
|
||||||
|
for f, b in zip(forward, backward):
|
||||||
|
if f != "unknown":
|
||||||
|
resolved.append(f)
|
||||||
|
elif b != "unknown":
|
||||||
|
resolved.append(b)
|
||||||
|
else:
|
||||||
|
resolved.append("unknown")
|
||||||
|
|
||||||
|
# Group consecutive same-lang lines
|
||||||
|
blocks: list = []
|
||||||
|
cur_lang: "str | None" = None
|
||||||
|
cur_block: list = []
|
||||||
|
for line, lang in zip(lines, resolved):
|
||||||
|
if lang != cur_lang:
|
||||||
|
if cur_block and cur_lang is not None:
|
||||||
|
blocks.append((cur_lang, cur_block))
|
||||||
|
cur_block = [line]
|
||||||
|
cur_lang = lang
|
||||||
|
else:
|
||||||
|
cur_block.append(line)
|
||||||
|
if cur_block and cur_lang is not None:
|
||||||
|
blocks.append((cur_lang, cur_block))
|
||||||
|
|
||||||
|
# Walk blocks pairing ES then EN of equal length
|
||||||
|
i = 0
|
||||||
|
while i < len(blocks) - 1:
|
||||||
|
lang_a, lines_a = blocks[i]
|
||||||
|
lang_b, lines_b = blocks[i + 1]
|
||||||
|
if lang_a == "es" and lang_b == "en" and len(lines_a) == len(lines_b):
|
||||||
|
for es, en in zip(lines_a, lines_b):
|
||||||
|
cards.append(card(es, en))
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
# If reversed order (some pages have EN column on left), try that too
|
||||||
|
if lang_a == "en" and lang_b == "es" and len(lines_a) == len(lines_b):
|
||||||
|
for es, en in zip(lines_b, lines_a):
|
||||||
|
cards.append(card(es, en))
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return cards
|
||||||
|
|
||||||
|
|
||||||
|
def clean_instruction(text: str) -> str:
|
||||||
|
"""Strip leading/trailing emphasis markers from a parsed instruction."""
|
||||||
|
# Our XHTML parser emitted * and ** for emphasis; flatten them
|
||||||
|
t = re.sub(r"\*+", "", text)
|
||||||
|
return t.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def merge() -> None:
|
||||||
|
chapters_data = load(CHAPTERS_JSON)
|
||||||
|
answers_data = load(ANSWERS_JSON)
|
||||||
|
try:
|
||||||
|
ocr_data = load(OCR_JSON)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("ocr.json not found — proceeding with empty OCR data")
|
||||||
|
ocr_data = {}
|
||||||
|
|
||||||
|
answers = answers_data["answers"]
|
||||||
|
chapters = chapters_data["chapters"]
|
||||||
|
parts = chapters_data.get("part_memberships", {})
|
||||||
|
|
||||||
|
book_chapters = []
|
||||||
|
all_vocab_cards = []
|
||||||
|
missing_ocr = set()
|
||||||
|
current_section_title = ""
|
||||||
|
|
||||||
|
for ch in chapters:
|
||||||
|
out_blocks = []
|
||||||
|
current_section_title = ch["title"]
|
||||||
|
|
||||||
|
for bi, block in enumerate(ch["blocks"]):
|
||||||
|
k = block["kind"]
|
||||||
|
|
||||||
|
if k == "heading":
|
||||||
|
current_section_title = block["text"]
|
||||||
|
out_blocks.append(block)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if k == "paragraph":
|
||||||
|
out_blocks.append(block)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if k == "key_vocab_header":
|
||||||
|
out_blocks.append(block)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if k == "vocab_image":
|
||||||
|
ocr_entry = ocr_data.get(block["src"])
|
||||||
|
if ocr_entry is None:
|
||||||
|
missing_ocr.add(block["src"])
|
||||||
|
derived = build_vocab_cards_for_block(
|
||||||
|
block, ocr_entry, ch, current_section_title, bi
|
||||||
|
)
|
||||||
|
all_vocab_cards.extend(derived)
|
||||||
|
out_blocks.append({
|
||||||
|
"kind": "vocab_table",
|
||||||
|
"sourceImage": block["src"],
|
||||||
|
"ocrLines": ocr_entry.get("lines", []) if ocr_entry else [],
|
||||||
|
"ocrConfidence": ocr_entry.get("confidence", 0.0) if ocr_entry else 0.0,
|
||||||
|
"cardCount": len(derived),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if k == "exercise":
|
||||||
|
ans = answers.get(block["id"])
|
||||||
|
image_ocr_lines = []
|
||||||
|
for src in block.get("image_refs", []):
|
||||||
|
e = ocr_data.get(src)
|
||||||
|
if e is None:
|
||||||
|
missing_ocr.add(src)
|
||||||
|
continue
|
||||||
|
image_ocr_lines.extend(e.get("lines", []))
|
||||||
|
|
||||||
|
# Build the final prompt list. If we have text prompts from
|
||||||
|
# XHTML, prefer them. Otherwise, attempt to use OCR lines.
|
||||||
|
prompts = [p for p in block.get("prompts", []) if p.strip()]
|
||||||
|
extras = [e for e in block.get("extra", []) if e.strip()]
|
||||||
|
if not prompts and image_ocr_lines:
|
||||||
|
# Extract numbered lines from OCR (look for "1. ..." pattern)
|
||||||
|
for line in image_ocr_lines:
|
||||||
|
m = re.match(r"^(\d+)[.)]\s*(.+)", line.strip())
|
||||||
|
if m:
|
||||||
|
prompts.append(f"{m.group(1)}. {m.group(2)}")
|
||||||
|
|
||||||
|
# Cross-reference prompts with answers
|
||||||
|
sub = ans["subparts"] if ans else []
|
||||||
|
answer_items = []
|
||||||
|
for sp in sub:
|
||||||
|
for it in sp["items"]:
|
||||||
|
answer_items.append({
|
||||||
|
"label": sp["label"],
|
||||||
|
"number": it["number"],
|
||||||
|
"answer": it["answer"],
|
||||||
|
"alternates": it["alternates"],
|
||||||
|
})
|
||||||
|
|
||||||
|
out_blocks.append({
|
||||||
|
"kind": "exercise",
|
||||||
|
"id": block["id"],
|
||||||
|
"ansAnchor": block.get("ans_anchor", ""),
|
||||||
|
"instruction": clean_instruction(block.get("instruction", "")),
|
||||||
|
"extra": extras,
|
||||||
|
"prompts": prompts,
|
||||||
|
"ocrLines": image_ocr_lines,
|
||||||
|
"freeform": ans["freeform"] if ans else False,
|
||||||
|
"answerItems": answer_items,
|
||||||
|
"answerRaw": ans["raw"] if ans else "",
|
||||||
|
"answerSubparts": sub,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
out_blocks.append(block)
|
||||||
|
|
||||||
|
book_chapters.append({
|
||||||
|
"id": ch["id"],
|
||||||
|
"number": ch["number"],
|
||||||
|
"title": ch["title"],
|
||||||
|
"part": ch.get("part"),
|
||||||
|
"blocks": out_blocks,
|
||||||
|
})
|
||||||
|
|
||||||
|
book = {
|
||||||
|
"courseName": COURSE_NAME,
|
||||||
|
"totalChapters": len(book_chapters),
|
||||||
|
"totalExercises": sum(
|
||||||
|
1 for ch in book_chapters for b in ch["blocks"] if b["kind"] == "exercise"
|
||||||
|
),
|
||||||
|
"totalVocabTables": sum(
|
||||||
|
1 for ch in book_chapters for b in ch["blocks"] if b["kind"] == "vocab_table"
|
||||||
|
),
|
||||||
|
"totalVocabCards": len(all_vocab_cards),
|
||||||
|
"parts": parts,
|
||||||
|
"chapters": book_chapters,
|
||||||
|
}
|
||||||
|
OUT_BOOK.write_text(json.dumps(book, ensure_ascii=False))
|
||||||
|
|
||||||
|
# Vocab cards as a separate file (grouped per chapter so they can be seeded
|
||||||
|
# as CourseDecks in the existing schema).
|
||||||
|
vocab_by_chapter: dict = {}
|
||||||
|
for card in all_vocab_cards:
|
||||||
|
vocab_by_chapter.setdefault(card["chapter"], []).append(card)
|
||||||
|
OUT_VOCAB.write_text(json.dumps({
|
||||||
|
"courseName": COURSE_NAME,
|
||||||
|
"chapters": [
|
||||||
|
{
|
||||||
|
"chapter": ch_num,
|
||||||
|
"cards": cards,
|
||||||
|
}
|
||||||
|
for ch_num, cards in sorted(vocab_by_chapter.items())
|
||||||
|
],
|
||||||
|
}, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print(f"Wrote {OUT_BOOK}")
|
||||||
|
print(f"Wrote {OUT_VOCAB}")
|
||||||
|
print(f"Chapters: {book['totalChapters']}")
|
||||||
|
print(f"Exercises: {book['totalExercises']}")
|
||||||
|
print(f"Vocab tables: {book['totalVocabTables']}")
|
||||||
|
print(f"Vocab cards (auto): {book['totalVocabCards']}")
|
||||||
|
if missing_ocr:
|
||||||
|
print(f"Missing OCR for {len(missing_ocr)} images (first 5): {sorted(list(missing_ocr))[:5]}")
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
total_exercises = book["totalExercises"]
|
||||||
|
exercises_with_prompts = sum(
|
||||||
|
1 for ch in book_chapters for b in ch["blocks"]
|
||||||
|
if b["kind"] == "exercise" and (b["prompts"] or b["extra"])
|
||||||
|
)
|
||||||
|
exercises_with_answers = sum(
|
||||||
|
1 for ch in book_chapters for b in ch["blocks"]
|
||||||
|
if b["kind"] == "exercise" and b["answerItems"]
|
||||||
|
)
|
||||||
|
exercises_freeform = sum(
|
||||||
|
1 for ch in book_chapters for b in ch["blocks"]
|
||||||
|
if b["kind"] == "exercise" and b["freeform"]
|
||||||
|
)
|
||||||
|
print(f"Exercises with prompts: {exercises_with_prompts}/{total_exercises}")
|
||||||
|
print(f"Exercises with answers: {exercises_with_answers}/{total_exercises}")
|
||||||
|
print(f"Freeform exercises: {exercises_freeform}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
merge()
|
||||||
126
Conjuga/Scripts/textbook/build_review.py
Normal file
126
Conjuga/Scripts/textbook/build_review.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render book.json + ocr.json into a static HTML review page.
|
||||||
|
|
||||||
|
The HTML surfaces low-confidence OCR results in red, and shows the parsed
|
||||||
|
exercise prompts/answers next to the original image. Designed for rapid
|
||||||
|
visual diffing against the source book.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
BOOK = HERE / "book.json"
|
||||||
|
OCR = HERE / "ocr.json"
|
||||||
|
OUT_HTML = HERE / "review.html"
|
||||||
|
EPUB_IMAGES = Path(HERE).parents[2] / "epub_extract" / "OEBPS"
|
||||||
|
IMAGE_REL = EPUB_IMAGES.relative_to(HERE.parent) if False else EPUB_IMAGES
|
||||||
|
|
||||||
|
|
||||||
|
def load(p: Path) -> dict:
|
||||||
|
return json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def esc(s: str) -> str:
|
||||||
|
return html.escape(s or "")
|
||||||
|
|
||||||
|
|
||||||
|
def img_tag(src: str) -> str:
|
||||||
|
full = (EPUB_IMAGES / src).resolve()
|
||||||
|
return f'<img src="file://{full}" alt="{esc(src)}" class="src"/>'
|
||||||
|
|
||||||
|
|
||||||
|
def render() -> None:
|
||||||
|
book = load(BOOK)
|
||||||
|
ocr = load(OCR) if OCR.exists() else {}
|
||||||
|
|
||||||
|
out: list = []
|
||||||
|
out.append("""<!DOCTYPE html>
|
||||||
|
<html><head><meta charset='utf-8'><title>Book review</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, system-ui, sans-serif; margin: 2em; max-width: 1000px; color: #222; }
|
||||||
|
h1 { color: #c44; }
|
||||||
|
h2.chapter { background: #eee; padding: 0.5em; border-left: 4px solid #c44; }
|
||||||
|
h3.heading { color: #555; }
|
||||||
|
.para { margin: 0.5em 0; }
|
||||||
|
.vocab-table { background: #fafff0; padding: 0.5em; margin: 0.5em 0; border: 1px solid #bda; border-radius: 6px; }
|
||||||
|
.ocr-line { font-family: ui-monospace, monospace; font-size: 12px; }
|
||||||
|
.lowconf { color: #c44; background: #fee; }
|
||||||
|
.exercise { background: #fff8e8; padding: 0.5em; margin: 0.75em 0; border: 1px solid #cb9; border-radius: 6px; }
|
||||||
|
.prompt { font-family: ui-monospace, monospace; font-size: 13px; margin: 2px 0; }
|
||||||
|
.answer { color: #080; font-family: ui-monospace, monospace; font-size: 13px; }
|
||||||
|
img.src { max-width: 520px; border: 1px solid #ccc; margin: 4px 0; }
|
||||||
|
.kv { color: #04a; font-weight: bold; }
|
||||||
|
summary { cursor: pointer; font-weight: bold; color: #666; }
|
||||||
|
.card-pair { font-family: ui-monospace, monospace; font-size: 12px; }
|
||||||
|
.card-es { color: #04a; }
|
||||||
|
.card-en { color: #555; }
|
||||||
|
.counts { color: #888; font-size: 12px; }
|
||||||
|
</style></head><body>""")
|
||||||
|
out.append(f"<h1>{esc(book['courseName'])} — review</h1>")
|
||||||
|
out.append(f"<p>{book['totalChapters']} chapters · {book['totalExercises']} exercises · {book['totalVocabTables']} vocab tables · {book['totalVocabCards']} auto-derived cards</p>")
|
||||||
|
|
||||||
|
for ch in book["chapters"]:
|
||||||
|
part = ch.get("part")
|
||||||
|
part_str = f" (Part {part})" if part else ""
|
||||||
|
out.append(f"<h2 class='chapter'>Chapter {ch['number']}: {esc(ch['title'])}{esc(part_str)}</h2>")
|
||||||
|
|
||||||
|
for b in ch["blocks"]:
|
||||||
|
kind = b["kind"]
|
||||||
|
if kind == "heading":
|
||||||
|
level = b["level"]
|
||||||
|
out.append(f"<h{level} class='heading'>{esc(b['text'])}</h{level}>")
|
||||||
|
elif kind == "paragraph":
|
||||||
|
out.append(f"<p class='para'>{esc(b['text'])}</p>")
|
||||||
|
elif kind == "key_vocab_header":
|
||||||
|
out.append(f"<p class='kv'>★ Key Vocabulary</p>")
|
||||||
|
elif kind == "vocab_table":
|
||||||
|
src = b["sourceImage"]
|
||||||
|
conf = b["ocrConfidence"]
|
||||||
|
conf_class = "lowconf" if conf < 0.85 else ""
|
||||||
|
out.append(f"<div class='vocab-table'>")
|
||||||
|
out.append(f"<details><summary>vocab {esc(src)} · confidence {conf:.2f} · {b['cardCount']} card(s)</summary>")
|
||||||
|
out.append(img_tag(src))
|
||||||
|
out.append("<div>")
|
||||||
|
for line in b.get("ocrLines", []):
|
||||||
|
out.append(f"<div class='ocr-line {conf_class}'>{esc(line)}</div>")
|
||||||
|
out.append("</div>")
|
||||||
|
# Show derived pairs (if any). We don't have them inline in book.json,
|
||||||
|
# but we can recompute from ocrLines using the same function.
|
||||||
|
out.append("</details></div>")
|
||||||
|
elif kind == "exercise":
|
||||||
|
out.append(f"<div class='exercise'>")
|
||||||
|
out.append(f"<b>Exercise {esc(b['id'])}</b> — <i>{esc(b['instruction'])}</i>")
|
||||||
|
if b.get("extra"):
|
||||||
|
for e in b["extra"]:
|
||||||
|
out.append(f"<div class='para'>{esc(e)}</div>")
|
||||||
|
if b.get("ocrLines"):
|
||||||
|
out.append(f"<details><summary>OCR lines from image</summary>")
|
||||||
|
for line in b["ocrLines"]:
|
||||||
|
out.append(f"<div class='ocr-line'>{esc(line)}</div>")
|
||||||
|
out.append("</details>")
|
||||||
|
if b.get("prompts"):
|
||||||
|
out.append("<div><b>Parsed prompts:</b></div>")
|
||||||
|
for p in b["prompts"]:
|
||||||
|
out.append(f"<div class='prompt'>• {esc(p)}</div>")
|
||||||
|
if b.get("answerItems"):
|
||||||
|
out.append("<div><b>Answer key:</b></div>")
|
||||||
|
for a in b["answerItems"]:
|
||||||
|
label_str = f"{a['label']}. " if a.get("label") else ""
|
||||||
|
alts = ", ".join(a["alternates"])
|
||||||
|
alt_str = f" <span style='color:#999'>(also: {esc(alts)})</span>" if alts else ""
|
||||||
|
out.append(f"<div class='answer'>{esc(label_str)}{a['number']}. {esc(a['answer'])}{alt_str}</div>")
|
||||||
|
if b.get("freeform"):
|
||||||
|
out.append("<div style='color:#c44'>(Freeform — answers will vary)</div>")
|
||||||
|
for img_src in b.get("image_refs", []):
|
||||||
|
out.append(img_tag(img_src))
|
||||||
|
out.append("</div>")
|
||||||
|
|
||||||
|
out.append("</body></html>")
|
||||||
|
OUT_HTML.write_text("\n".join(out), encoding="utf-8")
|
||||||
|
print(f"Wrote {OUT_HTML}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
render()
|
||||||
205
Conjuga/Scripts/textbook/extract_answers.py
Normal file
205
Conjuga/Scripts/textbook/extract_answers.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Parse ans.xhtml into structured answers.json.
|
||||||
|
|
||||||
|
Output schema:
|
||||||
|
{
|
||||||
|
"answers": {
|
||||||
|
"1.1": {
|
||||||
|
"id": "1.1",
|
||||||
|
"anchor": "ch1ans1",
|
||||||
|
"chapter": 1,
|
||||||
|
"subparts": [
|
||||||
|
{"label": null, "items": [
|
||||||
|
{"number": 1, "answer": "el", "alternates": []},
|
||||||
|
{"number": 2, "answer": "el", "alternates": []},
|
||||||
|
...
|
||||||
|
]}
|
||||||
|
],
|
||||||
|
"freeform": false, # true if "Answers will vary"
|
||||||
|
"raw": "..." # raw text for fallback
|
||||||
|
},
|
||||||
|
"2.4": { # multi-part exercise
|
||||||
|
"subparts": [
|
||||||
|
{"label": "A", "items": [...]},
|
||||||
|
{"label": "B", "items": [...]},
|
||||||
|
{"label": "C", "items": [...]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from bs4 import BeautifulSoup, NavigableString
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3] / "epub_extract" / "OEBPS"
|
||||||
|
OUT = Path(__file__).resolve().parent / "answers.json"
|
||||||
|
|
||||||
|
ANSWER_CLASSES = {"answerq", "answerq1", "answerq2", "answerqa"}
|
||||||
|
EXERCISE_ID_RE = re.compile(r"^([0-9]+)\.([0-9]+)$")
|
||||||
|
SUBPART_LABEL_RE = re.compile(r"^([A-Z])\b")
|
||||||
|
NUMBERED_ITEM_RE = re.compile(r"(?:^|\s)(\d+)\.\s+")
|
||||||
|
FREEFORM_PATTERNS = [
|
||||||
|
re.compile(r"answers? will vary", re.IGNORECASE),
|
||||||
|
re.compile(r"answer will vary", re.IGNORECASE),
|
||||||
|
]
|
||||||
|
OR_TOKEN = "{{OR}}"
|
||||||
|
|
||||||
|
|
||||||
|
def render_with_or(p) -> str:
|
||||||
|
"""Convert <p> to plain text, replacing 'OR' span markers with sentinel."""
|
||||||
|
soup = BeautifulSoup(str(p), "lxml")
|
||||||
|
# Replace <span class="small">OR</span> with sentinel
|
||||||
|
for span in soup.find_all("span"):
|
||||||
|
cls = span.get("class") or []
|
||||||
|
if "small" in cls and span.get_text(strip=True).upper() == "OR":
|
||||||
|
span.replace_with(f" {OR_TOKEN} ")
|
||||||
|
# Drop pagebreak spans
|
||||||
|
for span in soup.find_all("span", attrs={"epub:type": "pagebreak"}):
|
||||||
|
span.decompose()
|
||||||
|
# Drop emphasis but keep text
|
||||||
|
for tag in soup.find_all(["em", "i", "strong", "b"]):
|
||||||
|
tag.unwrap()
|
||||||
|
text = soup.get_text(separator=" ", strip=False)
|
||||||
|
text = re.sub(r"\s+", " ", text).strip()
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def split_numbered_items(text: str) -> "list[dict]":
|
||||||
|
"""Given '1. el 2. la 3. el ...' return [{'number':1,'answer':'el'}, ...]."""
|
||||||
|
# Find positions of N. tokens
|
||||||
|
matches = list(NUMBERED_ITEM_RE.finditer(text))
|
||||||
|
items = []
|
||||||
|
for i, m in enumerate(matches):
|
||||||
|
num = int(m.group(1))
|
||||||
|
start = m.end()
|
||||||
|
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||||
|
body = text[start:end].strip().rstrip(".,;")
|
||||||
|
# Split alternates on the OR token
|
||||||
|
parts = [p.strip() for p in body.split(OR_TOKEN) if p.strip()]
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
items.append({
|
||||||
|
"number": num,
|
||||||
|
"answer": parts[0],
|
||||||
|
"alternates": parts[1:],
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def parse_subpart_label(text: str) -> "tuple[str | None, str]":
|
||||||
|
"""Try to peel a leading subpart label (A, B, C) from the text.
|
||||||
|
Returns (label_or_None, remaining_text)."""
|
||||||
|
# Pattern at start: "A " or "A " (lots of whitespace from <em>A</em><tab>)
|
||||||
|
m = re.match(r"^([A-Z])\s+(?=\d)", text)
|
||||||
|
if m:
|
||||||
|
return m.group(1), text[m.end():]
|
||||||
|
return None, text
|
||||||
|
|
||||||
|
|
||||||
|
def parse_answer_paragraph(p, exercise_id: str) -> "list[dict]":
|
||||||
|
"""Convert one <p> into a list of subparts.
|
||||||
|
For p.answerq, the text typically starts with the exercise id, then items.
|
||||||
|
For p.answerqa, the text starts with a subpart label letter."""
|
||||||
|
raw = render_with_or(p)
|
||||||
|
# Strip the leading exercise id if present
|
||||||
|
raw = re.sub(rf"^{re.escape(exercise_id)}\s*", "", raw)
|
||||||
|
|
||||||
|
label, body = parse_subpart_label(raw)
|
||||||
|
|
||||||
|
# Detect freeform
|
||||||
|
freeform = any(pat.search(body) for pat in FREEFORM_PATTERNS)
|
||||||
|
if freeform:
|
||||||
|
return [{"label": label, "items": [], "freeform": True, "raw": body}]
|
||||||
|
|
||||||
|
items = split_numbered_items(body)
|
||||||
|
return [{"label": label, "items": items, "freeform": False, "raw": body}]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
src = ROOT / "ans.xhtml"
|
||||||
|
soup = BeautifulSoup(src.read_text(encoding="utf-8"), "lxml")
|
||||||
|
body = soup.find("body")
|
||||||
|
|
||||||
|
answers: dict = {}
|
||||||
|
current_chapter = None
|
||||||
|
current_exercise_id: "str | None" = None
|
||||||
|
|
||||||
|
for el in body.find_all(["h3", "p"]):
|
||||||
|
classes = set(el.get("class") or [])
|
||||||
|
|
||||||
|
# Chapter boundary
|
||||||
|
if el.name == "h3" and "h3b" in classes:
|
||||||
|
text = el.get_text(strip=True)
|
||||||
|
m = re.search(r"Chapter\s+(\d+)", text)
|
||||||
|
if m:
|
||||||
|
current_chapter = int(m.group(1))
|
||||||
|
current_exercise_id = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
if el.name != "p" or not (classes & ANSWER_CLASSES):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find the exercise-id anchor (only present on p.answerq, not on continuation)
|
||||||
|
a = el.find("a", href=True)
|
||||||
|
ex_link = None
|
||||||
|
if a:
|
||||||
|
link_text = a.get_text(strip=True)
|
||||||
|
if EXERCISE_ID_RE.match(link_text):
|
||||||
|
ex_link = link_text
|
||||||
|
|
||||||
|
if ex_link:
|
||||||
|
current_exercise_id = ex_link
|
||||||
|
anchor = ""
|
||||||
|
href = a.get("href", "")
|
||||||
|
anchor_m = re.search(r"#(ch\d+ans\d+)", href + " " + (a.get("id") or ""))
|
||||||
|
anchor = anchor_m.group(1) if anchor_m else (a.get("id") or "")
|
||||||
|
# Use the anchor's `id` attr if it's the entry id (e.g. "ch1ans1")
|
||||||
|
entry_id = a.get("id") or anchor
|
||||||
|
|
||||||
|
answers[ex_link] = {
|
||||||
|
"id": ex_link,
|
||||||
|
"anchor": entry_id,
|
||||||
|
"chapter": current_chapter,
|
||||||
|
"subparts": [],
|
||||||
|
"freeform": False,
|
||||||
|
"raw": "",
|
||||||
|
}
|
||||||
|
new_subparts = parse_answer_paragraph(el, ex_link)
|
||||||
|
answers[ex_link]["subparts"].extend(new_subparts)
|
||||||
|
answers[ex_link]["raw"] = render_with_or(el)
|
||||||
|
answers[ex_link]["freeform"] = any(sp["freeform"] for sp in new_subparts)
|
||||||
|
else:
|
||||||
|
# Continuation paragraph for current exercise
|
||||||
|
if current_exercise_id and current_exercise_id in answers:
|
||||||
|
more = parse_answer_paragraph(el, current_exercise_id)
|
||||||
|
answers[current_exercise_id]["subparts"].extend(more)
|
||||||
|
if any(sp["freeform"] for sp in more):
|
||||||
|
answers[current_exercise_id]["freeform"] = True
|
||||||
|
|
||||||
|
out = {"answers": answers}
|
||||||
|
OUT.write_text(json.dumps(out, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
total = len(answers)
|
||||||
|
freeform = sum(1 for v in answers.values() if v["freeform"])
|
||||||
|
multipart = sum(1 for v in answers.values() if len(v["subparts"]) > 1)
|
||||||
|
total_items = sum(
|
||||||
|
len(sp["items"]) for v in answers.values() for sp in v["subparts"]
|
||||||
|
)
|
||||||
|
with_alternates = sum(
|
||||||
|
1 for v in answers.values()
|
||||||
|
for sp in v["subparts"] for it in sp["items"]
|
||||||
|
if it["alternates"]
|
||||||
|
)
|
||||||
|
print(f"Exercises with answers: {total}")
|
||||||
|
print(f" freeform: {freeform}")
|
||||||
|
print(f" multi-part (A/B/C): {multipart}")
|
||||||
|
print(f" total numbered items: {total_items}")
|
||||||
|
print(f" items with alternates:{with_alternates}")
|
||||||
|
print(f"Wrote {OUT}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
369
Conjuga/Scripts/textbook/extract_chapters.py
Normal file
369
Conjuga/Scripts/textbook/extract_chapters.py
Normal file
@@ -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()
|
||||||
94
Conjuga/Scripts/textbook/extract_pdf_text.py
Normal file
94
Conjuga/Scripts/textbook/extract_pdf_text.py
Normal file
@@ -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()
|
||||||
252
Conjuga/Scripts/textbook/fix_vocab.py
Normal file
252
Conjuga/Scripts/textbook/fix_vocab.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
#!/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"}
|
||||||
|
|
||||||
|
|
||||||
|
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(",.;:")
|
||||||
|
if first in SPANISH_ARTICLES:
|
||||||
|
es += 2
|
||||||
|
if 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()
|
||||||
147
Conjuga/Scripts/textbook/integrate_repaired.py
Normal file
147
Conjuga/Scripts/textbook/integrate_repaired.py
Normal file
@@ -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()
|
||||||
435
Conjuga/Scripts/textbook/merge_pdf_into_book.py
Normal file
435
Conjuga/Scripts/textbook/merge_pdf_into_book.py
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
#!/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 (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 {}
|
||||||
|
print(f"Mapped {len(pdf_pages)} PDF pages to book page numbers")
|
||||||
|
print(f"Loaded bounding-box pairs for {len(paired_vocab)} 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
|
||||||
|
|
||||||
|
# Prefer bounding-box pairs (from paired_vocab.json) when
|
||||||
|
# present. Fall back to the block-alternation heuristic.
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
if bbox_pairs:
|
||||||
|
cards_for_block = [
|
||||||
|
{"front": p["es"], "back": p["en"]}
|
||||||
|
for p in bbox_pairs
|
||||||
|
if p.get("es") and p.get("en")
|
||||||
|
]
|
||||||
|
# Also feed the flashcard deck
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
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()
|
||||||
232
Conjuga/Scripts/textbook/ocr_all_vocab.swift
Normal file
232
Conjuga/Scripts/textbook/ocr_all_vocab.swift
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
#!/usr/bin/env swift
|
||||||
|
// Bounding-box OCR over every vocab image, producing Spanish→English pairs.
|
||||||
|
// Much higher accuracy than the flat-OCR block-alternation heuristic because
|
||||||
|
// we use each recognized line's position on the page: rows are clustered by
|
||||||
|
// Y-coordinate and cells within a row are split by the biggest X gap.
|
||||||
|
//
|
||||||
|
// Usage: swift ocr_all_vocab.swift <image_list.json> <oebps_dir> <output.json>
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Vision
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
guard CommandLine.arguments.count >= 4 else {
|
||||||
|
print("Usage: swift ocr_all_vocab.swift <image_list.json> <oebps_dir> <output.json>")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageListURL = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||||
|
let oebpsDir = URL(fileURLWithPath: CommandLine.arguments[2])
|
||||||
|
let outputURL = URL(fileURLWithPath: CommandLine.arguments[3])
|
||||||
|
|
||||||
|
guard let listData = try? Data(contentsOf: imageListURL),
|
||||||
|
let imageNames = try? JSONDecoder().decode([String].self, from: listData) else {
|
||||||
|
print("Could not load image list at \(imageListURL.path)")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
print("Processing \(imageNames.count) images...")
|
||||||
|
|
||||||
|
struct RecognizedLine {
|
||||||
|
let text: String
|
||||||
|
let cx: Double
|
||||||
|
let cy: Double
|
||||||
|
let confidence: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Pair: Encodable {
|
||||||
|
var es: String
|
||||||
|
var en: String
|
||||||
|
var confidence: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ImageResult: Encodable {
|
||||||
|
var pairs: [Pair]
|
||||||
|
var columnCount: Int
|
||||||
|
var strategy: String
|
||||||
|
var lineCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
let spanishAccents = Set<Character>(["á","é","í","ó","ú","ñ","ü","Á","É","Í","Ó","Ú","Ñ","Ü","¿","¡"])
|
||||||
|
let spanishArticles: Set<String> = ["el","la","los","las","un","una","unos","unas"]
|
||||||
|
let englishStarters: Set<String> = ["the","a","an","to","my","his","her","our","their","your"]
|
||||||
|
let englishOnly: Set<String> = ["the","he","she","it","we","they","is","are","was","were","been","have","has","had","will","would"]
|
||||||
|
|
||||||
|
func classify(_ s: String) -> String {
|
||||||
|
let lower = s.lowercased()
|
||||||
|
if lower.contains(where: { spanishAccents.contains($0) }) { return "es" }
|
||||||
|
let first = lower.split(separator: " ").first.map(String.init)?.trimmingCharacters(in: .punctuationCharacters) ?? ""
|
||||||
|
if spanishArticles.contains(first) { return "es" }
|
||||||
|
if englishStarters.contains(first) || englishOnly.contains(first) { return "en" }
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
func recognize(_ cgImage: CGImage) -> [RecognizedLine] {
|
||||||
|
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
|
||||||
|
let req = VNRecognizeTextRequest()
|
||||||
|
req.recognitionLevel = .accurate
|
||||||
|
req.recognitionLanguages = ["es-ES", "es", "en-US"]
|
||||||
|
req.usesLanguageCorrection = true
|
||||||
|
if #available(macOS 13.0, *) { req.automaticallyDetectsLanguage = true }
|
||||||
|
try? handler.perform([req])
|
||||||
|
var out: [RecognizedLine] = []
|
||||||
|
for obs in req.results ?? [] {
|
||||||
|
guard let top = obs.topCandidates(1).first else { continue }
|
||||||
|
let s = top.string.trimmingCharacters(in: .whitespaces)
|
||||||
|
if s.isEmpty { continue }
|
||||||
|
let bb = obs.boundingBox
|
||||||
|
out.append(RecognizedLine(
|
||||||
|
text: s,
|
||||||
|
cx: Double(bb.origin.x + bb.width / 2),
|
||||||
|
cy: Double(1.0 - (bb.origin.y + bb.height / 2)),
|
||||||
|
confidence: Double(top.confidence)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a sorted-by-X line group into cells by finding the largest gap(s).
|
||||||
|
/// `desiredCells` = 2 for 2-col, 4 for 2-pair, etc.
|
||||||
|
func splitRow(_ lines: [RecognizedLine], into desiredCells: Int) -> [String] {
|
||||||
|
guard lines.count >= desiredCells else {
|
||||||
|
// Merge into fewer cells: just concatenate left-to-right.
|
||||||
|
return [lines.map(\.text).joined(separator: " ")]
|
||||||
|
}
|
||||||
|
let sorted = lines.sorted { $0.cx < $1.cx }
|
||||||
|
// Find (desiredCells - 1) biggest gaps
|
||||||
|
var gaps: [(idx: Int, gap: Double)] = []
|
||||||
|
for i in 1..<sorted.count {
|
||||||
|
gaps.append((i, sorted[i].cx - sorted[i - 1].cx))
|
||||||
|
}
|
||||||
|
let splitAt = gaps.sorted { $0.gap > $1.gap }
|
||||||
|
.prefix(desiredCells - 1)
|
||||||
|
.map(\.idx)
|
||||||
|
.sorted()
|
||||||
|
var cells: [[RecognizedLine]] = []
|
||||||
|
var start = 0
|
||||||
|
for s in splitAt {
|
||||||
|
cells.append(Array(sorted[start..<s]))
|
||||||
|
start = s
|
||||||
|
}
|
||||||
|
cells.append(Array(sorted[start..<sorted.count]))
|
||||||
|
return cells.map { $0.map(\.text).joined(separator: " ").trimmingCharacters(in: .whitespaces) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cluster lines into rows by Y proximity. Returns rows in top-to-bottom order.
|
||||||
|
func groupRows(_ lines: [RecognizedLine], tol: Double = 0.025) -> [[RecognizedLine]] {
|
||||||
|
let sorted = lines.sorted { $0.cy < $1.cy }
|
||||||
|
var rows: [[RecognizedLine]] = []
|
||||||
|
var current: [RecognizedLine] = []
|
||||||
|
for l in sorted {
|
||||||
|
if let last = current.last, abs(l.cy - last.cy) > tol {
|
||||||
|
rows.append(current)
|
||||||
|
current = [l]
|
||||||
|
} else {
|
||||||
|
current.append(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current.isEmpty { rows.append(current) }
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect likely column count: look at how many x-cluster peaks exist across all rows.
|
||||||
|
/// Clusters X-coords from all lines into buckets of 10% width.
|
||||||
|
func detectColumnCount(_ lines: [RecognizedLine]) -> Int {
|
||||||
|
guard !lines.isEmpty else { return 2 }
|
||||||
|
let step = 0.10
|
||||||
|
var buckets = [Int](repeating: 0, count: Int(1.0 / step) + 1)
|
||||||
|
for l in lines {
|
||||||
|
let b = min(max(0, Int(l.cx / step)), buckets.count - 1)
|
||||||
|
buckets[b] += 1
|
||||||
|
}
|
||||||
|
// A peak = a bucket with count > 10% of total lines
|
||||||
|
let threshold = max(2, lines.count / 10)
|
||||||
|
let peaks = buckets.filter { $0 >= threshold }.count
|
||||||
|
// Most tables are 2-col (peaks = 2). Some 4-col (2 ES/EN pairs side by side → peaks = 4).
|
||||||
|
// Roman/decorative layouts may show 1 peak; treat as 2.
|
||||||
|
switch peaks {
|
||||||
|
case 0, 1, 2: return 2
|
||||||
|
case 3: return 3
|
||||||
|
default: return 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge label-less cells into Spanish→English pairs.
|
||||||
|
/// `cells` is a row's cells (length = columnCount). For N=2, [es, en]. For N=4,
|
||||||
|
/// [es1, en1, es2, en2] (two pairs). For N=3, [es, en_short, en_long] (rare, merge).
|
||||||
|
func cellsToPairs(_ cells: [String], columnCount: Int) -> [(String, String)] {
|
||||||
|
switch columnCount {
|
||||||
|
case 2 where cells.count >= 2:
|
||||||
|
return [(cells[0], cells[1])]
|
||||||
|
case 3 where cells.count >= 3:
|
||||||
|
// 3-col source: es | en | en-alternate. Keep all three by merging EN sides.
|
||||||
|
return [(cells[0], [cells[1], cells[2]].joined(separator: " / "))]
|
||||||
|
case 4 where cells.count >= 4:
|
||||||
|
return [(cells[0], cells[1]), (cells[2], cells[3])]
|
||||||
|
default:
|
||||||
|
if cells.count >= 2 { return [(cells[0], cells.dropFirst().joined(separator: " "))] }
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Swap pair if orientation is backwards (English on left, Spanish on right).
|
||||||
|
func orientPair(_ pair: (String, String)) -> (String, String) {
|
||||||
|
let (a, b) = pair
|
||||||
|
let ca = classify(a), cb = classify(b)
|
||||||
|
if ca == "en" && cb == "es" { return (b, a) }
|
||||||
|
return pair
|
||||||
|
}
|
||||||
|
|
||||||
|
var results: [String: ImageResult] = [:]
|
||||||
|
var processed = 0
|
||||||
|
let startTime = Date()
|
||||||
|
|
||||||
|
for name in imageNames {
|
||||||
|
processed += 1
|
||||||
|
let url = oebpsDir.appendingPathComponent(name)
|
||||||
|
guard let nsImg = NSImage(contentsOf: url),
|
||||||
|
let tiff = nsImg.tiffRepresentation,
|
||||||
|
let rep = NSBitmapImageRep(data: tiff),
|
||||||
|
let cg = rep.cgImage else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let lines = recognize(cg)
|
||||||
|
if lines.isEmpty {
|
||||||
|
results[name] = ImageResult(pairs: [], columnCount: 2, strategy: "empty", lineCount: 0)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let columnCount = detectColumnCount(lines)
|
||||||
|
let rows = groupRows(lines, tol: 0.025)
|
||||||
|
var pairs: [Pair] = []
|
||||||
|
for row in rows {
|
||||||
|
guard row.count >= 2 else { continue }
|
||||||
|
let cells = splitRow(row, into: columnCount)
|
||||||
|
let rawPairs = cellsToPairs(cells, columnCount: columnCount)
|
||||||
|
for p in rawPairs {
|
||||||
|
let (es, en) = orientPair(p)
|
||||||
|
if es.count < 1 || en.count < 1 { continue }
|
||||||
|
let avgConf = row.reduce(0.0) { $0 + $1.confidence } / Double(row.count)
|
||||||
|
pairs.append(Pair(es: es, en: en, confidence: avgConf))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results[name] = ImageResult(
|
||||||
|
pairs: pairs,
|
||||||
|
columnCount: columnCount,
|
||||||
|
strategy: "bbox-row-split",
|
||||||
|
lineCount: lines.count
|
||||||
|
)
|
||||||
|
|
||||||
|
if processed % 50 == 0 || processed == imageNames.count {
|
||||||
|
let elapsed = Date().timeIntervalSince(startTime)
|
||||||
|
let rate = Double(processed) / max(elapsed, 0.001)
|
||||||
|
let eta = Double(imageNames.count - processed) / max(rate, 0.001)
|
||||||
|
print(String(format: "%d/%d %.1f img/s eta %.0fs", processed, imageNames.count, rate, eta))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let enc = JSONEncoder()
|
||||||
|
enc.outputFormatting = [.sortedKeys]
|
||||||
|
try enc.encode(results).write(to: outputURL)
|
||||||
|
let totalPairs = results.values.reduce(0) { $0 + $1.pairs.count }
|
||||||
|
let emptyTables = results.values.filter { $0.pairs.isEmpty }.count
|
||||||
|
print("Wrote \(results.count) results, \(totalPairs) total pairs, \(emptyTables) unpaired")
|
||||||
110
Conjuga/Scripts/textbook/ocr_images.swift
Normal file
110
Conjuga/Scripts/textbook/ocr_images.swift
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env swift
|
||||||
|
// OCR every JPG in the given input directory using the macOS Vision framework.
|
||||||
|
// Output: JSON map of { "<filename>": { "lines": [...], "confidence": Double } }
|
||||||
|
//
|
||||||
|
// Usage: swift ocr_images.swift <input_dir> <output_json>
|
||||||
|
// Example: swift ocr_images.swift ../../../epub_extract/OEBPS ocr.json
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Vision
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
guard CommandLine.arguments.count >= 3 else {
|
||||||
|
print("Usage: swift ocr_images.swift <input_dir> <output_json>")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputDir = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||||
|
let outputURL = URL(fileURLWithPath: CommandLine.arguments[2])
|
||||||
|
|
||||||
|
// Skip images that are icons/inline markers — not real content
|
||||||
|
let skipSubstrings = ["Common", "cover", "title"]
|
||||||
|
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
guard let enumerator = fileManager.enumerator(at: inputDir, includingPropertiesForKeys: nil) else {
|
||||||
|
print("Could not enumerate \(inputDir.path)")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var jpgs: [URL] = []
|
||||||
|
for case let url as URL in enumerator {
|
||||||
|
let name = url.lastPathComponent
|
||||||
|
guard name.hasSuffix(".jpg") || name.hasSuffix(".jpeg") || name.hasSuffix(".png") else { continue }
|
||||||
|
if skipSubstrings.contains(where: { name.contains($0) }) { continue }
|
||||||
|
jpgs.append(url)
|
||||||
|
}
|
||||||
|
jpgs.sort { $0.lastPathComponent < $1.lastPathComponent }
|
||||||
|
print("Found \(jpgs.count) images to OCR")
|
||||||
|
|
||||||
|
struct OCRResult: Encodable {
|
||||||
|
var lines: [String]
|
||||||
|
var confidence: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
var results: [String: OCRResult] = [:]
|
||||||
|
let total = jpgs.count
|
||||||
|
var processed = 0
|
||||||
|
let startTime = Date()
|
||||||
|
|
||||||
|
for url in jpgs {
|
||||||
|
processed += 1
|
||||||
|
let name = url.lastPathComponent
|
||||||
|
|
||||||
|
guard let nsImage = NSImage(contentsOf: url),
|
||||||
|
let tiffData = nsImage.tiffRepresentation,
|
||||||
|
let bitmap = NSBitmapImageRep(data: tiffData),
|
||||||
|
let cgImage = bitmap.cgImage else {
|
||||||
|
print("\(processed)/\(total) \(name) — could not load")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
|
||||||
|
let request = VNRecognizeTextRequest()
|
||||||
|
request.recognitionLevel = .accurate
|
||||||
|
request.recognitionLanguages = ["es-ES", "es", "en-US"]
|
||||||
|
request.usesLanguageCorrection = true
|
||||||
|
// For the 2020 book, automaticallyDetectsLanguage helps with mixed content
|
||||||
|
if #available(macOS 13.0, *) {
|
||||||
|
request.automaticallyDetectsLanguage = true
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try handler.perform([request])
|
||||||
|
let observations = request.results ?? []
|
||||||
|
var lines: [String] = []
|
||||||
|
var totalConfidence: Float = 0
|
||||||
|
var count = 0
|
||||||
|
for obs in observations {
|
||||||
|
if let top = obs.topCandidates(1).first {
|
||||||
|
let s = top.string.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !s.isEmpty {
|
||||||
|
lines.append(s)
|
||||||
|
totalConfidence += top.confidence
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let avg = count > 0 ? Double(totalConfidence) / Double(count) : 0.0
|
||||||
|
results[name] = OCRResult(lines: lines, confidence: avg)
|
||||||
|
} catch {
|
||||||
|
print("\(processed)/\(total) \(name) — error: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if processed % 50 == 0 || processed == total {
|
||||||
|
let elapsed = Date().timeIntervalSince(startTime)
|
||||||
|
let rate = Double(processed) / max(elapsed, 0.001)
|
||||||
|
let remaining = Double(total - processed) / max(rate, 0.001)
|
||||||
|
print(String(format: "%d/%d %.1f img/s eta %.0fs", processed, total, rate, remaining))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
do {
|
||||||
|
let data = try encoder.encode(results)
|
||||||
|
try data.write(to: outputURL)
|
||||||
|
print("Wrote \(results.count) OCR entries to \(outputURL.path)")
|
||||||
|
} catch {
|
||||||
|
print("Error writing output: \(error)")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
133
Conjuga/Scripts/textbook/ocr_pdf.swift
Normal file
133
Conjuga/Scripts/textbook/ocr_pdf.swift
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env swift
|
||||||
|
// Rasterize each page of a PDF at high DPI and OCR it with Vision.
|
||||||
|
// Output: { "<pdfIndex>": { "lines": [...], "confidence": Double, "bookPage": Int? } }
|
||||||
|
//
|
||||||
|
// Usage: swift ocr_pdf.swift <pdf_path> <output_json> [dpi]
|
||||||
|
// Example: swift ocr_pdf.swift "book.pdf" pdf_ocr.json 240
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Vision
|
||||||
|
import AppKit
|
||||||
|
import Quartz
|
||||||
|
|
||||||
|
guard CommandLine.arguments.count >= 3 else {
|
||||||
|
print("Usage: swift ocr_pdf.swift <pdf_path> <output_json> [dpi]")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let pdfURL = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||||
|
let outputURL = URL(fileURLWithPath: CommandLine.arguments[2])
|
||||||
|
let dpi: CGFloat = CommandLine.arguments.count >= 4 ? CGFloat(Double(CommandLine.arguments[3]) ?? 240.0) : 240.0
|
||||||
|
|
||||||
|
guard let pdfDoc = PDFDocument(url: pdfURL) else {
|
||||||
|
print("Could not open PDF at \(pdfURL.path)")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let pageCount = pdfDoc.pageCount
|
||||||
|
print("PDF has \(pageCount) pages. Rendering at \(dpi) DPI.")
|
||||||
|
|
||||||
|
struct PageResult: Encodable {
|
||||||
|
var lines: [String]
|
||||||
|
var confidence: Double
|
||||||
|
var bookPage: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
var results: [String: PageResult] = [:]
|
||||||
|
let startTime = Date()
|
||||||
|
|
||||||
|
// Render at scale = dpi / 72 (72 is default PDF DPI)
|
||||||
|
let scale: CGFloat = dpi / 72.0
|
||||||
|
|
||||||
|
for i in 0..<pageCount {
|
||||||
|
guard let page = pdfDoc.page(at: i) else { continue }
|
||||||
|
let pageBounds = page.bounds(for: .mediaBox)
|
||||||
|
let scaledSize = CGSize(width: pageBounds.width * scale, height: pageBounds.height * scale)
|
||||||
|
|
||||||
|
// Render the page into a CGImage
|
||||||
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||||
|
let bitmapInfo = CGImageAlphaInfo.noneSkipLast.rawValue
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: Int(scaledSize.width),
|
||||||
|
height: Int(scaledSize.height),
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
space: colorSpace,
|
||||||
|
bitmapInfo: bitmapInfo
|
||||||
|
) else {
|
||||||
|
print("\(i): could not create CGContext")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
context.setFillColor(CGColor(gray: 1.0, alpha: 1.0))
|
||||||
|
context.fill(CGRect(origin: .zero, size: scaledSize))
|
||||||
|
context.scaleBy(x: scale, y: scale)
|
||||||
|
page.draw(with: .mediaBox, to: context)
|
||||||
|
|
||||||
|
guard let cgImage = context.makeImage() else {
|
||||||
|
print("\(i): could not create CGImage")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
|
||||||
|
let request = VNRecognizeTextRequest()
|
||||||
|
request.recognitionLevel = .accurate
|
||||||
|
request.recognitionLanguages = ["es-ES", "es", "en-US"]
|
||||||
|
request.usesLanguageCorrection = true
|
||||||
|
if #available(macOS 13.0, *) {
|
||||||
|
request.automaticallyDetectsLanguage = true
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try handler.perform([request])
|
||||||
|
let observations = request.results ?? []
|
||||||
|
var lines: [String] = []
|
||||||
|
var totalConfidence: Float = 0
|
||||||
|
var count = 0
|
||||||
|
for obs in observations {
|
||||||
|
if let top = obs.topCandidates(1).first {
|
||||||
|
let s = top.string.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !s.isEmpty {
|
||||||
|
lines.append(s)
|
||||||
|
totalConfidence += top.confidence
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let avg = count > 0 ? Double(totalConfidence) / Double(count) : 0.0
|
||||||
|
|
||||||
|
// Try to detect book page number: a short numeric line in the first
|
||||||
|
// 3 or last 3 entries (typical page-number placement).
|
||||||
|
var bookPage: Int? = nil
|
||||||
|
let candidates = Array(lines.prefix(3)) + Array(lines.suffix(3))
|
||||||
|
for c in candidates {
|
||||||
|
let trimmed = c.trimmingCharacters(in: .whitespaces)
|
||||||
|
if let n = Int(trimmed), n >= 1 && n <= 1000 {
|
||||||
|
bookPage = n
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results[String(i)] = PageResult(lines: lines, confidence: avg, bookPage: bookPage)
|
||||||
|
} catch {
|
||||||
|
print("\(i): \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i + 1) % 25 == 0 || (i + 1) == pageCount {
|
||||||
|
let elapsed = Date().timeIntervalSince(startTime)
|
||||||
|
let rate = Double(i + 1) / max(elapsed, 0.001)
|
||||||
|
let remaining = Double(pageCount - (i + 1)) / max(rate, 0.001)
|
||||||
|
print(String(format: "%d/%d %.1f pg/s eta %.0fs", i + 1, pageCount, rate, remaining))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.sortedKeys]
|
||||||
|
do {
|
||||||
|
let data = try encoder.encode(results)
|
||||||
|
try data.write(to: outputURL)
|
||||||
|
print("Wrote \(results.count) pages to \(outputURL.path)")
|
||||||
|
} catch {
|
||||||
|
print("Error writing output: \(error)")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
177
Conjuga/Scripts/textbook/repair_quarantined.swift
Normal file
177
Conjuga/Scripts/textbook/repair_quarantined.swift
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env swift
|
||||||
|
// Re-OCR the images referenced in quarantined_cards.json using Vision with
|
||||||
|
// bounding-box info, then pair lines by column position (left = Spanish,
|
||||||
|
// right = English) instead of by document read order.
|
||||||
|
//
|
||||||
|
// Output: repaired_cards.json — {"byImage": {"f0142-02.jpg": [{"es":..., "en":...}, ...]}}
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Vision
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
guard CommandLine.arguments.count >= 4 else {
|
||||||
|
print("Usage: swift repair_quarantined.swift <quarantined.json> <epub_oebps_dir> <output.json>")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let quarantinedURL = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||||
|
let imageDir = URL(fileURLWithPath: CommandLine.arguments[2])
|
||||||
|
let outputURL = URL(fileURLWithPath: CommandLine.arguments[3])
|
||||||
|
|
||||||
|
guard let data = try? Data(contentsOf: quarantinedURL),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let cards = json["cards"] as? [[String: Any]] else {
|
||||||
|
print("Could not load \(quarantinedURL.path)")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var uniqueImages = Set<String>()
|
||||||
|
for card in cards {
|
||||||
|
if let src = card["sourceImage"] as? String { uniqueImages.insert(src) }
|
||||||
|
}
|
||||||
|
print("Unique images to re-OCR: \(uniqueImages.count)")
|
||||||
|
|
||||||
|
struct RecognizedLine {
|
||||||
|
let text: String
|
||||||
|
let cx: CGFloat // center X (normalized 0..1)
|
||||||
|
let cy: CGFloat // center Y (normalized 0..1 from top)
|
||||||
|
let confidence: Float
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Pair: Encodable {
|
||||||
|
var es: String
|
||||||
|
var en: String
|
||||||
|
var confidence: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ImageResult: Encodable {
|
||||||
|
var pairs: [Pair]
|
||||||
|
var lineCount: Int
|
||||||
|
var strategy: String
|
||||||
|
}
|
||||||
|
|
||||||
|
func classify(_ s: String) -> String {
|
||||||
|
// "es" if has accents or starts with ES article; "en" if starts with EN article; else "?"
|
||||||
|
let lower = s.lowercased()
|
||||||
|
let accentChars: Set<Character> = ["á", "é", "í", "ó", "ú", "ñ", "ü", "¿", "¡"]
|
||||||
|
if lower.contains(where: { accentChars.contains($0) }) { return "es" }
|
||||||
|
let first = lower.split(separator: " ").first.map(String.init) ?? ""
|
||||||
|
let esArticles: Set<String> = ["el", "la", "los", "las", "un", "una", "unos", "unas"]
|
||||||
|
let enStarters: Set<String> = ["the", "a", "an", "to", "my", "his", "her", "our", "their"]
|
||||||
|
if esArticles.contains(first) { return "es" }
|
||||||
|
if enStarters.contains(first) { return "en" }
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
func recognizeLines(cgImage: CGImage) -> [RecognizedLine] {
|
||||||
|
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
|
||||||
|
let request = VNRecognizeTextRequest()
|
||||||
|
request.recognitionLevel = .accurate
|
||||||
|
request.recognitionLanguages = ["es-ES", "es", "en-US"]
|
||||||
|
request.usesLanguageCorrection = true
|
||||||
|
if #available(macOS 13.0, *) {
|
||||||
|
request.automaticallyDetectsLanguage = true
|
||||||
|
}
|
||||||
|
do { try handler.perform([request]) } catch { return [] }
|
||||||
|
var out: [RecognizedLine] = []
|
||||||
|
for obs in request.results ?? [] {
|
||||||
|
guard let top = obs.topCandidates(1).first else { continue }
|
||||||
|
let s = top.string.trimmingCharacters(in: .whitespaces)
|
||||||
|
if s.isEmpty { continue }
|
||||||
|
// Vision's boundingBox is normalized with origin at lower-left
|
||||||
|
let bb = obs.boundingBox
|
||||||
|
let cx = bb.origin.x + bb.width / 2
|
||||||
|
let cyTop = 1.0 - (bb.origin.y + bb.height / 2) // flip to top-origin
|
||||||
|
out.append(RecognizedLine(text: s, cx: cx, cy: cyTop, confidence: top.confidence))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pair lines by column position: left column = Spanish, right column = English.
|
||||||
|
/// Groups lines into rows by Y proximity, then within each row pairs left-right.
|
||||||
|
func pairByPosition(_ lines: [RecognizedLine]) -> ([Pair], String) {
|
||||||
|
guard !lines.isEmpty else { return ([], "empty") }
|
||||||
|
|
||||||
|
// Cluster by Y into rows. Use adaptive row height: median line gap * 0.6
|
||||||
|
let sortedByY = lines.sorted { $0.cy < $1.cy }
|
||||||
|
var rows: [[RecognizedLine]] = []
|
||||||
|
var current: [RecognizedLine] = []
|
||||||
|
let rowTol: CGFloat = 0.015 // 1.5% of page height
|
||||||
|
for l in sortedByY {
|
||||||
|
if let last = current.last, abs(l.cy - last.cy) > rowTol {
|
||||||
|
rows.append(current)
|
||||||
|
current = [l]
|
||||||
|
} else {
|
||||||
|
current.append(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current.isEmpty { rows.append(current) }
|
||||||
|
|
||||||
|
var pairs: [Pair] = []
|
||||||
|
var strategy = "row-pair"
|
||||||
|
for row in rows {
|
||||||
|
guard row.count >= 2 else { continue }
|
||||||
|
// Sort row by X, split at midpoint; left = Spanish, right = English
|
||||||
|
let sortedX = row.sorted { $0.cx < $1.cx }
|
||||||
|
// Find gap: pick the biggest x-gap in the row to split
|
||||||
|
var maxGap: CGFloat = 0
|
||||||
|
var splitIdx = 1
|
||||||
|
for i in 1..<sortedX.count {
|
||||||
|
let gap = sortedX[i].cx - sortedX[i - 1].cx
|
||||||
|
if gap > maxGap {
|
||||||
|
maxGap = gap
|
||||||
|
splitIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let leftLines = Array(sortedX[0..<splitIdx])
|
||||||
|
let rightLines = Array(sortedX[splitIdx..<sortedX.count])
|
||||||
|
let leftText = leftLines.map(\.text).joined(separator: " ").trimmingCharacters(in: .whitespaces)
|
||||||
|
let rightText = rightLines.map(\.text).joined(separator: " ").trimmingCharacters(in: .whitespaces)
|
||||||
|
if leftText.isEmpty || rightText.isEmpty { continue }
|
||||||
|
// Verify language orientation — swap if we got it backwards
|
||||||
|
var es = leftText
|
||||||
|
var en = rightText
|
||||||
|
let lc = classify(es)
|
||||||
|
let rc = classify(en)
|
||||||
|
if lc == "en" && rc == "es" {
|
||||||
|
es = rightText
|
||||||
|
en = leftText
|
||||||
|
}
|
||||||
|
let avgConf = (leftLines + rightLines).reduce(Float(0)) { $0 + $1.confidence } / Float(leftLines.count + rightLines.count)
|
||||||
|
pairs.append(Pair(es: es, en: en, confidence: Double(avgConf)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if pairs.isEmpty { strategy = "no-rows" }
|
||||||
|
return (pairs, strategy)
|
||||||
|
}
|
||||||
|
|
||||||
|
var results: [String: ImageResult] = [:]
|
||||||
|
|
||||||
|
for name in uniqueImages.sorted() {
|
||||||
|
let url = imageDir.appendingPathComponent(name)
|
||||||
|
guard let img = NSImage(contentsOf: url),
|
||||||
|
let tiff = img.tiffRepresentation,
|
||||||
|
let rep = NSBitmapImageRep(data: tiff),
|
||||||
|
let cg = rep.cgImage else {
|
||||||
|
print("\(name): could not load")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let lines = recognizeLines(cgImage: cg)
|
||||||
|
let (pairs, strategy) = pairByPosition(lines)
|
||||||
|
results[name] = ImageResult(pairs: pairs, lineCount: lines.count, strategy: strategy)
|
||||||
|
print("\(name): \(lines.count) lines -> \(pairs.count) pairs via \(strategy)")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Output: Encodable {
|
||||||
|
var byImage: [String: ImageResult]
|
||||||
|
var totalPairs: Int
|
||||||
|
}
|
||||||
|
let output = Output(
|
||||||
|
byImage: results,
|
||||||
|
totalPairs: results.values.reduce(0) { $0 + $1.pairs.count }
|
||||||
|
)
|
||||||
|
|
||||||
|
let enc = JSONEncoder()
|
||||||
|
enc.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
try enc.encode(output).write(to: outputURL)
|
||||||
|
print("Wrote \(output.totalPairs) repaired pairs to \(outputURL.path)")
|
||||||
54
Conjuga/Scripts/textbook/run_pipeline.sh
Executable file
54
Conjuga/Scripts/textbook/run_pipeline.sh
Executable file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# End-to-end textbook extraction pipeline.
|
||||||
|
#
|
||||||
|
# Requires: Python 3 + lxml/beautifulsoup4/pypdf installed.
|
||||||
|
# macOS for Vision + NSSpellChecker (Swift).
|
||||||
|
#
|
||||||
|
# Inputs: EPUB extracted to epub_extract/OEBPS/ and the PDF at project root.
|
||||||
|
# Outputs: book.json, vocab_cards.json, manual_review.json, quarantined_cards.json
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
echo "=== Phase 1a: parse XHTML chapters ==="
|
||||||
|
python3 "$SCRIPT_DIR/extract_chapters.py"
|
||||||
|
|
||||||
|
echo "=== Phase 1b: parse answer key ==="
|
||||||
|
python3 "$SCRIPT_DIR/extract_answers.py"
|
||||||
|
|
||||||
|
if [ ! -f "$SCRIPT_DIR/ocr.json" ]; then
|
||||||
|
echo "=== Phase 1c: OCR EPUB images (first-time only) ==="
|
||||||
|
swift "$SCRIPT_DIR/ocr_images.swift" "$ROOT/epub_extract/OEBPS" "$SCRIPT_DIR/ocr.json"
|
||||||
|
else
|
||||||
|
echo "=== Phase 1c: EPUB OCR already cached ==="
|
||||||
|
fi
|
||||||
|
|
||||||
|
PDF_FILE="$(ls "$ROOT"/Complete\ Spanish\ Step-By-Step*.pdf 2>/dev/null | head -1 || true)"
|
||||||
|
if [ -n "$PDF_FILE" ] && [ ! -f "$SCRIPT_DIR/pdf_ocr.json" ]; then
|
||||||
|
echo "=== Phase 1d: OCR PDF pages (first-time only) ==="
|
||||||
|
swift "$SCRIPT_DIR/ocr_pdf.swift" "$PDF_FILE" "$SCRIPT_DIR/pdf_ocr.json" 240
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Phase 1e: merge into book.json ==="
|
||||||
|
python3 "$SCRIPT_DIR/merge_pdf_into_book.py"
|
||||||
|
|
||||||
|
echo "=== Phase 2: spell-check validation ==="
|
||||||
|
swift "$SCRIPT_DIR/validate_vocab.swift" "$SCRIPT_DIR/vocab_cards.json" "$SCRIPT_DIR/vocab_validation.json"
|
||||||
|
|
||||||
|
echo "=== Phase 3: auto-fix + quarantine pass 1 ==="
|
||||||
|
python3 "$SCRIPT_DIR/fix_vocab.py"
|
||||||
|
|
||||||
|
echo "=== Phase 3: auto-fix + quarantine pass 2 (convergence) ==="
|
||||||
|
swift "$SCRIPT_DIR/validate_vocab.swift" "$SCRIPT_DIR/vocab_cards.json" "$SCRIPT_DIR/vocab_validation.json"
|
||||||
|
python3 "$SCRIPT_DIR/fix_vocab.py"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Copy to app bundle ==="
|
||||||
|
cp "$SCRIPT_DIR/book.json" "$ROOT/Conjuga/Conjuga/textbook_data.json"
|
||||||
|
cp "$SCRIPT_DIR/vocab_cards.json" "$ROOT/Conjuga/Conjuga/textbook_vocab.json"
|
||||||
|
ls -lh "$ROOT/Conjuga/Conjuga/textbook_"*.json
|
||||||
|
echo ""
|
||||||
|
echo "Done. Bump textbookDataVersion in DataLoader.swift to trigger re-seed."
|
||||||
156
Conjuga/Scripts/textbook/validate_vocab.swift
Normal file
156
Conjuga/Scripts/textbook/validate_vocab.swift
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env swift
|
||||||
|
// Validate every Spanish/English word in vocab_cards.json using NSSpellChecker.
|
||||||
|
// For each flagged word, produce up to 3 candidate corrections.
|
||||||
|
//
|
||||||
|
// Usage: swift validate_vocab.swift <vocab_cards.json> <output_report.json>
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
guard CommandLine.arguments.count >= 3 else {
|
||||||
|
print("Usage: swift validate_vocab.swift <vocab_cards.json> <output_report.json>")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputURL = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||||
|
let outputURL = URL(fileURLWithPath: CommandLine.arguments[2])
|
||||||
|
|
||||||
|
guard let data = try? Data(contentsOf: inputURL),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let chapters = json["chapters"] as? [[String: Any]] else {
|
||||||
|
print("Could not load \(inputURL.path)")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let checker = NSSpellChecker.shared
|
||||||
|
|
||||||
|
// Tokenize — only letter runs (Unicode aware for Spanish accents)
|
||||||
|
func tokens(_ s: String) -> [String] {
|
||||||
|
let letters = CharacterSet.letters
|
||||||
|
return s.unicodeScalars
|
||||||
|
.split { !letters.contains($0) }
|
||||||
|
.map { String(String.UnicodeScalarView($0)) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal stopword set — names, proper nouns, numeric tokens already filtered
|
||||||
|
let stopES: Set<String> = [
|
||||||
|
"el", "la", "los", "las", "un", "una", "unos", "unas", "del", "al", "de",
|
||||||
|
"a", "en", "y", "o", "que", "no", "se", "con", "por", "para", "lo", "le",
|
||||||
|
"su", "mi", "tu", "yo", "te", "me", "es", "son", "está", "están",
|
||||||
|
]
|
||||||
|
let stopEN: Set<String> = [
|
||||||
|
"the", "a", "an", "to", "of", "in", "and", "or", "is", "are", "was", "were",
|
||||||
|
"be", "been", "my", "his", "her", "our", "their", "your",
|
||||||
|
]
|
||||||
|
|
||||||
|
func checkWord(_ w: String, lang: String, stop: Set<String>) -> [String]? {
|
||||||
|
// Return nil if word is OK, else list of candidate corrections.
|
||||||
|
if w.count < 2 { return nil }
|
||||||
|
if stop.contains(w.lowercased()) { return nil }
|
||||||
|
if w.rangeOfCharacter(from: .decimalDigits) != nil { return nil }
|
||||||
|
|
||||||
|
let range = checker.checkSpelling(
|
||||||
|
of: w,
|
||||||
|
startingAt: 0,
|
||||||
|
language: lang,
|
||||||
|
wrap: false,
|
||||||
|
inSpellDocumentWithTag: 0,
|
||||||
|
wordCount: nil
|
||||||
|
)
|
||||||
|
// Range of `(0, 0)` means no misspelling; otherwise we have a misspelling.
|
||||||
|
if range.location == NSNotFound || range.length == 0 { return nil }
|
||||||
|
|
||||||
|
let guesses = checker.guesses(
|
||||||
|
forWordRange: NSRange(location: 0, length: (w as NSString).length),
|
||||||
|
in: w,
|
||||||
|
language: lang,
|
||||||
|
inSpellDocumentWithTag: 0
|
||||||
|
) ?? []
|
||||||
|
return Array(guesses.prefix(3))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Flag: Encodable {
|
||||||
|
var chapter: Int
|
||||||
|
var front: String
|
||||||
|
var back: String
|
||||||
|
var badFront: [BadWord]
|
||||||
|
var badBack: [BadWord]
|
||||||
|
var sourceImage: String
|
||||||
|
}
|
||||||
|
struct BadWord: Encodable {
|
||||||
|
var word: String
|
||||||
|
var suggestions: [String]
|
||||||
|
var side: String // "es" or "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
var flags: [Flag] = []
|
||||||
|
var totalCards = 0
|
||||||
|
var totalBadES = 0
|
||||||
|
var totalBadEN = 0
|
||||||
|
|
||||||
|
for ch in chapters {
|
||||||
|
guard let chNum = ch["chapter"] as? Int,
|
||||||
|
let cards = ch["cards"] as? [[String: Any]] else { continue }
|
||||||
|
for card in cards {
|
||||||
|
totalCards += 1
|
||||||
|
let front = (card["front"] as? String) ?? ""
|
||||||
|
let back = (card["back"] as? String) ?? ""
|
||||||
|
let img = (card["sourceImage"] as? String) ?? ""
|
||||||
|
|
||||||
|
var badFront: [BadWord] = []
|
||||||
|
for w in tokens(front) {
|
||||||
|
if let sugg = checkWord(w, lang: "es", stop: stopES) {
|
||||||
|
badFront.append(BadWord(word: w, suggestions: sugg, side: "es"))
|
||||||
|
totalBadES += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var badBack: [BadWord] = []
|
||||||
|
for w in tokens(back) {
|
||||||
|
if let sugg = checkWord(w, lang: "en", stop: stopEN) {
|
||||||
|
badBack.append(BadWord(word: w, suggestions: sugg, side: "en"))
|
||||||
|
totalBadEN += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !badFront.isEmpty || !badBack.isEmpty {
|
||||||
|
flags.append(Flag(
|
||||||
|
chapter: chNum,
|
||||||
|
front: front,
|
||||||
|
back: back,
|
||||||
|
badFront: badFront,
|
||||||
|
badBack: badBack,
|
||||||
|
sourceImage: img
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Report: Encodable {
|
||||||
|
var totalCards: Int
|
||||||
|
var flaggedCards: Int
|
||||||
|
var flaggedSpanishWords: Int
|
||||||
|
var flaggedEnglishWords: Int
|
||||||
|
var flags: [Flag]
|
||||||
|
}
|
||||||
|
let report = Report(
|
||||||
|
totalCards: totalCards,
|
||||||
|
flaggedCards: flags.count,
|
||||||
|
flaggedSpanishWords: totalBadES,
|
||||||
|
flaggedEnglishWords: totalBadEN,
|
||||||
|
flags: flags
|
||||||
|
)
|
||||||
|
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
do {
|
||||||
|
let data = try encoder.encode(report)
|
||||||
|
try data.write(to: outputURL)
|
||||||
|
print("Cards: \(totalCards)")
|
||||||
|
print("Flagged cards: \(flags.count) (\(Double(flags.count)/Double(totalCards)*100.0 as Double)%)")
|
||||||
|
print("Flagged ES words: \(totalBadES)")
|
||||||
|
print("Flagged EN words: \(totalBadEN)")
|
||||||
|
print("Wrote \(outputURL.path)")
|
||||||
|
} catch {
|
||||||
|
print("Error writing output: \(error)")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
@@ -3,11 +3,15 @@ import PackageDescription
|
|||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "SharedModels",
|
name: "SharedModels",
|
||||||
platforms: [.iOS(.v18)],
|
platforms: [.iOS(.v18), .macOS(.v14)],
|
||||||
products: [
|
products: [
|
||||||
.library(name: "SharedModels", targets: ["SharedModels"]),
|
.library(name: "SharedModels", targets: ["SharedModels"]),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "SharedModels"),
|
.target(name: "SharedModels"),
|
||||||
|
.testTarget(
|
||||||
|
name: "SharedModelsTests",
|
||||||
|
dependencies: ["SharedModels"]
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
68
Conjuga/SharedModels/Sources/SharedModels/AnswerGrader.swift
Normal file
68
Conjuga/SharedModels/Sources/SharedModels/AnswerGrader.swift
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// On-device deterministic answer grader with partial-credit support.
|
||||||
|
/// No network calls, no API keys. Handles accent stripping and single-char typos.
|
||||||
|
public enum AnswerGrader {
|
||||||
|
|
||||||
|
/// Evaluate `userText` against the canonical answer (plus alternates).
|
||||||
|
/// Returns `.correct` for exact/normalized match, `.close` for accent-strip
|
||||||
|
/// match or Levenshtein distance 1, `.wrong` otherwise.
|
||||||
|
public static func grade(userText: String, canonical: String, alternates: [String] = []) -> TextbookGrade {
|
||||||
|
let candidates = [canonical] + alternates
|
||||||
|
let normalizedUser = normalize(userText)
|
||||||
|
if normalizedUser.isEmpty { return .wrong }
|
||||||
|
|
||||||
|
for c in candidates {
|
||||||
|
if normalize(c) == normalizedUser { return .correct }
|
||||||
|
}
|
||||||
|
for c in candidates {
|
||||||
|
if stripAccents(normalize(c)) == stripAccents(normalizedUser) {
|
||||||
|
return .close
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c in candidates {
|
||||||
|
if levenshtein(normalizedUser, normalize(c)) <= 1 {
|
||||||
|
return .close
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .wrong
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercase, collapse whitespace, strip leading/trailing punctuation.
|
||||||
|
public static func normalize(_ s: String) -> String {
|
||||||
|
let lowered = s.lowercased(with: Locale(identifier: "es"))
|
||||||
|
let collapsed = lowered.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||||
|
let trimmed = collapsed.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let punct = CharacterSet(charactersIn: ".,;:!?¿¡\"'()[]{}—–-")
|
||||||
|
return trimmed.trimmingCharacters(in: punct)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove combining diacritics (á→a, ñ→n, ü→u).
|
||||||
|
public static func stripAccents(_ s: String) -> String {
|
||||||
|
s.folding(options: .diacriticInsensitive, locale: Locale(identifier: "en"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard Levenshtein edit distance.
|
||||||
|
public static func levenshtein(_ a: String, _ b: String) -> Int {
|
||||||
|
if a == b { return 0 }
|
||||||
|
if a.isEmpty { return b.count }
|
||||||
|
if b.isEmpty { return a.count }
|
||||||
|
let aa = Array(a)
|
||||||
|
let bb = Array(b)
|
||||||
|
var prev = Array(0...bb.count)
|
||||||
|
var curr = Array(repeating: 0, count: bb.count + 1)
|
||||||
|
for i in 1...aa.count {
|
||||||
|
curr[0] = i
|
||||||
|
for j in 1...bb.count {
|
||||||
|
let cost = aa[i - 1] == bb[j - 1] ? 0 : 1
|
||||||
|
curr[j] = min(
|
||||||
|
prev[j] + 1,
|
||||||
|
curr[j - 1] + 1,
|
||||||
|
prev[j - 1] + cost
|
||||||
|
)
|
||||||
|
}
|
||||||
|
swap(&prev, &curr)
|
||||||
|
}
|
||||||
|
return prev[bb.count]
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Conjuga/SharedModels/Sources/SharedModels/Conversation.swift
Normal file
48
Conjuga/SharedModels/Sources/SharedModels/Conversation.swift
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import SwiftData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@Model
|
||||||
|
public final class Conversation {
|
||||||
|
public var id: String = ""
|
||||||
|
public var scenario: String = ""
|
||||||
|
public var level: String = ""
|
||||||
|
public var messages: String = "[]"
|
||||||
|
public var createdDate: Date = Date()
|
||||||
|
|
||||||
|
public init(scenario: String, level: String) {
|
||||||
|
self.id = UUID().uuidString
|
||||||
|
self.scenario = scenario
|
||||||
|
self.level = level
|
||||||
|
self.messages = "[]"
|
||||||
|
self.createdDate = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ChatMessage: Codable, Identifiable, Hashable {
|
||||||
|
public var id: String
|
||||||
|
public let role: String // "assistant" or "user"
|
||||||
|
public let content: String
|
||||||
|
public let correction: String?
|
||||||
|
|
||||||
|
public init(role: String, content: String, correction: String? = nil) {
|
||||||
|
self.id = UUID().uuidString
|
||||||
|
self.role = role
|
||||||
|
self.content = content
|
||||||
|
self.correction = correction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Conversation {
|
||||||
|
public var decodedMessages: [ChatMessage] {
|
||||||
|
guard let data = messages.data(using: .utf8) else { return [] }
|
||||||
|
return (try? JSONDecoder().decode([ChatMessage].self, from: data)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
public func appendMessage(_ message: ChatMessage) {
|
||||||
|
var msgs = decodedMessages
|
||||||
|
msgs.append(message)
|
||||||
|
if let data = try? JSONEncoder().encode(msgs), let str = String(data: data, encoding: .utf8) {
|
||||||
|
messages = str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Persistent record of a YouTube video downloaded to the device (Issue #21).
|
||||||
|
/// Files live in the app's documents directory under `videos/<videoId>.mp4`;
|
||||||
|
/// this model tracks the metadata needed to locate, display, and manage them.
|
||||||
|
///
|
||||||
|
/// Lives in the local store, not CloudKit — downloads are per-device.
|
||||||
|
@Model
|
||||||
|
public final class DownloadedVideo {
|
||||||
|
/// YouTube video ID — the primary key (unique).
|
||||||
|
@Attribute(.unique) public var videoId: String = ""
|
||||||
|
public var title: String = ""
|
||||||
|
public var filename: String = ""
|
||||||
|
public var byteCount: Int = 0
|
||||||
|
public var downloadedAt: Date = Date()
|
||||||
|
|
||||||
|
public init(videoId: String, title: String, filename: String, byteCount: Int) {
|
||||||
|
self.videoId = videoId
|
||||||
|
self.title = title
|
||||||
|
self.filename = filename
|
||||||
|
self.byteCount = byteCount
|
||||||
|
self.downloadedAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Constructs approximate English translations for Spanish conjugation forms
|
||||||
|
/// by combining the verb's English infinitive with person pronouns and tense auxiliaries.
|
||||||
|
///
|
||||||
|
/// Not perfect for irregular English verbs (go→went, be→was) but covers the
|
||||||
|
/// common patterns well enough for a learning context.
|
||||||
|
public enum EnglishConjugator {
|
||||||
|
|
||||||
|
public static func translate(english: String, tenseId: String, personIndex: Int) -> String {
|
||||||
|
let base = english.hasPrefix("to ") ? String(english.dropFirst(3)).trimmingCharacters(in: .whitespaces) : english
|
||||||
|
guard !base.isEmpty else { return "" }
|
||||||
|
|
||||||
|
let pronoun = pronoun(for: personIndex)
|
||||||
|
|
||||||
|
switch tenseId {
|
||||||
|
// Indicative
|
||||||
|
case "ind_presente":
|
||||||
|
return "\(pronoun) \(presentForm(base, personIndex: personIndex))"
|
||||||
|
case "ind_preterito":
|
||||||
|
return "\(pronoun) \(pastForm(base))"
|
||||||
|
case "ind_imperfecto":
|
||||||
|
return "\(pronoun) used to \(base)"
|
||||||
|
case "ind_futuro":
|
||||||
|
return "\(pronoun) will \(base)"
|
||||||
|
case "ind_perfecto":
|
||||||
|
return "\(pronoun) \(haveForm(personIndex)) \(pastParticiple(base))"
|
||||||
|
case "ind_pluscuamperfecto":
|
||||||
|
return "\(pronoun) had \(pastParticiple(base))"
|
||||||
|
case "ind_futuro_perfecto":
|
||||||
|
return "\(pronoun) will have \(pastParticiple(base))"
|
||||||
|
case "ind_preterito_anterior":
|
||||||
|
return "\(pronoun) had \(pastParticiple(base))"
|
||||||
|
|
||||||
|
// Conditional
|
||||||
|
case "cond_presente":
|
||||||
|
return "\(pronoun) would \(base)"
|
||||||
|
case "cond_perfecto":
|
||||||
|
return "\(pronoun) would have \(pastParticiple(base))"
|
||||||
|
|
||||||
|
// Subjunctive
|
||||||
|
case "subj_presente":
|
||||||
|
return "that \(pronoun) \(base)"
|
||||||
|
case "subj_imperfecto_1", "subj_imperfecto_2":
|
||||||
|
return "that \(pronoun) would \(base)"
|
||||||
|
case "subj_perfecto":
|
||||||
|
return "that \(pronoun) \(haveForm(personIndex)) \(pastParticiple(base))"
|
||||||
|
case "subj_pluscuamperfecto_1", "subj_pluscuamperfecto_2":
|
||||||
|
return "that \(pronoun) had \(pastParticiple(base))"
|
||||||
|
case "subj_futuro":
|
||||||
|
return "that \(pronoun) will \(base)"
|
||||||
|
case "subj_futuro_perfecto":
|
||||||
|
return "that \(pronoun) will have \(pastParticiple(base))"
|
||||||
|
|
||||||
|
// Imperative
|
||||||
|
case "imp_afirmativo":
|
||||||
|
return imperativeAffirmative(base, personIndex: personIndex)
|
||||||
|
case "imp_negativo":
|
||||||
|
return "don't \(base)"
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "\(pronoun) \(base)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pronouns
|
||||||
|
|
||||||
|
private static func pronoun(for personIndex: Int) -> String {
|
||||||
|
switch personIndex {
|
||||||
|
case 0: "I"
|
||||||
|
case 1: "you"
|
||||||
|
case 2: "he/she"
|
||||||
|
case 3: "we"
|
||||||
|
case 4: "you all"
|
||||||
|
case 5: "they"
|
||||||
|
default: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Present tense
|
||||||
|
|
||||||
|
private static func presentForm(_ base: String, personIndex: Int) -> String {
|
||||||
|
// 3rd person singular adds -s/-es
|
||||||
|
guard personIndex == 2 else { return base }
|
||||||
|
let words = base.split(separator: " ")
|
||||||
|
guard let first = words.first else { return base }
|
||||||
|
let verb = String(first)
|
||||||
|
let rest = words.dropFirst().joined(separator: " ")
|
||||||
|
let conjugated = addThirdPersonS(verb)
|
||||||
|
return rest.isEmpty ? conjugated : "\(conjugated) \(rest)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func addThirdPersonS(_ verb: String) -> String {
|
||||||
|
if verb == "have" { return "has" }
|
||||||
|
if verb == "be" { return "is" }
|
||||||
|
if verb == "do" { return "does" }
|
||||||
|
if verb == "go" { return "goes" }
|
||||||
|
if verb.hasSuffix("sh") || verb.hasSuffix("ch") || verb.hasSuffix("x") ||
|
||||||
|
verb.hasSuffix("s") || verb.hasSuffix("z") || verb.hasSuffix("o") {
|
||||||
|
return verb + "es"
|
||||||
|
}
|
||||||
|
if verb.hasSuffix("y") && verb.count > 1 {
|
||||||
|
let yIndex = verb.index(before: verb.endIndex)
|
||||||
|
let beforeY = verb[verb.index(before: yIndex)]
|
||||||
|
if !"aeiou".contains(beforeY) {
|
||||||
|
return String(verb.dropLast()) + "ies"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return verb + "s"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Past tense
|
||||||
|
|
||||||
|
private static func pastForm(_ base: String) -> String {
|
||||||
|
// Check common irregulars first
|
||||||
|
let words = base.split(separator: " ")
|
||||||
|
guard let first = words.first else { return base }
|
||||||
|
let verb = String(first).lowercased()
|
||||||
|
let rest = words.dropFirst().joined(separator: " ")
|
||||||
|
|
||||||
|
let irregular: String? = commonIrregularPast[verb]
|
||||||
|
let past = irregular ?? addEd(String(first))
|
||||||
|
return rest.isEmpty ? past : "\(past) \(rest)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func addEd(_ verb: String) -> String {
|
||||||
|
if verb.hasSuffix("e") { return verb + "d" }
|
||||||
|
if verb.hasSuffix("y") && verb.count > 1 {
|
||||||
|
let beforeY = verb[verb.index(before: verb.endIndex)]
|
||||||
|
if !"aeiou".contains(beforeY) {
|
||||||
|
return String(verb.dropLast()) + "ied"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return verb + "ed"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Past participle
|
||||||
|
|
||||||
|
private static func pastParticiple(_ base: String) -> String {
|
||||||
|
let words = base.split(separator: " ")
|
||||||
|
guard let first = words.first else { return base }
|
||||||
|
let verb = String(first).lowercased()
|
||||||
|
let rest = words.dropFirst().joined(separator: " ")
|
||||||
|
|
||||||
|
let irregular: String? = commonIrregularParticiple[verb]
|
||||||
|
let participle = irregular ?? addEd(String(first))
|
||||||
|
return rest.isEmpty ? participle : "\(participle) \(rest)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gerund
|
||||||
|
|
||||||
|
private static func gerund(_ base: String) -> String {
|
||||||
|
let words = base.split(separator: " ")
|
||||||
|
guard let first = words.first else { return base }
|
||||||
|
let verb = String(first)
|
||||||
|
let rest = words.dropFirst().joined(separator: " ")
|
||||||
|
|
||||||
|
let ing: String
|
||||||
|
if verb.hasSuffix("ie") {
|
||||||
|
ing = String(verb.dropLast(2)) + "ying"
|
||||||
|
} else if verb.hasSuffix("e") && !verb.hasSuffix("ee") {
|
||||||
|
ing = String(verb.dropLast()) + "ing"
|
||||||
|
} else {
|
||||||
|
ing = verb + "ing"
|
||||||
|
}
|
||||||
|
return rest.isEmpty ? ing : "\(ing) \(rest)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auxiliaries
|
||||||
|
|
||||||
|
private static func haveForm(_ personIndex: Int) -> String {
|
||||||
|
personIndex == 2 ? "has" : "have"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func beForm(_ personIndex: Int) -> String {
|
||||||
|
switch personIndex {
|
||||||
|
case 0: "am"
|
||||||
|
case 2: "is"
|
||||||
|
default: "are"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Imperative
|
||||||
|
|
||||||
|
private static func imperativeAffirmative(_ base: String, personIndex: Int) -> String {
|
||||||
|
switch personIndex {
|
||||||
|
case 1, 4: "\(base)!"
|
||||||
|
case 3: "let's \(base)!"
|
||||||
|
default: "\(base)!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Irregular lookups (most common English irregulars)
|
||||||
|
|
||||||
|
private static let commonIrregularPast: [String: String] = [
|
||||||
|
"be": "was/were", "have": "had", "do": "did", "go": "went",
|
||||||
|
"say": "said", "get": "got", "make": "made", "know": "knew",
|
||||||
|
"think": "thought", "take": "took", "come": "came", "see": "saw",
|
||||||
|
"want": "wanted", "give": "gave", "tell": "told", "find": "found",
|
||||||
|
"put": "put", "leave": "left", "bring": "brought", "begin": "began",
|
||||||
|
"keep": "kept", "hold": "held", "write": "wrote", "stand": "stood",
|
||||||
|
"hear": "heard", "let": "let", "mean": "meant", "set": "set",
|
||||||
|
"meet": "met", "run": "ran", "pay": "paid", "sit": "sat",
|
||||||
|
"speak": "spoke", "read": "read", "grow": "grew", "lose": "lost",
|
||||||
|
"fall": "fell", "feel": "felt", "cut": "cut", "sell": "sold",
|
||||||
|
"drive": "drove", "buy": "bought", "wear": "wore", "choose": "chose",
|
||||||
|
"sleep": "slept", "eat": "ate", "drink": "drank", "swim": "swam",
|
||||||
|
"fly": "flew", "break": "broke", "sing": "sang", "catch": "caught",
|
||||||
|
"send": "sent", "build": "built", "spend": "spent", "win": "won",
|
||||||
|
"fight": "fought", "throw": "threw", "teach": "taught", "lead": "led",
|
||||||
|
"understand": "understood", "draw": "drew", "ride": "rode",
|
||||||
|
"rise": "rose", "shake": "shook", "forget": "forgot",
|
||||||
|
"shoot": "shot", "wake": "woke", "bite": "bit", "hide": "hid",
|
||||||
|
"lay": "laid", "lie": "lay", "strike": "struck", "hang": "hung",
|
||||||
|
"blow": "blew", "dig": "dug", "feed": "fed", "forgive": "forgave",
|
||||||
|
"freeze": "froze", "hurt": "hurt", "light": "lit", "shut": "shut",
|
||||||
|
"steal": "stole", "stick": "stuck", "sweep": "swept",
|
||||||
|
"swing": "swung", "tear": "tore",
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let commonIrregularParticiple: [String: String] = [
|
||||||
|
"be": "been", "have": "had", "do": "done", "go": "gone",
|
||||||
|
"say": "said", "get": "gotten", "make": "made", "know": "known",
|
||||||
|
"think": "thought", "take": "taken", "come": "come", "see": "seen",
|
||||||
|
"give": "given", "tell": "told", "find": "found",
|
||||||
|
"put": "put", "leave": "left", "bring": "brought", "begin": "begun",
|
||||||
|
"keep": "kept", "hold": "held", "write": "written", "stand": "stood",
|
||||||
|
"hear": "heard", "let": "let", "mean": "meant", "set": "set",
|
||||||
|
"meet": "met", "run": "run", "pay": "paid", "sit": "sat",
|
||||||
|
"speak": "spoken", "read": "read", "grow": "grown", "lose": "lost",
|
||||||
|
"fall": "fallen", "feel": "felt", "cut": "cut", "sell": "sold",
|
||||||
|
"drive": "driven", "buy": "bought", "wear": "worn", "choose": "chosen",
|
||||||
|
"sleep": "slept", "eat": "eaten", "drink": "drunk", "swim": "swum",
|
||||||
|
"fly": "flown", "break": "broken", "sing": "sung", "catch": "caught",
|
||||||
|
"send": "sent", "build": "built", "spend": "spent", "win": "won",
|
||||||
|
"fight": "fought", "throw": "thrown", "teach": "taught", "lead": "led",
|
||||||
|
"understand": "understood", "draw": "drawn", "ride": "ridden",
|
||||||
|
"rise": "risen", "shake": "shaken", "forget": "forgotten",
|
||||||
|
"shoot": "shot", "wake": "woken", "bite": "bitten", "hide": "hidden",
|
||||||
|
"lay": "laid", "lie": "lain", "strike": "struck", "hang": "hung",
|
||||||
|
"blow": "blown", "dig": "dug", "feed": "fed", "forgive": "forgiven",
|
||||||
|
"freeze": "frozen", "hurt": "hurt", "light": "lit", "shut": "shut",
|
||||||
|
"steal": "stolen", "stick": "stuck", "sweep": "swept",
|
||||||
|
"swing": "swung", "tear": "torn",
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Pure practice-pool filtering (Issue #26).
|
||||||
|
///
|
||||||
|
/// Takes plain value snapshots of the verb + irregular-span data and computes
|
||||||
|
/// the set of verb IDs eligible for practice under the user's selected filters.
|
||||||
|
/// Deliberately decoupled from SwiftData so the same logic is directly testable
|
||||||
|
/// without a ModelContainer.
|
||||||
|
public enum PracticeFilter {
|
||||||
|
|
||||||
|
/// Minimal verb snapshot for filtering.
|
||||||
|
public struct VerbSlot: Sendable, Hashable {
|
||||||
|
public let id: Int
|
||||||
|
public let level: String
|
||||||
|
public init(id: Int, level: String) {
|
||||||
|
self.id = id
|
||||||
|
self.level = level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal irregular-span snapshot for filtering.
|
||||||
|
public struct IrregularSlot: Sendable, Hashable {
|
||||||
|
public let verbId: Int
|
||||||
|
public let category: IrregularSpan.SpanCategory
|
||||||
|
public init(verbId: Int, category: IrregularSpan.SpanCategory) {
|
||||||
|
self.verbId = verbId
|
||||||
|
self.category = category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Union of `VerbLevelGroup.dataLevels(for:)` across every user-facing level.
|
||||||
|
/// An empty input produces an empty result; callers decide the empty semantics.
|
||||||
|
public static func dataLevels(forSelectedLevels levels: Set<String>) -> Set<String> {
|
||||||
|
levels.reduce(into: Set<String>()) { acc, level in
|
||||||
|
acc.formUnion(VerbLevelGroup.dataLevels(for: level))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verb IDs whose `level` falls inside any of the selected level groups.
|
||||||
|
public static func verbIDs(
|
||||||
|
matchingLevels selectedLevels: Set<String>,
|
||||||
|
in verbs: [VerbSlot]
|
||||||
|
) -> Set<Int> {
|
||||||
|
guard !selectedLevels.isEmpty else { return [] }
|
||||||
|
let expanded = dataLevels(forSelectedLevels: selectedLevels)
|
||||||
|
return Set(verbs.filter { expanded.contains($0.level) }.map(\.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verb IDs that have at least one irregular span in the requested categories.
|
||||||
|
/// Returns an empty set when `categories` is empty — caller decides whether
|
||||||
|
/// that means "no constraint" or "no matches".
|
||||||
|
public static func verbIDs(
|
||||||
|
matchingIrregularCategories categories: Set<IrregularSpan.SpanCategory>,
|
||||||
|
in spans: [IrregularSlot]
|
||||||
|
) -> Set<Int> {
|
||||||
|
guard !categories.isEmpty else { return [] }
|
||||||
|
var ids = Set<Int>()
|
||||||
|
for slot in spans where categories.contains(slot.category) {
|
||||||
|
ids.insert(slot.verbId)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Practice pool: verbs at the selected levels, intersected with irregular
|
||||||
|
/// categories when that filter is active.
|
||||||
|
///
|
||||||
|
/// Semantics (Issue #26):
|
||||||
|
/// - `selectedLevels` empty → empty pool (literal).
|
||||||
|
/// - `irregularCategories` empty → no irregular constraint (all verbs at level).
|
||||||
|
public static func allowedVerbIDs(
|
||||||
|
verbs: [VerbSlot],
|
||||||
|
spans: [IrregularSlot],
|
||||||
|
selectedLevels: Set<String>,
|
||||||
|
irregularCategories: Set<IrregularSpan.SpanCategory>
|
||||||
|
) -> Set<Int> {
|
||||||
|
let levelIDs = verbIDs(matchingLevels: selectedLevels, in: verbs)
|
||||||
|
guard !irregularCategories.isEmpty else { return levelIDs }
|
||||||
|
let irregularIDs = verbIDs(matchingIrregularCategories: irregularCategories, in: spans)
|
||||||
|
return levelIDs.intersection(irregularIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A single entry from the curated "100 most common reflexive verbs" list
|
||||||
|
/// (Gitea issue #28). Sourced from spanishwithdaniel.com.
|
||||||
|
///
|
||||||
|
/// `baseInfinitive` is the stem without the reflexive "-se" suffix, used to
|
||||||
|
/// match this entry to the app's Verb records (which store bare infinitives).
|
||||||
|
/// `usageHint` captures trailing prepositions or set-phrase completions — e.g.,
|
||||||
|
/// "a" for `acercarse a`, "de acuerdo" for `ponerse de acuerdo`. Nil when the
|
||||||
|
/// reflexive form has no commonly paired preposition.
|
||||||
|
public struct ReflexiveVerb: Codable, Hashable, Sendable {
|
||||||
|
public let infinitive: String
|
||||||
|
public let baseInfinitive: String
|
||||||
|
public let english: String
|
||||||
|
public let usageHint: String?
|
||||||
|
|
||||||
|
public init(infinitive: String, baseInfinitive: String, english: String, usageHint: String? = nil) {
|
||||||
|
self.infinitive = infinitive
|
||||||
|
self.baseInfinitive = baseInfinitive
|
||||||
|
self.english = english
|
||||||
|
self.usageHint = usageHint
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user