diff --git a/backend/scripts/bisaya-course-phase1.js b/backend/scripts/bisaya-course-phase1.js new file mode 100644 index 0000000..d15c38c --- /dev/null +++ b/backend/scripts/bisaya-course-phase1.js @@ -0,0 +1,24 @@ +export const BISAYA_PHASE1_DIDACTICS = { + 'Begrüßungen & Höflichkeit': { + corePatterns: [ + { target: 'Kumusta ka?', gloss: 'Wie geht es dir?' }, + { target: 'Maayong buntag.', gloss: 'Guten Morgen.' }, + { target: 'Maayong adlaw.', gloss: 'Guten Tag.' }, + { target: 'Maayong gabii.', gloss: 'Guten Abend.' }, + { target: 'Maayong gabii, matulog na ta.', gloss: 'Guten Abend, wir legen uns schlafen.' }, + { target: 'Katulog og maayo.', gloss: 'Schlaf gut.' }, + { target: 'Kapoy na ka?', gloss: 'Bist du müde?' }, + { target: 'Matulog na ta.', gloss: 'Lass uns schlafen gehen.' }, + { target: 'Inom sa og tubig.', gloss: 'Trink Wasser.' }, + { target: 'Patya ang suga.', gloss: 'Mach das Licht aus.' }, + { target: 'Tabuni ang imong kaugalingon.', gloss: 'Deck dich zu.' }, + { target: 'Ugma nasad.', gloss: 'Bis morgen wieder.' }, + { target: 'Damgo og nindot.', gloss: 'Träum schön.' }, + { target: 'Amping.', gloss: 'Pass auf dich auf.' }, + { target: 'Babay.', gloss: 'Tschüss.' }, + { target: 'Maayo ko.', gloss: 'Mir geht es gut.' }, + { target: 'Salamat.', gloss: 'Danke.' }, + { target: 'Palihug.', gloss: 'Bitte.' } + ] + } +}; diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index 36f5c24..d0db972 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -12,6 +12,7 @@ import UserParam from '../models/community/user_param.js'; import { sequelize } from '../utils/sequelize.js'; import { notifyUser } from '../utils/socket.js'; import { Op } from 'sequelize'; +import { BISAYA_PHASE1_DIDACTICS } from '../scripts/bisaya-course-phase1.js'; export default class VocabService { async _getUserByHashedId(hashedUserId) { @@ -347,6 +348,27 @@ export default class VocabService { }); } + _mergeCorePatternGlosses(primaryPatterns = [], fallbackPatterns = []) { + const fallbackByTarget = new Map( + fallbackPatterns + .map((entry) => this._normalizeCorePatternEntry(entry)) + .filter(Boolean) + .map((entry) => [this._normalizeLexeme(entry.target), entry.gloss || '']) + ); + + return primaryPatterns.map((entry) => { + const normalized = this._normalizeCorePatternEntry(entry); + if (!normalized) { + return null; + } + if (normalized.gloss) { + return normalized; + } + const gloss = fallbackByTarget.get(this._normalizeLexeme(normalized.target)) || ''; + return gloss ? { ...normalized, gloss } : normalized; + }).filter(Boolean); + } + _normalizeStructuredList(value, keys = ['title', 'text']) { if (!value) return []; if (Array.isArray(value)) { @@ -560,9 +582,13 @@ export default class VocabService { const learningGoals = this._normalizeStringList(plainLesson.learningGoals); const extractedTrainerVocabs = this._extractTrainerVocabsFromExercises(grammarExercises); - const corePatterns = this._enrichCorePatternsWithGloss( - this._normalizeCorePatternList(plainLesson.corePatterns), - extractedTrainerVocabs + const phase1FallbackCorePatterns = BISAYA_PHASE1_DIDACTICS[plainLesson.title]?.corePatterns || []; + const corePatterns = this._mergeCorePatternGlosses( + this._enrichCorePatternsWithGloss( + this._normalizeCorePatternList(plainLesson.corePatterns), + extractedTrainerVocabs + ), + phase1FallbackCorePatterns ); const grammarFocus = this._normalizeStructuredList(plainLesson.grammarFocus, ['title', 'text', 'example']); const explicitSpeakingPrompts = this._normalizeStructuredList(plainLesson.speakingPrompts, ['title', 'prompt', 'cue']); @@ -578,9 +604,12 @@ export default class VocabService { ], corePatterns: corePatterns.length > 0 ? corePatterns - : this._enrichCorePatternsWithGloss( - uniquePatterns.slice(0, 5).map((s) => ({ target: String(s || '').trim(), gloss: '' })).filter((p) => p.target), - extractedTrainerVocabs + : this._mergeCorePatternGlosses( + this._enrichCorePatternsWithGloss( + uniquePatterns.slice(0, 5).map((s) => ({ target: String(s || '').trim(), gloss: '' })).filter((p) => p.target), + extractedTrainerVocabs + ), + phase1FallbackCorePatterns ), grammarFocus: grammarFocus.length > 0 ? grammarFocus : uniqueGrammarExplanations.slice(0, 4), speakingPrompts: explicitSpeakingPrompts.length > 0 ? explicitSpeakingPrompts : speakingPrompts.slice(0, 4), diff --git a/frontend/src/i18n/locales/de/socialnetwork.json b/frontend/src/i18n/locales/de/socialnetwork.json index 4faeaf6..bae3467 100644 --- a/frontend/src/i18n/locales/de/socialnetwork.json +++ b/frontend/src/i18n/locales/de/socialnetwork.json @@ -456,7 +456,9 @@ "vocabPrepStep2": "Gehe die gleichen Begriffe noch einmal durch (aktive Wiederholung, ohne zu üben).", "vocabPrepConfirm2": "Zweite Durchsicht erledigt", "vocabPrepReady": "Du kannst jetzt mit dem Vokabeltrainer starten.", + "vocabOverviewToggle": "Gesamtübersicht der Begriffe anzeigen", "vocabTrainerLockedHint": "Bitte bestätige zuerst zwei Lern-Durchgänge bei „Vorbereitung vor dem Vokabeltrainer“.", + "exerciseUnlockHintAfterPrep": "Arbeite zuerst die vorbereiteten Begriffe durch. Danach wird die Kapitel-Prüfung freigeschaltet.", "speakingTasks": "Sprechaufträge", "speakingPrompt": "Sprechauftrag", "practicalTasks": "Praxisaufgaben", diff --git a/frontend/src/i18n/locales/en/socialnetwork.json b/frontend/src/i18n/locales/en/socialnetwork.json index ee7559d..81eb85c 100644 --- a/frontend/src/i18n/locales/en/socialnetwork.json +++ b/frontend/src/i18n/locales/en/socialnetwork.json @@ -456,7 +456,9 @@ "vocabPrepStep2": "Go through the same items again (active review, not testing yet).", "vocabPrepConfirm2": "Second pass done", "vocabPrepReady": "You can start the vocabulary trainer now.", + "vocabOverviewToggle": "Show full overview of terms", "vocabTrainerLockedHint": "Please confirm two preparation steps under “Preparation before the vocabulary trainer” first.", + "exerciseUnlockHintAfterPrep": "Work through the prepared terms first. The chapter test will unlock afterwards.", "speakingTasks": "Speaking Tasks", "speakingPrompt": "Speaking Prompt", "practicalTasks": "Practical Tasks", diff --git a/frontend/src/i18n/locales/es/socialnetwork.json b/frontend/src/i18n/locales/es/socialnetwork.json index 065fa1b..9c52d20 100644 --- a/frontend/src/i18n/locales/es/socialnetwork.json +++ b/frontend/src/i18n/locales/es/socialnetwork.json @@ -454,7 +454,9 @@ "vocabPrepStep2": "Repasa los mismos elementos otra vez (repaso activo, aún sin practicar).", "vocabPrepConfirm2": "Segunda lectura hecha", "vocabPrepReady": "Ya puedes iniciar el entrenador de vocabulario.", + "vocabOverviewToggle": "Mostrar vista general completa de los términos", "vocabTrainerLockedHint": "Confirma primero los dos pasos de preparación arriba.", + "exerciseUnlockHintAfterPrep": "Primero recorre los términos preparados. Después se desbloqueará la prueba del capítulo.", "speakingTasks": "Tareas orales", "speakingPrompt": "Tarea oral", "practicalTasks": "Tareas prácticas", diff --git a/frontend/src/views/social/VocabLessonView.vue b/frontend/src/views/social/VocabLessonView.vue index 51c710f..c85ceae 100644 --- a/frontend/src/views/social/VocabLessonView.vue +++ b/frontend/src/views/social/VocabLessonView.vue @@ -247,24 +247,27 @@

{{ $t('socialnetwork.vocab.courses.vocabPrepReady') }}

- -
+
-

{{ $t('socialnetwork.vocab.courses.importantVocab') }}

+ + {{ $t('socialnetwork.vocab.courses.vocabOverviewToggle') }} +

{{ $t('socialnetwork.vocab.courses.vocabInfoText') }}

-
- {{ vocab.learning }} +
+ {{ vocab.learning || '—' }} {{ vocab.reference }}
-
+
-
+

{{ $t('socialnetwork.vocab.courses.vocabTrainer') }}

Wiederholung läuft schrittweise mit @@ -367,7 +370,7 @@
-
+

{{ $t('socialnetwork.vocab.courses.noVocabInfo') }}

@@ -830,7 +833,7 @@ export default { return 1; }, trainerNewFocusTarget() { - const vocabCount = this.importantVocab?.length || 0; + const vocabCount = this.trainableLessonVocab?.length || 0; const exerciseCount = this.effectiveExercises?.length || 0; const durationBonus = Math.max(0, Math.round((this.lesson?.targetMinutes || 0) / 5) - 1); const baseTarget = Math.ceil((Math.max(vocabCount, 4) * 1.35) + (exerciseCount * 0.35) + durationBonus); @@ -858,9 +861,16 @@ export default { canAccessExercises() { if (!this.hasExercises) return false; const isReview = this.lesson?.lessonType === 'review' || this.lesson?.lessonType === 'vocab_review'; - return isReview || this.exercisePreparationCompleted; + if (isReview) return true; + if (this.trainableLessonVocab.length === 0 && this.prepItems.length > 0) { + return this.lessonPrepStage >= 2; + } + return this.exercisePreparationCompleted; }, exerciseUnlockHint() { + if (this.trainableLessonVocab.length === 0 && this.prepItems.length > 0) { + return this.$t('socialnetwork.vocab.courses.exerciseUnlockHintAfterPrep'); + } if (this.hasPreviousVocab) { return `Lerne zuerst die neuen Inhalte der Lektion und arbeite dich durch ungefähr ${this.trainerExerciseUnlockAttempts} Trainerfragen. Ältere Vokabeln werden dabei nach und nach zugemischt.`; } @@ -931,6 +941,39 @@ export default { return []; } }, + lessonVocab() { + const vocabByReference = new Map(); + const addEntry = (entry) => { + const reference = String(entry?.reference || '').trim(); + const learning = String(entry?.learning || '').trim(); + if (!reference) return; + const key = reference.toLowerCase(); + if (!vocabByReference.has(key)) { + vocabByReference.set(key, { learning, reference }); + return; + } + const existing = vocabByReference.get(key); + if (!existing.learning && learning) { + existing.learning = learning; + } + }; + + this.normalizedCorePatterns.forEach((item) => { + addEntry({ + learning: item.gloss || '', + reference: item.target || '' + }); + }); + + this.importantVocab.forEach((item) => { + addEntry(item); + }); + + return Array.from(vocabByReference.values()); + }, + trainableLessonVocab() { + return this.lessonVocab.filter((entry) => entry.learning && entry.reference && entry.learning !== entry.reference); + }, lessonDidactics() { return this.lesson?.didactics || { learningGoals: [], @@ -947,21 +990,7 @@ export default { .filter(Boolean); }, prepItems() { - if (this.normalizedCorePatterns.length > 0) { - const glossByReference = new Map( - (this.importantVocab || []) - .map((item) => [String(item?.reference || '').trim().toLowerCase(), String(item?.learning || '').trim()]) - .filter(([reference, learning]) => reference && learning) - ); - return this.normalizedCorePatterns.map((item) => { - if (item.gloss) { - return item; - } - const gloss = glossByReference.get(String(item.target || '').trim().toLowerCase()) || ''; - return gloss ? { ...item, gloss } : item; - }); - } - return (this.importantVocab || []) + return this.lessonVocab .map((item) => ({ target: String(item?.reference || '').trim(), gloss: String(item?.learning || '').trim() @@ -981,7 +1010,7 @@ export default { return this.normalizedCorePatterns.some((p) => p.gloss); }, canStartVocabTrainerPrep() { - if (!this.importantVocab || this.importantVocab.length === 0) { + if (!this.trainableLessonVocab || this.trainableLessonVocab.length === 0) { return false; } if (this.prepItems.length > 0 && this.lessonPrepStage < 2) { @@ -1794,17 +1823,17 @@ export default { // Vokabeltrainer-Methoden startVocabTrainer() { debugLog('[VocabLessonView] startVocabTrainer aufgerufen'); - if (!this.importantVocab || this.importantVocab.length === 0) { + if (!this.trainableLessonVocab || this.trainableLessonVocab.length === 0) { debugLog('[VocabLessonView] Keine Vokabeln vorhanden'); return; } if (!this.canStartVocabTrainerPrep) { return; } - debugLog('[VocabLessonView] Vokabeln gefunden:', this.importantVocab.length); + debugLog('[VocabLessonView] Vokabeln gefunden:', this.trainableLessonVocab.length); debugLog('[VocabLessonView] Alte Vokabeln:', this.previousVocab?.length || 0); this.vocabTrainerActive = true; - this.vocabTrainerPool = [...this.importantVocab]; + this.vocabTrainerPool = [...this.trainableLessonVocab]; this.vocabTrainerMode = 'multiple_choice'; this.vocabTrainerAutoSwitchedToTyping = false; this.vocabTrainerCorrect = 0; @@ -1817,7 +1846,7 @@ export default { this.vocabTrainerMixedPool = this._buildMixedPool(); this.vocabTrainerPhase = 'current'; this.vocabTrainerMixedAttempts = 0; - this.vocabTrainerPool = [...this.importantVocab]; + this.vocabTrainerPool = [...this.trainableLessonVocab]; debugLog('[VocabLessonView] Mixed-Pool:', this.vocabTrainerMixedPool.length, 'Vokabeln'); debugLog('[VocabLessonView] Rufe nextVocabQuestion auf'); this.$nextTick(() => { @@ -1841,7 +1870,7 @@ export default { /** Erstellt den Mixed-Pool aus vorherigen Lektions-Vokabeln (ohne Duplikate der aktuellen Lektion) */ _buildMixedPool() { if (!this.previousVocab || this.previousVocab.length === 0) return []; - const currentKeys = new Set(this.importantVocab.map(v => this.getVocabKey(v))); + const currentKeys = new Set(this.trainableLessonVocab.map(v => this.getVocabKey(v))); const filtered = this.previousVocab.filter(v => !currentKeys.has(this.getVocabKey(v))); // Zufällig mischen und auf 40 begrenzen const shuffled = [...filtered].sort(() => Math.random() - 0.5); @@ -1868,7 +1897,7 @@ export default { debugLog('[VocabLessonView] Mixed-Phase abgeschlossen, wechsle zu Typing'); this.vocabTrainerMode = 'typing'; this.vocabTrainerAutoSwitchedToTyping = true; - this.vocabTrainerPool = [...this.importantVocab, ...this.vocabTrainerMixedPool]; + this.vocabTrainerPool = [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool]; this.vocabTrainerCorrect = 0; this.vocabTrainerWrong = 0; this.vocabTrainerTotalAttempts = 0; @@ -1880,8 +1909,8 @@ export default { this.vocabTrainerMode = 'multiple_choice'; this.vocabTrainerAutoSwitchedToTyping = false; this.vocabTrainerPool = this.vocabTrainerPhase === 'mixed' - ? [...this.importantVocab, ...this.vocabTrainerMixedPool] - : [...this.importantVocab]; + ? [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool] + : [...this.trainableLessonVocab]; // Reset Stats für Multiple Choice Modus this.vocabTrainerCorrect = 0; this.vocabTrainerWrong = 0; @@ -1988,7 +2017,7 @@ export default { this.checkVocabModeSwitch(); let questionSource = 'current'; - let sourcePool = this.importantVocab; + let sourcePool = this.trainableLessonVocab; if (this.vocabTrainerMode === 'typing') { sourcePool = this.vocabTrainerPool; @@ -2001,14 +2030,14 @@ export default { } if (!sourcePool || sourcePool.length === 0) { - sourcePool = this.importantVocab; + sourcePool = this.trainableLessonVocab; questionSource = 'current'; } const randomIndex = Math.floor(Math.random() * sourcePool.length); const vocab = sourcePool[randomIndex]; this.vocabTrainerDirection = Math.random() < 0.5 ? 'L2R' : 'R2L'; - const allTrainerVocabs = [...this.importantVocab, ...this.vocabTrainerMixedPool]; + const allTrainerVocabs = [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool]; const prompt = this.vocabTrainerDirection === 'L2R' ? vocab.learning : vocab.reference; const acceptableAnswers = this.getEquivalentVocabAnswers( prompt, @@ -2795,6 +2824,17 @@ export default { margin: 20px 0; } +.vocab-list__summary { + cursor: pointer; + font-weight: 600; + color: #5f4313; + margin-bottom: 12px; +} + +.vocab-list[open] .vocab-list__summary { + margin-bottom: 14px; +} + .vocab-list h4 { margin-bottom: 15px; color: #333;