diff --git a/backend/controllers/vocabController.js b/backend/controllers/vocabController.js index 38793e1..2b8f800 100644 --- a/backend/controllers/vocabController.js +++ b/backend/controllers/vocabController.js @@ -22,11 +22,15 @@ class VocabController { this.getChapter = this._wrapWithUser((userId, req) => this.service.getChapter(userId, req.params.chapterId)); this.listChapterVocabs = this._wrapWithUser((userId, req) => this.service.listChapterVocabs(userId, req.params.chapterId)); this.addVocabToChapter = this._wrapWithUser((userId, req) => this.service.addVocabToChapter(userId, req.params.chapterId, req.body), { successStatus: 201 }); + this.getLessonVocabPool = this._wrapWithUser((userId, req) => this.service.getLessonVocabPool(userId, req.params.lessonId)); // Courses this.createCourse = this._wrapWithUser((userId, req) => this.service.createCourse(userId, req.body), { successStatus: 201 }); this.getCourses = this._wrapWithUser((userId, req) => this.service.getCourses(userId, req.query)); this.getCourse = this._wrapWithUser((userId, req) => this.service.getCourse(userId, req.params.courseId)); + this.getCompletedLessonVocabPool = this._wrapWithUser((userId, req) => + this.service.getCompletedLessonVocabPool(userId, req.params.courseId, req.query.untilLessonId) + ); this.getVocabDistractorPool = this._wrapWithUser((userId, req) => this.service.getVocabDistractorPool(userId, req.params.courseId, req.query.beforeLessonId) ); @@ -80,4 +84,3 @@ class VocabController { } export default VocabController; - diff --git a/backend/routers/vocabRouter.js b/backend/routers/vocabRouter.js index 8760e25..ab5da22 100644 --- a/backend/routers/vocabRouter.js +++ b/backend/routers/vocabRouter.js @@ -22,12 +22,14 @@ router.get('/languages/:languageId/search', vocabController.searchVocabs); router.get('/chapters/:chapterId', vocabController.getChapter); router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs); router.post('/chapters/:chapterId/vocabs', vocabController.addVocabToChapter); +router.get('/lessons/:lessonId/vocab-pool', vocabController.getLessonVocabPool); // Courses router.post('/courses', vocabController.createCourse); router.get('/courses', vocabController.getCourses); router.get('/courses/my', vocabController.getMyCourses); router.post('/courses/find-by-code', vocabController.getCourseByShareCode); +router.get('/courses/:courseId/completed-lesson-vocabs', vocabController.getCompletedLessonVocabPool); router.get('/courses/:courseId/distractor-pool', vocabController.getVocabDistractorPool); router.get('/courses/:courseId', vocabController.getCourse); router.put('/courses/:courseId', vocabController.updateCourse); @@ -59,4 +61,3 @@ router.put('/grammar-exercises/:exerciseId', vocabController.updateGrammarExerci router.delete('/grammar-exercises/:exerciseId', vocabController.deleteGrammarExercise); export default router; - diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index 89d3b77..c4573e5 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -150,6 +150,110 @@ export default class VocabService { .replace(/\s+/g, ' '); } + _parseExercisePayload(value) { + if (!value) return {}; + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch { + return {}; + } + } + if (typeof value === 'object') { + return value; + } + return {}; + } + + _extractTrainerVocabsFromExercises(exercises = []) { + const vocabMap = new Map(); + + exercises.forEach((exercise) => { + try { + const qData = this._parseExercisePayload(exercise.questionData); + const aData = this._parseExercisePayload(exercise.answerData); + const exerciseType = exercise.exerciseType?.name || qData.type || ''; + + if (exerciseType === 'multiple_choice') { + const options = Array.isArray(qData.options) ? qData.options : []; + const correctAnswer = Array.isArray(aData.correctAnswer) + ? options[aData.correctAnswer[0]] + : options[aData.correctAnswer ?? aData.correct ?? 0]; + const question = String(qData.question || qData.text || ''); + + let match = question.match(/Wie sagt man ['"]([^'"]+)['"]/i); + if (match && match[1] && correctAnswer && match[1].trim() !== String(correctAnswer).trim()) { + vocabMap.set(`${match[1]}-${correctAnswer}`, { + learning: match[1], + reference: String(correctAnswer) + }); + return; + } + + match = question.match(/Was bedeutet ['"]([^'"]+)['"]/i); + if (match && match[1] && correctAnswer && match[1].trim() !== String(correctAnswer).trim()) { + vocabMap.set(`${correctAnswer}-${match[1]}`, { + learning: String(correctAnswer), + reference: match[1] + }); + } + return; + } + + if (exerciseType === 'gap_fill') { + const answers = Array.isArray(aData.answers) + ? aData.answers + : (aData.correct ? (Array.isArray(aData.correct) ? aData.correct : [aData.correct]) : []); + const text = String(qData.text || ''); + const nativeWords = Array.from(text.matchAll(/\(([^)]+)\)/g), (m) => String(m[1] || '').trim()); + + if (!answers.length || !nativeWords.length) { + return; + } + + answers.forEach((answer, index) => { + const nativeWord = nativeWords[index]; + const normalizedAnswer = String(answer || '').trim(); + if (!nativeWord || !normalizedAnswer || nativeWord === normalizedAnswer) { + return; + } + vocabMap.set(`${nativeWord}-${normalizedAnswer}`, { + learning: nativeWord, + reference: normalizedAnswer + }); + }); + } + } catch (error) { + console.warn('Fehler beim Extrahieren von Trainer-Vokabeln:', error); + } + }); + + return Array.from(vocabMap.values()); + } + + _extractTrainerVocabsFromLessonDidactics(lesson) { + const vocabMap = new Map(); + const speakingPrompts = Array.isArray(lesson?.speakingPrompts) ? lesson.speakingPrompts : []; + const practicalTasks = Array.isArray(lesson?.practicalTasks) ? lesson.practicalTasks : []; + const corePatterns = Array.isArray(lesson?.corePatterns) ? lesson.corePatterns : []; + + speakingPrompts.forEach((prompt, index) => { + const learning = String(prompt?.prompt || prompt?.title || '').trim(); + const reference = String(prompt?.cue || corePatterns[index] || corePatterns[0] || '').trim(); + if (!learning || !reference || learning === reference) return; + vocabMap.set(`${learning}-${reference}`, { learning, reference }); + }); + + practicalTasks.forEach((task, index) => { + const learning = String(task?.text || task?.title || '').trim(); + const reference = String(corePatterns[index] || corePatterns[0] || '').trim(); + if (!learning || !reference || learning === reference) return; + vocabMap.set(`${learning}-${reference}`, { learning, reference }); + }); + + return Array.from(vocabMap.values()); + } + _normalizeStringList(value) { if (!value) return []; if (Array.isArray(value)) { @@ -810,6 +914,175 @@ export default class VocabService { return { languageId: access.id, isOwner: access.isOwner, vocabs: rows }; } + async getLessonVocabPool(hashedUserId, lessonId) { + const user = await this._getUserByHashedId(hashedUserId); + const lesson = await VocabCourseLesson.findByPk(lessonId, { + include: [ + { + model: VocabCourse, + as: 'course' + }, + { + model: VocabGrammarExercise, + as: 'grammarExercises', + include: [ + { + model: VocabGrammarExerciseType, + as: 'exerciseType' + } + ], + required: false + } + ] + }); + + if (!lesson) { + const err = new Error('Lesson not found'); + err.status = 404; + throw err; + } + + if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) { + const err = new Error('Access denied'); + err.status = 403; + throw err; + } + + const progress = await VocabCourseProgress.findOne({ + where: { + userId: user.id, + lessonId: lesson.id + } + }); + + if (lesson.course.ownerUserId !== user.id && !progress?.completed) { + const err = new Error('Lesson must be completed first'); + err.status = 403; + throw err; + } + + const extractedFromExercises = this._extractTrainerVocabsFromExercises( + (lesson.grammarExercises || []).map((exercise) => exercise.get({ plain: true })) + ); + const vocabs = extractedFromExercises.length > 0 + ? extractedFromExercises + : this._extractTrainerVocabsFromLessonDidactics(lesson.get({ plain: true })); + + return { + lesson: { + id: lesson.id, + title: lesson.title, + courseId: lesson.courseId, + courseTitle: lesson.course.title + }, + vocabs + }; + } + + async getCompletedLessonVocabPool(hashedUserId, courseId, untilLessonId = null) { + const user = await this._getUserByHashedId(hashedUserId); + const course = await VocabCourse.findByPk(courseId); + + if (!course) { + const err = new Error('Course not found'); + err.status = 404; + throw err; + } + + if (course.ownerUserId !== user.id && !course.isPublic) { + const err = new Error('Access denied'); + err.status = 403; + throw err; + } + + let maxLessonNumber = null; + if (untilLessonId) { + const untilLesson = await VocabCourseLesson.findOne({ + where: { + id: untilLessonId, + courseId: course.id + }, + attributes: ['lessonNumber'] + }); + + if (!untilLesson) { + const err = new Error('Lesson not found'); + err.status = 404; + throw err; + } + + maxLessonNumber = untilLesson.lessonNumber; + } + + const completedProgress = await VocabCourseProgress.findAll({ + where: { + userId: user.id, + courseId: course.id, + completed: true + }, + attributes: ['lessonId'] + }); + + const completedLessonIds = completedProgress.map((entry) => entry.lessonId); + if (completedLessonIds.length === 0) { + return { courseId: course.id, vocabs: [] }; + } + + const lessonWhere = { + id: { + [Op.in]: completedLessonIds + }, + courseId: course.id + }; + + if (maxLessonNumber != null) { + lessonWhere.lessonNumber = { + [Op.lte]: maxLessonNumber + }; + } + + const lessons = await VocabCourseLesson.findAll({ + where: lessonWhere, + attributes: ['id', 'speakingPrompts', 'practicalTasks', 'corePatterns'], + order: [['lessonNumber', 'ASC']] + }); + + const lessonIds = lessons.map((lesson) => lesson.id); + if (!lessonIds.length) { + return { courseId: course.id, vocabs: [] }; + } + + const exercises = await VocabGrammarExercise.findAll({ + where: { + lessonId: { + [Op.in]: lessonIds + } + }, + include: [ + { + model: VocabGrammarExerciseType, + as: 'exerciseType' + } + ], + order: [['lessonId', 'ASC'], ['exerciseNumber', 'ASC']] + }); + + const extractedFromExercises = this._extractTrainerVocabsFromExercises(exercises.map((exercise) => exercise.get({ plain: true }))); + const fallbackVocabs = lessons.flatMap((lesson) => + this._extractTrainerVocabsFromLessonDidactics(lesson.get({ plain: true })) + ); + const mergedVocabs = new Map(); + [...extractedFromExercises, ...fallbackVocabs].forEach((entry) => { + if (!entry?.learning || !entry?.reference) return; + mergedVocabs.set(`${entry.learning}-${entry.reference}`, entry); + }); + + return { + courseId: course.id, + vocabs: Array.from(mergedVocabs.values()) + }; + } + async searchVocabs(hashedUserId, languageId, { q = '', learning = '', motherTongue = '' } = {}) { const user = await this._getUserByHashedId(hashedUserId); const access = await this._getLanguageAccess(user.id, languageId); diff --git a/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue b/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue index 18207c1..0ed4885 100644 --- a/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue +++ b/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue @@ -110,7 +110,7 @@ export default { components: { DialogWidget }, data() { return { - openParams: null, // { languageId, chapterId } + openParams: null, // { languageId, chapterId, lessonId, courseId } onClose: null, loading: false, allVocabs: false, @@ -168,12 +168,12 @@ export default { }, }, methods: { - open({ languageId, chapterId, onClose = null }) { + open({ languageId, chapterId, lessonId, courseId, onClose = null }) { if (this.autoAdvanceTimer) { clearTimeout(this.autoAdvanceTimer); this.autoAdvanceTimer = null; } - this.openParams = { languageId, chapterId }; + this.openParams = { languageId, chapterId, lessonId, courseId }; this.onClose = typeof onClose === 'function' ? onClose : null; this.allVocabs = false; this.simpleMode = false; @@ -231,7 +231,7 @@ export default { }, resetQuestion() { this.current = null; - this.direction = Math.random() < 0.5 ? 'L2R' : 'R2L'; + this.direction = this.openParams?.lessonId ? 'L2R' : (Math.random() < 0.5 ? 'L2R' : 'R2L'); this.acceptableAnswers = []; this.choiceOptions = []; this.typedAnswer = ''; @@ -272,7 +272,19 @@ export default { this.loading = true; try { let res; - if (this.allVocabs) { + if (this.openParams.lessonId) { + if (this.allVocabs && this.openParams.courseId) { + res = await apiClient.get(`/api/vocab/courses/${this.openParams.courseId}/completed-lesson-vocabs`, { + params: { + untilLessonId: this.openParams.lessonId + } + }); + this.pool = res.data?.vocabs || []; + } else { + res = await apiClient.get(`/api/vocab/lessons/${this.openParams.lessonId}/vocab-pool`); + this.pool = res.data?.vocabs || []; + } + } else if (this.allVocabs) { res = await apiClient.get(`/api/vocab/languages/${this.openParams.languageId}/vocabs`); this.pool = res.data?.vocabs || []; } else { @@ -530,5 +542,3 @@ export default { font-weight: bold; } - - diff --git a/frontend/src/views/social/VocabCourseView.vue b/frontend/src/views/social/VocabCourseView.vue index 9b7968b..67c7253 100644 --- a/frontend/src/views/social/VocabCourseView.vue +++ b/frontend/src/views/social/VocabCourseView.vue @@ -88,6 +88,13 @@ > {{ getLessonProgress(lesson.id)?.completed ? $t('socialnetwork.vocab.courses.review') : $t('socialnetwork.vocab.courses.start') }} + @@ -99,6 +106,8 @@ + +
@@ -138,9 +147,11 @@ import { mapGetters } from 'vuex'; import apiClient from '@/utils/axios.js'; import { confirmAction, showApiError, showInfo, showSuccess } from '@/utils/feedback.js'; +import VocabPracticeDialog from '@/dialogues/socialnetwork/VocabPracticeDialog.vue'; export default { name: 'VocabCourseView', + components: { VocabPracticeDialog }, props: { courseId: { type: String, @@ -325,6 +336,12 @@ export default { openLesson(lessonId) { this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}`); }, + openLessonPractice(lesson) { + this.$refs.practiceDialog?.open?.({ + courseId: this.courseId, + lessonId: lesson.id + }); + }, openLessonAssistant(lessonId) { this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}?assistant=1`); },