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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user