Compare commits
53 Commits
4e874f60d7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
3b8a8a7f1a |
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.
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
/* 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 */; };
|
||||||
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; };
|
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; };
|
||||||
|
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */; };
|
||||||
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; };
|
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; };
|
||||||
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; };
|
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; };
|
||||||
|
1B0B3B2C771AD72E25B3493C /* StemChangeToggleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F08E1DC6932D9EA1D380913 /* StemChangeToggleTests.swift */; };
|
||||||
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; };
|
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.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 */; };
|
||||||
@@ -21,39 +23,66 @@
|
|||||||
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 */; };
|
||||||
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; };
|
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; };
|
||||||
|
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10603F454E54341AA4B9931 /* ConversationService.swift */; };
|
||||||
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 */; };
|
||||||
|
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3698CE7ACF148318615293E /* VocabReviewView.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 */; };
|
||||||
|
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */; };
|
||||||
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; };
|
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; };
|
||||||
|
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2179562E54E148C98219D /* ListeningView.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 */; };
|
||||||
|
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A649B04B8B3C49419AD9219C /* ClozeView.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 */; };
|
||||||
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
|
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
|
||||||
|
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
|
||||||
|
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */; };
|
||||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
|
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
|
||||||
|
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5FE6E149F54A6BA7D01D99 /* ChatView.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 */; };
|
||||||
|
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; };
|
||||||
|
7A1B2C3D4E5F60718293A4B5 /* textbook_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293A4C6 /* textbook_data.json */; };
|
||||||
|
7A1B2C3D4E5F60718293A4B6 /* textbook_vocab.json in Resources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293A4C7 /* textbook_vocab.json */; };
|
||||||
|
7A1B2C3D4E5F60718293AA01 /* TextbookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293AA11 /* TextbookChapterListView.swift */; };
|
||||||
|
7A1B2C3D4E5F60718293AA02 /* TextbookChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293AA12 /* TextbookChapterView.swift */; };
|
||||||
|
7A1B2C3D4E5F60718293AA03 /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293AA13 /* TextbookExerciseView.swift */; };
|
||||||
|
7A1B2C3D4E5F60718293AA04 /* AnswerChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293AA14 /* AnswerChecker.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 */; };
|
||||||
|
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */; };
|
||||||
|
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327659ABFD524514B6D2D505 /* StoryGenerator.swift */; };
|
||||||
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; };
|
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; };
|
||||||
|
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */; };
|
||||||
|
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8B6081226847E0A0A174BC /* StoryReaderView.swift */; };
|
||||||
|
943728CD3E65FE6CCADB05EE /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF3A181BF2399D34C23DA933 /* StemChangeConjugationView.swift */; };
|
||||||
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; };
|
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; };
|
||||||
|
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */; };
|
||||||
|
96A3E5FA8EC63123D97365E1 /* TextbookFlowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEA84E15880A9D56DE18F33 /* TextbookFlowUITests.swift */; };
|
||||||
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 */; };
|
||||||
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 */; };
|
||||||
|
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; };
|
||||||
|
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */; };
|
||||||
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
|
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
|
||||||
|
BC662C36AC503E00A977CEC1 /* VocabGridTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584E0FDA939E3B82EECA4B5 /* VocabGridTests.swift */; };
|
||||||
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; };
|
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; };
|
||||||
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; };
|
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; };
|
||||||
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5983A534E4836F30B5281ACB /* MainTabView.swift */; };
|
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5983A534E4836F30B5281ACB /* MainTabView.swift */; };
|
||||||
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; };
|
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; };
|
||||||
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; };
|
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; };
|
||||||
|
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5667AA04211A449A9150BD28 /* ChatLibraryView.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 */; };
|
||||||
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 */; };
|
||||||
@@ -62,14 +91,19 @@
|
|||||||
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 */; };
|
||||||
|
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FB24DF8D7436CB5210ACE /* StudyTimerService.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 */; };
|
||||||
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */; };
|
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */; };
|
||||||
|
E82C743EB1FDF6B67ED22EAD /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6153A5C7241C1AB0373AA17 /* Foundation.framework */; };
|
||||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
|
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
|
||||||
|
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D535EF6988A24B47B70209A2 /* PronunciationService.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 */; };
|
||||||
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
|
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
|
||||||
|
F7E459C46F25A8A45D7E0DFB /* AllChaptersScreenshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A630C74D28CE1B280C9F296 /* AllChaptersScreenshotTests.swift */; };
|
||||||
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 */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@@ -82,6 +116,13 @@
|
|||||||
remoteGlobalIDString = F73909B4044081DB8F6272AF;
|
remoteGlobalIDString = F73909B4044081DB8F6272AF;
|
||||||
remoteInfo = ConjugaWidgetExtension;
|
remoteInfo = ConjugaWidgetExtension;
|
||||||
};
|
};
|
||||||
|
6E1F966015DA38BD4E3CE8AF /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = AB7396D9C3E14B65B5238368 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 96127FACA68AE541F5C0F8BC;
|
||||||
|
remoteInfo = Conjuga;
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
@@ -99,12 +140,15 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
02B2179562E54E148C98219D /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.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>"; };
|
||||||
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>"; };
|
||||||
|
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.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>"; };
|
||||||
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; };
|
||||||
|
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
|
||||||
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>"; };
|
||||||
@@ -114,60 +158,92 @@
|
|||||||
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>"; };
|
||||||
|
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.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>"; };
|
||||||
|
27B2A75AAF79A9402AAF3F57 /* ConjugaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ConjugaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
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>"; };
|
||||||
|
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.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>"; };
|
||||||
|
327659ABFD524514B6D2D505 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.swift; 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>"; };
|
||||||
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>"; };
|
||||||
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>"; };
|
||||||
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>"; };
|
||||||
|
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.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>"; };
|
||||||
|
6584E0FDA939E3B82EECA4B5 /* VocabGridTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabGridTests.swift; 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>"; };
|
||||||
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>"; };
|
||||||
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>"; };
|
||||||
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>"; };
|
||||||
|
7A1B2C3D4E5F60718293A4C6 /* textbook_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_data.json; sourceTree = "<group>"; };
|
||||||
|
7A1B2C3D4E5F60718293A4C7 /* textbook_vocab.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_vocab.json; sourceTree = "<group>"; };
|
||||||
|
7A1B2C3D4E5F60718293AA11 /* TextbookChapterListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterListView.swift; sourceTree = "<group>"; };
|
||||||
|
7A1B2C3D4E5F60718293AA12 /* TextbookChapterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterView.swift; sourceTree = "<group>"; };
|
||||||
|
7A1B2C3D4E5F60718293AA13 /* TextbookExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookExerciseView.swift; sourceTree = "<group>"; };
|
||||||
|
7A1B2C3D4E5F60718293AA14 /* AnswerChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerChecker.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>"; };
|
||||||
|
8A630C74D28CE1B280C9F296 /* AllChaptersScreenshotTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AllChaptersScreenshotTests.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>"; };
|
||||||
|
8F08E1DC6932D9EA1D380913 /* StemChangeToggleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StemChangeToggleTests.swift; sourceTree = "<group>"; };
|
||||||
|
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.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; };
|
||||||
|
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
|
||||||
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>"; };
|
||||||
|
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; };
|
||||||
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
|
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
|
||||||
|
A6153A5C7241C1AB0373AA17 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||||
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>"; };
|
||||||
|
A649B04B8B3C49419AD9219C /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.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>"; };
|
||||||
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>"; };
|
||||||
|
CEEA84E15880A9D56DE18F33 /* TextbookFlowUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TextbookFlowUITests.swift; sourceTree = "<group>"; };
|
||||||
|
CF3A181BF2399D34C23DA933 /* StemChangeConjugationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StemChangeConjugationView.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; };
|
||||||
|
D3698CE7ACF148318615293E /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; };
|
||||||
|
D535EF6988A24B47B70209A2 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
|
||||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; };
|
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; };
|
||||||
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; };
|
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; };
|
||||||
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; };
|
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; };
|
||||||
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; };
|
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; };
|
||||||
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; };
|
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; };
|
||||||
|
E10603F454E54341AA4B9931 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; };
|
||||||
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; };
|
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; };
|
||||||
|
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.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>"; };
|
||||||
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>"; };
|
||||||
|
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
|
||||||
|
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.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 */
|
||||||
@@ -187,6 +263,14 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
C5C1BB325D49EE6ED3AC3D5F /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
E82C743EB1FDF6B67ED22EAD /* Foundation.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -198,9 +282,12 @@
|
|||||||
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */,
|
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */,
|
||||||
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
|
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
|
||||||
BC273716CD14A99EFF8206CA /* course_data.json */,
|
BC273716CD14A99EFF8206CA /* course_data.json */,
|
||||||
|
7A1B2C3D4E5F60718293A4C6 /* textbook_data.json */,
|
||||||
|
7A1B2C3D4E5F60718293A4C7 /* textbook_vocab.json */,
|
||||||
7E6AF62A3A949630E067DC22 /* Info.plist */,
|
7E6AF62A3A949630E067DC22 /* Info.plist */,
|
||||||
353C5DE41FD410FA82E3AED7 /* Models */,
|
353C5DE41FD410FA82E3AED7 /* Models */,
|
||||||
1994867BC8E985795A172854 /* Services */,
|
1994867BC8E985795A172854 /* Services */,
|
||||||
|
BFC1AEBE02CE22E6474FFEA6 /* Utilities */,
|
||||||
3C75490F53C34A37084FF478 /* ViewModels */,
|
3C75490F53C34A37084FF478 /* ViewModels */,
|
||||||
A81CA75762B08D35D5B7A44D /* Views */,
|
A81CA75762B08D35D5B7A44D /* Views */,
|
||||||
);
|
);
|
||||||
@@ -211,6 +298,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
BCCC95A95581458E068E0484 /* SettingsView.swift */,
|
BCCC95A95581458E068E0484 /* SettingsView.swift */,
|
||||||
|
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */,
|
||||||
);
|
);
|
||||||
path = Settings;
|
path = Settings;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -230,8 +318,15 @@
|
|||||||
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
|
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
|
||||||
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
|
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
|
||||||
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
|
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
|
||||||
|
7A1B2C3D4E5F60718293AA14 /* AnswerChecker.swift */,
|
||||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
||||||
|
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
|
||||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
||||||
|
E10603F454E54341AA4B9931 /* ConversationService.swift */,
|
||||||
|
D535EF6988A24B47B70209A2 /* PronunciationService.swift */,
|
||||||
|
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */,
|
||||||
|
327659ABFD524514B6D2D505 /* StoryGenerator.swift */,
|
||||||
|
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */,
|
||||||
777C696A841803D5B775B678 /* ReferenceStore.swift */,
|
777C696A841803D5B775B678 /* ReferenceStore.swift */,
|
||||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
|
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
|
||||||
49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
|
49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
|
||||||
@@ -263,6 +358,7 @@
|
|||||||
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
|
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
|
||||||
DAFE27F29412021AEC57E728 /* TestResult.swift */,
|
DAFE27F29412021AEC57E728 /* TestResult.swift */,
|
||||||
E536AD1180FE10576EAC884A /* UserProgress.swift */,
|
E536AD1180FE10576EAC884A /* UserProgress.swift */,
|
||||||
|
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */,
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -314,8 +410,14 @@
|
|||||||
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
|
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
|
||||||
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
|
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
|
||||||
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
|
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
|
||||||
|
D3698CE7ACF148318615293E /* VocabReviewView.swift */,
|
||||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
||||||
10C16AA6022E4742898745CE /* TypingView.swift */,
|
10C16AA6022E4742898745CE /* TypingView.swift */,
|
||||||
|
895E547BEFB5D0FBF676BE33 /* Lyrics */,
|
||||||
|
8A1DED0596E04DDE9536A9A9 /* Stories */,
|
||||||
|
DFD75E32A53845A693D98F48 /* Chat */,
|
||||||
|
02B2179562E54E148C98219D /* ListeningView.swift */,
|
||||||
|
A649B04B8B3C49419AD9219C /* ClozeView.swift */,
|
||||||
);
|
);
|
||||||
path = Practice;
|
path = Practice;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -325,10 +427,32 @@
|
|||||||
children = (
|
children = (
|
||||||
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
|
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
|
||||||
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
|
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
|
||||||
|
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.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>";
|
||||||
|
};
|
||||||
|
8A1DED0596E04DDE9536A9A9 /* Stories */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */,
|
||||||
|
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */,
|
||||||
|
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */,
|
||||||
|
);
|
||||||
|
path = Stories;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
A591A3B6F1F13D23D68D7A9D = {
|
A591A3B6F1F13D23D68D7A9D = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -336,6 +460,8 @@
|
|||||||
4B183AB0C56BC2EC302531E7 /* ConjugaWidget */,
|
4B183AB0C56BC2EC302531E7 /* ConjugaWidget */,
|
||||||
F7D740BB7D1E23949D4C1AE5 /* Packages */,
|
F7D740BB7D1E23949D4C1AE5 /* Packages */,
|
||||||
F605D24E5EA11065FD18AF7E /* Products */,
|
F605D24E5EA11065FD18AF7E /* Products */,
|
||||||
|
B442229C0A26C1D531472C7D /* Frameworks */,
|
||||||
|
C77B065CF67D1F5128E10CC7 /* ConjugaUITests */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -355,6 +481,14 @@
|
|||||||
path = Views;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
B442229C0A26C1D531472C7D /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E772BA9C3FF67FEA9A034B4B /* iOS */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
BA34B77A38B698101DBBE241 /* Dashboard */ = {
|
BA34B77A38B698101DBBE241 /* Dashboard */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -370,17 +504,59 @@
|
|||||||
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */,
|
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */,
|
||||||
833516C5D57F164C8660A479 /* CourseView.swift */,
|
833516C5D57F164C8660A479 /* CourseView.swift */,
|
||||||
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
|
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
|
||||||
|
7A1B2C3D4E5F60718293AA11 /* TextbookChapterListView.swift */,
|
||||||
|
7A1B2C3D4E5F60718293AA12 /* TextbookChapterView.swift */,
|
||||||
|
7A1B2C3D4E5F60718293AA13 /* TextbookExerciseView.swift */,
|
||||||
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
|
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
|
||||||
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
|
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
|
||||||
|
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */,
|
||||||
|
CF3A181BF2399D34C23DA933 /* StemChangeConjugationView.swift */,
|
||||||
);
|
);
|
||||||
path = Course;
|
path = Course;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
BFC1AEBE02CE22E6474FFEA6 /* Utilities */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
);
|
||||||
|
path = Utilities;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C77B065CF67D1F5128E10CC7 /* ConjugaUITests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CEEA84E15880A9D56DE18F33 /* TextbookFlowUITests.swift */,
|
||||||
|
8A630C74D28CE1B280C9F296 /* AllChaptersScreenshotTests.swift */,
|
||||||
|
8F08E1DC6932D9EA1D380913 /* StemChangeToggleTests.swift */,
|
||||||
|
6584E0FDA939E3B82EECA4B5 /* VocabGridTests.swift */,
|
||||||
|
);
|
||||||
|
name = ConjugaUITests;
|
||||||
|
path = ConjugaUITests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
DFD75E32A53845A693D98F48 /* Chat */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */,
|
||||||
|
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */,
|
||||||
|
);
|
||||||
|
path = Chat;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
E772BA9C3FF67FEA9A034B4B /* iOS */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A6153A5C7241C1AB0373AA17 /* Foundation.framework */,
|
||||||
|
);
|
||||||
|
name = iOS;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
F605D24E5EA11065FD18AF7E /* Products */ = {
|
F605D24E5EA11065FD18AF7E /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
16C1F74196C3C5628953BE3F /* Conjuga.app */,
|
16C1F74196C3C5628953BE3F /* Conjuga.app */,
|
||||||
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */,
|
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */,
|
||||||
|
27B2A75AAF79A9402AAF3F57 /* ConjugaUITests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -418,6 +594,24 @@
|
|||||||
productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */;
|
productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
|
C6CC399BFD5A2574CB9956B4 /* ConjugaUITests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = F454EA7279A44C5E151F71BA /* Build configuration list for PBXNativeTarget "ConjugaUITests" */;
|
||||||
|
buildPhases = (
|
||||||
|
66589E8F78971725CA2066ED /* Sources */,
|
||||||
|
C5C1BB325D49EE6ED3AC3D5F /* Frameworks */,
|
||||||
|
425DC31DA6EF2C4C7A873DAA /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
04C7E3C8079DE56024C2154E /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = ConjugaUITests;
|
||||||
|
productName = ConjugaUITests;
|
||||||
|
productReference = 27B2A75AAF79A9402AAF3F57 /* ConjugaUITests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
|
};
|
||||||
F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */ = {
|
F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = EA7E12CF28EB750C2B8BB2F1 /* Build configuration list for PBXNativeTarget "ConjugaWidgetExtension" */;
|
buildConfigurationList = EA7E12CF28EB750C2B8BB2F1 /* Build configuration list for PBXNativeTarget "ConjugaWidgetExtension" */;
|
||||||
@@ -470,16 +664,25 @@
|
|||||||
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
|
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = F605D24E5EA11065FD18AF7E /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
96127FACA68AE541F5C0F8BC /* Conjuga */,
|
96127FACA68AE541F5C0F8BC /* Conjuga */,
|
||||||
F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */,
|
F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */,
|
||||||
|
C6CC399BFD5A2574CB9956B4 /* ConjugaUITests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
425DC31DA6EF2C4C7A873DAA /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
B74A8384221C70A670B902D8 /* Resources */ = {
|
B74A8384221C70A670B902D8 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -487,6 +690,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 */,
|
||||||
|
7A1B2C3D4E5F60718293A4B5 /* textbook_data.json in Resources */,
|
||||||
|
7A1B2C3D4E5F60718293A4B6 /* textbook_vocab.json in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -504,6 +709,10 @@
|
|||||||
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 */,
|
||||||
|
7A1B2C3D4E5F60718293AA01 /* TextbookChapterListView.swift in Sources */,
|
||||||
|
7A1B2C3D4E5F60718293AA02 /* TextbookChapterView.swift in Sources */,
|
||||||
|
7A1B2C3D4E5F60718293AA03 /* TextbookExerciseView.swift in Sources */,
|
||||||
|
7A1B2C3D4E5F60718293AA04 /* AnswerChecker.swift in Sources */,
|
||||||
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */,
|
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */,
|
||||||
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */,
|
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */,
|
||||||
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */,
|
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */,
|
||||||
@@ -518,6 +727,11 @@
|
|||||||
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 */,
|
||||||
|
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 */,
|
||||||
@@ -548,7 +762,25 @@
|
|||||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
||||||
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
||||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
||||||
|
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
|
||||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
||||||
|
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */,
|
||||||
|
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */,
|
||||||
|
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */,
|
||||||
|
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */,
|
||||||
|
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */,
|
||||||
|
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */,
|
||||||
|
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */,
|
||||||
|
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */,
|
||||||
|
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */,
|
||||||
|
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */,
|
||||||
|
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */,
|
||||||
|
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */,
|
||||||
|
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */,
|
||||||
|
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */,
|
||||||
|
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */,
|
||||||
|
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */,
|
||||||
|
943728CD3E65FE6CCADB05EE /* StemChangeConjugationView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -566,9 +798,26 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
66589E8F78971725CA2066ED /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
96A3E5FA8EC63123D97365E1 /* TextbookFlowUITests.swift in Sources */,
|
||||||
|
F7E459C46F25A8A45D7E0DFB /* AllChaptersScreenshotTests.swift in Sources */,
|
||||||
|
1B0B3B2C771AD72E25B3493C /* StemChangeToggleTests.swift in Sources */,
|
||||||
|
BC662C36AC503E00A977CEC1 /* VocabGridTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
|
04C7E3C8079DE56024C2154E /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
name = Conjuga;
|
||||||
|
target = 96127FACA68AE541F5C0F8BC /* Conjuga */;
|
||||||
|
targetProxy = 6E1F966015DA38BD4E3CE8AF /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
0B370CF10B68E386093E5BB2 /* PBXTargetDependency */ = {
|
0B370CF10B68E386093E5BB2 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */;
|
target = F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */;
|
||||||
@@ -717,6 +966,24 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
A923186E44A25A8086B27A34 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.conjuga.app.uitests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_TARGET_NAME = Conjuga;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
B9223DC55BB69E9AB81B59AE /* Debug */ = {
|
B9223DC55BB69E9AB81B59AE /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -782,6 +1049,23 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
DB8C0F513F77A50F2EF2D561 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.conjuga.app.uitests;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_TARGET_NAME = Conjuga;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -812,6 +1096,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Debug;
|
defaultConfigurationName = Debug;
|
||||||
};
|
};
|
||||||
|
F454EA7279A44C5E151F71BA /* Build configuration list for PBXNativeTarget "ConjugaUITests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
A923186E44A25A8086B27A34 /* Release */,
|
||||||
|
DB8C0F513F77A50F2EF2D561 /* Debug */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
|||||||
@@ -53,6 +53,16 @@
|
|||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</MacroExpansion>
|
</MacroExpansion>
|
||||||
<Testables>
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "C77B065CF67D1F5128E10CC7"
|
||||||
|
BuildableName = "ConjugaUITests.xctest"
|
||||||
|
BlueprintName = "ConjugaUITests"
|
||||||
|
ReferencedContainer = "container:Conjuga.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
<CommandLineArguments>
|
<CommandLineArguments>
|
||||||
</CommandLineArguments>
|
</CommandLineArguments>
|
||||||
|
|||||||
@@ -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,10 @@ 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()
|
||||||
|
|
||||||
let localContainer: ModelContainer
|
let localContainer: ModelContainer
|
||||||
let cloudContainer: ModelContainer
|
let cloudContainer: ModelContainer
|
||||||
@@ -66,15 +68,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 +111,23 @@ 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)
|
||||||
.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 +143,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 +200,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 +211,7 @@ 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,
|
||||||
]),
|
]),
|
||||||
url: url,
|
url: url,
|
||||||
cloudKitDatabase: .none
|
cloudKitDatabase: .none
|
||||||
@@ -196,6 +219,7 @@ 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,
|
||||||
configurations: localConfig
|
configurations: localConfig
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -224,7 +248,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 = 3 // bump: SavedSong moved to cloud container
|
||||||
let key = "localStoreResetVersion"
|
let key = "localStoreResetVersion"
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
|
|
||||||
@@ -239,4 +263,5 @@ struct ConjugaApp: App {
|
|||||||
|
|
||||||
defaults.set(resetVersion, forKey: key)
|
defaults.set(resetVersion, forKey: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
<string>public.app-category.education</string>
|
<string>public.app-category.education</string>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
|
<string>Conjuga uses speech recognition to check your Spanish pronunciation and transcribe what you say during listening exercises.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Conjuga needs microphone access to record your voice for pronunciation practice.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>fetch</string>
|
<string>fetch</string>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
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 = 9
|
||||||
|
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,78 @@ 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
|
||||||
|
seedTextbookData(context: context)
|
||||||
|
UserDefaults.standard.set(textbookDataVersion, forKey: textbookDataKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-seed textbook data if the version has changed.
|
||||||
|
static func refreshTextbookDataIfNeeded(container: ModelContainer) async {
|
||||||
|
let shared = UserDefaults.standard
|
||||||
|
if shared.integer(forKey: textbookDataKey) >= textbookDataVersion { return }
|
||||||
|
|
||||||
|
print("Textbook data version outdated — re-seeding...")
|
||||||
|
let context = ModelContext(container)
|
||||||
|
|
||||||
|
// Only wipe textbook chapters and our textbook-scoped CourseDecks
|
||||||
|
// (not the LanGo decks, which live in the same tables).
|
||||||
|
try? context.delete(model: TextbookChapter.self)
|
||||||
|
let textbookCourseName = "Complete Spanish Step-by-Step"
|
||||||
|
let deckDescriptor = FetchDescriptor<CourseDeck>(
|
||||||
|
predicate: #Predicate<CourseDeck> { $0.courseName == textbookCourseName }
|
||||||
|
)
|
||||||
|
if let decks = try? context.fetch(deckDescriptor) {
|
||||||
|
for deck in decks { context.delete(deck) }
|
||||||
|
}
|
||||||
|
try? context.save()
|
||||||
|
|
||||||
|
seedTextbookData(context: context)
|
||||||
|
shared.set(textbookDataVersion, forKey: textbookDataKey)
|
||||||
|
print("Textbook data re-seeded to version \(textbookDataVersion)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 +324,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 +375,154 @@ actor DataLoader {
|
|||||||
context.insert(reviewCard)
|
context.insert(reviewCard)
|
||||||
return reviewCard
|
return reviewCard
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Textbook seeding
|
||||||
|
|
||||||
|
private static func seedTextbookData(context: ModelContext) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
print("[DataLoader] ERROR: Could not parse textbook_data.json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
try? context.save()
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ struct PracticeSessionService {
|
|||||||
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext))
|
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext))
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +58,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +150,7 @@ 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 = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
|
||||||
let now = Date()
|
let now = Date()
|
||||||
@@ -157,11 +161,20 @@ 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? {
|
||||||
@@ -222,6 +235,20 @@ struct PracticeSessionService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func pickCommonTenseForm() -> VerbForm? {
|
||||||
|
let settings = settings()
|
||||||
|
let coreTenseIDs = TenseID.coreTenseIDs
|
||||||
|
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
|
||||||
|
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 = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
|
||||||
|
|||||||
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -96,7 +97,7 @@ final class PracticeViewModel {
|
|||||||
hasCards = true
|
hasCards = true
|
||||||
isLoading = true
|
isLoading = true
|
||||||
let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext)
|
let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext)
|
||||||
guard let cardLoad = service.nextCard(for: focusMode) else {
|
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)
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ struct CourseQuizView: View {
|
|||||||
@State private var currentIndex = 0
|
@State private var currentIndex = 0
|
||||||
@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 sentenceQuestion: SentenceQuizEngine.Question?
|
||||||
|
|
||||||
// Per-question state
|
// Per-question state
|
||||||
@State private var userAnswer = ""
|
@State private var userAnswer = ""
|
||||||
@@ -60,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 {
|
||||||
@@ -98,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) {
|
||||||
@@ -111,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 }
|
||||||
@@ -131,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 {
|
||||||
@@ -146,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,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 {
|
||||||
@@ -311,16 +357,13 @@ struct CourseQuizView: View {
|
|||||||
// Nav arrows
|
// Nav arrows
|
||||||
HStack {
|
HStack {
|
||||||
Button {
|
Button {
|
||||||
guard currentIndex > 0 else { return }
|
goBack()
|
||||||
currentIndex -= 1
|
|
||||||
resetQuestion()
|
|
||||||
prepareQuestion()
|
|
||||||
} label: {
|
} label: {
|
||||||
Label("Previous", systemImage: "chevron.left")
|
Label("Previous", systemImage: "chevron.left")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
}
|
}
|
||||||
.tint(.secondary)
|
.tint(.secondary)
|
||||||
.disabled(currentIndex == 0)
|
.disabled(currentIndex == 0 || isAdvancing)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -332,6 +375,7 @@ struct CourseQuizView: View {
|
|||||||
.labelStyle(.titleAndIcon)
|
.labelStyle(.titleAndIcon)
|
||||||
}
|
}
|
||||||
.tint(.blue)
|
.tint(.blue)
|
||||||
|
.disabled(isAdvancing)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
@@ -419,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 {
|
||||||
@@ -437,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) {
|
||||||
@@ -454,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()
|
||||||
@@ -475,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)
|
||||||
}
|
}
|
||||||
@@ -498,6 +549,8 @@ struct CourseQuizView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func advance() {
|
private func advance() {
|
||||||
|
guard !isAdvancing else { return }
|
||||||
|
isAdvancing = true
|
||||||
currentIndex += 1
|
currentIndex += 1
|
||||||
if isComplete {
|
if isComplete {
|
||||||
saveResult()
|
saveResult()
|
||||||
@@ -505,6 +558,20 @@ struct CourseQuizView: View {
|
|||||||
resetQuestion()
|
resetQuestion()
|
||||||
prepareQuestion()
|
prepareQuestion()
|
||||||
}
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||||
|
isAdvancing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func goBack() {
|
||||||
|
guard !isAdvancing, currentIndex > 0 else { return }
|
||||||
|
isAdvancing = true
|
||||||
|
currentIndex -= 1
|
||||||
|
resetQuestion()
|
||||||
|
prepareQuestion()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||||
|
isAdvancing = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveResult() {
|
private func saveResult() {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -87,6 +87,20 @@ struct GrammarNoteDetailView: View {
|
|||||||
|
|
||||||
// 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)
|
||||||
@@ -101,40 +115,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 +322,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 +386,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 +416,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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,14 +450,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 +494,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
97
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift
Normal file
97
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
|
||||||
|
struct LyricsReaderView: View {
|
||||||
|
let song: SavedSong
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
headerSection
|
||||||
|
lyricsBody
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer()
|
||||||
|
}
|
||||||
|
.navigationTitle(song.title)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
let _ = {
|
||||||
|
print("[LyricsReader] ES lines: \(spanishLines.count), EN lines: \(englishLines.count), rendering: \(lineCount)")
|
||||||
|
for i in 0..<min(15, lineCount) {
|
||||||
|
let es = i < spanishLines.count ? spanishLines[i] : "(none)"
|
||||||
|
let en = i < englishLines.count ? englishLines[i] : "(none)"
|
||||||
|
print(" [\(i)] ES: \(es.isEmpty ? "(blank)" : es)")
|
||||||
|
print(" EN: \(en.isEmpty ? "(blank)" : en)")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(0..<lineCount, id: \.self) { index in
|
||||||
|
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 {
|
||||||
|
Text(es)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
}
|
||||||
|
if !en.isEmpty {
|
||||||
|
Text(en)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,12 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Reference") {
|
||||||
|
NavigationLink("How Features Work") {
|
||||||
|
FeatureReferenceView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section("About") {
|
Section("About") {
|
||||||
LabeledContent("Version", value: "1.0.0")
|
LabeledContent("Version", value: "1.0.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,15 +38,30 @@ 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")
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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",
|
||||||
|
]
|
||||||
|
}
|
||||||
25
Conjuga/SharedModels/Sources/SharedModels/SavedSong.swift
Normal file
25
Conjuga/SharedModels/Sources/SharedModels/SavedSong.swift
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import SwiftData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@Model
|
||||||
|
public final class SavedSong {
|
||||||
|
public var id: String = ""
|
||||||
|
public var title: String = ""
|
||||||
|
public var artist: String = ""
|
||||||
|
public var lyricsES: String = ""
|
||||||
|
public var lyricsEN: String = ""
|
||||||
|
public var albumArtURL: String = ""
|
||||||
|
public var appleMusicURL: String = ""
|
||||||
|
public var savedDate: Date = Date()
|
||||||
|
|
||||||
|
public init(title: String, artist: String, lyricsES: String, lyricsEN: String, albumArtURL: String = "", appleMusicURL: String = "") {
|
||||||
|
self.id = UUID().uuidString
|
||||||
|
self.title = title
|
||||||
|
self.artist = artist
|
||||||
|
self.lyricsES = lyricsES
|
||||||
|
self.lyricsEN = lyricsEN
|
||||||
|
self.albumArtURL = albumArtURL
|
||||||
|
self.appleMusicURL = appleMusicURL
|
||||||
|
self.savedDate = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Pure logic for the Complete the Sentence quiz type.
|
||||||
|
///
|
||||||
|
/// Given a `VocabCard` with example sentences, the engine determines whether a
|
||||||
|
/// blankable question can be produced and builds the `Question` used by the UI.
|
||||||
|
/// No SwiftUI dependency — exists in SharedModels so it can be unit-tested in
|
||||||
|
/// isolation and reused by other surfaces.
|
||||||
|
public struct SentenceQuizEngine {
|
||||||
|
|
||||||
|
public struct Question: Equatable, Sendable {
|
||||||
|
public let sentenceES: String
|
||||||
|
public let sentenceEN: String
|
||||||
|
/// The exact substring in `sentenceES` that was blanked (original casing preserved).
|
||||||
|
public let blankWord: String
|
||||||
|
/// `sentenceES` with `blankWord` replaced by a visible blank marker.
|
||||||
|
public let displayTemplate: String
|
||||||
|
/// Index into the card's `examplesES` that this question was built from.
|
||||||
|
public let exampleIndex: Int
|
||||||
|
|
||||||
|
public init(sentenceES: String, sentenceEN: String, blankWord: String, displayTemplate: String, exampleIndex: Int) {
|
||||||
|
self.sentenceES = sentenceES
|
||||||
|
self.sentenceEN = sentenceEN
|
||||||
|
self.blankWord = blankWord
|
||||||
|
self.displayTemplate = displayTemplate
|
||||||
|
self.exampleIndex = exampleIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker string substituted into `displayTemplate` in place of the blank word.
|
||||||
|
public static let blankMarker = "_____"
|
||||||
|
|
||||||
|
/// True when the card has at least one example sentence where a blank can be determined,
|
||||||
|
/// either via a stored `examplesBlanks` entry or by substring-matching `card.front`.
|
||||||
|
public static func hasValidSentence(for card: VocabCard) -> Bool {
|
||||||
|
guard !card.examplesES.isEmpty else { return false }
|
||||||
|
for i in card.examplesES.indices {
|
||||||
|
if isBlankResolvable(card: card, exampleIndex: i) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the set of example indices that can produce a valid blank.
|
||||||
|
public static func resolvableIndices(for card: VocabCard) -> [Int] {
|
||||||
|
card.examplesES.indices.filter { isBlankResolvable(card: card, exampleIndex: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a question from the card by picking a random resolvable example.
|
||||||
|
/// Returns nil if no example qualifies.
|
||||||
|
public static func buildQuestion(for card: VocabCard) -> Question? {
|
||||||
|
let candidates = resolvableIndices(for: card)
|
||||||
|
guard let pick = candidates.randomElement() else { return nil }
|
||||||
|
return buildQuestion(for: card, exampleIndex: pick)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic variant — builds a question from a specific example index.
|
||||||
|
/// Returns nil if that example doesn't contain a resolvable blank.
|
||||||
|
public static func buildQuestion(for card: VocabCard, exampleIndex: Int) -> Question? {
|
||||||
|
guard exampleIndex >= 0, exampleIndex < card.examplesES.count else { return nil }
|
||||||
|
let sentence = card.examplesES[exampleIndex]
|
||||||
|
guard sentence.split(separator: " ").count >= minimumWordCount else { return nil }
|
||||||
|
let sentenceEN = exampleIndex < card.examplesEN.count ? card.examplesEN[exampleIndex] : ""
|
||||||
|
|
||||||
|
let storedBlank = exampleIndex < card.examplesBlanks.count ? card.examplesBlanks[exampleIndex] : ""
|
||||||
|
|
||||||
|
// Prefer the stored blank if present and actually appears in the sentence.
|
||||||
|
if !storedBlank.isEmpty, let range = sentence.range(of: storedBlank, options: .caseInsensitive) {
|
||||||
|
return makeQuestion(sentence: sentence, sentenceEN: sentenceEN, range: range, exampleIndex: exampleIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to substring match on card.front.
|
||||||
|
if !card.front.isEmpty, let range = sentence.range(of: card.front, options: .caseInsensitive) {
|
||||||
|
return makeQuestion(sentence: sentence, sentenceEN: sentenceEN, range: range, exampleIndex: exampleIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
/// Minimum number of whitespace-separated tokens for an example to count as
|
||||||
|
/// a real sentence (filters out phonetic glosses like "discutir(dees-koo-teer)").
|
||||||
|
public static let minimumWordCount = 4
|
||||||
|
|
||||||
|
private static func isBlankResolvable(card: VocabCard, exampleIndex: Int) -> Bool {
|
||||||
|
let sentence = card.examplesES[exampleIndex]
|
||||||
|
guard sentence.split(separator: " ").count >= minimumWordCount else { return false }
|
||||||
|
let storedBlank = exampleIndex < card.examplesBlanks.count ? card.examplesBlanks[exampleIndex] : ""
|
||||||
|
if !storedBlank.isEmpty, sentence.range(of: storedBlank, options: .caseInsensitive) != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !card.front.isEmpty, sentence.range(of: card.front, options: .caseInsensitive) != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeQuestion(sentence: String, sentenceEN: String, range: Range<String.Index>, exampleIndex: Int) -> Question {
|
||||||
|
let blankWord = String(sentence[range])
|
||||||
|
var template = sentence
|
||||||
|
template.replaceSubrange(range, with: blankMarker)
|
||||||
|
return Question(
|
||||||
|
sentenceES: sentence,
|
||||||
|
sentenceEN: sentenceEN,
|
||||||
|
blankWord: blankWord,
|
||||||
|
displayTemplate: template,
|
||||||
|
exampleIndex: exampleIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
Conjuga/SharedModels/Sources/SharedModels/Story.swift
Normal file
67
Conjuga/SharedModels/Sources/SharedModels/Story.swift
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import SwiftData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@Model
|
||||||
|
public final class Story {
|
||||||
|
public var id: String = ""
|
||||||
|
public var title: String = ""
|
||||||
|
public var bodyES: String = ""
|
||||||
|
public var bodyEN: String = ""
|
||||||
|
public var level: String = ""
|
||||||
|
public var wordAnnotations: String = "[]"
|
||||||
|
public var quizQuestions: String = "[]"
|
||||||
|
public var createdDate: Date = Date()
|
||||||
|
|
||||||
|
public init(title: String, bodyES: String, bodyEN: String, level: String, wordAnnotations: String, quizQuestions: String) {
|
||||||
|
self.id = UUID().uuidString
|
||||||
|
self.title = title
|
||||||
|
self.bodyES = bodyES
|
||||||
|
self.bodyEN = bodyEN
|
||||||
|
self.level = level
|
||||||
|
self.wordAnnotations = wordAnnotations
|
||||||
|
self.quizQuestions = quizQuestions
|
||||||
|
self.createdDate = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON Helpers
|
||||||
|
|
||||||
|
public struct WordAnnotation: Codable, Identifiable, Hashable {
|
||||||
|
public var id: String { word }
|
||||||
|
public let word: String
|
||||||
|
public let baseForm: String
|
||||||
|
public let english: String
|
||||||
|
public let partOfSpeech: String
|
||||||
|
|
||||||
|
public init(word: String, baseForm: String, english: String, partOfSpeech: String) {
|
||||||
|
self.word = word
|
||||||
|
self.baseForm = baseForm
|
||||||
|
self.english = english
|
||||||
|
self.partOfSpeech = partOfSpeech
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct QuizQuestion: Codable, Identifiable, Hashable {
|
||||||
|
public var id: String { question }
|
||||||
|
public let question: String
|
||||||
|
public let options: [String]
|
||||||
|
public let correctIndex: Int
|
||||||
|
|
||||||
|
public init(question: String, options: [String], correctIndex: Int) {
|
||||||
|
self.question = question
|
||||||
|
self.options = options
|
||||||
|
self.correctIndex = correctIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Story {
|
||||||
|
public var decodedAnnotations: [WordAnnotation] {
|
||||||
|
guard let data = wordAnnotations.data(using: .utf8) else { return [] }
|
||||||
|
return (try? JSONDecoder().decode([WordAnnotation].self, from: data)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
public var decodedQuestions: [QuizQuestion] {
|
||||||
|
guard let data = quizQuestions.data(using: .utf8) else { return [] }
|
||||||
|
return (try? JSONDecoder().decode([QuizQuestion].self, from: data)) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// One chapter of the textbook. Ordered content blocks are stored as JSON in `bodyJSON`
|
||||||
|
/// (encoded [TextbookBlock]) since SwiftData @Model doesn't support heterogeneous arrays.
|
||||||
|
@Model
|
||||||
|
public final class TextbookChapter {
|
||||||
|
@Attribute(.unique) public var id: String = ""
|
||||||
|
public var number: Int = 0
|
||||||
|
public var title: String = ""
|
||||||
|
public var part: Int = 0 // 0 = no part assignment
|
||||||
|
public var courseName: String = ""
|
||||||
|
public var bodyJSON: Data = Data()
|
||||||
|
public var exerciseCount: Int = 0
|
||||||
|
public var vocabTableCount: Int = 0
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
number: Int,
|
||||||
|
title: String,
|
||||||
|
part: Int,
|
||||||
|
courseName: String,
|
||||||
|
bodyJSON: Data,
|
||||||
|
exerciseCount: Int,
|
||||||
|
vocabTableCount: Int
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.number = number
|
||||||
|
self.title = title
|
||||||
|
self.part = part
|
||||||
|
self.courseName = courseName
|
||||||
|
self.bodyJSON = bodyJSON
|
||||||
|
self.exerciseCount = exerciseCount
|
||||||
|
self.vocabTableCount = vocabTableCount
|
||||||
|
}
|
||||||
|
|
||||||
|
public func blocks() -> [TextbookBlock] {
|
||||||
|
(try? JSONDecoder().decode([TextbookBlock].self, from: bodyJSON)) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One content block within a chapter. Polymorphic via `kind`.
|
||||||
|
public struct TextbookBlock: Codable, Identifiable, Sendable {
|
||||||
|
public enum Kind: String, Codable, Sendable {
|
||||||
|
case heading
|
||||||
|
case paragraph
|
||||||
|
case keyVocabHeader = "key_vocab_header"
|
||||||
|
case vocabTable = "vocab_table"
|
||||||
|
case exercise
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: String { "\(kind.rawValue):\(index)" }
|
||||||
|
public var index: Int
|
||||||
|
public var kind: Kind
|
||||||
|
|
||||||
|
// heading
|
||||||
|
public var level: Int?
|
||||||
|
// heading / paragraph
|
||||||
|
public var text: String?
|
||||||
|
|
||||||
|
// vocab_table
|
||||||
|
public var sourceImage: String?
|
||||||
|
public var ocrLines: [String]?
|
||||||
|
public var ocrConfidence: Double?
|
||||||
|
public var cards: [TextbookVocabPair]?
|
||||||
|
|
||||||
|
// exercise
|
||||||
|
public var exerciseId: String?
|
||||||
|
public var instruction: String?
|
||||||
|
public var extra: [String]?
|
||||||
|
public var prompts: [String]?
|
||||||
|
public var answerItems: [TextbookAnswerItem]?
|
||||||
|
public var freeform: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TextbookVocabPair: Codable, Sendable {
|
||||||
|
public var front: String
|
||||||
|
public var back: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TextbookAnswerItem: Codable, Sendable {
|
||||||
|
public var label: String? // A/B/C subpart label or nil
|
||||||
|
public var number: Int
|
||||||
|
public var answer: String
|
||||||
|
public var alternates: [String]
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Per-prompt grading state recorded after the user submits an exercise.
|
||||||
|
public enum TextbookGrade: Int, Codable, Sendable {
|
||||||
|
case wrong = 0
|
||||||
|
case close = 1
|
||||||
|
case correct = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User's attempt for one exercise. Stored in the cloud container so progress
|
||||||
|
/// syncs across devices.
|
||||||
|
@Model
|
||||||
|
public final class TextbookExerciseAttempt {
|
||||||
|
/// Deterministic id: "<courseName>|<exerciseId>". CloudKit-synced models can't
|
||||||
|
/// use @Attribute(.unique); code that writes attempts must fetch-or-create.
|
||||||
|
public var id: String = ""
|
||||||
|
public var courseName: String = ""
|
||||||
|
public var chapterNumber: Int = 0
|
||||||
|
public var exerciseId: String = ""
|
||||||
|
|
||||||
|
/// JSON-encoded per-prompt state array.
|
||||||
|
/// Each entry: { "number": Int, "userText": String, "grade": Int }
|
||||||
|
public var stateJSON: Data = Data()
|
||||||
|
|
||||||
|
public var lastAttemptAt: Date = Date()
|
||||||
|
public var correctCount: Int = 0
|
||||||
|
public var closeCount: Int = 0
|
||||||
|
public var wrongCount: Int = 0
|
||||||
|
public var totalCount: Int = 0
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
courseName: String,
|
||||||
|
chapterNumber: Int,
|
||||||
|
exerciseId: String,
|
||||||
|
stateJSON: Data = Data(),
|
||||||
|
lastAttemptAt: Date = Date(),
|
||||||
|
correctCount: Int = 0,
|
||||||
|
closeCount: Int = 0,
|
||||||
|
wrongCount: Int = 0,
|
||||||
|
totalCount: Int = 0
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.courseName = courseName
|
||||||
|
self.chapterNumber = chapterNumber
|
||||||
|
self.exerciseId = exerciseId
|
||||||
|
self.stateJSON = stateJSON
|
||||||
|
self.lastAttemptAt = lastAttemptAt
|
||||||
|
self.correctCount = correctCount
|
||||||
|
self.closeCount = closeCount
|
||||||
|
self.wrongCount = wrongCount
|
||||||
|
self.totalCount = totalCount
|
||||||
|
}
|
||||||
|
|
||||||
|
public func promptStates() -> [TextbookPromptState] {
|
||||||
|
(try? JSONDecoder().decode([TextbookPromptState].self, from: stateJSON)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setPromptStates(_ states: [TextbookPromptState]) {
|
||||||
|
stateJSON = (try? JSONEncoder().encode(states)) ?? Data()
|
||||||
|
correctCount = states.filter { $0.grade == .correct }.count
|
||||||
|
closeCount = states.filter { $0.grade == .close }.count
|
||||||
|
wrongCount = states.filter { $0.grade == .wrong }.count
|
||||||
|
totalCount = states.count
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func attemptId(courseName: String, exerciseId: String) -> String {
|
||||||
|
"\(courseName)|\(exerciseId)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TextbookPromptState: Codable, Sendable {
|
||||||
|
public var number: Int
|
||||||
|
public var userText: String
|
||||||
|
public var grade: TextbookGrade
|
||||||
|
|
||||||
|
public init(number: Int, userText: String, grade: TextbookGrade) {
|
||||||
|
self.number = number
|
||||||
|
self.userText = userText
|
||||||
|
self.grade = grade
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ public final class VocabCard {
|
|||||||
public var deckId: String = ""
|
public var deckId: String = ""
|
||||||
public var examplesES: [String] = []
|
public var examplesES: [String] = []
|
||||||
public var examplesEN: [String] = []
|
public var examplesEN: [String] = []
|
||||||
|
/// Per-example blank word for Complete the Sentence quiz. Index-aligned with `examplesES`.
|
||||||
|
/// Empty string at a given index means "fall back to substring-matching card.front".
|
||||||
|
public var examplesBlanks: [String] = []
|
||||||
|
|
||||||
public var deck: CourseDeck?
|
public var deck: CourseDeck?
|
||||||
|
|
||||||
@@ -18,11 +21,12 @@ public final class VocabCard {
|
|||||||
public var dueDate: Date = Date()
|
public var dueDate: Date = Date()
|
||||||
public var lastReviewDate: Date?
|
public var lastReviewDate: Date?
|
||||||
|
|
||||||
public init(front: String, back: String, deckId: String, examplesES: [String] = [], examplesEN: [String] = []) {
|
public init(front: String, back: String, deckId: String, examplesES: [String] = [], examplesEN: [String] = [], examplesBlanks: [String] = []) {
|
||||||
self.front = front
|
self.front = front
|
||||||
self.back = back
|
self.back = back
|
||||||
self.deckId = deckId
|
self.deckId = deckId
|
||||||
self.examplesES = examplesES
|
self.examplesES = examplesES
|
||||||
self.examplesEN = examplesEN
|
self.examplesEN = examplesEN
|
||||||
|
self.examplesBlanks = examplesBlanks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import SharedModels
|
||||||
|
|
||||||
|
@Suite("AnswerGrader")
|
||||||
|
struct AnswerGraderTests {
|
||||||
|
|
||||||
|
@Test("exact match is correct")
|
||||||
|
func exact() {
|
||||||
|
#expect(AnswerGrader.grade(userText: "tengo", canonical: "tengo") == .correct)
|
||||||
|
#expect(AnswerGrader.grade(userText: "Tengo", canonical: "tengo") == .correct)
|
||||||
|
#expect(AnswerGrader.grade(userText: " tengo ", canonical: "tengo") == .correct)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("missing accent is close")
|
||||||
|
func missingAccent() {
|
||||||
|
#expect(AnswerGrader.grade(userText: "esta", canonical: "está") == .close)
|
||||||
|
#expect(AnswerGrader.grade(userText: "nino", canonical: "niño") == .close)
|
||||||
|
#expect(AnswerGrader.grade(userText: "asi", canonical: "así") == .close)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("single-char typo is close")
|
||||||
|
func singleCharTypo() {
|
||||||
|
// deletion
|
||||||
|
#expect(AnswerGrader.grade(userText: "tngo", canonical: "tengo") == .close)
|
||||||
|
// insertion
|
||||||
|
#expect(AnswerGrader.grade(userText: "tengoo", canonical: "tengo") == .close)
|
||||||
|
// substitution
|
||||||
|
#expect(AnswerGrader.grade(userText: "tengu", canonical: "tengo") == .close)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("two-char typo is wrong")
|
||||||
|
func twoCharTypo() {
|
||||||
|
#expect(AnswerGrader.grade(userText: "tngu", canonical: "tengo") == .wrong)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("empty is wrong")
|
||||||
|
func empty() {
|
||||||
|
#expect(AnswerGrader.grade(userText: "", canonical: "tengo") == .wrong)
|
||||||
|
#expect(AnswerGrader.grade(userText: " ", canonical: "tengo") == .wrong)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("alternates accepted")
|
||||||
|
func alternates() {
|
||||||
|
#expect(AnswerGrader.grade(userText: "flaca", canonical: "delgada", alternates: ["flaca"]) == .correct)
|
||||||
|
#expect(AnswerGrader.grade(userText: "flacca", canonical: "delgada", alternates: ["flaca"]) == .close)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("punctuation stripped")
|
||||||
|
func punctuation() {
|
||||||
|
#expect(AnswerGrader.grade(userText: "el libro.", canonical: "el libro") == .correct)
|
||||||
|
#expect(AnswerGrader.grade(userText: "¿dónde?", canonical: "dónde") == .correct)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("very different text is wrong")
|
||||||
|
func wrong() {
|
||||||
|
#expect(AnswerGrader.grade(userText: "hola", canonical: "tengo") == .wrong)
|
||||||
|
#expect(AnswerGrader.grade(userText: "casa", canonical: "perro") == .wrong)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("normalize produces expected output")
|
||||||
|
func normalize() {
|
||||||
|
#expect(AnswerGrader.normalize(" Hola ") == "hola")
|
||||||
|
#expect(AnswerGrader.normalize("ABC!") == "abc")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("stripAccents handles common Spanish diacritics")
|
||||||
|
func stripAccents() {
|
||||||
|
#expect(AnswerGrader.stripAccents("niño") == "nino")
|
||||||
|
#expect(AnswerGrader.stripAccents("está") == "esta")
|
||||||
|
#expect(AnswerGrader.stripAccents("güero") == "guero")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("levenshtein computes edit distance")
|
||||||
|
func levenshtein() {
|
||||||
|
#expect(AnswerGrader.levenshtein("kitten", "sitting") == 3)
|
||||||
|
#expect(AnswerGrader.levenshtein("flaw", "lawn") == 2)
|
||||||
|
#expect(AnswerGrader.levenshtein("abc", "abc") == 0)
|
||||||
|
#expect(AnswerGrader.levenshtein("", "abc") == 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import SharedModels
|
||||||
|
|
||||||
|
/// Invariants that the shipped `course_data.json` must satisfy for the
|
||||||
|
/// Complete the Sentence quiz to work for every card in every course.
|
||||||
|
///
|
||||||
|
/// These tests read the repo's `course_data.json` from a fixed relative path.
|
||||||
|
/// They act as the pass/fail oracle for the content gap-fill work: they fail
|
||||||
|
/// before the gap-fill pass is complete and pass once every card has at least
|
||||||
|
/// three examples and at least one of them yields a resolvable blank.
|
||||||
|
@Suite("Content coverage — course_data.json")
|
||||||
|
struct ContentCoverageTests {
|
||||||
|
|
||||||
|
// Repo-relative path from this test file to the bundled data file.
|
||||||
|
// SharedModels/Tests/SharedModelsTests/ContentCoverageTests.swift
|
||||||
|
// → ../../../../Conjuga/course_data.json
|
||||||
|
private static let courseDataPath: String = {
|
||||||
|
let here = URL(fileURLWithPath: #filePath)
|
||||||
|
return here
|
||||||
|
.deletingLastPathComponent() // SharedModelsTests
|
||||||
|
.deletingLastPathComponent() // Tests
|
||||||
|
.deletingLastPathComponent() // SharedModels
|
||||||
|
.deletingLastPathComponent() // Conjuga (repo package parent)
|
||||||
|
.appendingPathComponent("Conjuga/course_data.json")
|
||||||
|
.path
|
||||||
|
}()
|
||||||
|
|
||||||
|
struct CardRef {
|
||||||
|
let courseName: String
|
||||||
|
let weekNumber: Int
|
||||||
|
let deckTitle: String
|
||||||
|
let front: String
|
||||||
|
let back: String
|
||||||
|
let examples: [[String: String]]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load every card in course_data.json.
|
||||||
|
static func loadAllCards() throws -> [CardRef] {
|
||||||
|
let url = URL(fileURLWithPath: courseDataPath)
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let courses = json["courses"] as? [[String: Any]] else {
|
||||||
|
Issue.record("course_data.json is not in the expected shape")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var cards: [CardRef] = []
|
||||||
|
for course in courses {
|
||||||
|
let cname = course["course"] as? String ?? "<unknown>"
|
||||||
|
let weeks = course["weeks"] as? [[String: Any]] ?? []
|
||||||
|
for week in weeks {
|
||||||
|
let wnum = week["week"] as? Int ?? -1
|
||||||
|
let decks = week["decks"] as? [[String: Any]] ?? []
|
||||||
|
for deck in decks {
|
||||||
|
let title = deck["title"] as? String ?? "<unknown>"
|
||||||
|
let rawCards = deck["cards"] as? [[String: Any]] ?? []
|
||||||
|
for raw in rawCards {
|
||||||
|
let front = raw["front"] as? String ?? ""
|
||||||
|
let back = raw["back"] as? String ?? ""
|
||||||
|
let examples = (raw["examples"] as? [[String: String]]) ?? []
|
||||||
|
cards.append(CardRef(
|
||||||
|
courseName: cname,
|
||||||
|
weekNumber: wnum,
|
||||||
|
deckTitle: title,
|
||||||
|
front: front,
|
||||||
|
back: back,
|
||||||
|
examples: examples
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func vocabCard(from ref: CardRef) -> VocabCard {
|
||||||
|
var exES: [String] = []
|
||||||
|
var exEN: [String] = []
|
||||||
|
var exBlanks: [String] = []
|
||||||
|
for ex in ref.examples {
|
||||||
|
if let es = ex["es"] {
|
||||||
|
exES.append(es)
|
||||||
|
exEN.append(ex["en"] ?? "")
|
||||||
|
exBlanks.append(ex["blank"] ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return VocabCard(
|
||||||
|
front: ref.front,
|
||||||
|
back: ref.back,
|
||||||
|
deckId: "\(ref.courseName)_w\(ref.weekNumber)_\(ref.deckTitle)",
|
||||||
|
examplesES: exES,
|
||||||
|
examplesEN: exEN,
|
||||||
|
examplesBlanks: exBlanks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("course_data.json exists and parses")
|
||||||
|
func fileExists() throws {
|
||||||
|
let cards = try Self.loadAllCards()
|
||||||
|
#expect(cards.count > 0, "Expected at least one card in course_data.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every card has at least three example sentences")
|
||||||
|
func everyCardHasThreeExamples() throws {
|
||||||
|
let cards = try Self.loadAllCards()
|
||||||
|
var failures: [String] = []
|
||||||
|
for ref in cards {
|
||||||
|
if ref.examples.count < 3 {
|
||||||
|
failures.append("[\(ref.courseName) / w\(ref.weekNumber) / \(ref.deckTitle)] '\(ref.front)' has \(ref.examples.count) examples")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !failures.isEmpty {
|
||||||
|
let head = Array(failures.prefix(10)).joined(separator: "\n")
|
||||||
|
Issue.record("\(failures.count) cards have fewer than 3 examples. First 10:\n\(head)")
|
||||||
|
}
|
||||||
|
#expect(failures.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every card yields a resolvable SentenceQuizEngine question")
|
||||||
|
func everyCardHasBlankableSentence() throws {
|
||||||
|
let cards = try Self.loadAllCards()
|
||||||
|
var failures: [String] = []
|
||||||
|
for ref in cards {
|
||||||
|
let card = Self.vocabCard(from: ref)
|
||||||
|
if !SentenceQuizEngine.hasValidSentence(for: card) {
|
||||||
|
failures.append("[\(ref.courseName) / w\(ref.weekNumber) / \(ref.deckTitle)] '\(ref.front)'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !failures.isEmpty {
|
||||||
|
let head = Array(failures.prefix(15)).joined(separator: "\n")
|
||||||
|
Issue.record("\(failures.count) cards have no resolvable sentence for Complete the Sentence. First 15:\n\(head)")
|
||||||
|
}
|
||||||
|
#expect(failures.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every generated question has a non-empty blank word and display template")
|
||||||
|
func questionIntegrity() throws {
|
||||||
|
let cards = try Self.loadAllCards()
|
||||||
|
var failures: [String] = []
|
||||||
|
for ref in cards {
|
||||||
|
let card = Self.vocabCard(from: ref)
|
||||||
|
// Try to build a question from each resolvable index deterministically
|
||||||
|
for idx in SentenceQuizEngine.resolvableIndices(for: card) {
|
||||||
|
guard let q = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: idx) else {
|
||||||
|
failures.append("[\(ref.courseName) / '\(ref.front)'] index \(idx) returned nil despite being resolvable")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q.blankWord.isEmpty {
|
||||||
|
failures.append("[\(ref.courseName) / '\(ref.front)'] index \(idx) has empty blankWord")
|
||||||
|
}
|
||||||
|
if !q.displayTemplate.contains(SentenceQuizEngine.blankMarker) {
|
||||||
|
failures.append("[\(ref.courseName) / '\(ref.front)'] index \(idx) display template missing blank marker")
|
||||||
|
}
|
||||||
|
if q.displayTemplate == q.sentenceES {
|
||||||
|
failures.append("[\(ref.courseName) / '\(ref.front)'] index \(idx) display template unchanged from sentence")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !failures.isEmpty {
|
||||||
|
let head = Array(failures.prefix(10)).joined(separator: "\n")
|
||||||
|
Issue.record("\(failures.count) question integrity failures. First 10:\n\(head)")
|
||||||
|
}
|
||||||
|
#expect(failures.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import SharedModels
|
||||||
|
|
||||||
|
@Suite("EnglishConjugator")
|
||||||
|
struct EnglishConjugatorTests {
|
||||||
|
|
||||||
|
// MARK: - haber (to have) — irregular English verb
|
||||||
|
|
||||||
|
@Test("haber present: I have / you have / he/she has")
|
||||||
|
func haberPresent() {
|
||||||
|
#expect(t("to have", "ind_presente", 0) == "I have")
|
||||||
|
#expect(t("to have", "ind_presente", 1) == "you have")
|
||||||
|
#expect(t("to have", "ind_presente", 2) == "he/she has")
|
||||||
|
#expect(t("to have", "ind_presente", 3) == "we have")
|
||||||
|
#expect(t("to have", "ind_presente", 5) == "they have")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("haber preterite: I had")
|
||||||
|
func haberPreterite() {
|
||||||
|
#expect(t("to have", "ind_preterito", 0) == "I had")
|
||||||
|
#expect(t("to have", "ind_preterito", 2) == "he/she had")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("haber future: I will have")
|
||||||
|
func haberFuture() {
|
||||||
|
#expect(t("to have", "ind_futuro", 0) == "I will have")
|
||||||
|
#expect(t("to have", "ind_futuro", 3) == "we will have")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("haber conditional: I would have")
|
||||||
|
func haberConditional() {
|
||||||
|
#expect(t("to have", "cond_presente", 0) == "I would have")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("haber present perfect: I have had / he/she has had")
|
||||||
|
func haberPresentPerfect() {
|
||||||
|
#expect(t("to have", "ind_perfecto", 0) == "I have had")
|
||||||
|
#expect(t("to have", "ind_perfecto", 2) == "he/she has had")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ir (to go) — irregular English verb
|
||||||
|
|
||||||
|
@Test("ir present: I go / he/she goes")
|
||||||
|
func irPresent() {
|
||||||
|
#expect(t("to go", "ind_presente", 0) == "I go")
|
||||||
|
#expect(t("to go", "ind_presente", 2) == "he/she goes")
|
||||||
|
#expect(t("to go", "ind_presente", 5) == "they go")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ir preterite: I went")
|
||||||
|
func irPreterite() {
|
||||||
|
#expect(t("to go", "ind_preterito", 0) == "I went")
|
||||||
|
#expect(t("to go", "ind_preterito", 2) == "he/she went")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ir imperfect: I used to go")
|
||||||
|
func irImperfect() {
|
||||||
|
#expect(t("to go", "ind_imperfecto", 0) == "I used to go")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ir present perfect: I have gone")
|
||||||
|
func irPresentPerfect() {
|
||||||
|
#expect(t("to go", "ind_perfecto", 0) == "I have gone")
|
||||||
|
#expect(t("to go", "ind_perfecto", 2) == "he/she has gone")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ser (to be) — most irregular English verb
|
||||||
|
|
||||||
|
@Test("ser present: he/she is")
|
||||||
|
func serPresent() {
|
||||||
|
#expect(t("to be", "ind_presente", 2) == "he/she is")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ser preterite: I was/were")
|
||||||
|
func serPreterite() {
|
||||||
|
#expect(t("to be", "ind_preterito", 0) == "I was/were")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ser present perfect: I have been")
|
||||||
|
func serPresentPerfect() {
|
||||||
|
#expect(t("to be", "ind_perfecto", 0) == "I have been")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - hablar (to speak)
|
||||||
|
|
||||||
|
@Test("hablar present: I speak / he/she speaks")
|
||||||
|
func hablarPresent() {
|
||||||
|
#expect(t("to speak", "ind_presente", 0) == "I speak")
|
||||||
|
#expect(t("to speak", "ind_presente", 2) == "he/she speaks")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("hablar preterite: I spoke")
|
||||||
|
func hablarPreterite() {
|
||||||
|
#expect(t("to speak", "ind_preterito", 0) == "I spoke")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("hablar present perfect: I have spoken")
|
||||||
|
func hablarPresentPerfect() {
|
||||||
|
#expect(t("to speak", "ind_perfecto", 0) == "I have spoken")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - comer (to eat)
|
||||||
|
|
||||||
|
@Test("comer preterite: I ate")
|
||||||
|
func comerPreterite() {
|
||||||
|
#expect(t("to eat", "ind_preterito", 0) == "I ate")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("comer present perfect: I have eaten")
|
||||||
|
func comerPresentPerfect() {
|
||||||
|
#expect(t("to eat", "ind_perfecto", 0) == "I have eaten")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - vivir (to live) — regular English verb
|
||||||
|
|
||||||
|
@Test("vivir present: I live / he/she lives")
|
||||||
|
func vivirPresent() {
|
||||||
|
#expect(t("to live", "ind_presente", 0) == "I live")
|
||||||
|
#expect(t("to live", "ind_presente", 2) == "he/she lives")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("vivir preterite: I lived")
|
||||||
|
func vivirPreterite() {
|
||||||
|
#expect(t("to live", "ind_preterito", 0) == "I lived")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - abatir (to knock down) — multi-word verb
|
||||||
|
|
||||||
|
@Test("abatir present: I knock down / he/she knocks down")
|
||||||
|
func abatirPresent() {
|
||||||
|
#expect(t("to knock down", "ind_presente", 0) == "I knock down")
|
||||||
|
#expect(t("to knock down", "ind_presente", 2) == "he/she knocks down")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("abatir conditional: I would knock down")
|
||||||
|
func abatirConditional() {
|
||||||
|
#expect(t("to knock down", "cond_presente", 0) == "I would knock down")
|
||||||
|
#expect(t("to knock down", "cond_presente", 2) == "he/she would knock down")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("abatir preterite: I knocked down")
|
||||||
|
func abatirPreterite() {
|
||||||
|
#expect(t("to knock down", "ind_preterito", 0) == "I knocked down")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conditional
|
||||||
|
|
||||||
|
@Test("conditional: I would speak")
|
||||||
|
func conditional() {
|
||||||
|
#expect(t("to speak", "cond_presente", 0) == "I would speak")
|
||||||
|
#expect(t("to speak", "cond_presente", 2) == "he/she would speak")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("conditional perfect: I would have gone")
|
||||||
|
func conditionalPerfect() {
|
||||||
|
#expect(t("to go", "cond_perfecto", 0) == "I would have gone")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subjunctive
|
||||||
|
|
||||||
|
@Test("present subjunctive: that I speak")
|
||||||
|
func presentSubjunctive() {
|
||||||
|
#expect(t("to speak", "subj_presente", 0) == "that I speak")
|
||||||
|
#expect(t("to speak", "subj_presente", 2) == "that he/she speak")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("imperfect subjunctive (ra): that I would speak")
|
||||||
|
func imperfectSubjunctive1() {
|
||||||
|
#expect(t("to speak", "subj_imperfecto_1", 0) == "that I would speak")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("imperfect subjunctive (se): that I would speak")
|
||||||
|
func imperfectSubjunctive2() {
|
||||||
|
#expect(t("to speak", "subj_imperfecto_2", 0) == "that I would speak")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("subjunctive perfect: that I have spoken")
|
||||||
|
func subjunctivePerfect() {
|
||||||
|
#expect(t("to speak", "subj_perfecto", 0) == "that I have spoken")
|
||||||
|
#expect(t("to speak", "subj_perfecto", 2) == "that he/she has spoken")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("subjunctive pluperfect: that I had gone")
|
||||||
|
func subjunctivePluperfect() {
|
||||||
|
#expect(t("to go", "subj_pluscuamperfecto_1", 0) == "that I had gone")
|
||||||
|
#expect(t("to go", "subj_pluscuamperfecto_2", 0) == "that I had gone")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("subjunctive future: that I will speak")
|
||||||
|
func subjunctiveFuture() {
|
||||||
|
#expect(t("to speak", "subj_futuro", 0) == "that I will speak")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("subjunctive future perfect: that I will have spoken")
|
||||||
|
func subjunctiveFuturePerfect() {
|
||||||
|
#expect(t("to speak", "subj_futuro_perfecto", 0) == "that I will have spoken")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Imperative
|
||||||
|
|
||||||
|
@Test("imperative affirmative")
|
||||||
|
func imperativeAffirmative() {
|
||||||
|
#expect(t("to speak", "imp_afirmativo", 1) == "speak!")
|
||||||
|
#expect(t("to speak", "imp_afirmativo", 3) == "let's speak!")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("imperative negative")
|
||||||
|
func imperativeNegative() {
|
||||||
|
#expect(t("to speak", "imp_negativo", 1) == "don't speak")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compound indicative tenses
|
||||||
|
|
||||||
|
@Test("pluperfect: I had spoken")
|
||||||
|
func pluperfect() {
|
||||||
|
#expect(t("to speak", "ind_pluscuamperfecto", 0) == "I had spoken")
|
||||||
|
#expect(t("to go", "ind_pluscuamperfecto", 0) == "I had gone")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("future perfect: I will have spoken")
|
||||||
|
func futurePerfect() {
|
||||||
|
#expect(t("to speak", "ind_futuro_perfecto", 0) == "I will have spoken")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("preterite anterior: I had spoken (same as pluperfect in English)")
|
||||||
|
func preteriteAnterior() {
|
||||||
|
#expect(t("to speak", "ind_preterito_anterior", 0) == "I had spoken")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Edge cases
|
||||||
|
|
||||||
|
@Test("empty english returns empty string")
|
||||||
|
func emptyEnglish() {
|
||||||
|
#expect(t("", "ind_presente", 0) == "")
|
||||||
|
#expect(t("to ", "ind_presente", 0) == "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("unknown tense falls back to pronoun + base")
|
||||||
|
func unknownTense() {
|
||||||
|
#expect(t("to speak", "some_future_tense", 0) == "I speak")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("3rd person present: study → studies")
|
||||||
|
func thirdPersonYRule() {
|
||||||
|
#expect(t("to study", "ind_presente", 2) == "he/she studies")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("3rd person present: play → plays")
|
||||||
|
func thirdPersonVowelY() {
|
||||||
|
#expect(t("to play", "ind_presente", 2) == "he/she plays")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("3rd person present: watch → watches")
|
||||||
|
func thirdPersonChRule() {
|
||||||
|
#expect(t("to watch", "ind_presente", 2) == "he/she watches")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("past regular: carry → carried")
|
||||||
|
func pastYRule() {
|
||||||
|
#expect(t("to carry", "ind_preterito", 0) == "I carried")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper
|
||||||
|
|
||||||
|
private func t(_ english: String, _ tenseId: String, _ personIndex: Int) -> String {
|
||||||
|
EnglishConjugator.translate(english: english, tenseId: tenseId, personIndex: personIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import SharedModels
|
||||||
|
|
||||||
|
@Suite("SentenceQuizEngine")
|
||||||
|
struct SentenceQuizEngineTests {
|
||||||
|
|
||||||
|
// MARK: - hasValidSentence
|
||||||
|
|
||||||
|
@Test("No examples returns false")
|
||||||
|
func noExamples() {
|
||||||
|
let card = VocabCard(front: "comer", back: "to eat", deckId: "d", examplesES: [], examplesEN: [])
|
||||||
|
#expect(SentenceQuizEngine.hasValidSentence(for: card) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Example containing target word returns true via substring fallback")
|
||||||
|
func substringMatch() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "manzana",
|
||||||
|
back: "apple",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: ["Yo como una manzana roja."],
|
||||||
|
examplesEN: ["I eat a red apple."]
|
||||||
|
)
|
||||||
|
#expect(SentenceQuizEngine.hasValidSentence(for: card))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Example whose stored blank appears returns true even if target word is missing")
|
||||||
|
func storedBlankMatch() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "comer",
|
||||||
|
back: "to eat",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: ["Yo como manzanas todos los días."],
|
||||||
|
examplesEN: ["I eat apples every day."],
|
||||||
|
examplesBlanks: ["como"]
|
||||||
|
)
|
||||||
|
#expect(SentenceQuizEngine.hasValidSentence(for: card))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Example with neither stored blank nor substring match returns false for that example")
|
||||||
|
func neitherMatches() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "comer",
|
||||||
|
back: "to eat",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: ["Ella prepara la cena."],
|
||||||
|
examplesEN: ["She prepares dinner."]
|
||||||
|
)
|
||||||
|
#expect(SentenceQuizEngine.hasValidSentence(for: card) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("At least one resolvable example across many makes the card valid")
|
||||||
|
func oneOfManyResolves() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "comer",
|
||||||
|
back: "to eat",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: [
|
||||||
|
"Ella prepara la cena.",
|
||||||
|
"Los niños van al parque.",
|
||||||
|
"Quiero comer algo ahora."
|
||||||
|
],
|
||||||
|
examplesEN: ["", "", ""]
|
||||||
|
)
|
||||||
|
#expect(SentenceQuizEngine.hasValidSentence(for: card))
|
||||||
|
#expect(SentenceQuizEngine.resolvableIndices(for: card) == [2])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Phonetic glosses are rejected (too few words)")
|
||||||
|
func phoneticGlossRejected() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "discutir",
|
||||||
|
back: "to discuss",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: [
|
||||||
|
"discutir(dees-koo-teer)",
|
||||||
|
"INTRANSITIVE VERB",
|
||||||
|
"Los amigos van a discutir el tema."
|
||||||
|
],
|
||||||
|
examplesEN: ["", "", "The friends are going to discuss the topic."]
|
||||||
|
)
|
||||||
|
// Only index 2 is a real sentence (≥4 words AND contains the target)
|
||||||
|
#expect(SentenceQuizEngine.hasValidSentence(for: card))
|
||||||
|
#expect(SentenceQuizEngine.resolvableIndices(for: card) == [2])
|
||||||
|
// Phonetic entry at index 0 returns nil
|
||||||
|
#expect(SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - buildQuestion (deterministic)
|
||||||
|
|
||||||
|
@Test("Builds question from substring match, preserves original casing")
|
||||||
|
func buildFromSubstring() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "manzana",
|
||||||
|
back: "apple",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: ["Yo como una manzana roja."],
|
||||||
|
examplesEN: ["I eat a red apple."]
|
||||||
|
)
|
||||||
|
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||||
|
#expect(question != nil)
|
||||||
|
#expect(question?.sentenceES == "Yo como una manzana roja.")
|
||||||
|
#expect(question?.sentenceEN == "I eat a red apple.")
|
||||||
|
#expect(question?.blankWord == "manzana")
|
||||||
|
#expect(question?.displayTemplate == "Yo como una _____ roja.")
|
||||||
|
#expect(question?.exampleIndex == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Builds question from stored blank when provided")
|
||||||
|
func buildFromStoredBlank() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "comer",
|
||||||
|
back: "to eat",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: ["Yo como manzanas todos los días."],
|
||||||
|
examplesEN: ["I eat apples every day."],
|
||||||
|
examplesBlanks: ["como"]
|
||||||
|
)
|
||||||
|
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||||
|
#expect(question?.blankWord == "como")
|
||||||
|
#expect(question?.displayTemplate == "Yo _____ manzanas todos los días.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Stored blank takes precedence over substring match")
|
||||||
|
func storedBlankWins() {
|
||||||
|
// Card teaches "manzana" (would substring-match), but the stored blank is the verb "como"
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "manzana",
|
||||||
|
back: "apple",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: ["Yo como una manzana."],
|
||||||
|
examplesEN: ["I eat an apple."],
|
||||||
|
examplesBlanks: ["como"]
|
||||||
|
)
|
||||||
|
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||||
|
#expect(question?.blankWord == "como")
|
||||||
|
#expect(question?.displayTemplate == "Yo _____ una manzana.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Falls back to substring match when stored blank is empty")
|
||||||
|
func fallbackWhenStoredBlankEmpty() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "manzana",
|
||||||
|
back: "apple",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: ["Yo como una manzana."],
|
||||||
|
examplesEN: ["I eat an apple."],
|
||||||
|
examplesBlanks: [""]
|
||||||
|
)
|
||||||
|
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||||
|
#expect(question?.blankWord == "manzana")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Falls back to substring match when stored blank doesn't actually appear in the sentence")
|
||||||
|
func fallbackWhenStoredBlankMissing() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "manzana",
|
||||||
|
back: "apple",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: ["Yo como una manzana."],
|
||||||
|
examplesEN: ["I eat an apple."],
|
||||||
|
examplesBlanks: ["nonexistent"]
|
||||||
|
)
|
||||||
|
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||||
|
#expect(question?.blankWord == "manzana")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Preserves original capitalization when blanking (substring is case-insensitive)")
|
||||||
|
func preservesCapitalization() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "hola",
|
||||||
|
back: "hello",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: ["Hola amiga, ¿cómo estás hoy?"],
|
||||||
|
examplesEN: ["Hello friend, how are you today?"]
|
||||||
|
)
|
||||||
|
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||||
|
#expect(question?.blankWord == "Hola")
|
||||||
|
#expect(question?.displayTemplate == "_____ amiga, ¿cómo estás hoy?")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Blanks phrase cards when target front contains spaces")
|
||||||
|
func phraseCardBlank() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "¿cómo estás?",
|
||||||
|
back: "how are you?",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: ["Hola amiga, ¿cómo estás? Estoy bien."],
|
||||||
|
examplesEN: ["Hi friend, how are you? I am well."]
|
||||||
|
)
|
||||||
|
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||||
|
#expect(question?.blankWord == "¿cómo estás?")
|
||||||
|
#expect(question?.displayTemplate == "Hola amiga, _____ Estoy bien.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Returns nil when the example has no resolvable blank")
|
||||||
|
func unresolvableExampleReturnsNil() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "comer",
|
||||||
|
back: "to eat",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: ["Ella prepara la cena."],
|
||||||
|
examplesEN: ["She prepares dinner."]
|
||||||
|
)
|
||||||
|
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||||
|
#expect(question == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Returns nil when example index is out of range")
|
||||||
|
func outOfRangeIndexReturnsNil() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "comer",
|
||||||
|
back: "to eat",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: ["Yo como."],
|
||||||
|
examplesEN: [""]
|
||||||
|
)
|
||||||
|
#expect(SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 5) == nil)
|
||||||
|
#expect(SentenceQuizEngine.buildQuestion(for: card, exampleIndex: -1) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - buildQuestion (random)
|
||||||
|
|
||||||
|
@Test("Random buildQuestion always picks a resolvable example")
|
||||||
|
func randomPickIsResolvable() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "comer",
|
||||||
|
back: "to eat",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: [
|
||||||
|
"Ella prepara la cena.", // unresolvable (no "comer")
|
||||||
|
"Los niños van al parque.", // unresolvable
|
||||||
|
"Quiero comer algo rico ahora.", // resolvable (substring, ≥4 words)
|
||||||
|
"El perro come su comida diaria." // unresolvable — "come" but not "comer"
|
||||||
|
],
|
||||||
|
examplesEN: ["", "", "", ""]
|
||||||
|
)
|
||||||
|
// Only index 2 is resolvable (contains "comer" literally and has ≥4 words)
|
||||||
|
for _ in 0..<25 {
|
||||||
|
let q = SentenceQuizEngine.buildQuestion(for: card)
|
||||||
|
#expect(q?.exampleIndex == 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Random buildQuestion returns nil when no examples resolve")
|
||||||
|
func randomNilWhenNothingResolves() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "comer",
|
||||||
|
back: "to eat",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: ["Ella prepara la cena."],
|
||||||
|
examplesEN: [""]
|
||||||
|
)
|
||||||
|
#expect(SentenceQuizEngine.buildQuestion(for: card) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Array alignment edge cases
|
||||||
|
|
||||||
|
@Test("examplesBlanks shorter than examplesES is handled gracefully")
|
||||||
|
func blanksArrayShorterThanExamples() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "comer",
|
||||||
|
back: "to eat",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: ["Yo como mucho pan.", "Tú comes en casa."],
|
||||||
|
examplesEN: ["I eat a lot of bread.", "You eat at home."],
|
||||||
|
examplesBlanks: ["como"] // only covers index 0
|
||||||
|
)
|
||||||
|
// Index 0: stored blank match
|
||||||
|
let q0 = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||||
|
#expect(q0?.blankWord == "como")
|
||||||
|
// Index 1: no stored blank, "comer" doesn't appear literally → unresolvable
|
||||||
|
let q1 = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 1)
|
||||||
|
#expect(q1 == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Display template uses the engine's blank marker constant")
|
||||||
|
func blankMarkerConstant() {
|
||||||
|
let card = VocabCard(
|
||||||
|
front: "perro",
|
||||||
|
back: "dog",
|
||||||
|
deckId: "d",
|
||||||
|
examplesES: ["El perro ladra todo el día."],
|
||||||
|
examplesEN: ["The dog barks all day."]
|
||||||
|
)
|
||||||
|
let q = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||||
|
#expect(q?.displayTemplate.contains(SentenceQuizEngine.blankMarker) == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
docs/spanish-fundamentals/01-the-introduction.md
Normal file
21
docs/spanish-fundamentals/01-the-introduction.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# 01. The Introduction
|
||||||
|
|
||||||
|
- **Time range:** 00:00:00 – 00:01:04 (duration 00:01:04)
|
||||||
|
- **Source:** [A Complete Guide To Every Fundamental In Spanish (The Conclusion)](https://youtube.com/watch?v=YHDZSHCt1DE&t=0s)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **[on-screen 00:00:00]**
|
||||||
|
> Conditi Compars.. rare 6 ove Onal aratiy, es red guee® Conjugations Tense Adjective, Petives & 6 ne e ° oxe™ ~o This Video Prepositions Direct & Indirect 1 Object wees Has EVERY Spanish ,°«. vers Concept You Need rast tense Por&Para FutureTense Negatives Reflexives Imperfect Tense
|
||||||
|
|
||||||
|
**[00:00:02]** principle and fundamental that you need in order to understand how Spanish operates as a language after watching this video you will have a clear basis of Spanish and understand how to use its ideas properly in essence this video is a long collection of all of my previous videos combined that show and explain each Spanish concept individually so that way you don't have to search each concept on its own everything that you need in Spanish is in this video aside from verbs like gust and the difference
|
||||||
|
|
||||||
|
> **[on-screen 00:00:25]**
|
||||||
|
> Verbs like “Gustar” “Qué” & “Cual”
|
||||||
|
|
||||||
|
**[00:00:26]** between K and qu I've decided to not
|
||||||
|
|
||||||
|
> **[on-screen 00:00:28]**
|
||||||
|
> Verbs star” “Quée’ ual”
|
||||||
|
|
||||||
|
**[00:00:28]** describe them because these are Spanish Concepts that do not need thorough explanations aside from that everything else is in this video some moments will have weird sentences like this concept is for a future video but that is because all of my videos are edited into one long video some parts will be slower quieter and maybe even faster than others and I do apologize for these moments like I said at the beginning this is a simple collection of all my previous videos combined so therefore I cannot go back and change them in any way what's in this video is the same across all of my previous videos and I will end the video with a short conclusion explaining why I showed these ideas as they are anyhow enjoy this is
|
||||||
271
docs/spanish-fundamentals/02-spanish-fundamentals.md
Normal file
271
docs/spanish-fundamentals/02-spanish-fundamentals.md
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
# 02. Spanish Fundamentals
|
||||||
|
|
||||||
|
- **Time range:** 00:01:04 – 00:10:32 (duration 00:09:28)
|
||||||
|
- **Source:** [A Complete Guide To Every Fundamental In Spanish (The Conclusion)](https://youtube.com/watch?v=YHDZSHCt1DE&t=64s)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**[00:01:05]** my first official video on my channel
|
||||||
|
|
||||||
|
> **[on-screen 00:01:05]**
|
||||||
|
> The Spanish Language And Its Fundamentals
|
||||||
|
|
||||||
|
**[00:01:06]** that's going to go in depth with the aspects of a language that you would need to know to speak I chose to go with Spanish first because I'm learning it right now and I would even say that I
|
||||||
|
|
||||||
|
> **[on-screen 00:01:13]**
|
||||||
|
> Cd My Espanol: Intermediate - Advanced -> Reading & Writing Moderate -> Speaking Regular - Listening
|
||||||
|
|
||||||
|
**[00:01:14]** have an intermediate to an advanced level of Spanish in all aspects of reading and writing moderate speaking and a bit of listening though I'm not a native Spanish speaker I do have a substantial amount of knowledge when it comes down to the fundamentals of the language and by fundamentals I mean the
|
||||||
|
|
||||||
|
> **[on-screen 00:01:26]**
|
||||||
|
> Spanish Fundamentals: Words, phrases, and sentences
|
||||||
|
|
||||||
|
**[00:01:26]** first words phrases and sentences that you would need to know to start start speaking Spanish on a beginner level everybody has to start with the
|
||||||
|
|
||||||
|
> **[on-screen 00:01:32]**
|
||||||
|
> Everybody HAS TO START With The Fundamentals Alphabet, Words, Verbs, And Phrases
|
||||||
|
|
||||||
|
**[00:01:33]** fundamentals of the language understanding the basic syntax of the language the alphabet words verbs phrases and everything in between the
|
||||||
|
|
||||||
|
> **[on-screen 00:01:40]**
|
||||||
|
> The Spanish Alphabet: Aa Bb Cc Dd Ee Ff Gg Hh li Jj Kk Ll Mm Nn Na Oo Pp Qq Rr Ss Tt Uu Vv Ww Xx Yy Zz
|
||||||
|
|
||||||
|
**[00:01:40]** Spanish alphabet is actually no different than the English alphabet following the same letters but there are a few differences like having an in
|
||||||
|
|
||||||
|
> **[on-screen 00:01:45]**
|
||||||
|
> The Spanish Alphabet: Aa Bb Cc Dd Ee Ff Gg Hh li Jj Kk Ll Mm Nn Na Oo Pp Qq Rr Ss Tt Uu Vv Ww Xx Yy Zz
|
||||||
|
|
||||||
|
**[00:01:46]** which is an N with a squiggle over it having 27 letters instead of 26 like in
|
||||||
|
|
||||||
|
> **[on-screen 00:01:48]**
|
||||||
|
> The Spanish Alphabet: Aa Bb Cc Dd Ee Ff Gg Hh li Jj Kk LL Mm Nn Na Oo Pp Qq Rr Ss Tt Uu Vv Ww Xx Yy Zz 27 Letters
|
||||||
|
|
||||||
|
**[00:01:50]** English and also every letter is pronounced differently as someone who's
|
||||||
|
|
||||||
|
> **[on-screen 00:01:53]**
|
||||||
|
> The Spanish Alphabet: Aa Bb Cc Dd Ee Ff Gg Hh li Jj Kk Ll Mm Nn Na Oo Pp Qq Rr Ss Tt Uu Vv Ww Xx Yy Zz
|
||||||
|
|
||||||
|
**[00:01:53]** learning Spanish you don't necessarily need to learn how to pronounce every letter of the alphabet what you do need to learn is how to use these letters in context however I will still pronounce them for you so that you can understand how the language is spoken a b c d e one spoken as a side note people sometimes like to include the letters CH
|
||||||
|
|
||||||
|
> **[on-screen 00:02:27]**
|
||||||
|
> The Spanish Alphabet: Aa Bb Cc Dd Ee Ff Gg Hh li Jj Kk LL Mm Nn Na Oo Pp Qq Pr Ss Tt Uu Vv Ww Xx Yy Zz ch&ll
|
||||||
|
|
||||||
|
**[00:02:27]** and Y making the alphabet have 29 letters but these are mainly sound letters that are used a lot less and something important to say about the sound yeah typically speaking if you're someone who is not of Spanish Heritage you will pronounce the sound as yeah two L's make a ye sound like yav which is
|
||||||
|
|
||||||
|
> **[on-screen 00:02:41]**
|
||||||
|
> ll —> ye llave —> key
|
||||||
|
|
||||||
|
**[00:02:42]** Spanish for key now I would like to
|
||||||
|
|
||||||
|
> **[on-screen 00:02:43]**
|
||||||
|
> The Spanish Fundamentals:
|
||||||
|
|
||||||
|
**[00:02:44]** speak about the words that you would need to know to say daily if you want to speak Spanish for now the biggest advice that I can give is to just memorize these words and keep them locked in your mind and whenever I say memorize this or memorize that it basically means that I simplified the context to its easiest form it cannot get any easier than this so it's not not going to be difficult to memorize these words and have them locked in your memory here's what I'm going to begin with accents question
|
||||||
|
|
||||||
|
> **[on-screen 00:03:04]**
|
||||||
|
> The Spanish Fundamentals: 1.Accents 2. Question words 3.Prepositional words & adverbs 4.Pronouns 5. Days of the week 6.Months 7.Seasons 8.Time words 9.Numbers
|
||||||
|
|
||||||
|
**[00:03:05]** words prepositional words and adverbs pronouns days of the week months Seasons time words and numbers number one
|
||||||
|
|
||||||
|
> **[on-screen 00:03:12]**
|
||||||
|
> 1. Accents In Spanish:
|
||||||
|
|
||||||
|
**[00:03:12]** accents accents in Spanish essentially help to indicate which syllable of a word should be stressed out when spoken out loud the accents are placed above
|
||||||
|
|
||||||
|
> **[on-screen 00:03:18]**
|
||||||
|
> 1. Accents In Spanish: a, é, i, 0, U
|
||||||
|
|
||||||
|
**[00:03:19]** vowels and whenever you say them you put the emphasis of the sound on that vowel here's an example and I'm using this example as an example you don't have to know the rules for now yo ablo in
|
||||||
|
|
||||||
|
> **[on-screen 00:03:27]**
|
||||||
|
> 1. Accents In Spanish: Yo hablo ——-+! speak
|
||||||
|
|
||||||
|
**[00:03:28]** Spanish means I speak don't worry about the conjugation yo that's the pronunciation yo however ELO means he
|
||||||
|
|
||||||
|
> **[on-screen 00:03:35]**
|
||||||
|
> 1. Accents In Spanish: Yo hablo ——1 speak Elhabl6 ——~>He spoke El-> He El-> The
|
||||||
|
|
||||||
|
**[00:03:37]** spoke L with an accent mark means he because without the accent it means the article the masculine and also H's in Spanish are not pronounced so whenever you see a word beginning with a followed by a vowel just say the vowel as it is it's Noto Oro it's ablo ablo ablo ablo that's Accents in Spanish number two
|
||||||
|
|
||||||
|
> **[on-screen 00:04:00]**
|
||||||
|
> e 2. Question Words ¢ Where - Donde? ¢ When - Cuando? ¢ What - ¢Qué? e Why - éPor qué? © Who - ¢Quién? ¢ Which - gCual? ¢ How - ¢Como? ¢ How much/many - éCuanto/Cuanta/Cuantos/Cuantas?
|
||||||
|
|
||||||
|
**[00:04:01]** question words the best advice for these words is again just memorize them these are the question words and in Spanish they look like this where is d when is quando what is K why is for who is Ken which is kual how is and how much or how many is Quanto quanta quantos Quantas and also whenever you write a question
|
||||||
|
|
||||||
|
> **[on-screen 00:04:26]**
|
||||||
|
> e 2. Question Words ¢ Where - <Dénde? ¢ When - ¢Cuando? e What - ¢Qué? ¢ Why - ¢Por qué? © Who - Quién? ¢ Which - ;Cual? ¢ How - ¢Cémo? ¢ How much/many - ~Cuanto/Cuanta/Cuantos/Cuantas?
|
||||||
|
|
||||||
|
**[00:04:27]** with them you have to put an upside down question mark in the beginning this is a rule in the language and this is something good to remember if you see these words with accents the words are used as literal questions sometimes K
|
||||||
|
|
||||||
|
> **[on-screen 00:04:36]**
|
||||||
|
> que -> that "| wanted to say that I'm happy" "Yo Queria decir que estoy feliz"
|
||||||
|
|
||||||
|
**[00:04:37]** without an accent mark can mean that as in the sentence I wanted to tell you that I'm happy sometimes D without an
|
||||||
|
|
||||||
|
> **[on-screen 00:04:43]**
|
||||||
|
> donde -> where "This is where | came from" "Aqui es de donde yo vengo"
|
||||||
|
|
||||||
|
**[00:04:44]** accent mark can mean where I came from I'm not using it as a question I'm using it as a location additionally p means why because you can see the word being split and the emphasis is put on the K part however if you were to combine them together pronounced por this word means
|
||||||
|
|
||||||
|
> **[on-screen 00:04:58]**
|
||||||
|
> éPor qué? -> Why? Porque -> Because
|
||||||
|
|
||||||
|
**[00:05:00]** because por is why por is because the last thing to note is that some question words have genders and plurality for
|
||||||
|
|
||||||
|
> **[on-screen 00:05:06]**
|
||||||
|
> Gender & Plurality éQuienes? —_ Who? (plural)
|
||||||
|
|
||||||
|
**[00:05:07]** example if I ask kenes I'm asking about who as in multiple people instead of one person another example is how many if I
|
||||||
|
|
||||||
|
> **[on-screen 00:05:14]**
|
||||||
|
> Gender & Plurality éQuienes? — > who? (plural) . How many? ECuantas? —D (feminine)
|
||||||
|
|
||||||
|
**[00:05:14]** say Quantas I'm saying how many for them feminine because the ending ah is most of the time feminine in Spanish if I say quantos I'm saying how many for them masculine because the ending o is most of the time masculine in Spanish number
|
||||||
|
|
||||||
|
> **[on-screen 00:05:29]**
|
||||||
|
> 3. Prepositions & Adverbs
|
||||||
|
|
||||||
|
**[00:05:29]** three prepositional words and adverbs prepositional words can be Fanboys such
|
||||||
|
|
||||||
|
> **[on-screen 00:05:32]**
|
||||||
|
> 3. Prepositions & Adverbs F.A.N.B.O.Y.S e For - Para e And-Y ¢ Nor- Ni ¢ But - Pero eoOr-O e Yet - Pero/Sin Embargo ¢ So - Asi que e By- Por
|
||||||
|
|
||||||
|
**[00:05:33]** as for and nor but or yet so and as a bonus by for is para and is e nor is NI but is per make sure that you have one R
|
||||||
|
|
||||||
|
> **[on-screen 00:05:44]**
|
||||||
|
> pero -—> but perro ——> dog
|
||||||
|
|
||||||
|
**[00:05:45]** because two RS per this would be Spanish for dog or is O yet is used as still as
|
||||||
|
|
||||||
|
> **[on-screen 00:05:49]**
|
||||||
|
> 3. Prepositions & Adverbs F.A.N.B.O.Y.S e For - Para e And-Y ¢ Nor- Ni ¢ But - Pero eoOr-O e Yet - Pero/Sin Embargo ¢ So - Asi que e By- Por
|
||||||
|
|
||||||
|
> **[on-screen 00:05:51]**
|
||||||
|
> Yet -> Still | studied for my test, yet I failed | studied for my test, but still | failed
|
||||||
|
|
||||||
|
**[00:05:52]** in the sentence I studied for my test yet I failed I studied for my test but still I failed this word is is actually
|
||||||
|
|
||||||
|
> **[on-screen 00:05:59]**
|
||||||
|
> 3. Prepositions & Adverbs F.A.N.B.O.Y.S e For - Para e And-Y ¢ Nor- Ni ¢ But - Pero eoOr-O e Yet - Pero/Sin Embargo ¢ So - Asi que e By- Por
|
||||||
|
|
||||||
|
**[00:05:59]** used as Oro so is and by is now we have some
|
||||||
|
|
||||||
|
> **[on-screen 00:06:06]**
|
||||||
|
> 3. Prepositions & Adverbs Adverbs e If -Si ¢ Then - Entonces ¢ Also - También ¢ From, of - De e With - Con °To-A e In, On-En e Each - Cada
|
||||||
|
|
||||||
|
**[00:06:06]** adverbs if is C no accent because with an accent you have C which means yes
|
||||||
|
|
||||||
|
> **[on-screen 00:06:12]**
|
||||||
|
> 3. Prepositions & Adverbs Adverbs e If -Si ¢ Then - Entonces ¢ Also - También ¢ From, of - De e With - Con °To-A e In, On-En e Each - Cada
|
||||||
|
|
||||||
|
**[00:06:12]** then is Inon also Isen of and from both mean de but the meaning changes in context with is Con to is a in and on is n and each is kada just make sure you know this information number four pronouns I'll use this 2 X3 chart to First explain their position of order in English in English you have I you he you
|
||||||
|
|
||||||
|
> **[on-screen 00:06:34]**
|
||||||
|
> 4. Pronouns l We You all You Y'all He They
|
||||||
|
|
||||||
|
**[00:06:36]** can also include she but I'll say he just to put up some space we the pronoun in the fifth position is actually you all or y'all English doesn't have this pronoun but I will still included because Spanish has it and finally they
|
||||||
|
|
||||||
|
> **[on-screen 00:06:48]**
|
||||||
|
> 4. Pronouns Yo /|Nosotros/as Tu |Vosotros/as El/Ella/Usted |Ellos/as/Ustedes
|
||||||
|
|
||||||
|
**[00:06:48]** these are the pronouns in Spanish yo to with an accent because without the
|
||||||
|
|
||||||
|
> **[on-screen 00:06:52]**
|
||||||
|
> Tu —> YOu Tu — > Your
|
||||||
|
|
||||||
|
**[00:06:52]** accent it means to which is your L you
|
||||||
|
|
||||||
|
> **[on-screen 00:06:55]**
|
||||||
|
> 4. Pronouns Yo /|Nosotros/as Tu |Vosotros/as El/Ella/Usted |Ellos/as/Ustedes
|
||||||
|
|
||||||
|
**[00:06:55]** can also say a or andad actually means you formal like when you're talking to a professional person noos is masculine and noas is feminine these pronouns have genders votos is you all masculine and votas is you all feminine and AOS is they masculine AAS is they feminine andus is you all formal try not focusing
|
||||||
|
|
||||||
|
> **[on-screen 00:07:16]**
|
||||||
|
> 4. Pronouns Yo | Nosotros Tu |V Os El fe)
|
||||||
|
|
||||||
|
**[00:07:16]** on these pronouns because you will rarely use them in conversation make sure you know the main ones like yo to El andos number five days of the week
|
||||||
|
|
||||||
|
> **[on-screen 00:07:23]**
|
||||||
|
> 5. Days of the Week ¢ Monday - Lunes e Tuesday - Martes ¢ Wednesday - Miércoles e Thursday - Jueves e Friday - Viernes e Saturday - Sabado e Sunday - Domingo
|
||||||
|
|
||||||
|
**[00:07:25]** Monday is Lunes Tuesday is mares Wednesday is m is Sab Domingo you don't have to capitalize these words in Spanish as you do in English number six months once
|
||||||
|
|
||||||
|
> **[on-screen 00:07:37]**
|
||||||
|
> 6. Months ¢ enero - January ¢ febrero - February © marzo - March ¢ abril - April ¢ mayo - May ¢ junio - June e julio - July * agosto - August * septiembre - September ¢ octubre - October ¢ noviembre - November ¢ diciembre - December
|
||||||
|
|
||||||
|
**[00:07:38]** again you don't have to capitalize these feo abil Mayo jul AO sept OCT number seven seasons
|
||||||
|
|
||||||
|
> **[on-screen 00:07:52]**
|
||||||
|
> 7. Seasons ¢ verano - summer e otono - autumn/fall e invierno - winter ¢ primavera - spring
|
||||||
|
|
||||||
|
**[00:07:55]** Verano in Prima number eight time words
|
||||||
|
|
||||||
|
> **[on-screen 00:07:58]**
|
||||||
|
> 8. Time Words ¢ Second - Segundo/a e Minute - Minuto/a e Hour - Hora e Week - Semana e¢ Month - Mes e Year - Ano e Yesterday - Ayer ¢ Today - Hoy ¢ Tomorrow - Mahana
|
||||||
|
|
||||||
|
**[00:07:59]** and some of these have genders such as second which ISO Ora this can also mean
|
||||||
|
|
||||||
|
> **[on-screen 00:08:05]**
|
||||||
|
> Second —? Position "’m in second place" "Estoy en segundo lugar"
|
||||||
|
|
||||||
|
**[00:08:06]** second as an a position I'm in second place but the meaning changes in context
|
||||||
|
|
||||||
|
> **[on-screen 00:08:10]**
|
||||||
|
> 8. Time Words ¢ Second - Segundo/a e Minute - Minuto/a e Hour - Hora e Week - Semana ¢ Month - Mes e Year - Ano e Yesterday - Ayer ¢ Today - Hoy ¢ Tomorrow - Mahana
|
||||||
|
|
||||||
|
**[00:08:10]** minute which is or you can say Unos or
|
||||||
|
|
||||||
|
> **[on-screen 00:08:14]**
|
||||||
|
> Unos minutos Sp Some/A Few Unas minutas Minutes
|
||||||
|
|
||||||
|
**[00:08:15]** unas which both mean some or a few minutes hour is a week is month is mes
|
||||||
|
|
||||||
|
> **[on-screen 00:08:19]**
|
||||||
|
> 8. Time Words ¢ Second - Segundo/a e Minute - Minuto/a e Hour - Hora e Week - Semana e¢ Month - Mes e Year - Ano e Yesterday - Ayer ¢ Today - Hoy ¢ Tomorrow - Mahana
|
||||||
|
|
||||||
|
**[00:08:24]** year is ano make sure you put the because without it you have ano which is anus yesterday is a today is oi and
|
||||||
|
|
||||||
|
> **[on-screen 00:08:29]**
|
||||||
|
> 8. Time Words ¢ Second - Segundo/a e Minute - Minuto/a e Hour - Hora e Week - Semana e¢ Month - Mes e Year - Ano e Yesterday - Ayer ¢ Today - Hoy ¢ Tomorrow - Mahana
|
||||||
|
|
||||||
|
**[00:08:32]** tomorrow is Manana Manana can also mean
|
||||||
|
|
||||||
|
> **[on-screen 00:08:34]**
|
||||||
|
> . ——7 tomorrow manana — > morning
|
||||||
|
|
||||||
|
**[00:08:35]** morning but the meaning changes in context and last one number nine numbers
|
||||||
|
|
||||||
|
> **[on-screen 00:08:37]**
|
||||||
|
> 9. Numbers e1-uno e 11- once e 2-dos ¢ 12-doce e 3-tres e 13 -trece e 4- cuatro ¢ 14 -catorce e 5-cinco ¢ 15 - quince ° 6-seis ¢ 16 - dieciséis e 7- siete ¢ 17 - diecisiete ° 8-ocho ¢ 18 - dieciocho e 9-nueve ¢ 19 - deicineuve ° 10 - diez ¢ 20 - veinte
|
||||||
|
|
||||||
|
**[00:08:39]** now I'm not going to write every single number down because this will be a long video but I'll give the syntax of how to say numbers and from there you can say numbers on your own past 20 because all you do is take V and
|
||||||
|
|
||||||
|
> **[on-screen 00:09:08]**
|
||||||
|
> veinte veintid6s, veintitrés, etc
|
||||||
|
|
||||||
|
**[00:09:09]** then add any number you want to it but it has to be written as one word like V or V and so on 30 is TR e and whatever
|
||||||
|
|
||||||
|
> **[on-screen 00:09:16]**
|
||||||
|
> 9. Numbers ¢ 30 - treinta (y dos) e 40 - cuarenta e 50 - cincuenta e 60 - sesenta e 70 - setenta ¢ 80 - ochenta e 90 - noventa e 100 - cien ¢ 1000 - mil ¢ 1000000 - mill6n
|
||||||
|
|
||||||
|
**[00:09:18]** number you want like tros 40 mil and 1 million the last concept is
|
||||||
|
|
||||||
|
> **[on-screen 00:09:33]**
|
||||||
|
> 9. Numbers (& Positions) ° 1st - primero/a ¢ 2nd - segundo/a e 3rd - tercero/a e 4th - cuarto/a e 5th - quinto/a ° 6th - sexto/a ¢ 7th - séptimo/a ¢ 8th - octavo/a ¢ 9th - noveno/a e 10th - décimo/a
|
||||||
|
|
||||||
|
**[00:09:34]** positions of numbers and these have genders first is primo or Prima second is SEO Ora it can also mean second as in time I already covered
|
||||||
|
|
||||||
|
> **[on-screen 00:09:49]**
|
||||||
|
> — ‘oom cuarto SD. quarter (1/4) of time
|
||||||
|
|
||||||
|
**[00:09:51]** quarter of the time but the meaning again changes in context
|
||||||
|
|
||||||
|
> **[on-screen 00:09:54]**
|
||||||
|
> 9. Numbers (& Positions) ¢ 1st - primero/a ¢ 2nd - segundo/a e 3rd - tercero/a e 4th - cuarto/a e 5th - quinto/a ° 6th - sexto/a ¢ 7th - séptimo/a ¢ 8th - octavo/a ¢ 9th - noveno/a e 10th - décimo/a
|
||||||
|
|
||||||
|
**[00:09:55]** kto SE SE septimo Septima octavo octava noen noena deimo deima there is no point of learning numbers beyond that and it's actually a concept I'll cover in a future video so for now I want to say
|
||||||
|
|
||||||
|
> **[on-screen 00:10:10]**
|
||||||
|
> The Spanish Fundamentals: 1.Accents 2.Question words 3.Prepositional words and adverbs 4.Pronouns 5. Days of the week 6.Months 7.Seasons 8.Time words 9.Numbers
|
||||||
|
|
||||||
|
**[00:10:10]** that this is it for this video what I covered in this video is the fundamentals that you would need to start speaking Spanish they all begin here and of course if you don't memorize all of them you can always use a translator to translate the word that you forgot and then it will be locked in your mind accents question words prepositional words and adverbs pronouns days of the week months Seasons time words and numbers
|
||||||
133
docs/spanish-fundamentals/03-conjugating-verbs-present.md
Normal file
133
docs/spanish-fundamentals/03-conjugating-verbs-present.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# 03. Conjugating Verbs (Present)
|
||||||
|
|
||||||
|
- **Time range:** 00:10:32 – 00:16:23 (duration 00:05:51)
|
||||||
|
- **Source:** [A Complete Guide To Every Fundamental In Spanish (The Conclusion)](https://youtube.com/watch?v=YHDZSHCt1DE&t=632s)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**[00:10:32]** Spanish has a lot of verbs and a lot of
|
||||||
|
|
||||||
|
> **[on-screen 00:10:33]**
|
||||||
|
> Spanish Verbs And Conjugations
|
||||||
|
|
||||||
|
**[00:10:34]** conjugation for those verbs and in this video I'll go in depth and explain how this fundamental actually works I like to call this the primary fundamental of Spanish because it's the first system of the language where you'll need to think in terms of translation unlike memorization from the previous video
|
||||||
|
|
||||||
|
> **[on-screen 00:10:47]**
|
||||||
|
> Verb Conjugation Changing the form of a verb so that it fits its corresponding pronouns
|
||||||
|
|
||||||
|
**[00:10:47]** conjugation basically means that you're modifying a verb so that it fits the pronoun that you're writing the verb into and speaking of pronouns here's
|
||||||
|
|
||||||
|
> **[on-screen 00:10:53]**
|
||||||
|
> Spanish Pronouns: Yo Nosotros/as To Vosotros/as El Ellos/as
|
||||||
|
|
||||||
|
**[00:10:53]** their syntax in case you forgot from the previous video I'd also like to focus not just on important verbs that you have to know and how to conjugate but also on which pronouns to concentrate on the most because some pronouns are used way more often than others like yo to
|
||||||
|
|
||||||
|
> **[on-screen 00:11:05]**
|
||||||
|
> Spanish Pronouns: Yo Nosotros wi Tu otros/as A El Ellos/a
|
||||||
|
|
||||||
|
**[00:11:05]** and L are used way more often than noos Vos and AOS I'd first like to present what the idea of verb conjugation looks like in English to give you a base that you can relate to the reason why English
|
||||||
|
|
||||||
|
> **[on-screen 00:11:14]**
|
||||||
|
> to eat (present) leat | We eat You eat | Y'all eat Heeats | They eat
|
||||||
|
|
||||||
|
**[00:11:15]** is a very easy language is because it has a very minimal syntax and it doesn't really have a lot of variety in terms of conjugation if I want to use the verb to eat it will look like this in English and keep in mind that this is just a present form and also that the way you
|
||||||
|
|
||||||
|
> **[on-screen 00:11:25]**
|
||||||
|
> Determining Verbs Determining Verbs In English: In Spanish: Starts with the preposition "to" Has to end with -ar -er or -ir e toeat e hablar ¢ to walk ¢ comer ¢ to tell e vivir
|
||||||
|
|
||||||
|
**[00:11:26]** figure out verbs in English is by the preposition to to eat to walk to tell to do whatever but in Spanish in order to determine if a word is a verb it has to end in a r e r or I but let's focus on English for a second in English you say
|
||||||
|
|
||||||
|
> **[on-screen 00:11:39]**
|
||||||
|
> to eat (present) leat | We eat You eat | Y'all eat Heeats | They eat
|
||||||
|
|
||||||
|
**[00:11:40]** I eat you eat he eats you can also say she or it eats but we're focusing on pronouns that you would use realistically we eat you all eat there reason no you all in English but I will still include it because Spanish has it and then they eat looking at the syntax there's really n much in terms of conjugation because eat stays eat for 80% of the pronouns and you only add an s in the he pronoun because that's the syntax of the language in Spanish there
|
||||||
|
|
||||||
|
> **[on-screen 00:12:03]**
|
||||||
|
> Verbs In Spanish: e ar ending ° er ending e ir ending
|
||||||
|
|
||||||
|
**[00:12:03]** are verbs ending in a r e r and I like abl and V here are the meanings and
|
||||||
|
|
||||||
|
> **[on-screen 00:12:07]**
|
||||||
|
> Verbs In Spanish: e hablar - to speak ¢ comer - to eat e vivir - to live
|
||||||
|
|
||||||
|
**[00:12:09]** let's start with verbs ending in a r the
|
||||||
|
|
||||||
|
> **[on-screen 00:12:10]**
|
||||||
|
> -ar ending verbs: Oo amos as ais a an
|
||||||
|
|
||||||
|
**[00:12:11]** way that conjugation Works in Spanish is by dropping off the ending of the verb like abl and then you add the corresponding conjugation that fits the pronoun unlike the two conjugations that you have in English Spanish has six of them to conjugate simple verbs ending in a r you first drop the ending of the verb and then apply the ending that corresponds with the pronoun for y you put o for to you put as for l or aad you put a for noos or noas but again we're focusing on the pronouns you'll use the most when speaking so for noos you put Amos for Vos you put ice with an emphasis on the a ice and for AOS you put an let's use the verb abl which is a
|
||||||
|
|
||||||
|
> **[on-screen 00:12:49]**
|
||||||
|
> hablar - to speak: hablo | hablamos hablas hablais habla hablan
|
||||||
|
|
||||||
|
**[00:12:50]** verb you use a lot when you speak how would you conjugate the verb abl in the yo form you take a drop the ending and you add o so you get ablo the the more you try this concept the faster you'll get it for two you get AAS for l or a you get abla for noos you get aamos for votos you get abl and for AOS you get ablan try not concentrating on these
|
||||||
|
|
||||||
|
> **[on-screen 00:13:14]**
|
||||||
|
> hablar - to speak: hablo hablamos 4 hablas hab ais A habla ablan
|
||||||
|
|
||||||
|
**[00:13:14]** pronouns because the sentences that you can make with them are very minimal all we have to know for now is how to conjugate verbs ending in a r using every pronoun but you don't necessarily need to make a thousand sentences with them if you were to make phrases as examples try focusing more on these
|
||||||
|
|
||||||
|
> **[on-screen 00:13:26]**
|
||||||
|
> hablar - to speak: hablo y| hablamos V, me [me 7\ abla | -hablan
|
||||||
|
|
||||||
|
**[00:13:27]** pronouns one important thing to note is that the same system for conjugation
|
||||||
|
|
||||||
|
> **[on-screen 00:13:29]**
|
||||||
|
> The Same System Works For ALMOST Every -ar Verb
|
||||||
|
|
||||||
|
**[00:13:30]** works for almost every AR verb out there but I will not focus on all of them because there is no point plus there are verbs like gustar and pensar that are
|
||||||
|
|
||||||
|
> **[on-screen 00:13:37]**
|
||||||
|
> The Same System Works For ALMOST Every -ar Verb e Pensar e Gustar
|
||||||
|
|
||||||
|
**[00:13:38]** topics for future videos and also I don't like giving examples whenever I show the First Fundamental of Spanish because I believe that when you're learning the beginning you can generate examples on your own by simply translating new vocabulary that you encounter in your personal life plus as I said as long as you know how to conjugate verbs you're good to go because by learning how to say yo ablo
|
||||||
|
|
||||||
|
> **[on-screen 00:13:54]**
|
||||||
|
> Yo hablo: ¢ Yo hablo espanol e Yo hablo ruso e Yo hablo contigo
|
||||||
|
|
||||||
|
**[00:13:55]** you can already say many sentences like yo ablo Espanol y or you already said a few sentences with the words yo and you can probably say more based on whatever you want to say next up there are verbs ending in
|
||||||
|
|
||||||
|
> **[on-screen 00:14:07]**
|
||||||
|
> -er ending verbs \e] emos es éis e en
|
||||||
|
|
||||||
|
**[00:14:08]** eer and these verbs follow a similar syntax as verbs ending with a r for y you drop the ending of the verb and you put o for to you put s for l or a you put e for noos you put Emos foros you put Ace with an emphasis on the E Ace and for AOS you put n using the verb K
|
||||||
|
|
||||||
|
> **[on-screen 00:14:29]**
|
||||||
|
> comer - to eat: como comemos comes coméis come comen
|
||||||
|
|
||||||
|
**[00:14:29]** as an example how would you conjugate the verb K in the Y form you take K drop the ending and add o so you get KO this
|
||||||
|
|
||||||
|
> **[on-screen 00:14:37]**
|
||||||
|
> como — like "like | told him yesterday"
|
||||||
|
|
||||||
|
**[00:14:37]** word can also mean like as in the sentence like I told him yesterday but the meaning changes in context for two
|
||||||
|
|
||||||
|
> **[on-screen 00:14:43]**
|
||||||
|
> comer - to eat: como comemos comes coméis come comen
|
||||||
|
|
||||||
|
**[00:14:43]** you get k for L you get k for noos Kos foros and AOS com as I said again try not concentrating on these pronouns
|
||||||
|
|
||||||
|
> **[on-screen 00:14:55]**
|
||||||
|
> comer - to eat: como comemos [come comes eis A come omen
|
||||||
|
|
||||||
|
**[00:14:56]** because the phrases that you can make with them are mainly pointless there is no point in knowing how to conjugate every single e verb because you'll never use all of them I'm just using a useful verb like in order to show you how to conjugate regular e verbs the last
|
||||||
|
|
||||||
|
> **[on-screen 00:15:08]**
|
||||||
|
> -ir ending verbs: fo) imos es is e en
|
||||||
|
|
||||||
|
**[00:15:08]** concept is verbs ending in IR for yo you drop the ending of the verb and put o for to you put s for L you put e for noos you put imos for votos you put is with an emphasis on the E is and for AOS you put n you might also notice that the
|
||||||
|
|
||||||
|
> **[on-screen 00:15:26]**
|
||||||
|
> -er -ir ending verbs: Oo es e en
|
||||||
|
|
||||||
|
**[00:15:27]** pronouns y to L and AOS all use the same syntax as verbs ending in e which makes the language more convenient using the
|
||||||
|
|
||||||
|
> **[on-screen 00:15:34]**
|
||||||
|
> vivir - to live: vivo vivimos vives vivis vive viven
|
||||||
|
|
||||||
|
**[00:15:34]** verb VI as an example for the Y pronoun you take VI drop the ending and add o so you get Vivo for two you get Viv for L you get VI for noos you get Vios Vos VI AOS VI as I said again there is no point in knowing how to conjugate every single IR verb because you'll never use all of them so for now I want to say that this
|
||||||
|
|
||||||
|
> **[on-screen 00:15:57]**
|
||||||
|
> Spanish The fundamentals of -ar -er -ir verb conjugation
|
||||||
|
|
||||||
|
**[00:15:58]** is it for this video I could have made it a longer video where I gave examples and maybe quizzed you on some of the topics that I show today but I prefer not to I choose to end the concept here because I believe that this is a sufficient amount of information that one would need to know in order to understand verbs better in this video I just explained the fundamental of a r ER R and IR verb conjugation later you can start making sentences using different verbs and expressing any thought that you have in mind in Spanish this is the main fundamental of Spanish Spanish has
|
||||||
128
docs/spanish-fundamentals/04-articles.md
Normal file
128
docs/spanish-fundamentals/04-articles.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# 04. Articles
|
||||||
|
|
||||||
|
- **Time range:** 00:16:23 – 00:18:54 (duration 00:02:31)
|
||||||
|
- **Source:** [A Complete Guide To Every Fundamental In Spanish (The Conclusion)](https://youtube.com/watch?v=YHDZSHCt1DE&t=983s)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**[00:16:24]** two types of Articles definite and
|
||||||
|
|
||||||
|
> **[on-screen 00:16:25]**
|
||||||
|
> Definite: Indefinite:
|
||||||
|
|
||||||
|
**[00:16:25]** indefinite articles definite articles
|
||||||
|
|
||||||
|
> **[on-screen 00:16:27]**
|
||||||
|
> Definite: Indefinite: the "the" book
|
||||||
|
|
||||||
|
**[00:16:27]** speak of the articles the in English and it's also known as the article that specifies something such as the book
|
||||||
|
|
||||||
|
> **[on-screen 00:16:33]**
|
||||||
|
> Definite: Indefinite: the a, an, some "the" book "a" book
|
||||||
|
|
||||||
|
**[00:16:33]** indefinite articles speak of a and or some also known as articles that generalize things like a book in Spanish both types of Articles have gender and plurality the definite article the in
|
||||||
|
|
||||||
|
> **[on-screen 00:16:42]**
|
||||||
|
> Spanish Definite Articles: (the) el ——-> the(m &s) la ——> the(f&s)
|
||||||
|
|
||||||
|
**[00:16:43]** Spanish is L the masculine and singular and La feminine and singular a Libro
|
||||||
|
|
||||||
|
> **[on-screen 00:16:48]**
|
||||||
|
> Spanish Definite Articles: (the) el libro ———- > the book
|
||||||
|
|
||||||
|
**[00:16:48]** means the book and we know that this article is masculine because the ending
|
||||||
|
|
||||||
|
> **[on-screen 00:16:51]**
|
||||||
|
> Spanish Definite Articles: (the) el libro ———> the book
|
||||||
|
|
||||||
|
**[00:16:52]** of the noun that follows the article is masculine most nouns in Spanish that end
|
||||||
|
|
||||||
|
> **[on-screen 00:16:54]**
|
||||||
|
> Spanish Definite Articles: (the) el libro ———-> the book KRY
|
||||||
|
|
||||||
|
**[00:16:55]** in o tend to be masculine so we have to put the masculine definite article L likewise we do the same with the
|
||||||
|
|
||||||
|
> **[on-screen 00:17:00]**
|
||||||
|
> Spanish Definite Articles: (the) ellibro ———-> the book la piscina ———> the pool RK’
|
||||||
|
|
||||||
|
**[00:17:00]** feminine article Laina means the pool we know this article is feminine because it corresponds with the noun after it which is feminine most souns in Spanish that end in a tend to be feminine so we have to put the feminine definite article La
|
||||||
|
|
||||||
|
> **[on-screen 00:17:12]**
|
||||||
|
> Spanish Definite Articles: (the) el ——-> los la ——> las
|
||||||
|
|
||||||
|
**[00:17:12]** if we want to pluralize the Articles L becomes Los and La becomes l so Los
|
||||||
|
|
||||||
|
> **[on-screen 00:17:17]**
|
||||||
|
> Spanish Definite Articles: (the) el ——> los libros —— the books la —> las piscinas —— the pools
|
||||||
|
|
||||||
|
**[00:17:17]** libros would be the books and Nas would be the pools indefinite articles look
|
||||||
|
|
||||||
|
> **[on-screen 00:17:21]**
|
||||||
|
> Spanish Indefinite Articles: (a, an, some) un —-> a/an(m&s) una ——> a/an(f &s)
|
||||||
|
|
||||||
|
**[00:17:22]** like this in Spanish un is a or n masculine and singular and una is a or n feminine and singular un Libro would be
|
||||||
|
|
||||||
|
> **[on-screen 00:17:30]**
|
||||||
|
> Spanish Indefinite Articles: (a, an, some) unlibro ©—~ abook una piscina —— a pool
|
||||||
|
|
||||||
|
**[00:17:30]** a book and una Pina would be a pool also
|
||||||
|
|
||||||
|
> **[on-screen 00:17:33]**
|
||||||
|
> un libro - a book uno libro - one book
|
||||||
|
|
||||||
|
**[00:17:33]** it's really important not to say uno Libro because if we say that we're saying one book instead of a book we're
|
||||||
|
|
||||||
|
> **[on-screen 00:17:39]**
|
||||||
|
> un libro - a book Und libre~si= book
|
||||||
|
|
||||||
|
**[00:17:39]** working with articles not numbers so if
|
||||||
|
|
||||||
|
> **[on-screen 00:17:41]**
|
||||||
|
> Spanish Indefinite Articles: (a, an, some) un > unos libros ——; some books una——> unas piscinas ——- some pools
|
||||||
|
|
||||||
|
**[00:17:41]** we want to pluralize them we say Unos lios some books and unas some pools there are however a few strange words in Spanish and we need to cover those to such as class and car they both end with
|
||||||
|
|
||||||
|
> **[on-screen 00:17:52]**
|
||||||
|
> la(s) clase(s) ——— the class(es) la(s) carne(s) ——>, the meat(s)
|
||||||
|
|
||||||
|
**[00:17:53]** e but they actually use the feminine article La so la class is the class and La car is the meat other words may end in D such Asad and unad and those also
|
||||||
|
|
||||||
|
> **[on-screen 00:18:03]**
|
||||||
|
> la(s) ciudad(es) —> the city(ies) la(s) universidad(es) ————> the university(ies)
|
||||||
|
|
||||||
|
**[00:18:04]** use the feminine article La soad is the City and LA Universidad is the University you might also find words ending inion which is the English version of words ending in t n and these words also tend to use the feminine
|
||||||
|
|
||||||
|
> **[on-screen 00:18:15]**
|
||||||
|
> la(s) accidén(es) ——— the action(s)
|
||||||
|
|
||||||
|
**[00:18:15]** article La so la Aion is the action at last you might find a few exceptions like prma and prog and you would think that these words are feminine because they end in a but actually they end in Ma and words that end in MA in Spanish
|
||||||
|
|
||||||
|
> **[on-screen 00:18:27]**
|
||||||
|
> el problema ——— the problem el programa ——— the program
|
||||||
|
|
||||||
|
**[00:18:28]** use the masculine article L Elma is the problem and El prog is the program two
|
||||||
|
|
||||||
|
> **[on-screen 00:18:34]**
|
||||||
|
> el problema ——— the problem el programa ——-, the program dia agua
|
||||||
|
|
||||||
|
**[00:18:34]** more common words is Dia and AUA and you want to say that those are feminine because they end in a but they actually use the masculine article l l is the day
|
||||||
|
|
||||||
|
> **[on-screen 00:18:41]**
|
||||||
|
> el problema ——— the problem el programa ——_, the program el dia ———> the day el agua ——— > the water
|
||||||
|
|
||||||
|
**[00:18:42]** and L AUA is the water there's also this
|
||||||
|
|
||||||
|
> **[on-screen 00:18:44]**
|
||||||
|
> el problema ———> the problem el programa ——1 the program el dia ——— the day el agua ——— > the water foto
|
||||||
|
|
||||||
|
**[00:18:45]** word photo and this word actually uses the feminine article LA because photo is
|
||||||
|
|
||||||
|
> **[on-screen 00:18:48]**
|
||||||
|
> el problema ——— the problem el programa ——-, the program el dia ——— the day el agua ———> the water la foto
|
||||||
|
|
||||||
|
**[00:18:49]** short for photographia it ends in a so
|
||||||
|
|
||||||
|
> **[on-screen 00:18:50]**
|
||||||
|
> el problema ———> the problem el programa ——., the program el dia ———> the day el agua ——— > the water la fotografia ——— the photograph
|
||||||
|
|
||||||
|
**[00:18:52]** you want to put La in the beginning the
|
||||||
181
docs/spanish-fundamentals/05-the-verb-ser.md
Normal file
181
docs/spanish-fundamentals/05-the-verb-ser.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# 05. The Verb “Ser”
|
||||||
|
|
||||||
|
- **Time range:** 00:18:54 – 00:23:19 (duration 00:04:25)
|
||||||
|
- **Source:** [A Complete Guide To Every Fundamental In Spanish (The Conclusion)](https://youtube.com/watch?v=YHDZSHCt1DE&t=1134s)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**[00:18:54]** verb s in Spanish means to be as in being or existing and it syntax looks
|
||||||
|
|
||||||
|
> **[on-screen 00:18:58]**
|
||||||
|
> to be/exist lam We are Youare | Y'allare He is They are
|
||||||
|
|
||||||
|
**[00:18:58]** like this in English I am you are he or she it is we are yall are English doesn't have this pronoun but I'm still including it because Spanish has it and they are part of the reason why English is an easy language is because the conjugation of these verbs stays the same for most pronouns R is the same for
|
||||||
|
|
||||||
|
> **[on-screen 00:19:13]**
|
||||||
|
> to be/exist lam We are You are Y'all are He is They are
|
||||||
|
|
||||||
|
**[00:19:14]** we they and you but they change for I and he in Spanish however you have six
|
||||||
|
|
||||||
|
> **[on-screen 00:19:18]**
|
||||||
|
> ser - to be/exist (Yo) soy | (Nosotros) somos (El) es (Ellos) son
|
||||||
|
|
||||||
|
**[00:19:19]** different conjugations for each pronoun and actually the verb said is an irregular verb meaning that you cannot conjugate it like regular verbs and its syntax completely changes in every pronoun it looks like this in I can give is to just memorize these conjugations because there is no conjugation pattern to follow with them but also try not focusing on these
|
||||||
|
|
||||||
|
> **[on-screen 00:19:44]**
|
||||||
|
> ser - to be/exist (Yo) soy (Nosotros) Somos iNeres | ose Vosotros) sois (Tu) eres | ( m ) (El) es | (Ellos) sc
|
||||||
|
|
||||||
|
**[00:19:44]** pronouns because they're not used as often as the other ones in conversation however s is not used the same as it's
|
||||||
|
|
||||||
|
> **[on-screen 00:19:48]**
|
||||||
|
> The verb ser is NOT used the same as in English
|
||||||
|
|
||||||
|
**[00:19:49]** used in English you might have heard
|
||||||
|
|
||||||
|
> **[on-screen 00:19:51]**
|
||||||
|
> How teachers teach ser: e Who are you and from where, always use the verb ser e Permanent traits about oneself
|
||||||
|
|
||||||
|
**[00:19:51]** teachers use nursery rhymes to describe this verb saying who are you and from where always use the verb said or some teachers might also say that the verb said is applied for permanent traits about oneself I however don't like these
|
||||||
|
|
||||||
|
> **[on-screen 00:20:02]**
|
||||||
|
> How teachers teach ser: e Wiie-are you and from-where, e Permanen ae, eabout oneself
|
||||||
|
|
||||||
|
**[00:20:02]** explanations whatsoever because they tend to confuse students rather than make them understand the subject matter properly so this is going to be an easier explanation of what to do with the verb s the verb s mainly applies to
|
||||||
|
|
||||||
|
> **[on-screen 00:20:11]**
|
||||||
|
> What ser applies to: 1. Name, nationality, birthplace 2. Occupation 3. Physical traits (about oneself) 4. Generalizations 5. When and where are events 6. Time and date
|
||||||
|
|
||||||
|
**[00:20:12]** these uses your name nationality and place of origin occupation physical traits generalizations when and where are events taking place and time and date number one your name nationality
|
||||||
|
|
||||||
|
> **[on-screen 00:20:21]**
|
||||||
|
> 1. Name, nationality, birthplace:
|
||||||
|
|
||||||
|
**[00:20:22]** and place of origin if you want to say your name in Spanish you will say yoy
|
||||||
|
|
||||||
|
> **[on-screen 00:20:26]**
|
||||||
|
> 1. Name, nationality, birthplace: Yo soy Alex
|
||||||
|
|
||||||
|
**[00:20:26]** and then your name if you want to say your Spanish and you are from Spain you
|
||||||
|
|
||||||
|
> **[on-screen 00:20:30]**
|
||||||
|
> 1. Name, nationality, birthplace: Yo soy Alex Yo soy espanol Yo soy de Espana
|
||||||
|
|
||||||
|
**[00:20:31]** say the conjugation so is used here because you're talking about yourself and the same principle applies to the rest of the pronouns based on whichever conjugation you want to work with number two occupation if you want to say that
|
||||||
|
|
||||||
|
> **[on-screen 00:20:40]**
|
||||||
|
> 2. Occupation: He is a professor
|
||||||
|
|
||||||
|
**[00:20:41]** he is a professor you would say LS
|
||||||
|
|
||||||
|
> **[on-screen 00:20:43]**
|
||||||
|
> 2. Occupation: He is a professor El es profesor
|
||||||
|
|
||||||
|
**[00:20:44]** Professor also you don't have to put an
|
||||||
|
|
||||||
|
> **[on-screen 00:20:45]**
|
||||||
|
> 2. Occupation: He is a professor El es tif profesor
|
||||||
|
|
||||||
|
**[00:20:46]** indefinite article like un before Professor because it's a rule in Spanish
|
||||||
|
|
||||||
|
> **[on-screen 00:20:50]**
|
||||||
|
> 2. Occupation: He is a professor El es profesor
|
||||||
|
|
||||||
|
**[00:20:50]** so you would just say LS Professor he is a professor the same principle applies to the rest of the conjugations and whichever occupation you decide to say
|
||||||
|
|
||||||
|
> **[on-screen 00:20:57]**
|
||||||
|
> 3. Physical traits You are beautiful
|
||||||
|
|
||||||
|
**[00:20:57]** number three physical iCal traits if you want to say you are beautiful you will say toes Bonito or Bonita depending on
|
||||||
|
|
||||||
|
> **[on-screen 00:21:01]**
|
||||||
|
> 3. Physical traits You are beautiful Tu eres bonito/a
|
||||||
|
|
||||||
|
**[00:21:03]** the person and the reason you use said is because it's a trait that applies to the person all the time by saying you are beautiful to Edis Bonito you're saying that the person is beautiful always he was born beautiful he's beautiful now and he will die beautiful number four generalizations if you want
|
||||||
|
|
||||||
|
> **[on-screen 00:21:16]**
|
||||||
|
> 4. Generalizations It is important to work
|
||||||
|
|
||||||
|
**[00:21:17]** to say it is important to work you would
|
||||||
|
|
||||||
|
> **[on-screen 00:21:20]**
|
||||||
|
> 4. Generalizations It is important to work Es importante trabajar
|
||||||
|
|
||||||
|
**[00:21:20]** say in Spanish there is no notion of starting a sentence with the word it so you'll immediately start it with is s important number five when and where are
|
||||||
|
|
||||||
|
> **[on-screen 00:21:28]**
|
||||||
|
> 5. When and where are events The party is in the club
|
||||||
|
|
||||||
|
**[00:21:29]** events taking place if you want to say the party is in the club you would say
|
||||||
|
|
||||||
|
> **[on-screen 00:21:32]**
|
||||||
|
> 5. When and where are events The party is in the club La fiesta es en el club
|
||||||
|
|
||||||
|
**[00:21:32]** La Festa is in club similarly you can
|
||||||
|
|
||||||
|
> **[on-screen 00:21:34]**
|
||||||
|
> 5. When and where are events The party is at six La fiesta es a las seis
|
||||||
|
|
||||||
|
**[00:21:35]** say the party is at six which would be La Festa is the rule here is to always include alas if the number is plural or
|
||||||
|
|
||||||
|
> **[on-screen 00:21:41]**
|
||||||
|
> 5. When and where are events The party is at six La fiesta es a las seis
|
||||||
|
|
||||||
|
**[00:21:42]** more than one and speaking of time it's the last most important use of the verb said time and date you can say a simple sentence like it's Friday which would be
|
||||||
|
|
||||||
|
> **[on-screen 00:21:48]**
|
||||||
|
> 6. Time and dates It's Friday Es viernes
|
||||||
|
|
||||||
|
**[00:21:49]** a generalization and time and it would be svetness however when you start to speak of time as in a clock this is where the syntax gets slightly tricky if
|
||||||
|
|
||||||
|
> **[on-screen 00:21:57]**
|
||||||
|
> 6. Time and dates It's one PM Es launa de la tarde
|
||||||
|
|
||||||
|
**[00:21:57]** you want to say it's 1 p.m. the sentence would be de it begins with s but throws
|
||||||
|
|
||||||
|
> **[on-screen 00:22:01]**
|
||||||
|
> 6. Time and dates It's one PM Es la una de la tarde
|
||||||
|
|
||||||
|
**[00:22:02]** the definite article LA because it uses una as a feminine number so it's the one
|
||||||
|
|
||||||
|
> **[on-screen 00:22:06]**
|
||||||
|
> 6. Time and dates It's one PM Es la una de la tarde It’s the one in/of the afternoon
|
||||||
|
|
||||||
|
**[00:22:07]** in the afternoon or of the afternoon make sure that you include the article LA but primarily focus on the S because when you include numbers that are more
|
||||||
|
|
||||||
|
> **[on-screen 00:22:13]**
|
||||||
|
> 6. Time and dates It's two PM Son las dos de la tarde
|
||||||
|
|
||||||
|
**[00:22:14]** than one the amount of time becomes plural if you want to say it's 2 p.m. you would say because now we have plurality now
|
||||||
|
|
||||||
|
> **[on-screen 00:22:20]**
|
||||||
|
> 6. Time and dates It's two PM 2 or more are ay non-singular numbers Son las dos de la tarde
|
||||||
|
|
||||||
|
**[00:22:21]** the sentence is in plural because we have a non- singular digit so instead of saying s for one you would say son for for two and pluralize La for last and then you'd
|
||||||
|
|
||||||
|
> **[on-screen 00:22:31]**
|
||||||
|
> 6. Time and dates It's two PM 2 or more are J non-singular numbers Son las dos de la tarde It's the two in the afternoon
|
||||||
|
|
||||||
|
**[00:22:31]** say it's the two in the afternoon the same principle applies to other numbers of time such as or de so these are the
|
||||||
|
|
||||||
|
> **[on-screen 00:22:36]**
|
||||||
|
> 6. Time and dates It's three/four PM Son las tres/cuatro de la tarde
|
||||||
|
|
||||||
|
> **[on-screen 00:22:39]**
|
||||||
|
> What ser applies to: 1. Name, nationality, birthplace 2. Occupation 3. Physical traits (about oneself) 4. Generalizations 5. When and where are events 6. Time and date
|
||||||
|
|
||||||
|
**[00:22:39]** uses of the verb in Spanish and as a matter of fact the easiest way to remember them is to always remember that the verb said applies to factual
|
||||||
|
|
||||||
|
> **[on-screen 00:22:46]**
|
||||||
|
> ser is applied to FACTUAL STATEMENTS
|
||||||
|
|
||||||
|
**[00:22:47]** statements about oneself in case you weren't paying close attention everything that I've listed in this video were examples that apply factually about yourself by saying so Alex I'm
|
||||||
|
|
||||||
|
> **[on-screen 00:22:54]**
|
||||||
|
> Soy Alex - Factual Soy bonito - (some would say) Factual Es lunes - Factual Son las dos de la tarde - Factual
|
||||||
|
|
||||||
|
**[00:22:56]** factually stating that my name is Alex and I can not change that fact if I say soy Bonito I'm factually stating that I'm a beautiful person in general by saying es Lunes I'm factually stating that it's manday today by saying I'm factually stating that it's 2 in the afternoon right now everything that I've listed in this video were factual statements and now you
|
||||||
|
|
||||||
|
> **[on-screen 00:23:13]**
|
||||||
|
> Soy Alex - Factual Soy bonito - (some would say) Factual Es lunes - Factual Son las dos de la tarde - Factual ser is applied to permanent traits
|
||||||
|
|
||||||
|
**[00:23:13]** understand why some teachers in schools say that the verb said applies to permanent traits because these are all factual statements the present
|
||||||
146
docs/spanish-fundamentals/06-the-present-progressive.md
Normal file
146
docs/spanish-fundamentals/06-the-present-progressive.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# 06. The Present Progressive
|
||||||
|
|
||||||
|
- **Time range:** 00:23:19 – 00:26:08 (duration 00:02:49)
|
||||||
|
- **Source:** [A Complete Guide To Every Fundamental In Spanish (The Conclusion)](https://youtube.com/watch?v=YHDZSHCt1DE&t=1399s)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**[00:23:19]** progressive in Spanish is the English
|
||||||
|
|
||||||
|
> **[on-screen 00:23:20]**
|
||||||
|
> English Present Progressive: ¢ Verbs/Infinitives ending in -ing
|
||||||
|
|
||||||
|
**[00:23:21]** version of verbs ending in ing or as they're formally called infinitives infinitives are verbs that are placed
|
||||||
|
|
||||||
|
> **[on-screen 00:23:25]**
|
||||||
|
> Infinitive Verbs placed after conjugated verbs that don't change form or slightly get modified
|
||||||
|
|
||||||
|
**[00:23:26]** after already conjugated verbs and so they don't change or slightly get modified with the present progressive if
|
||||||
|
|
||||||
|
> **[on-screen 00:23:31]**
|
||||||
|
> to talk | am talking
|
||||||
|
|
||||||
|
**[00:23:31]** you want to use the verb to talk you would say I am talking by adding an ing to the infinitive I is the subject m is
|
||||||
|
|
||||||
|
> **[on-screen 00:23:37]**
|
||||||
|
> to talk _ | am talking — Sub; + attive bjecy conjugated infin verb "to be" for wit the | pronoun
|
||||||
|
|
||||||
|
**[00:23:38]** the conjugated verb to be for the I pronoun and talking is the infinitive that gets the ing added to it both in
|
||||||
|
|
||||||
|
> **[on-screen 00:23:43]**
|
||||||
|
> English & Spanish Present Progressive A continuous action being done in real time
|
||||||
|
|
||||||
|
**[00:23:43]** English and Spanish the present progressive indicates that an action is being done right now which means that there's progress happening in the present however the ing version of English looks different in Spanish at
|
||||||
|
|
||||||
|
> **[on-screen 00:23:53]**
|
||||||
|
> Spanish Present Progressive:
|
||||||
|
|
||||||
|
**[00:23:53]** first if you want to start a sentence in the present progressive in Spanish you would begin by saying your esto which
|
||||||
|
|
||||||
|
> **[on-screen 00:23:58]**
|
||||||
|
> Spanish Present Progressive: Yo estoy L lam
|
||||||
|
|
||||||
|
**[00:23:58]** means I am esto is actually an irregular
|
||||||
|
|
||||||
|
> **[on-screen 00:23:59]**
|
||||||
|
> The Verb "Estar" - to be estoy
|
||||||
|
|
||||||
|
**[00:24:01]** conjugation of the yo pronoun said from the verb estar which means to be there
|
||||||
|
|
||||||
|
> **[on-screen 00:24:05]**
|
||||||
|
> The Verb "Estar" - to be (Yo) estoy | (Nosotros) estamos (Tu) estas (Vosotros) estais (El) esta (Ellos) estan
|
||||||
|
|
||||||
|
**[00:24:05]** are different conjugations for this verb with different pronouns but the topic of this verb is for a future video in Spanish you have verbs ending in a r e r
|
||||||
|
|
||||||
|
> **[on-screen 00:24:10]**
|
||||||
|
> Spanish Verb Endings e ar ending e er ending e ir ending
|
||||||
|
|
||||||
|
**[00:24:12]** and I but their infinitive version of the present progressive is actually quite easy to remember for verbs ending
|
||||||
|
|
||||||
|
> **[on-screen 00:24:17]**
|
||||||
|
> Spanish Present Progressive ar ending Remove the ending and add -ando
|
||||||
|
|
||||||
|
**[00:24:17]** in AR you would want to remove the ending of the verb and then add the ending Ando using the verb abl in the
|
||||||
|
|
||||||
|
> **[on-screen 00:24:22]**
|
||||||
|
> Spanish Present Progressive hablar - to speak
|
||||||
|
|
||||||
|
**[00:24:23]** present progressive you would say youro
|
||||||
|
|
||||||
|
> **[on-screen 00:24:24]**
|
||||||
|
> Spanish Present Progressive hablar - to speak Yo estoy hablar
|
||||||
|
|
||||||
|
**[00:24:25]** remove the ending and then add Ando as
|
||||||
|
|
||||||
|
> **[on-screen 00:24:26]**
|
||||||
|
> Spanish Present Progressive hablar - to speak Yo estoy habl
|
||||||
|
|
||||||
|
> **[on-screen 00:24:27]**
|
||||||
|
> Spanish Present Progressive hablar - to speak Yo estoy hablando
|
||||||
|
|
||||||
|
**[00:24:27]** the ending of the infinitive so this way
|
||||||
|
|
||||||
|
> **[on-screen 00:24:29]**
|
||||||
|
> Spanish Present Progressive Yo estoy hablando | am speaking
|
||||||
|
|
||||||
|
**[00:24:29]** you get EST which means I am speaking the same principle applies to the rest of the pronouns in any infinitive that you want to use but keep in mind that
|
||||||
|
|
||||||
|
> **[on-screen 00:24:37]**
|
||||||
|
> The Verb "Estar" - to be (Yo) estoy | (Nosotros) estamos (Tu) estas (Vosotros) estais (El) esta (Ellos) estan
|
||||||
|
|
||||||
|
**[00:24:37]** there are six different conjugations for the verb estar which apply to their corresponding pronouns and as I said again the verb estar is for a future video for verbs ending in e r and I
|
||||||
|
|
||||||
|
> **[on-screen 00:24:44]**
|
||||||
|
> Spanish Present Progressive er and ir ending Remove the ending and add -iendo
|
||||||
|
|
||||||
|
**[00:24:46]** remove the ending of the verb and then add the ending e Endo using and in the
|
||||||
|
|
||||||
|
> **[on-screen 00:24:49]**
|
||||||
|
> Spanish Present Progressive comer - to eat vivir - to live
|
||||||
|
|
||||||
|
**[00:24:50]** present progressive you would say esto
|
||||||
|
|
||||||
|
> **[on-screen 00:24:52]**
|
||||||
|
> Spanish Present Progressive comer - to eat vivir - to live Yo estoy comiendo Yo estoy viviendo
|
||||||
|
|
||||||
|
**[00:24:53]** and Y EST viendo which is I am eating
|
||||||
|
|
||||||
|
> **[on-screen 00:24:55]**
|
||||||
|
> Spanish Present Progressive Yo estoy comiendo Yo estoy viviendo lam eating lam living
|
||||||
|
|
||||||
|
**[00:24:56]** and I am living the same principle applies to the rest of the pronouns and any infinitive that you want to use but once again remember to use the right conjugation of each pronoun at last you might encounter a few exceptions in Spanish where modifying some infinitives might require a bit more modification to make the verb sound better when spoken for instance you might see the verb which means to read and you would want
|
||||||
|
|
||||||
|
> **[on-screen 00:25:15]**
|
||||||
|
> leer - to read: Yo estoy leiendo
|
||||||
|
|
||||||
|
**[00:25:15]** say but this would be a mistake in Spanish because Spanish has a rule that
|
||||||
|
|
||||||
|
> **[on-screen 00:25:19]**
|
||||||
|
> leer - to read: Yo estoy leiendo
|
||||||
|
|
||||||
|
**[00:25:19]** says you cannot have three vowels next to each other so you have to modify one of them with a consonant to eliminate the repetitive pronunciation when the word is said so instead of saying Le Endo you would
|
||||||
|
|
||||||
|
> **[on-screen 00:25:28]**
|
||||||
|
> leer - to read: Yo estoy leyendo lam reading
|
||||||
|
|
||||||
|
**[00:25:28]** say which means I am reading and the same concept applies to any pronoun you want to use you might also find this verb D which means to sleep and you would want to say y esto but because
|
||||||
|
|
||||||
|
> **[on-screen 00:25:37]**
|
||||||
|
> dormir - to sleep: Yo estoy dormiendo
|
||||||
|
|
||||||
|
**[00:25:38]** dormir is a stem changing verb you have to change the stem of the verb to make its pronunciation sound better so instead of saying yo you would
|
||||||
|
|
||||||
|
> **[on-screen 00:25:46]**
|
||||||
|
> dormir - to sleep: Yo estoy durmiendo lam sleeping
|
||||||
|
|
||||||
|
**[00:25:46]** Sayo which means I am sleeping and the same idea applies to the rest of the pronouns there is however another stem
|
||||||
|
|
||||||
|
> **[on-screen 00:25:51]**
|
||||||
|
> decir - to say: Yo estoy deciendo
|
||||||
|
|
||||||
|
**[00:25:52]** changing verb in Spanish like which means to say you would want to Sayo but Spanish says that you have to change the stem of the verb to make it sound better so instead of saying Endo you would Sayo which would mean I am saying and
|
||||||
|
|
||||||
|
> **[on-screen 00:26:04]**
|
||||||
|
> decir - to say: Yo estoy diciendo lam saying
|
||||||
|
|
||||||
|
**[00:26:05]** once again the same principle applies to the rest of the pronouns the verb estar
|
||||||
221
docs/spanish-fundamentals/07-the-verb-estar.md
Normal file
221
docs/spanish-fundamentals/07-the-verb-estar.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# 07. The Verb “Estar”
|
||||||
|
|
||||||
|
- **Time range:** 00:26:08 – 00:32:28 (duration 00:06:20)
|
||||||
|
- **Source:** [A Complete Guide To Every Fundamental In Spanish (The Conclusion)](https://youtube.com/watch?v=YHDZSHCt1DE&t=1568s)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**[00:26:09]** in Spanish means to be as in being or existing unlike the weird conjugations
|
||||||
|
|
||||||
|
> **[on-screen 00:26:12]**
|
||||||
|
> ser - to be/exist (Yo) soy | (Nosotros) somos (El) es (Ellos) son
|
||||||
|
|
||||||
|
**[00:26:13]** with the verb the verb EST actually
|
||||||
|
|
||||||
|
> **[on-screen 00:26:14]**
|
||||||
|
> estar - to be/exist (Yo) estoy | (Nosotros) estamos (Tu) estas (Vosotros) estais (El) esta (Ellos) estan
|
||||||
|
|
||||||
|
**[00:26:15]** follows the normal syntax of conjugating regular ar verbs and it looks like this in Spanish to EST or EST EST EST and estan before I explain the primary uses of the verb EST I first need to note a few important things about this verb just visually looking at its syntax you can probably tell that the conjugation for the yo pronoun is irregular because
|
||||||
|
|
||||||
|
> **[on-screen 00:26:37]**
|
||||||
|
> estar - to be/exist (Yo) estoy | (Nosotros) estamos (Tu) estas (Vosotros) estais (El) esta (Ellos) estan
|
||||||
|
|
||||||
|
**[00:26:38]** it ends with a Y and this is done specifically to not get it confused with the demonstrative adjective esto esto
|
||||||
|
|
||||||
|
> **[on-screen 00:26:43]**
|
||||||
|
> estoy - (I) am esto - this (n)
|
||||||
|
|
||||||
|
**[00:26:44]** means this in the neutral form whenever you're referring to something and you don't know what it is you will always
|
||||||
|
|
||||||
|
> **[on-screen 00:26:48]**
|
||||||
|
> estoy - (I) am esto - this (n) éQué es esto? What is this?
|
||||||
|
|
||||||
|
**[00:26:48]** say kesto indicating what is this without knowing if the object you're referring to is masculine or feminine
|
||||||
|
|
||||||
|
> **[on-screen 00:26:54]**
|
||||||
|
> estar - to be/exist (Yo) estoy | (Nosotros) estamos (Tu) estas (Vosotros) estais (El) esta (Ellos) estan
|
||||||
|
|
||||||
|
**[00:26:54]** another important thing to note with estar is that the pronouns to L and AOS all have accents on the A and this is also done on purpose because if you were
|
||||||
|
|
||||||
|
> **[on-screen 00:27:02]**
|
||||||
|
> estas - These (f) esta - This (f)
|
||||||
|
|
||||||
|
**[00:27:02]** to remove the accents you would have different words these words pronounced estas and esta mean these and this feminine but with the accent they mean
|
||||||
|
|
||||||
|
> **[on-screen 00:27:09]**
|
||||||
|
> estas - These (f) esta - This (f) estas - (You) are esta - (He/She) is
|
||||||
|
|
||||||
|
**[00:27:10]** you are and he or she is so it's really
|
||||||
|
|
||||||
|
> **[on-screen 00:27:13]**
|
||||||
|
> estar - to be/exist (Yo) estoy | (Nosotros) estamos (Tu) estas (Vosotros) estais (El) esta (Ellos) estan
|
||||||
|
|
||||||
|
**[00:27:13]** important to put accents on them and put the emphasis on the a as always try not
|
||||||
|
|
||||||
|
> **[on-screen 00:27:17]**
|
||||||
|
> estar - to be/exist (Yo) estoy | (Nosotros) estamos (Tu) estas Wosotros estais (El) esta (Ellos) esta
|
||||||
|
|
||||||
|
**[00:27:17]** focusing on these conjugations because they're not used as often as the other ones in conversation now the most
|
||||||
|
|
||||||
|
> **[on-screen 00:27:21]**
|
||||||
|
> What estar applies to:
|
||||||
|
|
||||||
|
**[00:27:22]** important thing to note about the verb estar is that even though it means to be as in being its uses are completely different from the verb s which I explained in one of my previous videos the verb estar mainly applies to these uses the present progressive location
|
||||||
|
|
||||||
|
> **[on-screen 00:27:33]**
|
||||||
|
> What estar applies to: 1. Present progressive 2. Location (spatial relationship) 3. Health, conditions, and emotions
|
||||||
|
|
||||||
|
**[00:27:34]** and health conditions and emotions number one the present progressive the
|
||||||
|
|
||||||
|
> **[on-screen 00:27:37]**
|
||||||
|
> 1. Present Progressive
|
||||||
|
|
||||||
|
**[00:27:38]** present progressive is something that I explained in the video before this one so you should be familiar with the syntax but now this is where we can start using the conjugations that apply to other pronouns for instance if you
|
||||||
|
|
||||||
|
> **[on-screen 00:27:47]**
|
||||||
|
> 1. Present Progressive He is running —— El esta corriendo You are thinking ——> Tu estas pensando
|
||||||
|
|
||||||
|
**[00:27:47]** want to say that he is running you would use the conjugated verb estar for the he pronoun which would be if you want to say you are thinking you would say to EST both of these verbs are actions that are happening right now which explains why the verb EST is used here and the same principle applies to any pronoun and verb that you want to use in the present progressive number
|
||||||
|
|
||||||
|
> **[on-screen 00:28:06]**
|
||||||
|
> e 2. Location (spatial relationship relative to where someone or something is)
|
||||||
|
|
||||||
|
**[00:28:06]** two location and whenever I speak of location I speak of spatial relationships relative to where something or someone is as of this moment you might have heard the saying
|
||||||
|
|
||||||
|
> **[on-screen 00:28:15]**
|
||||||
|
> e 2. Location (spatial relationship relative to where someone or something is) éDonde estas (tu)? ——-4 Where are you?
|
||||||
|
|
||||||
|
**[00:28:15]** don't EST which means where are you the reason why the verb EST is used here is because the question is asking where one is right now and if you're answering this question you will likewise use the
|
||||||
|
|
||||||
|
> **[on-screen 00:28:23]**
|
||||||
|
> e 2. Location (spatial relationship relative to where someone or something is) éDonde estas (tu)? Yo estoy en lacasa (in the house)
|
||||||
|
|
||||||
|
**[00:28:24]** verb estar by saying yo esto in whichever location you want to say using location with a can also indicate where something or someone is relative to a different object when asking don't EST you can also reply
|
||||||
|
|
||||||
|
> **[on-screen 00:28:35]**
|
||||||
|
> e 2. Location (spatial relationship relative to where someone or something is) éDonde estas (tu)? Yo estoy al lado de la casa (to the next of the house)
|
||||||
|
|
||||||
|
**[00:28:36]** with which would mean I am next to the house or to the next of the house and the reason why St is used here is because it uses a location in relation to something else the same principle applies to any pronoun verb and location you want to use and the last usage to
|
||||||
|
|
||||||
|
> **[on-screen 00:28:49]**
|
||||||
|
> 3. Health/Conditions/Emotions
|
||||||
|
|
||||||
|
**[00:28:49]** know what the verb estar is health conditions and emotions and this is by far the trickiest use of the verb estar because it's the number one concept that most students struggle with with whenever I refer to conditions and emotions I'm talking about adjectives
|
||||||
|
|
||||||
|
> **[on-screen 00:29:00]**
|
||||||
|
> 3. Health/Conditions/Emotions Conditions and emotions refer to something that is being felt in the current moment and NOT a physical trait
|
||||||
|
|
||||||
|
**[00:29:01]** that people use to refer to something that they feel right now and not a physical trait you might remember me
|
||||||
|
|
||||||
|
> **[on-screen 00:29:06]**
|
||||||
|
> 3. Physical traits You are beautiful Tu eres bonito/a
|
||||||
|
|
||||||
|
**[00:29:06]** saying that the verb said is used for physical traits and while that's true the conditions and emotions of people
|
||||||
|
|
||||||
|
> **[on-screen 00:29:10]**
|
||||||
|
> 3. Health/Conditions/Emotions Conditions and emotions refer to something that is being felt in the current moment and NOTa physical trait
|
||||||
|
|
||||||
|
**[00:29:11]** and sometimes objects primarily refer to something that somebody feels rather than being a factual statement looking at this example both the words Alto and
|
||||||
|
|
||||||
|
> **[on-screen 00:29:17]**
|
||||||
|
> 3. Health/Conditions/Emotions El es alto Yo estoy feliz He is tall lam happy
|
||||||
|
|
||||||
|
**[00:29:19]** Feliz are adjectives but one is a
|
||||||
|
|
||||||
|
> **[on-screen 00:29:21]**
|
||||||
|
> 3. Health/Conditions/Emotions El es alto Yo estoy feliz He is tall lam happy tall is a factual happy is an emotion and physical trait that changes over time
|
||||||
|
|
||||||
|
**[00:29:21]** factual and physical trait while the other is an emotion that changes over time while the verb said refers to factual statements part of which includes physical traits which are factual about oneself the adjective is using the conjugation s because the verb said refers to factual statements LS Alto he is tall is a factual statement because you cannot change that fact however once you start including emotions this is where you need to have a different sense of being because by saying yiz I am happy I'm indicating
|
||||||
|
|
||||||
|
> **[on-screen 00:29:46]**
|
||||||
|
> 3. Health/Conditions/Emotions El es alto Yo estoy feliz He is tall lam happy tall is a factual happy is an emotion and physical trait that changes over time happy indicates having a feeling of happiness that will change, rather than a fact
|
||||||
|
|
||||||
|
**[00:29:46]** that I'm feeling happy and that my feeling will change in time rather than this being a factual statement about me
|
||||||
|
|
||||||
|
> **[on-screen 00:29:51]**
|
||||||
|
> 3. Health/Conditions/Emotions El esta alto Yo soy feliz
|
||||||
|
|
||||||
|
**[00:29:51]** if you were to switch them and say and this is where the meaning in both sentences completely Chang changes by saying Alto you're basically saying
|
||||||
|
|
||||||
|
> **[on-screen 00:30:00]**
|
||||||
|
> 3. Health/Conditions/Emotions El esta alto Yo soy feliz He is feeling tall
|
||||||
|
|
||||||
|
**[00:30:00]** that he is feeling tall rather than him factually being tall which would be an
|
||||||
|
|
||||||
|
> **[on-screen 00:30:03]**
|
||||||
|
> 3. Health/Conditions/Emotions El esta alto Yo soy feliz s feelin l
|
||||||
|
|
||||||
|
**[00:30:03]** incorrect use of the verb EST because the verb estar refers to conditions and
|
||||||
|
|
||||||
|
> **[on-screen 00:30:06]**
|
||||||
|
> 3. Health/Conditions/Emotions Elesta alto Yo soy feliz estar refers to conditions and emotions that actively change over time
|
||||||
|
|
||||||
|
**[00:30:07]** emotions that actively change over time Alto means tall and masculine which is an adjective that refers to a physical and factual trait about oneself rather than a Feeling by saying y fiz I'm
|
||||||
|
|
||||||
|
> **[on-screen 00:30:16]**
|
||||||
|
> 3. Health/Conditions/Emotions El esta alto Yo soy feliz
|
||||||
|
|
||||||
|
**[00:30:17]** saying that I am happy as in I am a
|
||||||
|
|
||||||
|
> **[on-screen 00:30:19]**
|
||||||
|
> 3. Health/Conditions/Emotions El esta alto Yo soy feliz lama happy person in general
|
||||||
|
|
||||||
|
**[00:30:19]** happy person in general I was born happy I'm happy now and I will die happy all of this being a false statement because
|
||||||
|
|
||||||
|
> **[on-screen 00:30:24]**
|
||||||
|
> 3. Health/Conditions/Emotions El esta alto Yo.soy feliz lama py person in general
|
||||||
|
|
||||||
|
> **[on-screen 00:30:25]**
|
||||||
|
> 3. Health/Conditions/Emotions El esta alto Yosoy feliz lama py pers6n in general happiness is a feeling that changes which doesn’t allow the verb ser to be used
|
||||||
|
|
||||||
|
**[00:30:25]** happiness is a feeling that changes over time it doesn't allow the verb said to be used here so instead you would want
|
||||||
|
|
||||||
|
> **[on-screen 00:30:30]**
|
||||||
|
> 3. Health/Conditions/Emotions ser estar El es alto Yo estoy feliz N\ / physical and factual conditions and traits about oneself emotions that change over time
|
||||||
|
|
||||||
|
**[00:30:30]** to look carefully at the difference between physical and factual traits about oneself and conditions and emotions that change over time and with conditions and emotions you might find
|
||||||
|
|
||||||
|
> **[on-screen 00:30:38]**
|
||||||
|
> 3. Health/Conditions/Emotions lam good - Yo estoy bien You are busy - Tu estas ocupado The doors are open - Las puertas estan abiertas
|
||||||
|
|
||||||
|
**[00:30:38]** these phrases and adjectives to be the most practical and 99% of the time they all use the verb estar because all of these conditions are emotions that change over time and don't remain factual I am good esto BN indicates that I'm feeling good rather than me being a good person in general you are busy estas okup indicates that you are busy as of this moment and you will not be busy in the future which doesn't allow the statement to be factual about you hence EST is used the doors are open this means that the doors are open now but the recondition will probably change in the future and also you might have noticed that the ending of some of these adjectives end in o or as and that is because adjectives in Spanish have gender and plurality and as a matter of fact I will describe the concept of adjectives in the video after this one hopefully I'm making myself as clear as possible with what to do with the verb estar and in case you still don't understand the concept the verb estar
|
||||||
|
|
||||||
|
> **[on-screen 00:31:26]**
|
||||||
|
> Estar mainly applies to uses that are happening right now, and so they will change in the future
|
||||||
|
|
||||||
|
**[00:31:26]** mainly applies to uses that are happening right now at this moment and they're most likely to change in the future just like I listed examples in my set video the uses of the verb estar likewise have a connective pattern across all examples that are used in this video there are other uses of the verb estar like weather expressions but
|
||||||
|
|
||||||
|
> **[on-screen 00:31:40]**
|
||||||
|
> 4. Weather expressions esta nublado - it is cloudy
|
||||||
|
|
||||||
|
**[00:31:41]** they're not as important as the primary
|
||||||
|
|
||||||
|
> **[on-screen 00:31:42]**
|
||||||
|
> 4. ther expressions esta n do - itis
|
||||||
|
|
||||||
|
**[00:31:43]** uses in this video the present
|
||||||
|
|
||||||
|
> **[on-screen 00:31:44]**
|
||||||
|
> Estar mainly applies to uses that are happening right now, and so they will change in the future 1. Present progressive 2. Location 3. Conditions and emotions
|
||||||
|
|
||||||
|
**[00:31:44]** progressive location and conditions and emotions are the primary uses of the verb estop and all of these uses have a connection and that is they're happening right now by saying eloro I'm saying
|
||||||
|
|
||||||
|
> **[on-screen 00:31:52]**
|
||||||
|
> 1. Present progressive e él esta corriendo - he is running right now 2. Location 3. Conditions and emotions
|
||||||
|
|
||||||
|
**[00:31:54]** that he is running right now but he will not be running in the future by saying y
|
||||||
|
|
||||||
|
> **[on-screen 00:31:57]**
|
||||||
|
> 1. Present progressive e él esta corriendo - he is running right now 2. Location e yo estoy en la casa - | am in the house right now 3. Conditions and emotions
|
||||||
|
|
||||||
|
**[00:31:58]** esta I'm saying that I'm in the house right now but I will not be in the future by saying to EST ok I'm saying
|
||||||
|
|
||||||
|
> **[on-screen 00:32:02]**
|
||||||
|
> 1. Present progressive e él esta corriendo - he is running right now 2. Location ¢ yo estoy en la casa - | am in the house right now 3. Conditions and emotions e tu estas ocupado - you are busy right now
|
||||||
|
|
||||||
|
**[00:32:04]** that you are busy right now but you will not be in the future when you really think about it it makes sense why Spanish has two verbs for being or to be because half the time you utilize verbs that describe you factually and these can never change but on the other half of the time you're describing yourself
|
||||||
|
|
||||||
|
> **[on-screen 00:32:16]**
|
||||||
|
> Ser Estar sermlanen vemborar
|
||||||
|
|
||||||
|
**[00:32:17]** using traits that apply for the moment and now you might also understand why some teachers say that the verb said applies to permanent traits while estar refers to Temporary traits because some conditions last forever while other happen right now Spanish has many
|
||||||
180
docs/spanish-fundamentals/08-descriptive-adjectives.md
Normal file
180
docs/spanish-fundamentals/08-descriptive-adjectives.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# 08. Descriptive Adjectives
|
||||||
|
|
||||||
|
- **Time range:** 00:32:28 – 00:35:52 (duration 00:03:24)
|
||||||
|
- **Source:** [A Complete Guide To Every Fundamental In Spanish (The Conclusion)](https://youtube.com/watch?v=YHDZSHCt1DE&t=1948s)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **[on-screen 00:32:28]**
|
||||||
|
> Spanish Descriptive Adjectives:
|
||||||
|
|
||||||
|
**[00:32:28]** different types of adjectives and in this video I would like to explain how descriptive adjectives work in context and by descriptive adjectives I mean adjectives that physically or
|
||||||
|
|
||||||
|
> **[on-screen 00:32:35]**
|
||||||
|
> Spanish Descriptive Adjectives: adjectives that physically or conditionally describe something or someone
|
||||||
|
|
||||||
|
**[00:32:36]** conditionally describe something or someone in Spanish all adjectives have
|
||||||
|
|
||||||
|
> **[on-screen 00:32:38]**
|
||||||
|
> Spanish Descriptive Adjectives: e alto/a(s) - tall ¢ bajo/a(s) - short ¢ bonito/a(s) - beautiful e feo/a(s) - ugly
|
||||||
|
|
||||||
|
**[00:32:39]** gender and plurality with the exceptions
|
||||||
|
|
||||||
|
> **[on-screen 00:32:41]**
|
||||||
|
> Spanish Descriptive Adjectives: facil/faciles - easy ¢ dificil/dificiles - difficult ¢ importante(s) - important e inteligente(s) - intelligent/smart
|
||||||
|
|
||||||
|
**[00:32:41]** of a few words that have a neutral ending but still follow plurality
|
||||||
|
|
||||||
|
> **[on-screen 00:32:44]**
|
||||||
|
> Spanish Descriptive Adjectives: e alto/a(s) - tall ¢ bajo/a(s) - short ¢ bonito/a(s) - beautiful e feo/a(s) - ugly
|
||||||
|
|
||||||
|
**[00:32:44]** adjectives ending in O are masculine adjectives ending in a are feminine and
|
||||||
|
|
||||||
|
> **[on-screen 00:32:48]**
|
||||||
|
> Spanish Descriptive Adjectives: facil/faciles - easy ¢ dificil/dificiles - difficult e importante(s) - important e inteligente(s) - intelligent/smart
|
||||||
|
|
||||||
|
**[00:32:48]** neutral adjectives vary based on whoever the subject is of the sentence if you want to say that you are tall and
|
||||||
|
|
||||||
|
> **[on-screen 00:32:52]**
|
||||||
|
> Spanish Descriptive Adjectives: | am tall and beautiful
|
||||||
|
|
||||||
|
**[00:32:53]** beautiful you would say y Alto I Bonito
|
||||||
|
|
||||||
|
> **[on-screen 00:32:54]**
|
||||||
|
> Spanish Descriptive Adjectives: | am tall and beautiful Yo soy alto y bonito (m)
|
||||||
|
|
||||||
|
**[00:32:56]** if you're referring to someone that's masculine and ala ionita would refer to
|
||||||
|
|
||||||
|
> **[on-screen 00:32:58]**
|
||||||
|
> Spanish Descriptive Adjectives: | am tall and beautiful Yo soy alto y bonito (m) Yo soy alta y bonita (f)
|
||||||
|
|
||||||
|
**[00:32:59]** someone who is feminine if you were to
|
||||||
|
|
||||||
|
> **[on-screen 00:33:00]**
|
||||||
|
> Spanish Descriptive Adjectives: They are ugly (m)
|
||||||
|
|
||||||
|
**[00:33:00]** say that they are ugly and masculine you would say AOS fos because now there are
|
||||||
|
|
||||||
|
> **[on-screen 00:33:04]**
|
||||||
|
> Spanish Descriptive Adjectives: They are ugly (m) Ellos son feos (m)
|
||||||
|
|
||||||
|
**[00:33:05]** multiple people which generates plurality if you were to work with
|
||||||
|
|
||||||
|
> **[on-screen 00:33:07]**
|
||||||
|
> Spanish Descriptive Adjectives: These classes are easy
|
||||||
|
|
||||||
|
**[00:33:08]** adjectives that have a neutral ending and say a sentence like these classes are easy you would say estas classes fil
|
||||||
|
|
||||||
|
> **[on-screen 00:33:12]**
|
||||||
|
> Spanish Descriptive Adjectives: These classes are easy Estas clases son faciles
|
||||||
|
|
||||||
|
**[00:33:14]** by adding an Es at the end of the adjective to fit the plurality of the sentence if you were to say we are
|
||||||
|
|
||||||
|
> **[on-screen 00:33:17]**
|
||||||
|
> Spanish Descriptive Adjectives: We are intelligent Nosotros somos inteligentes
|
||||||
|
|
||||||
|
**[00:33:18]** intelligent you would say no intentes because the ending of the adjective matches the plurality of the sentence the same exact principle applies to any pronoun and adjective you would like to use however taking simple sentences like
|
||||||
|
|
||||||
|
> **[on-screen 00:33:28]**
|
||||||
|
> Spanish Descriptive Adjectives:
|
||||||
|
|
||||||
|
> **[on-screen 00:33:30]**
|
||||||
|
> Spanish Descriptive Adjectives: The boy is smart El chico es inteligente
|
||||||
|
|
||||||
|
**[00:33:30]** the boy is smar in might make learning too impractical because you're generating sentences that are too easy to say or sentences that are not said as often as others if you were to instead say the smart boy this is where the
|
||||||
|
|
||||||
|
> **[on-screen 00:33:40]**
|
||||||
|
> Spanish Descriptive Adjectives: The smart boy
|
||||||
|
|
||||||
|
**[00:33:41]** syntax would start changing positions in the sentence you would want to say El intell Cho the smart boy but this would
|
||||||
|
|
||||||
|
> **[on-screen 00:33:45]**
|
||||||
|
> Spanish Descriptive Adjectives: The smart boy El inteligente chico
|
||||||
|
|
||||||
|
> **[on-screen 00:33:47]**
|
||||||
|
> Spanish Descriptive Adjectives: The smart boy El inteligente chico
|
||||||
|
|
||||||
|
**[00:33:47]** be a mistake in Spanish because Spanish has a rule that says you have to put
|
||||||
|
|
||||||
|
> **[on-screen 00:33:50]**
|
||||||
|
> Spanish Descriptive Adjectives: The smart boy El inteligente chico
|
||||||
|
|
||||||
|
**[00:33:50]** nouns before adjectives in order to determine the subject from something else so instead of saying El int Cho you would say El Cho int which technically
|
||||||
|
|
||||||
|
> **[on-screen 00:33:56]**
|
||||||
|
> Spanish Descriptive Adjectives: The smart boy El chico inteligente
|
||||||
|
|
||||||
|
**[00:33:58]** would translate as the boy smart but
|
||||||
|
|
||||||
|
> **[on-screen 00:33:59]**
|
||||||
|
> Spanish Descriptive Adjectives: The smart boy El chico inteligente The boy smart
|
||||||
|
|
||||||
|
**[00:34:00]** logically speaking it means the boy that is smart but this is not included in
|
||||||
|
|
||||||
|
> **[on-screen 00:34:02]**
|
||||||
|
> Spanish Descriptive Adjectives: The smart boy El chico inteligente The boy that is smart
|
||||||
|
|
||||||
|
> **[on-screen 00:34:03]**
|
||||||
|
> Spanish Descriptive Adjectives: The smart boy El chico inteligente The boy thatis smart
|
||||||
|
|
||||||
|
**[00:34:03]** Spanish because it doesn't need to in English whenever you're describing
|
||||||
|
|
||||||
|
> **[on-screen 00:34:05]**
|
||||||
|
> English Descriptive Adjectives: This important video - Este video importante The difficult lesson - La lecci6n dificil
|
||||||
|
|
||||||
|
**[00:34:06]** subjects you put adjectives before nouns but in Spanish you have to put adjectives after nouns because it's a rule in the language and once again the same principle applies to any noun and adjective you want to use there is
|
||||||
|
|
||||||
|
> **[on-screen 00:34:15]**
|
||||||
|
> Spanish Descriptive Adjectives:
|
||||||
|
|
||||||
|
**[00:34:15]** however an important rule to consider whenever you're referring to adjectives that are used factually and physically about oneself and an adjective that is a condition that changes over time in English you may have sentences like I am
|
||||||
|
|
||||||
|
> **[on-screen 00:34:25]**
|
||||||
|
> English Descriptive Adjectives: | am short lam tired
|
||||||
|
|
||||||
|
**[00:34:25]** short and I am tired and both of these
|
||||||
|
|
||||||
|
> **[on-screen 00:34:27]**
|
||||||
|
> English Descriptive Adjectives: | am short | am tired
|
||||||
|
|
||||||
|
**[00:34:27]** sentences use the same conjugated form of the verb to be in this case I am because in English we don't care about the continuation of the sentence as long as we use the properly conjugated form of to be to match the corresponding pronoun in Spanish however you have to
|
||||||
|
|
||||||
|
> **[on-screen 00:34:37]**
|
||||||
|
> Spanish Descriptive Adjectives: | am short lam tired Yo soy bajo Yo estoy cansado
|
||||||
|
|
||||||
|
**[00:34:38]** watch out for these things because these sentences contain two senses of being
|
||||||
|
|
||||||
|
> **[on-screen 00:34:41]**
|
||||||
|
> Spanish Descriptive Adjectives: | am short | am tired Yo soy bajo Yo estoy cansado ser (physical and estar (emotion factual trait) that changes)
|
||||||
|
|
||||||
|
**[00:34:41]** one using a physical and factual trait about yourself while the other expresses an emotion that you feel which will change in the future and with descriptive adjectives the same rule
|
||||||
|
|
||||||
|
> **[on-screen 00:34:47]**
|
||||||
|
> Spanish Descriptive Adjectives: La chica hermosa - The beautiful girl La chica es hermosa - The girl is beautiful El hombre relajado - The relaxed man El hombre esta relajado - The man is relaxed
|
||||||
|
|
||||||
|
**[00:34:48]** applies for both adjectives that apply factually and conditionally the only challenge is figuring out whether to use S or estar with physical traits and conditions and emotions la Osa the beautiful girl can also be said asosa the girl is beautiful which uses said to factually describe the subject the relaxed man can also be as the man is relaxed which uses the verb to express the emotional condition of the subject and with these being
|
||||||
|
|
||||||
|
> **[on-screen 00:35:15]**
|
||||||
|
> Spanish Descriptive Adjectives: ser: estar: ° alto/a(s) ¢ aburrido/a(s) © bajo/a(s) ® cansando/a(s) ¢ bonito/a(s) ¢ enfermo/a(s) ° feo/a(s) listo/a(s) facil/faciles ° seguro/a(s) ¢ dificil/dificiles © preparado/a(s) ¢ importante(s) e relajado/a(s) e inteligentes(s) ° triste(s)
|
||||||
|
|
||||||
|
**[00:35:15]** physical traits that are used with s and these being conditions that are used with estar these are all commonly used descriptive adjectives that you can use to construct sentences on a daily basis all you simply do is choose any pronoun you want to use select any AD and then remember which verb to use when describing something or somebody for
|
||||||
|
|
||||||
|
> **[on-screen 00:35:30]**
|
||||||
|
> Spanish Descriptive Adjectives: He is smart
|
||||||
|
|
||||||
|
**[00:35:30]** example if you want to say that he is smart you would say LS intell because
|
||||||
|
|
||||||
|
> **[on-screen 00:35:33]**
|
||||||
|
> Spanish Descriptive Adjectives: He is smart El es inteligente
|
||||||
|
|
||||||
|
**[00:35:34]** the word s is the correctly conjugated form of said of the he pronoun being used to express a factual statement
|
||||||
|
|
||||||
|
> **[on-screen 00:35:39]**
|
||||||
|
> Spanish Descriptive Adjectives: We are sad
|
||||||
|
|
||||||
|
**[00:35:39]** likewise if you want to say we are sad you would say noos estamos tristes and
|
||||||
|
|
||||||
|
> **[on-screen 00:35:42]**
|
||||||
|
> Spanish Descriptive Adjectives: We are sad Nosotros estamos tristes
|
||||||
|
|
||||||
|
**[00:35:44]** you would use EST as the conjugated form of the wi pronoun because EST is used for emotions that change over time and you would also pluralize trist because you have plurality in the sentence
|
||||||
141
docs/spanish-fundamentals/09-possessive-adjectives.md
Normal file
141
docs/spanish-fundamentals/09-possessive-adjectives.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# 09. Possessive Adjectives
|
||||||
|
|
||||||
|
- **Time range:** 00:35:52 – 00:38:32 (duration 00:02:40)
|
||||||
|
- **Source:** [A Complete Guide To Every Fundamental In Spanish (The Conclusion)](https://youtube.com/watch?v=YHDZSHCt1DE&t=2152s)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**[00:35:52]** possessive adjectives in Spanish indicate that something is being possessed by somebody or is in the own hands of somebody the English version of
|
||||||
|
|
||||||
|
> **[on-screen 00:35:57]**
|
||||||
|
> English Possessive Adjectives: my our your y'all's his/her/its | their
|
||||||
|
|
||||||
|
**[00:35:58]** this would be my your his her or it our Ys English doesn't have this adjective but I'm still including it because Spanish has it and finally there in
|
||||||
|
|
||||||
|
> **[on-screen 00:36:07]**
|
||||||
|
> Spanish Possessive Adjectives: mi nuestro/a tu vuestro/a su su
|
||||||
|
|
||||||
|
**[00:36:07]** Spanish these possessive adjectives look like this me with no accent because with
|
||||||
|
|
||||||
|
> **[on-screen 00:36:11]**
|
||||||
|
> mi - my (adjective) mi - me (pronoun)
|
||||||
|
|
||||||
|
**[00:36:11]** the accent you'll have a direct object pronoun me to with no accent because
|
||||||
|
|
||||||
|
> **[on-screen 00:36:14]**
|
||||||
|
> tu - your (adjective) tu - you (pronoun)
|
||||||
|
|
||||||
|
**[00:36:15]** with the accent you have the pronoun you
|
||||||
|
|
||||||
|
> **[on-screen 00:36:17]**
|
||||||
|
> su - his, her, their
|
||||||
|
|
||||||
|
**[00:36:17]** Su and this adjective can simultaneously mean his her or there and you can only tell the difference between them in context and finally noest vestro VRA
|
||||||
|
|
||||||
|
> **[on-screen 00:36:24]**
|
||||||
|
> Spanish Possessive Adjectives: mi nuestro/a tu vuestro/a su su
|
||||||
|
|
||||||
|
**[00:36:26]** when the interesting things about possessive adjectives in Spanish is that the adjectives nestro and vestro are the
|
||||||
|
|
||||||
|
> **[on-screen 00:36:31]**
|
||||||
|
> Spanish Possessive Adjectives: mi nuestro/a tu vuestro/a su su
|
||||||
|
|
||||||
|
**[00:36:31]** only adjectives that have gender if you're referring to something masculine in Spanish and you want to use the hour adjective you would say nestro and then
|
||||||
|
|
||||||
|
> **[on-screen 00:36:36]**
|
||||||
|
> our cat nuestro gato
|
||||||
|
|
||||||
|
**[00:36:37]** whatever the follow-up is you can do the
|
||||||
|
|
||||||
|
> **[on-screen 00:36:38]**
|
||||||
|
> our cat nuestro gato our rose nuestra rosa
|
||||||
|
|
||||||
|
**[00:36:38]** same with nestra using feminine words and you can replicate this concept using
|
||||||
|
|
||||||
|
> **[on-screen 00:36:41]**
|
||||||
|
> y‘all's cat vuestro gato y‘all's rose vuestra rosa
|
||||||
|
|
||||||
|
**[00:36:42]** the vestro adjective however I recommend
|
||||||
|
|
||||||
|
> **[on-screen 00:36:43]**
|
||||||
|
> Spanish Possessive Adjectives: mi nuestro/a tu V o/a su su
|
||||||
|
|
||||||
|
**[00:36:44]** not focusing on these adjectives because they're not used as often as the other ones in Spanish and also all of these possessive adjectives have plurality and the way that you pluralize them is by simply adding an S at the end of every
|
||||||
|
|
||||||
|
> **[on-screen 00:36:53]**
|
||||||
|
> Spanish Possessive Adjectives: mis nuestro/as tus vuestro/as sus SUS
|
||||||
|
|
||||||
|
**[00:36:53]** adjective and with plurality you can only use it when you're referring to noun that are not singular for example if you want to say my car you would say m cooche but saying my cars would be M cooches if you want to say your dog you would say two perro and saying your dogs would be twoos however using the Sue
|
||||||
|
|
||||||
|
> **[on-screen 00:37:08]**
|
||||||
|
> your dogs tus perros
|
||||||
|
|
||||||
|
> **[on-screen 00:37:09]**
|
||||||
|
> su - his, her, their
|
||||||
|
|
||||||
|
**[00:37:09]** adjective is where the syntax gets a bit tricky and like I said again this adjective can mean his her and there it can be pluralized and you can only tell the difference between them in context
|
||||||
|
|
||||||
|
> **[on-screen 00:37:18]**
|
||||||
|
> | talk with his friend
|
||||||
|
|
||||||
|
**[00:37:18]** you can have a sentence in English like I talk with his friend and in Spanish the sentence would be Yu Amigo in
|
||||||
|
|
||||||
|
> **[on-screen 00:37:22]**
|
||||||
|
> | talk with his friend Yo hablo con su amigo
|
||||||
|
|
||||||
|
**[00:37:24]** English understanding the adjective is very easy because who have an adjective
|
||||||
|
|
||||||
|
> **[on-screen 00:37:26]**
|
||||||
|
> I talk with his friend Yo hablo con su amigo
|
||||||
|
|
||||||
|
**[00:37:27]** that specifies who it is in this case it's masculine in Spanish however it
|
||||||
|
|
||||||
|
> **[on-screen 00:37:30]**
|
||||||
|
> | talk with his friend Yo hablo con su amigo
|
||||||
|
|
||||||
|
**[00:37:30]** would be difficult to tell if Su refers to his her or their a tip that I can give to not get these confused is to always specify who is the subject within the sentence that you're saying you can
|
||||||
|
|
||||||
|
> **[on-screen 00:37:39]**
|
||||||
|
> | talk with John and with his father
|
||||||
|
|
||||||
|
**[00:37:39]** say a sentence like I talk with Jon and with his father and in Spanish the sentence would
|
||||||
|
|
||||||
|
> **[on-screen 00:37:44]**
|
||||||
|
> | talk with John and with his father Yo hablo con John y con su padre
|
||||||
|
|
||||||
|
**[00:37:44]** be and in this context you would know that the adjective Su is masculine and indicates his because JN is a masculine name likewise you can have a sentence
|
||||||
|
|
||||||
|
> **[on-screen 00:37:51]**
|
||||||
|
> | talk with Emma and with her mother
|
||||||
|
|
||||||
|
**[00:37:52]** like I talk with Emma and with her mother and in Spanish the sentence would be you Emma
|
||||||
|
|
||||||
|
> **[on-screen 00:37:56]**
|
||||||
|
> | talk with Emma and with her mother Yo hablo con Emma y con su madre
|
||||||
|
|
||||||
|
**[00:37:57]** and in this context we know that Su is
|
||||||
|
|
||||||
|
> **[on-screen 00:37:59]**
|
||||||
|
> | talk with Emma and with her mother Yo hablo con Emma y con su madre
|
||||||
|
|
||||||
|
**[00:37:59]** feminine and indicates her because Emma is feminine at last you can have a
|
||||||
|
|
||||||
|
> **[on-screen 00:38:02]**
|
||||||
|
> | talk with my parents and with their friends
|
||||||
|
|
||||||
|
**[00:38:02]** sentence that utilizes two adjectives and you can pluralize them both like I talk with my parents and with their friends and in Spanish it would
|
||||||
|
|
||||||
|
> **[on-screen 00:38:09]**
|
||||||
|
> | talk with my parents and with their friends Yo hablo con mis padres y con sus amigos
|
||||||
|
|
||||||
|
**[00:38:11]** be is pluralized because padis is a plural noun and sus is also pluralized
|
||||||
|
|
||||||
|
> **[on-screen 00:38:15]**
|
||||||
|
> | talk with my parents and with their friends Yo hablo con mis padres y con sus amigos
|
||||||
|
|
||||||
|
**[00:38:16]** because of Amigos but it mainly refers to the adjective there because of my parents's friends which is they friends
|
||||||
|
|
||||||
|
> **[on-screen 00:38:21]**
|
||||||
|
> Spanish Possessive Adjectives: mi(s) nuestro/a(s) tu(s) | vuestro/a(s) su(s) su(s)
|
||||||
|
|
||||||
|
**[00:38:21]** using the system is actually quite useful to keep these possessive adjectives in the back of your mind because the sentences that you can make with them are practical and Limitless and once again the same principle applies to any sentence you want to say using these adjectives demonstra of
|
||||||
98
docs/spanish-fundamentals/10-demonstrative-adjectives.md
Normal file
98
docs/spanish-fundamentals/10-demonstrative-adjectives.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# 10. Demonstrative Adjectives
|
||||||
|
|
||||||
|
- **Time range:** 00:38:32 – 00:40:50 (duration 00:02:18)
|
||||||
|
- **Source:** [A Complete Guide To Every Fundamental In Spanish (The Conclusion)](https://youtube.com/watch?v=YHDZSHCt1DE&t=2312s)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**[00:38:32]** adjectives in Spanish are adjectives that are used to indicate a specific word or precisely determine what something is in English it's very easy
|
||||||
|
|
||||||
|
> **[on-screen 00:38:37]**
|
||||||
|
> English Demonstrative Adjectives: This That
|
||||||
|
|
||||||
|
**[00:38:38]** to decide on these adjectives because you have only two primary words that determine something and these words are this and that and if you want to pluralize them this becomes these and
|
||||||
|
|
||||||
|
> **[on-screen 00:38:45]**
|
||||||
|
> English Demonstrative Adjectives: This —— These That—— Those
|
||||||
|
|
||||||
|
**[00:38:46]** that becomes those in Spanish you have
|
||||||
|
|
||||||
|
> **[on-screen 00:38:48]**
|
||||||
|
> Spanish Demonstrative Adjectives:
|
||||||
|
|
||||||
|
**[00:38:48]** the same concept along with a bit more variety gender and plurality in Spanish
|
||||||
|
|
||||||
|
> **[on-screen 00:38:52]**
|
||||||
|
> Spanish Demonstrative Adjectives: this (m & f) that (m & f) este ese esta esa
|
||||||
|
|
||||||
|
**[00:38:52]** this would be Estee masculine and esta feminine and that would be ESS masculine and Essa feminine it's really tempting
|
||||||
|
|
||||||
|
> **[on-screen 00:39:00]**
|
||||||
|
> Spanish Demonstrative Adjectives: this (m & f) that (m & f) esto eso esta esa
|
||||||
|
|
||||||
|
**[00:39:00]** to say esto or ESO because the feminine version ends in a so you want to put an
|
||||||
|
|
||||||
|
> **[on-screen 00:39:04]**
|
||||||
|
> Spanish Demonstrative Adjectives: this (m & f) that (m & f) esto eso esta esa
|
||||||
|
|
||||||
|
**[00:39:05]** O for the masculine adjectives however Spanish does have these words esto and ESO but these are adjectives that have the neuter gender meaning that you don't know if these adjectives refer to something masculine or feminine roughly 80% of the time you would use these
|
||||||
|
|
||||||
|
> **[on-screen 00:39:17]**
|
||||||
|
> éQue es esto? éQue es eso?
|
||||||
|
|
||||||
|
**[00:39:17]** words in sentences like kesto or keso meaning what is this or what is that these are simple sentences to remember whenever you decide to speak Spanish Additionally you could also use these
|
||||||
|
|
||||||
|
> **[on-screen 00:39:26]**
|
||||||
|
> This is for everybody Esto es para todos
|
||||||
|
|
||||||
|
**[00:39:27]** words for making sentences that have generalizations such as this is for everybody EST esos and the same concept
|
||||||
|
|
||||||
|
> **[on-screen 00:39:32]**
|
||||||
|
> That is for everybody Eso es para todos
|
||||||
|
|
||||||
|
**[00:39:32]** applies to ESO in any continuation that you want to say overall you just have to
|
||||||
|
|
||||||
|
> **[on-screen 00:39:35]**
|
||||||
|
> Spanish Demonstrative Adjectives: this (m & f) that (m & f) este ese esta esa
|
||||||
|
|
||||||
|
**[00:39:36]** remember that Estee and are masculine and esta Anda are feminine if you want to pluralize them both EST and esta become estos and estas and and Esa
|
||||||
|
|
||||||
|
> **[on-screen 00:39:44]**
|
||||||
|
> Spanish Demonstrative Adjectives: this (m & f) that (m & f) estos ese estas esa
|
||||||
|
|
||||||
|
**[00:39:47]** become esos and esas Visually looking at the syntax the plurality for esta and Esa is very simple because all you do is put an S at the end of the adjectives but for Estee and the ending changes to estos and esos and that's the only tough part to remember and also remember to not put accents on
|
||||||
|
|
||||||
|
> **[on-screen 00:40:01]**
|
||||||
|
> NO ACCENTS FOR ESTA & ESTAS
|
||||||
|
|
||||||
|
**[00:40:02]** esta and estas because if you do you
|
||||||
|
|
||||||
|
> **[on-screen 00:40:04]**
|
||||||
|
> NO ACCENTS FOR ESTA & ESTAS esta - this (f) esta - (he/she) is estas - these (f) } estas - (you) are
|
||||||
|
|
||||||
|
**[00:40:04]** will have different words additionally Spanish also has these words AEL and AA both of which mean that as in something that's over there if you want to pluralize them AEL becomes AOS and AA becomes AAS even though this demonstrative adjective is used less than the others it's actually helpful to say in some cases but moreover it's important to just know these words and understand when to use them regarding
|
||||||
|
|
||||||
|
> **[on-screen 00:40:25]**
|
||||||
|
> Spanish Demonstrative Adjectives: este/os ese/os esta/s esa/s
|
||||||
|
|
||||||
|
**[00:40:25]** examples you you can use Estee and ESS with masculine nouns like Estee Libro
|
||||||
|
|
||||||
|
> **[on-screen 00:40:29]**
|
||||||
|
> este libro - this book (m & s) esta casa - this house (f & s)
|
||||||
|
|
||||||
|
**[00:40:29]** and esta you can use ESS and Essa with feminine nouns likeo and esaa and if you
|
||||||
|
|
||||||
|
> **[on-screen 00:40:33]**
|
||||||
|
> este libro - this book (m & s) esta casa - this house (f & s) ese curso - that course (m &s) esa mesa - that table (f & s)
|
||||||
|
|
||||||
|
**[00:40:35]** want to pluralize any of them you would have estos libros estas kasas esos csos
|
||||||
|
|
||||||
|
> **[on-screen 00:40:38]**
|
||||||
|
> estos libros - these books (m & p) estas casas - these houses (f & p) esos cursos - those courses (m & p) esas mesas - those tables (f & p)
|
||||||
|
|
||||||
|
**[00:40:40]** and esas mesas using these demonstrative
|
||||||
|
|
||||||
|
> **[on-screen 00:40:42]**
|
||||||
|
> Spanish Demonstrative Adjectives: este/os ese/os esta/s esa/s
|
||||||
|
|
||||||
|
**[00:40:42]** adjectives you can actually make many sentences with them especially when you're trying to determine something and once you have enough practice you'll find these words to be very useful and practical you've probably heard many
|
||||||
245
docs/spanish-fundamentals/11-useful-greetings-farewells.md
Normal file
245
docs/spanish-fundamentals/11-useful-greetings-farewells.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# 11. Useful Greetings & Farewells
|
||||||
|
|
||||||
|
- **Time range:** 00:40:50 – 00:43:53 (duration 00:03:03)
|
||||||
|
- **Source:** [A Complete Guide To Every Fundamental In Spanish (The Conclusion)](https://youtube.com/watch?v=YHDZSHCt1DE&t=2450s)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **[on-screen 00:40:50]**
|
||||||
|
> Spanish Greetings & Farewells:
|
||||||
|
|
||||||
|
**[00:40:51]** phrases in Spanish used for greetings and farewells some of which are useful and some of which are kind of pointless and in this video I'd like to present to you what phrases are the best to use when saying greetings and farewells in Spanish bi Veno is probably the most
|
||||||
|
|
||||||
|
> **[on-screen 00:41:02]**
|
||||||
|
> Spanish Greetings & Farewells: bienvenido
|
||||||
|
|
||||||
|
**[00:41:03]** popular greeting there is which literally translates as welcome if you
|
||||||
|
|
||||||
|
> **[on-screen 00:41:06]**
|
||||||
|
> Spanish Greetings & Farewells: bienvenido welcome
|
||||||
|
|
||||||
|
**[00:41:06]** break the word apart you'll get bienn in
|
||||||
|
|
||||||
|
> **[on-screen 00:41:08]**
|
||||||
|
> Spanish Greetings & Farewells: bien / venido
|
||||||
|
|
||||||
|
**[00:41:08]** venido bienn means well and venido means
|
||||||
|
|
||||||
|
> **[on-screen 00:41:09]**
|
||||||
|
> Spanish Greetings & Farewells: bien / venido well
|
||||||
|
|
||||||
|
> **[on-screen 00:41:10]**
|
||||||
|
> Spanish Greetings & Farewells: bien / venido well come
|
||||||
|
|
||||||
|
**[00:41:11]** come which is a form of speech taken
|
||||||
|
|
||||||
|
> **[on-screen 00:41:12]**
|
||||||
|
> Spanish Present Perfect: haber venido to have come
|
||||||
|
|
||||||
|
**[00:41:13]** from the present perfect Abed Veno to have come and if you take that word Veno
|
||||||
|
|
||||||
|
> **[on-screen 00:41:17]**
|
||||||
|
> Spanish Present Perfect: haber venido to have come
|
||||||
|
|
||||||
|
**[00:41:17]** and combine it with B you get bi Veno
|
||||||
|
|
||||||
|
> **[on-screen 00:41:18]**
|
||||||
|
> Spanish Greetings & Farewells: bienvenido
|
||||||
|
|
||||||
|
> **[on-screen 00:41:20]**
|
||||||
|
> Spanish Greetings & Farewells: bienvenido it's well to have come
|
||||||
|
|
||||||
|
**[00:41:20]** it's well to have come or wellcome and
|
||||||
|
|
||||||
|
> **[on-screen 00:41:21]**
|
||||||
|
> Spanish Greetings & Farewells: bienvenido welcome
|
||||||
|
|
||||||
|
**[00:41:22]** if you're referring to more than one person you can also say bi venos buenos
|
||||||
|
|
||||||
|
> **[on-screen 00:41:25]**
|
||||||
|
> Spanish Greetings & Farewells: bienvenidos welcome
|
||||||
|
|
||||||
|
**[00:41:26]** Das literally translat as good days and the ending of Buenos perfectly corresponds to the ending of Das because it's pluralized in masculine but mainly speaking buenos dias is used more as
|
||||||
|
|
||||||
|
> **[on-screen 00:41:36]**
|
||||||
|
> Spanish Greetings & Farewells: buenos dias good morning
|
||||||
|
|
||||||
|
**[00:41:36]** good morning rather than good days you
|
||||||
|
|
||||||
|
> **[on-screen 00:41:38]**
|
||||||
|
> Spanish Greetings & Farewells: buenas noches goodnight
|
||||||
|
|
||||||
|
**[00:41:38]** also have this phrase bu noes which is literally good nights or good night when
|
||||||
|
|
||||||
|
> **[on-screen 00:41:42]**
|
||||||
|
> Spanish Greetings & Farewells:
|
||||||
|
|
||||||
|
**[00:41:42]** beginning a conversation in Spanish a person might begin the conversation by immediately saying kapasa which
|
||||||
|
|
||||||
|
> **[on-screen 00:41:47]**
|
||||||
|
> Spanish Greetings & Farewells: éQueé pasa?
|
||||||
|
|
||||||
|
**[00:41:47]** translates as what's going on because
|
||||||
|
|
||||||
|
> **[on-screen 00:41:49]**
|
||||||
|
> pasar - to go on/ to happen
|
||||||
|
|
||||||
|
**[00:41:49]** the verb Pasar can actually mean to go or to happen whenever somebody says
|
||||||
|
|
||||||
|
> **[on-screen 00:41:53]**
|
||||||
|
> Spanish Greetings & Farewells: éQueé pasa? What's going on?
|
||||||
|
|
||||||
|
**[00:41:53]** kasasa they're literally saying what's going on or what's happening what's
|
||||||
|
|
||||||
|
> **[on-screen 00:41:56]**
|
||||||
|
> Spanish Greetings & Farewells: éQueé pasa? What's going on? What's happening?
|
||||||
|
|
||||||
|
> **[on-screen 00:41:57]**
|
||||||
|
> Spanish Greetings & Farewells: What's happening?
|
||||||
|
|
||||||
|
**[00:41:57]** happening can also be rephrased asando
|
||||||
|
|
||||||
|
> **[on-screen 00:41:59]**
|
||||||
|
> Spanish Greetings & Farewells: What's happening? éQué esta pasando?
|
||||||
|
|
||||||
|
**[00:42:00]** using the present progressive and we know that it's the present progressive
|
||||||
|
|
||||||
|
> **[on-screen 00:42:03]**
|
||||||
|
> Spanish Greetings & Farewells: What's happening? éQué esta pasando?
|
||||||
|
|
||||||
|
**[00:42:03]** because it's using a conjugation of EST
|
||||||
|
|
||||||
|
> **[on-screen 00:42:05]**
|
||||||
|
> Spanish Greetings & Farewells: What's happening? éQue esta pasando?
|
||||||
|
|
||||||
|
**[00:42:05]** and adds Ando at the end of the infinitive and as a matter of fact it's
|
||||||
|
|
||||||
|
> **[on-screen 00:42:08]**
|
||||||
|
> Spanish Greetings & Farewells: What is happening? éQué esta pasando?
|
||||||
|
|
||||||
|
**[00:42:08]** the same way the sentence Works in English you can say or maybe if you want to get fancy you can sayal ketal literally translates
|
||||||
|
|
||||||
|
> **[on-screen 00:42:15]**
|
||||||
|
> Spanish Greetings & Farewells: éQué tal?
|
||||||
|
|
||||||
|
**[00:42:16]** as what's such or what's the matter but
|
||||||
|
|
||||||
|
> **[on-screen 00:42:17]**
|
||||||
|
> Spanish Greetings & Farewells: éQué tal? What's such? What's the matter?
|
||||||
|
|
||||||
|
**[00:42:18]** the meaning is mainly how are you and
|
||||||
|
|
||||||
|
> **[on-screen 00:42:20]**
|
||||||
|
> Spanish Greetings & Farewells: éQué tal? How are you?
|
||||||
|
|
||||||
|
**[00:42:20]** the number one phrase that probably everybody heard when learning Spanish is K estas or KO estas which Lally translat
|
||||||
|
|
||||||
|
> **[on-screen 00:42:24]**
|
||||||
|
> Spanish Greetings & Farewells: How are you? éCoOmo estas (tu)?
|
||||||
|
|
||||||
|
**[00:42:26]** as how are you and with this phrase we know to use the verb estar to ask
|
||||||
|
|
||||||
|
> **[on-screen 00:42:29]**
|
||||||
|
> Spanish Greetings & Farewells: How are you? ~Como estas (tu)?
|
||||||
|
|
||||||
|
**[00:42:30]** someone about their well-being because the verb estar mainly applies to actions and emotions that are happening right now and so they're most likely to change in the future by answering this question
|
||||||
|
|
||||||
|
> **[on-screen 00:42:38]**
|
||||||
|
> Spanish Greetings & Farewells: ~Como estas (tu)?
|
||||||
|
|
||||||
|
**[00:42:38]** you would say something like esto
|
||||||
|
|
||||||
|
> **[on-screen 00:42:40]**
|
||||||
|
> Spanish Greetings & Farewells: ~Como estas (tu)? (Yo) estoy bien
|
||||||
|
|
||||||
|
**[00:42:40]** because you're indicating that you're feeling good or feeling well which explains why your answer will also use a conjugation of estar at last there's
|
||||||
|
|
||||||
|
> **[on-screen 00:42:47]**
|
||||||
|
> Spanish Greetings & Farewells: (Muchas) Gracias Thank you (very much)
|
||||||
|
|
||||||
|
**[00:42:47]** gracias or muchas gracias which means thanks or thank you very much and if you want to sound polite you'll reply with
|
||||||
|
|
||||||
|
> **[on-screen 00:42:53]**
|
||||||
|
> Spanish Greetings & Farewells: (Muchas) Gracias Thank you (very much) De nada
|
||||||
|
|
||||||
|
**[00:42:53]** Danada which means of nothing indicating
|
||||||
|
|
||||||
|
> **[on-screen 00:42:54]**
|
||||||
|
> Spanish Greetings & Farewells: De nada Of nothing
|
||||||
|
|
||||||
|
**[00:42:55]** thank you very much and there is no need
|
||||||
|
|
||||||
|
> **[on-screen 00:42:57]**
|
||||||
|
> Spanish Greetings & Farewells: De nada Of nothing No need to thank me
|
||||||
|
|
||||||
|
**[00:42:57]** to thank me but overall denada is mainly
|
||||||
|
|
||||||
|
> **[on-screen 00:42:59]**
|
||||||
|
> Spanish Greetings & Farewells: De nada You are welcome
|
||||||
|
|
||||||
|
**[00:42:59]** used as you're welcome if you're leaving
|
||||||
|
|
||||||
|
> **[on-screen 00:43:01]**
|
||||||
|
> Spanish Greetings & Farewells: Adios / Chau
|
||||||
|
|
||||||
|
**[00:43:01]** the conversation you might say something like adios or chiao both of which mean
|
||||||
|
|
||||||
|
> **[on-screen 00:43:06]**
|
||||||
|
> Spanish Greetings & Farewells: Adios / Chau Goodbye
|
||||||
|
|
||||||
|
**[00:43:06]** by or goodbye but the word adios can actually be broken down into two words a
|
||||||
|
|
||||||
|
> **[on-screen 00:43:11]**
|
||||||
|
> Spanish Greetings & Farewells: A/ Dids
|
||||||
|
|
||||||
|
**[00:43:11]** and dios which literally translates as
|
||||||
|
|
||||||
|
> **[on-screen 00:43:13]**
|
||||||
|
> Spanish Greetings & Farewells: A/ Dios To God
|
||||||
|
|
||||||
|
**[00:43:13]** to God when Spanish was first originating as a language the expression to God meant to have a good farewell as in to God you go but the meaning changed
|
||||||
|
|
||||||
|
> **[on-screen 00:43:19]**
|
||||||
|
> Spanish Greetings & Farewells: A/ Didés To God you go
|
||||||
|
|
||||||
|
**[00:43:20]** over time which resulted simply in goodbye there's also this phrase aista
|
||||||
|
|
||||||
|
> **[on-screen 00:43:23]**
|
||||||
|
> Spanish Greetings & Farewells: Hasta la vista
|
||||||
|
|
||||||
|
**[00:43:25]** which is constructed using a preposition
|
||||||
|
|
||||||
|
> **[on-screen 00:43:26]**
|
||||||
|
> Spanish Greetings & Farewells: Hasta la vista
|
||||||
|
|
||||||
|
**[00:43:27]** article and a noun and it literally translates as until the view or until
|
||||||
|
|
||||||
|
> **[on-screen 00:43:30]**
|
||||||
|
> Spanish Greetings & Farewells: Hasta la vista Until the view/next time
|
||||||
|
|
||||||
|
**[00:43:31]** the next time I see you or more of a sophisticated and modern meaning would be see you later see you later can also
|
||||||
|
|
||||||
|
> **[on-screen 00:43:35]**
|
||||||
|
> Spanish Greetings & Farewells: Hasta la vista See you later
|
||||||
|
|
||||||
|
**[00:43:36]** be said as aao which actually translates
|
||||||
|
|
||||||
|
> **[on-screen 00:43:37]**
|
||||||
|
> Spanish Greetings & Farewells: See you later Hasta luego
|
||||||
|
|
||||||
|
**[00:43:39]** as until later you might have also seen
|
||||||
|
|
||||||
|
> **[on-screen 00:43:40]**
|
||||||
|
> Spanish Greetings & Farewells: Hasta pronto
|
||||||
|
|
||||||
|
**[00:43:40]** this phrase ASA Pronto which translates
|
||||||
|
|
||||||
|
> **[on-screen 00:43:43]**
|
||||||
|
> Spanish Greetings & Farewells: Hasta pronto Until soon
|
||||||
|
|
||||||
|
**[00:43:43]** as until soon but its Advanced definition means see you soon and finally hola means hello or hi po means
|
||||||
|
|
||||||
|
> **[on-screen 00:43:47]**
|
||||||
|
> Spanish Greetings & Farewells: Hola - Hello/Hi Por Favor - Please Perdon - I'm sorry
|
||||||
|
|
||||||
|
**[00:43:49]** please and pdon is a polite way of saying I'm sorry the verb p in Spanish
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user