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 @@
+