feat(vocab): add lesson and completed lesson vocab pool endpoints
All checks were successful
Deploy to production / deploy (push) Successful in 3m5s
All checks were successful
Deploy to production / deploy (push) Successful in 3m5s
- Implemented new endpoints in VocabController for retrieving vocab pools based on lessons and completed lessons. - Updated vocabRouter to include routes for accessing lesson vocab pools and completed lesson vocab pools. - Enhanced VocabService with methods to extract vocab from exercises and lesson didactics, improving vocabulary retrieval for users. - Modified VocabPracticeDialog and VocabCourseView components to support new vocab pool functionalities, enhancing user experience in vocabulary practice.
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -88,6 +88,13 @@
|
||||
>
|
||||
{{ getLessonProgress(lesson.id)?.completed ? $t('socialnetwork.vocab.courses.review') : $t('socialnetwork.vocab.courses.start') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="getLessonProgress(lesson.id)?.completed"
|
||||
@click="openLessonPractice(lesson)"
|
||||
class="btn-edit"
|
||||
>
|
||||
Im Trainer üben
|
||||
</button>
|
||||
<button v-if="isOwner" @click="editLesson(lesson.id)" class="btn-edit">{{ $t('socialnetwork.vocab.courses.edit') }}</button>
|
||||
<button v-if="isOwner" @click="deleteLesson(lesson.id)" class="btn-delete">{{ $t('general.delete') }}</button>
|
||||
</div>
|
||||
@@ -99,6 +106,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VocabPracticeDialog ref="practiceDialog" />
|
||||
|
||||
<!-- Add Lesson Dialog -->
|
||||
<div v-if="showAddLessonDialog" class="dialog-overlay" @click="showAddLessonDialog = false">
|
||||
<div class="dialog" @click.stop>
|
||||
@@ -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`);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user