diff --git a/src/editors/containers/ProblemEditor/data/OLXParser.js b/src/editors/containers/ProblemEditor/data/OLXParser.js index a33c41ea8..2aac68400 100644 --- a/src/editors/containers/ProblemEditor/data/OLXParser.js +++ b/src/editors/containers/ProblemEditor/data/OLXParser.js @@ -3,7 +3,7 @@ import { XMLParser, XMLBuilder } from 'fast-xml-parser'; import _ from 'lodash-es'; -import { ProblemTypeKeys } from '../../../data/constants/problem'; +import { ProblemTypeKeys, RichTextProblems } from '../../../data/constants/problem'; export const indexToLetterMap = [...Array(26)].map((val, i) => String.fromCharCode(i + 65)); @@ -64,7 +64,8 @@ export class OLXParser { constructor(olxString) { this.problem = {}; this.questionData = {}; - const questionOptions = { + this.richTextProblem = {}; + const richTextOptions = { ignoreAttributes: false, alwaysCreateTextNode: true, numberParseOptions: { @@ -91,20 +92,74 @@ export class OLXParser { }, processEntities: false, }; - // There are two versions of the parsed XLM because the question requires the order of the - // parsed data to be preserved. However, all the other widgets need the data grouped by + const richTextBuilderOptions = { + ignoreAttributes: false, + numberParseOptions: { + leadingZeros: false, + hex: false, + }, + preserveOrder: true, + processEntities: false, + }; + // There are two versions of the parsed XLM because the fields using tinymce require the order + // of the parsed data to be preserved. However, all the other widgets need the data grouped by // the wrapping tag. - const questionParser = new XMLParser(questionOptions); + const richTextParser = new XMLParser(richTextOptions); const parser = new XMLParser(parserOptions); this.builder = new XMLBuilder(builderOptions); + this.richTextBuilder = new XMLBuilder(richTextBuilderOptions); this.parsedOLX = parser.parse(olxString); - this.parsedQuestionOLX = questionParser.parse(olxString); + this.richTextOLX = richTextParser.parse(olxString); if (_.has(this.parsedOLX, 'problem')) { this.problem = this.parsedOLX.problem; - this.questionData = this.parsedQuestionOLX[0].problem; + this.questionData = this.richTextOLX[0].problem; + this.richTextProblem = this.richTextOLX[0].problem; } } + /** getPreservedAnswersAndFeedback(problemType, widgetName, option) + * getPreservedAnswersAndFeedback takes a problemType, widgetName, and a valid option. The + * olx for the given problem type and widget is parsed. Do to the structure of xml that is + * parsed with the prsereved attribute, the function has to loop through arrays of objects. + * The first for-loop checks for feedback tags and answer choices and appended to the + * preservedAnswers. The nested for loop checks for feedback and answer values inside the + * option (answer) tags. + * @param {string} problemType - string of the olx problem type + * @param {string} widgetName - string of the wrapping tag name + * (optioninput, choicegroup, checkboxgroup, addition_answer) + * @param {string} option - string of the type of answers (choice, option, correcthint, stringequalhint) + * @return {array} array containing answer objects and possibly an array of grouped feedback + */ + getPreservedAnswersAndFeedback(problemType, widgetName, option) { + const [problemBody] = this.richTextProblem.filter(section => Object.keys(section).includes(problemType)); + const isChoiceProblem = !([ProblemTypeKeys.NUMERIC, ProblemTypeKeys.TEXTINPUT].includes(problemType)); + const preservedAnswers = []; + let correctAnswerFeedbackTag = option; + let incorrectAnswerFeedbackTag; + if (problemType === ProblemTypeKeys.TEXTINPUT) { + [correctAnswerFeedbackTag, incorrectAnswerFeedbackTag] = option; + } + const problemBodyArr = problemBody[problemType]; + problemBodyArr.forEach(subtag => { + const tagNames = Object.keys(subtag); + if (!isChoiceProblem && tagNames.includes(correctAnswerFeedbackTag)) { + preservedAnswers.unshift(subtag[correctAnswerFeedbackTag]); + } + if (problemType === ProblemTypeKeys.TEXTINPUT && tagNames.includes(incorrectAnswerFeedbackTag)) { + preservedAnswers.push(subtag); + } + if (tagNames.includes(widgetName)) { + const currentAnswerArr = subtag[widgetName]; + currentAnswerArr.forEach(answer => { + if (Object.keys(answer).includes(correctAnswerFeedbackTag)) { + preservedAnswers.push(answer[correctAnswerFeedbackTag]); + } + }); + } + }); + return preservedAnswers; + } + /** parseMultipleChoiceAnswers(problemType, widgetName, option) * parseMultipleChoiceAnswers takes a problemType, widgetName, and a valid option. The * olx for the given problem type and widget is parsed. Depending on the problem @@ -119,6 +174,11 @@ export class OLXParser { * @return {object} object containing an array of answer objects and possibly an array of grouped feedback */ parseMultipleChoiceAnswers(problemType, widgetName, option) { + const preservedAnswers = this.getPreservedAnswersAndFeedback( + problemType, + widgetName, + option, + ); const answers = []; let data = {}; const widget = _.get(this.problem, `${problemType}.${widgetName}`); @@ -127,7 +187,7 @@ export class OLXParser { throw new Error('Misc Tags, reverting to Advanced Editor'); } const choice = _.get(widget, option); - const isComplexAnswer = [ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT].includes(problemType); + const isComplexAnswer = RichTextProblems.includes(problemType); if (_.isEmpty(choice)) { answers.push( { @@ -138,14 +198,14 @@ export class OLXParser { ); } else if (_.isArray(choice)) { choice.forEach((element, index) => { + const [preservedAnswer, ...preservedFeedback] = preservedAnswers[index]; let title = element['#text']; - if (isComplexAnswer) { - const answerTitle = stripNonTextTags({ input: element, tag: `${option}hint` }); - title = this.builder.build(answerTitle); + if (isComplexAnswer && preservedAnswer) { + title = this.richTextBuilder.build([preservedAnswer]); } const correct = eval(element['@_correct'].toLowerCase()); const id = indexToLetterMap[index]; - const feedback = this.getAnswerFeedback(element, `${option}hint`); + const feedback = this.getAnswerFeedback(preservedFeedback, `${option}hint`); answers.push( { id, @@ -156,12 +216,12 @@ export class OLXParser { ); }); } else { + const [preservedAnswer, ...preservedFeedback] = preservedAnswers[0]; let title = choice['#text']; - if (isComplexAnswer) { - const answerTitle = stripNonTextTags({ input: choice, tag: `${option}hint` }); - title = this.builder.build(answerTitle); + if (isComplexAnswer && preservedAnswer) { + title = this.richTextBuilder.build([preservedAnswer]); } - const feedback = this.getAnswerFeedback(choice, `${option}hint`); + const feedback = this.getAnswerFeedback(preservedFeedback, `${option}hint`); answers.push({ correct: eval(choice['@_correct'].toLowerCase()), id: indexToLetterMap[answers.length], @@ -180,38 +240,28 @@ export class OLXParser { return data; } - /** getAnswerFeedback(choice, hintKey) - * getAnswerFeedback takes a choice and a valid option. The choice object is checked for - * selected and unselected feedback. The respective values are added to the feedback object. - * The feedback object is returned. + /** getAnswerFeedback(preservedFeedback, hintKey) + * getAnswerFeedback takes preservedFeedback and a valid option. The preservedFeedback object + * is checked for selected and unselected feedback. The respective values are added to the + * feedback object. The feedback object is returned. * @param {object} choice - object of an answer choice * @param {string} hintKey - string of the wrapping tag name (optionhint or choicehint) * @return {object} object containing selected and unselected feedback */ - getAnswerFeedback(choice, hintKey) { - let feedback = {}; + getAnswerFeedback(preservedFeedback, hintKey) { + const feedback = {}; let feedbackKeys = 'selectedFeedback'; - if (_.has(choice, hintKey)) { - const answerFeedback = choice[hintKey]; - if (_.isArray(answerFeedback)) { - answerFeedback.forEach((element) => { - if (_.has(element, '@_selected')) { - feedbackKeys = eval(element['@_selected'].toLowerCase()) ? 'selectedFeedback' : 'unselectedFeedback'; - } - feedback = { - ...feedback, - [feedbackKeys]: this.builder.build(element), - }; - }); - } else { - if (_.has(answerFeedback, '@_selected')) { - feedbackKeys = eval(answerFeedback['@_selected'].toLowerCase()) ? 'selectedFeedback' : 'unselectedFeedback'; + if (_.isEmpty(preservedFeedback)) { return feedback; } + + preservedFeedback.forEach((feedbackArr) => { + if (_.has(feedbackArr, hintKey)) { + if (_.has(feedbackArr, ':@') && _.has(feedbackArr[':@'], '@_selected')) { + const isSelectedFeedback = feedbackArr[':@']['@_selected'] === 'true'; + feedbackKeys = isSelectedFeedback ? 'selectedFeedback' : 'unselectedFeedback'; } - feedback = { - [feedbackKeys]: this.builder.build(answerFeedback), - }; + feedback[feedbackKeys] = this.richTextBuilder.build(feedbackArr[hintKey]); } - } + }); return feedback; } @@ -256,24 +306,32 @@ export class OLXParser { * @return {object} object containing an array of answer objects and object of additionalStringAttributes */ parseStringResponse() { + const [firstCorrectFeedback, ...preservedFeedback] = this.getPreservedAnswersAndFeedback( + ProblemTypeKeys.TEXTINPUT, + 'additional_answer', + ['correcthint', 'stringequalhint'], + ); const { stringresponse } = this.problem; const answers = []; let answerFeedback = ''; let additionalStringAttributes = {}; let data = {}; - const feedback = this.getFeedback(stringresponse); + const firstFeedback = this.getFeedback(firstCorrectFeedback); answers.push({ id: indexToLetterMap[answers.length], title: stringresponse['@_answer'], correct: true, - selectedFeedback: feedback, + selectedFeedback: firstFeedback, }); + const additionalAnswerFeedback = preservedFeedback.filter(feedback => _.isArray(feedback)); + const stringEqualHintFeedback = preservedFeedback.filter(feedback => !_.isArray(feedback)); + // Parsing additional_answer for string response. const additionalAnswer = _.get(stringresponse, 'additional_answer', []); if (_.isArray(additionalAnswer)) { - additionalAnswer.forEach((newAnswer) => { - answerFeedback = this.getFeedback(newAnswer); + additionalAnswer.forEach((newAnswer, indx) => { + answerFeedback = this.getFeedback(additionalAnswerFeedback[indx]); answers.push({ id: indexToLetterMap[answers.length], title: newAnswer['@_answer'], @@ -282,7 +340,7 @@ export class OLXParser { }); }); } else { - answerFeedback = this.getFeedback(additionalAnswer); + answerFeedback = this.getFeedback(additionalAnswerFeedback[0]); answers.push({ id: indexToLetterMap[answers.length], title: additionalAnswer['@_answer'], @@ -294,9 +352,8 @@ export class OLXParser { // Parsing stringequalhint for string response. const stringEqualHint = _.get(stringresponse, 'stringequalhint', []); if (_.isArray(stringEqualHint)) { - stringEqualHint.forEach((newAnswer) => { - const parsedFeedback = stripNonTextTags({ input: newAnswer, tag: '@_answer' }); - answerFeedback = this.builder.build(parsedFeedback); + stringEqualHint.forEach((newAnswer, indx) => { + answerFeedback = this.richTextBuilder.build(stringEqualHintFeedback[indx].stringequalhint); answers.push({ id: indexToLetterMap[answers.length], title: newAnswer['@_answer'], @@ -305,8 +362,7 @@ export class OLXParser { }); }); } else { - const parsedFeedback = stripNonTextTags({ input: stringEqualHint, tag: '@_answer' }); - answerFeedback = this.builder.build(parsedFeedback); + answerFeedback = this.richTextBuilder.build(stringEqualHintFeedback[0].stringequalhint); answers.push({ id: indexToLetterMap[answers.length], title: stringEqualHint['@_answer'], @@ -340,11 +396,16 @@ export class OLXParser { * @return {object} object containing an array of answer objects */ parseNumericResponse() { + const [firstCorrectFeedback, ...preservedFeedback] = this.getPreservedAnswersAndFeedback( + ProblemTypeKeys.NUMERIC, + 'additional_answer', + 'correcthint', + ); const { numericalresponse } = this.problem; let answerFeedback = ''; const answers = []; let responseParam = {}; - const feedback = this.getFeedback(numericalresponse); + const feedback = this.getFeedback(firstCorrectFeedback); if (_.has(numericalresponse, 'responseparam')) { const type = _.get(numericalresponse, 'responseparam.@_type'); const defaultValue = _.get(numericalresponse, 'responseparam.@_default'); @@ -365,8 +426,8 @@ export class OLXParser { // Parsing additional_answer for numerical response. const additionalAnswer = _.get(numericalresponse, 'additional_answer', []); if (_.isArray(additionalAnswer)) { - additionalAnswer.forEach((newAnswer) => { - answerFeedback = this.getFeedback(newAnswer); + additionalAnswer.forEach((newAnswer, indx) => { + answerFeedback = this.getFeedback(preservedFeedback[indx]); answers.push({ id: indexToLetterMap[answers.length], title: newAnswer['@_answer'], @@ -375,7 +436,7 @@ export class OLXParser { }); }); } else { - answerFeedback = this.getFeedback(additionalAnswer); + answerFeedback = this.getFeedback(preservedFeedback[0]); answers.push({ id: indexToLetterMap[answers.length], title: additionalAnswer['@_answer'], @@ -397,16 +458,6 @@ export class OLXParser { * @return {string} string of OLX */ parseQuestions(problemType) { - const options = { - ignoreAttributes: false, - numberParseOptions: { - leadingZeros: false, - hex: false, - }, - preserveOrder: true, - processEntities: false, - }; - const builder = new XMLBuilder(options); const problemArray = _.get(this.questionData[0], problemType) || this.questionData; const questionArray = []; @@ -429,7 +480,7 @@ export class OLXParser { }); } }); - const questionString = builder.build(questionArray); + const questionString = this.richTextBuilder.build(questionArray); return questionString.replace(//gm, '').replace(/<\/description>/gm, ''); } @@ -442,28 +493,26 @@ export class OLXParser { getHints() { const hintsObject = []; if (_.has(this.problem, 'demandhint.hint')) { - const hint = _.get(this.problem, 'demandhint.hint'); - if (_.isArray(hint)) { - hint.forEach(element => { - const hintValue = this.builder.build(element); - hintsObject.push({ - id: hintsObject.length, - value: hintValue, + const preservedProblem = this.richTextProblem; + preservedProblem.forEach(obj => { + const objKeys = Object.keys(obj); + if (objKeys.includes('demandhint')) { + const currentDemandHint = obj.demandhint; + currentDemandHint.forEach(hint => { + const hintValue = this.richTextBuilder.build(hint.hint); + hintsObject.push({ + id: hintsObject.length, + value: hintValue, + }); }); - }); - } else { - const hintValue = this.builder.build(hint); - hintsObject.push({ - id: hintsObject.length, - value: hintValue, - }); - } + } + }); } return hintsObject; } - /** parseQuestions(problemType) - * parseQuestions takes a problemType. The problem type is used to determine where the + /** getSolutionExplanation(problemType) + * getSolutionExplanation takes a problemType. The problem type is used to determine where the * text for the solution lies (sibling or child to warpping problem type tags). * Using the XMLBuilder, the solution is built removing the redundant "explanation" that is * appended for Studio styling purposes. The string version of the OLX is return. @@ -472,31 +521,22 @@ export class OLXParser { */ getSolutionExplanation(problemType) { if (!_.has(this.problem, `${problemType}.solution`) && !_.has(this.problem, 'solution')) { return null; } - let solution = _.get(this.problem, `${problemType}.solution`, null) || _.get(this.problem, 'solution', null); - const wrapper = Object.keys(solution)[0]; - if (Object.keys(solution).length === 1 && wrapper === 'div') { - const parsedSolution = {}; - Object.entries(solution.div).forEach(([key, value]) => { - if (key.indexOf('@_' === -1)) { - // The redundant "explanation" title should be removed. - // If the key is a paragraph or h2, and the text of either the first or only item is "Explanation." - if ( - (key === 'p' || key === 'h2') - && (_.get(value, '#text', null) === 'Explanation' - || (_.isArray(value) && _.get(value[0], '#text', null) === 'Explanation')) - ) { - if (_.isArray(value)) { - value.shift(); - parsedSolution[key] = value; - } - } else { - parsedSolution[key] = value; - } + const [problemBody] = this.richTextProblem.filter(section => Object.keys(section).includes(problemType)); + let { solution } = problemBody[problemType].pop(); + const { div } = solution[0]; + if (solution.length === 1 && div) { + div.forEach((block) => { + const [key] = Object.keys(block); + const [value] = block[key]; + if ((key === 'p' || key === 'h2') + && (_.get(value, '#text', null) === 'Explanation') + ) { + div.shift(); } }); - solution = parsedSolution; + solution = div; } - const solutionString = this.builder.build(solution); + const solutionString = this.richTextBuilder.build(solution); return solutionString; } @@ -508,9 +548,8 @@ export class OLXParser { * @return {string} string of feedback */ getFeedback(xmlElement) { - if (!_.has(xmlElement, 'correcthint')) { return ''; } - const feedback = _.get(xmlElement, 'correcthint'); - const feedbackString = this.builder.build(feedback); + if (_.isEmpty(xmlElement)) { return ''; } + const feedbackString = this.richTextBuilder.build(xmlElement); return feedbackString; } diff --git a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js index 24947f877..d6bfba43c 100644 --- a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js +++ b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js @@ -5,15 +5,7 @@ import { ToleranceTypes } from '../components/EditProblemView/SettingsWidget/set class ReactStateOLXParser { constructor(problemState) { - const parserOptions = { - ignoreAttributes: false, - alwaysCreateTextNode: true, - numberParseOptions: { - leadingZeros: false, - hex: false, - }, - }; - const questionParserOptions = { + const richTextParserOptions = { ignoreAttributes: false, alwaysCreateTextNode: true, numberParseOptions: { @@ -22,7 +14,7 @@ class ReactStateOLXParser { }, preserveOrder: true, }; - const questionBuilderOptions = { + const richTextBuilderOptions = { ignoreAttributes: false, attributeNamePrefix: '@_', suppressBooleanAttributes: false, @@ -33,20 +25,9 @@ class ReactStateOLXParser { }, preserveOrder: true, }; - const builderOptions = { - ignoreAttributes: false, - attributeNamePrefix: '@_', - suppressBooleanAttributes: false, - format: true, - numberParseOptions: { - leadingZeros: false, - hex: false, - }, - }; - this.questionParser = new XMLParser(questionParserOptions); - this.parser = new XMLParser(parserOptions); - this.builder = new XMLBuilder(builderOptions); - this.questionBuilder = new XMLBuilder(questionBuilderOptions); + + this.richTextParser = new XMLParser(richTextParserOptions); + this.richTextBuilder = new XMLBuilder(richTextBuilderOptions); this.editorObject = problemState.editorObject; this.problemState = problemState.problem; } @@ -61,21 +42,17 @@ class ReactStateOLXParser { const hintsArray = []; const { hints } = this.editorObject; if (hints.length < 1) { - return {}; + return hintsArray; } hints.forEach(hint => { if (hint.length > 0) { - const parsedHint = this.parser.parse(hint); + const parsedHint = this.richTextParser.parse(hint); hintsArray.push({ - ...parsedHint, + hint: [...parsedHint], }); } }); - const demandhint = { - demandhint: { - hint: hintsArray, - }, - }; + const demandhint = [{ demandhint: hintsArray }]; return demandhint; } @@ -88,19 +65,16 @@ class ReactStateOLXParser { */ addSolution() { const { solution } = this.editorObject; - if (!solution || solution.length <= 0) { return {}; } - const solutionTitle = { '#text': 'Explanation' }; - const parsedSolution = this.parser.parse(solution); - const paragraphs = parsedSolution.p; - const withWrapper = _.isArray(paragraphs) ? [solutionTitle, ...paragraphs] : [solutionTitle, paragraphs]; - const solutionObject = { - solution: { - div: { - '@_class': 'detailed-solution', - p: withWrapper, - }, - }, - }; + if (!solution || solution.length <= 0) { return []; } + const solutionTitle = { p: [{ '#text': 'Explanation' }] }; + const parsedSolution = this.richTextParser.parse(solution); + const withWrapper = [solutionTitle, ...parsedSolution]; + const solutionObject = [{ + solution: [{ + ':@': { '@_class': 'detailed-solution' }, + div: [...withWrapper], + }], + }]; return solutionObject; } @@ -120,7 +94,6 @@ class ReactStateOLXParser { addMultiSelectAnswers(option) { const choice = []; let compoundhint = []; - let widget = {}; // eslint-disable-next-line prefer-const let { answers, problemType } = this.problemState; const answerTitles = this.editorObject?.answers; @@ -146,8 +119,8 @@ class ReactStateOLXParser { */ answers.forEach((answer) => { const feedback = []; - let singleAnswer = {}; - const title = answerTitles ? this.parser.parse(answerTitles[answer.id]) : { '#text': answer.title }; + let singleAnswer = []; + const title = answerTitles ? this.richTextParser.parse(answerTitles[answer.id]) : [{ '#text': answer.title }]; const currentSelectedFeedback = selectedFeedback?.[answer.id] || null; const currentUnselectedFeedback = unselectedFeedback?.[answer.id] || null; let isEmpty; @@ -158,45 +131,37 @@ class ReactStateOLXParser { } if (title && !isEmpty) { if (currentSelectedFeedback && problemType === ProblemTypeKeys.MULTISELECT) { - const parsedSelectedFeedback = this.parser.parse(currentSelectedFeedback); + const parsedSelectedFeedback = this.richTextParser.parse(currentSelectedFeedback); feedback.push({ - ...parsedSelectedFeedback, - '@_selected': true, + ':@': { '@_selected': true }, + [`${option}hint`]: parsedSelectedFeedback, }); } if (currentSelectedFeedback && problemType !== ProblemTypeKeys.MULTISELECT) { - const parsedSelectedFeedback = this.parser.parse(currentSelectedFeedback); + const parsedSelectedFeedback = this.richTextParser.parse(currentSelectedFeedback); feedback.push({ - ...parsedSelectedFeedback, + [`${option}hint`]: parsedSelectedFeedback, }); } if (currentUnselectedFeedback && problemType === ProblemTypeKeys.MULTISELECT) { - const parsedUnselectedFeedback = this.parser.parse(currentUnselectedFeedback); + const parsedUnselectedFeedback = this.richTextParser.parse(currentUnselectedFeedback); feedback.push({ - ...parsedUnselectedFeedback, - '@_selected': false, + ':@': { '@_selected': false }, + [`${option}hint`]: parsedUnselectedFeedback, }); } - if (feedback.length) { - singleAnswer[`${option}hint`] = feedback; - } singleAnswer = { - '@_correct': answer.correct, - ...title, - ...singleAnswer, + ':@': { '@_correct': answer.correct }, + [option]: [...title, ...feedback], }; choice.push(singleAnswer); } }); - widget = { [option]: choice }; if (_.has(this.problemState, 'groupFeedbackList') && problemType === ProblemTypeKeys.MULTISELECT) { compoundhint = this.addGroupFeedbackList(); - widget = { - ...widget, - compoundhint, - }; + choice.push(...compoundhint); } - return widget; + return choice; } /** addGroupFeedbackList() @@ -210,8 +175,8 @@ class ReactStateOLXParser { const { groupFeedbackList } = this.problemState; groupFeedbackList.forEach((element) => { compoundhint.push({ - '#text': element.feedback, - '@_value': element.answers.join(' '), + compoundhint: [{ '#text': element.feedback }], + ':@': { '@_value': element.answers.join(' ') }, }); }); return compoundhint; @@ -224,7 +189,7 @@ class ReactStateOLXParser { */ addQuestion() { const { question } = this.editorObject; - const questionObject = this.questionParser.parse(question); + const questionObject = this.richTextParser.parse(question); /* Removes block tags like

or

that surround the