// Parse OLX to JavaScript objects. /* eslint no-eval: 0 */ import { XMLParser, XMLBuilder } from 'fast-xml-parser'; import _ from 'lodash-es'; import { ProblemTypeKeys } from '../../../data/constants/problem'; export const indexToLetterMap = [...Array(26)].map((val, i) => String.fromCharCode(i + 65)); export const nonQuestionKeys = [ '@_answer', '@_type', 'additional_answer', 'checkboxgroup', 'choicegroup', 'choiceresponse', 'correcthint', 'demandhint', 'formulaequationinput', 'multiplechoiceresponse', 'numericalresponse', 'optioninput', 'optionresponse', 'responseparam', 'solution', 'stringequalhint', 'stringresponse', 'textline', ]; export const responseKeys = [ 'multiplechoiceresponse', 'numericalresponse', 'optionresponse', 'stringresponse', 'choiceresponse', 'multiplechoiceresponse', 'truefalseresponse', 'optionresponse', 'numericalresponse', 'stringresponse', 'customresponse', 'symbolicresponse', 'coderesponse', 'externalresponse', 'formularesponse', 'schematicresponse', 'imageresponse', 'annotationresponse', 'choicetextresponse', ]; export const stripNonTextTags = ({ input, tag }) => { const stripedTags = {}; Object.entries(input).forEach(([key, value]) => { if (key !== tag) { stripedTags[key] = value; } }); return stripedTags; }; export class OLXParser { constructor(olxString) { this.problem = {}; this.questionData = {}; const questionOptions = { ignoreAttributes: false, alwaysCreateTextNode: true, numberParseOptions: { leadingZeros: false, hex: false, }, preserveOrder: true, processEntities: false, }; const parserOptions = { ignoreAttributes: false, alwaysCreateTextNode: true, numberParseOptions: { leadingZeros: false, hex: false, }, processEntities: false, }; const builderOptions = { ignoreAttributes: false, numberParseOptions: { leadingZeros: false, hex: false, }, 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 // the wrapping tag. const questionParser = new XMLParser(questionOptions); const parser = new XMLParser(parserOptions); this.builder = new XMLBuilder(builderOptions); this.parsedOLX = parser.parse(olxString); this.parsedQuestionOLX = questionParser.parse(olxString); if (_.has(this.parsedOLX, 'problem')) { this.problem = this.parsedOLX.problem; this.questionData = this.parsedQuestionOLX[0].problem; } } /** 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 * type, the title for an answer will be parsed differently because of single select and multiselect * problems are rich text while dropdown answers are plain text. The rich text is parsed into an object * and is converted back into a string before being added to the answer object. The parsing returns a * data object with an array of answer objects. If the olx has grouped feedback, this will also be * included in the data object. * @param {string} problemType - string of the olx problem type * @param {string} widgetName - string of the wrapping tag name (optioninput, choicegroup, checkboxgroup) * @param {string} option - string of the type of answers (choice or option) * @return {object} object containing an array of answer objects and possibly an array of grouped feedback */ parseMultipleChoiceAnswers(problemType, widgetName, option) { const answers = []; let data = {}; const widget = _.get(this.problem, `${problemType}.${widgetName}`); const permissableTags = ['choice', '@_type', 'compoundhint', 'option', '#text']; if (_.keys(widget).some((tag) => !permissableTags.includes(tag))) { throw new Error('Misc Tags, reverting to Advanced Editor'); } const choice = _.get(widget, option); const isComplexAnswer = [ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT].includes(problemType); if (_.isEmpty(choice)) { answers.push( { id: indexToLetterMap[answers.length], title: '', correct: true, }, ); } else if (_.isArray(choice)) { choice.forEach((element, index) => { let title = element['#text']; if (isComplexAnswer) { const answerTitle = stripNonTextTags({ input: element, tag: `${option}hint` }); title = this.builder.build(answerTitle); } const correct = eval(element['@_correct'].toLowerCase()); const id = indexToLetterMap[index]; const feedback = this.getAnswerFeedback(element, `${option}hint`); answers.push( { id, correct, title, ...feedback, }, ); }); } else { let title = choice['#text']; if (isComplexAnswer) { const answerTitle = stripNonTextTags({ input: choice, tag: `${option}hint` }); title = this.builder.build(answerTitle); } const feedback = this.getAnswerFeedback(choice, `${option}hint`); answers.push({ correct: eval(choice['@_correct'].toLowerCase()), id: indexToLetterMap[answers.length], title, ...feedback, }); } data = { answers }; const groupFeedbackList = this.getGroupedFeedback(widget); if (groupFeedbackList.length) { data = { ...data, groupFeedbackList, }; } 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. * @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 = {}; 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'; } feedback = { [feedbackKeys]: this.builder.build(answerFeedback), }; } } return feedback; } /** getGroupedFeedback(choices) * getGroupedFeedback takes choices. The choices with the attribute compoundhint are parsed for * the text value and the answers associated with the feedback. The groupFeedback array is returned. * @param {object} choices - object of problem's subtags * @return {array} array containing objects of feedback and associated answer ids */ getGroupedFeedback(choices) { const groupFeedback = []; if (_.has(choices, 'compoundhint')) { const groupFeedbackArray = choices.compoundhint; if (_.isArray(groupFeedbackArray)) { groupFeedbackArray.forEach((element) => { const parsedFeedback = stripNonTextTags({ input: element, tag: '@_value' }); groupFeedback.push({ id: groupFeedback.length, answers: element['@_value'].split(' '), feedback: this.builder.build(parsedFeedback), }); }); } else { const parsedFeedback = stripNonTextTags({ input: groupFeedbackArray, tag: '@_value' }); groupFeedback.push({ id: groupFeedback.length, answers: groupFeedbackArray['@_value'].split(' '), feedback: this.builder.build(parsedFeedback), }); } } return groupFeedback; } /** parseStringResponse() * The OLX saved to the class constuctor is parsed for text input answers. There are two * types of tags with the answer attribute, stringresponse (the problem wrapper) and * additional_answer. Looping through each tag, the associated title and feedback are added * to the answers object and appended to the answers array. The array returned in an object * with the key "answers". The object also conatins additional attributes that belong to the * string response tag. * @return {object} object containing an array of answer objects and object of additionalStringAttributes */ parseStringResponse() { const { stringresponse } = this.problem; const answers = []; let answerFeedback = ''; let additionalStringAttributes = {}; let data = {}; const feedback = this.getFeedback(stringresponse); answers.push({ id: indexToLetterMap[answers.length], title: stringresponse['@_answer'], correct: true, selectedFeedback: feedback, }); // Parsing additional_answer for string response. const additionalAnswer = _.get(stringresponse, 'additional_answer', []); if (_.isArray(additionalAnswer)) { additionalAnswer.forEach((newAnswer) => { answerFeedback = this.getFeedback(newAnswer); answers.push({ id: indexToLetterMap[answers.length], title: newAnswer['@_answer'], correct: true, selectedFeedback: answerFeedback, }); }); } else { answerFeedback = this.getFeedback(additionalAnswer); answers.push({ id: indexToLetterMap[answers.length], title: additionalAnswer['@_answer'], correct: true, selectedFeedback: answerFeedback, }); } // 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); answers.push({ id: indexToLetterMap[answers.length], title: newAnswer['@_answer'], correct: false, selectedFeedback: answerFeedback, }); }); } else { const parsedFeedback = stripNonTextTags({ input: stringEqualHint, tag: '@_answer' }); answerFeedback = this.builder.build(parsedFeedback); answers.push({ id: indexToLetterMap[answers.length], title: stringEqualHint['@_answer'], correct: false, selectedFeedback: answerFeedback, }); } // TODO: Support multiple types. additionalStringAttributes = { type: _.get(stringresponse, '@_type'), textline: { size: _.get(stringresponse, 'textline.@_size'), }, }; data = { answers, additionalStringAttributes, }; return data; } /** parseNumericResponse() * The OLX saved to the class constuctor is parsed for numeric answers. There are two * types of tags for numeric answers, responseparam and additional_answer. Looping through * each tag, the associated title and feedback and if the answer is an answer range are * added to the answers object and appended to the answers array. The array returned in * an object with the key "answers". * @return {object} object containing an array of answer objects */ parseNumericResponse() { const { numericalresponse } = this.problem; let answerFeedback = ''; const answers = []; let responseParam = {}; const feedback = this.getFeedback(numericalresponse); if (_.has(numericalresponse, 'responseparam')) { const type = _.get(numericalresponse, 'responseparam.@_type'); const defaultValue = _.get(numericalresponse, 'responseparam.@_default'); responseParam = { [type]: defaultValue, }; } const isAnswerRange = /[([]\d*,\d*[)\]]/gm.test(numericalresponse['@_answer']); answers.push({ id: indexToLetterMap[answers.length], title: numericalresponse['@_answer'], correct: true, selectedFeedback: feedback, isAnswerRange, ...responseParam, }); // Parsing additional_answer for numerical response. const additionalAnswer = _.get(numericalresponse, 'additional_answer', []); if (_.isArray(additionalAnswer)) { additionalAnswer.forEach((newAnswer) => { answerFeedback = this.getFeedback(newAnswer); answers.push({ id: indexToLetterMap[answers.length], title: newAnswer['@_answer'], correct: true, selectedFeedback: answerFeedback, }); }); } else { answerFeedback = this.getFeedback(additionalAnswer); answers.push({ id: indexToLetterMap[answers.length], title: additionalAnswer['@_answer'], correct: true, selectedFeedback: answerFeedback, isAnswerRange: false, }); } return { answers }; } /** parseQuestions(problemType) * parseQuestions takes a problemType. The problem type is used to determine where the * text for the question lies (sibling or child to warpping problem type tags). * Using the XMLBuilder, the question is built with its proper children (including label * and description). The string version of the OLX is return, replacing the description * tags with italicized tags for styling purposes. * @param {string} problemType - string of the olx problem type * @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 = []; problemArray.forEach(tag => { const tagName = Object.keys(tag)[0]; if (!nonQuestionKeys.includes(tagName)) { if (tagName === 'script') { throw new Error('Script Tag, reverting to Advanced Editor'); } questionArray.push(tag); } else if (responseKeys.includes(tagName)) { /*