Files
frontend-app-authoring/src/editors/containers/ProblemEditor/data/OLXParser.js

558 lines
18 KiB
JavaScript

// 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) {
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) {
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) {
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() {
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() {
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) {
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)) {
/* <label> and <description> tags often are both valid olx as siblings or children of response type tags.
They, however, do belong in the question, so we append them to the question.
*/
tag[tagName].forEach(subTag => {
const subTagName = Object.keys(subTag)[0];
if (subTagName === 'label' || subTagName === 'description') {
questionArray.push(subTag);
}
});
}
});
const questionString = builder.build(questionArray);
return questionString.replace(/<description>/gm, '<em>').replace(/<\/description>/gm, '</em>');
}
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,
});
});
} else {
const hintValue = this.builder.build(hint);
hintsObject.push({
id: hintsObject.length,
value: hintValue,
});
}
}
return hintsObject;
}
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;
}
}
});
solution = parsedSolution;
}
const solutionString = this.builder.build(solution);
return solutionString;
}
getFeedback(xmlElement) {
if (!_.has(xmlElement, 'correcthint')) { return ''; }
const feedback = _.get(xmlElement, 'correcthint');
const feedbackString = this.builder.build(feedback);
return feedbackString;
}
getProblemType() {
const problemKeys = Object.keys(this.problem);
const problemTypeKeys = problemKeys.filter(key => Object.values(ProblemTypeKeys).indexOf(key) !== -1);
if (problemTypeKeys.length === 0) {
// a blank problem is a problem which contains only `<problem></problem>` as it's olx.
// blank problems are not given types, so that a type may be selected.
if (problemKeys.length === 1 && problemKeys[0] === '#text' && this.problem[problemKeys[0]] === '') {
return null;
}
// if we have no matching problem type, the problem is advanced.
return ProblemTypeKeys.ADVANCED;
}
// make sure compound problems are treated as advanced
if ((problemTypeKeys.length > 1)
|| (_.isArray(this.problem[problemTypeKeys[0]])
&& this.problem[problemTypeKeys[0]].length > 1)) {
return ProblemTypeKeys.ADVANCED;
}
const problemType = problemTypeKeys[0];
return problemType;
}
getGeneralFeedback({ answers, problemType }) {
/* Feedback is Generalized for a Problem IFF:
1. The problem is of Types: Single Select or Dropdown.
2. All the problem's incorrect, if Selected answers are equivalent strings, and there is no other feedback.
*/
if (problemType === ProblemTypeKeys.SINGLESELECT || problemType === ProblemTypeKeys.DROPDOWN) {
const firstIncorrectAnswerText = answers.find(answer => answer.correct === false)?.selectedFeedback;
const isAllIncorrectSelectedFeedbackTheSame = answers.every(answer => (answer.correct
? true
: answer?.selectedFeedback === firstIncorrectAnswerText
));
if (isAllIncorrectSelectedFeedbackTheSame) {
return firstIncorrectAnswerText;
}
}
return '';
}
getParsedOLXData() {
if (_.isEmpty(this.problem)) {
return {};
}
if (Object.keys(this.problem).some((key) => key.indexOf('@_') !== -1)) {
throw new Error('Misc Attributes asscoiated with problem, opening in advanced editor');
}
let answersObject = {};
let additionalAttributes = {};
let groupFeedbackList = [];
const problemType = this.getProblemType();
const hints = this.getHints();
const question = this.parseQuestions(problemType);
const solutionExplanation = this.getSolutionExplanation(problemType);
switch (problemType) {
case ProblemTypeKeys.DROPDOWN:
answersObject = this.parseMultipleChoiceAnswers(ProblemTypeKeys.DROPDOWN, 'optioninput', 'option');
break;
case ProblemTypeKeys.TEXTINPUT:
answersObject = this.parseStringResponse();
break;
case ProblemTypeKeys.NUMERIC:
answersObject = this.parseNumericResponse();
break;
case ProblemTypeKeys.MULTISELECT:
answersObject = this.parseMultipleChoiceAnswers(ProblemTypeKeys.MULTISELECT, 'checkboxgroup', 'choice');
break;
case ProblemTypeKeys.SINGLESELECT:
answersObject = this.parseMultipleChoiceAnswers(ProblemTypeKeys.SINGLESELECT, 'choicegroup', 'choice');
break;
case ProblemTypeKeys.ADVANCED:
return {
problemType,
settings: {},
};
default:
// if problem is unset, return null
return {};
}
const generalFeedback = this.getGeneralFeedback({ answers: answersObject.answers, problemType });
if (_.has(answersObject, 'additionalStringAttributes')) {
additionalAttributes = { ...answersObject.additionalStringAttributes };
}
if (_.has(answersObject, 'groupFeedbackList')) {
groupFeedbackList = answersObject.groupFeedbackList;
}
const { answers } = answersObject;
const settings = { hints };
if (ProblemTypeKeys.NUMERIC === problemType && _.has(answers[0], 'tolerance')) {
const toleranceValue = answers[0].tolerance;
if (!toleranceValue || toleranceValue.length === 0) {
settings.tolerance = { value: null, type: 'None' };
} else if (toleranceValue.includes('%')) {
settings.tolerance = { value: parseInt(toleranceValue.slice(0, -1)), type: 'Percent' };
} else {
settings.tolerance = { value: parseInt(toleranceValue), type: 'Number' };
}
}
if (solutionExplanation) { settings.solutionExplanation = solutionExplanation; }
return {
question,
settings,
answers,
problemType,
additionalAttributes,
generalFeedback,
groupFeedbackList,
};
}
}