799 lines
30 KiB
JavaScript
799 lines
30 KiB
JavaScript
// Parse OLX to JavaScript objects.
|
|
/* eslint no-eval: 0 */
|
|
|
|
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
|
|
import {
|
|
get, has, keys, isArray, isEmpty,
|
|
} from 'lodash';
|
|
import {
|
|
ProblemTypeKeys,
|
|
RichTextProblems,
|
|
settingsOlxAttributes,
|
|
ignoredOlxAttributes,
|
|
} 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',
|
|
];
|
|
|
|
/**
|
|
* Regular expression to validate numeric answer ranges in OLX format.
|
|
* Matches ranges in the form of (min, max) or [min, max] where:
|
|
* - Both min and max are required and can be:
|
|
* - integers (e.g. 1, -5)
|
|
* - decimals (e.g. 1.5, -0.25)
|
|
* - fractions (e.g. 1/2, -3/4)
|
|
* - Whitespace around numbers and comma is optional
|
|
* - Parentheses () indicate exclusive bounds
|
|
* - Square brackets [] indicate inclusive bounds
|
|
*
|
|
* @example
|
|
* // Valid patterns:
|
|
* (1, 5)
|
|
* [1, 5]
|
|
* (1.5, 5.5)
|
|
* [-5, 10]
|
|
* (-3.5, 7)
|
|
* (-1,1)
|
|
* (-1,1]
|
|
* [-1,1)
|
|
* [1,1/2]
|
|
* [1/2, 2]
|
|
* [1/4, 1/2]
|
|
* (1,1/2]
|
|
* [1/2, 2)
|
|
* (1/4, 1/2)
|
|
*
|
|
* @example
|
|
* // Invalid patterns:
|
|
* (5,1)
|
|
* (1,)
|
|
* (,1)
|
|
* [1 5]
|
|
* {1,5}
|
|
* [--5,10]
|
|
* []
|
|
*/
|
|
export const answerRangeFormatRegex = /^[([]\s*-?(?:\d+(?:\.\d+)?|\d+\/\d+)\s*,\s*-?(?:\d+(?:\.\d+)?|\d+\/\d+)\s*[)\]]$/m;
|
|
|
|
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) {
|
|
// There are two versions of the parsed XLM because the fields using tinymce require the order
|
|
// of the parsed data and spacing values to be preserved. However, all the other widgets need
|
|
// the data grouped by the wrapping tag. Examples of the parsed format can be found here:
|
|
// https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/docs/v4/2.XMLparseOptions.md
|
|
const baseParserOptions = {
|
|
ignoreAttributes: false,
|
|
numberParseOptions: {
|
|
leadingZeros: false,
|
|
hex: false,
|
|
},
|
|
processEntities: false,
|
|
};
|
|
|
|
// Base Parser
|
|
this.problem = {};
|
|
const parserOptions = {
|
|
...baseParserOptions,
|
|
alwaysCreateTextNode: true,
|
|
};
|
|
const builderOptions = {
|
|
...baseParserOptions,
|
|
};
|
|
const parser = new XMLParser(parserOptions);
|
|
this.builder = new XMLBuilder(builderOptions);
|
|
this.parsedOLX = parser.parse(olxString);
|
|
if (has(this.parsedOLX, 'problem')) {
|
|
this.problem = this.parsedOLX.problem;
|
|
}
|
|
|
|
// Parser with `preservedOrder: true` and `trimValues: false`
|
|
this.richTextProblem = [];
|
|
const richTextOptions = {
|
|
...baseParserOptions,
|
|
alwaysCreateTextNode: true,
|
|
preserveOrder: true,
|
|
trimValues: false,
|
|
};
|
|
const richTextBuilderOptions = {
|
|
...baseParserOptions,
|
|
preserveOrder: true,
|
|
trimValues: false,
|
|
};
|
|
const richTextParser = new XMLParser(richTextOptions);
|
|
this.richTextBuilder = new XMLBuilder(richTextBuilderOptions);
|
|
this.richTextOLX = richTextParser.parse(olxString);
|
|
if (has(this.parsedOLX, '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, additional_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];
|
|
let hasCorrectAnswerFeedback = isChoiceProblem;
|
|
problemBodyArr.forEach(subtag => {
|
|
const tagNames = Object.keys(subtag);
|
|
if (!isChoiceProblem && tagNames.includes(correctAnswerFeedbackTag)) {
|
|
preservedAnswers.unshift(subtag[correctAnswerFeedbackTag]);
|
|
hasCorrectAnswerFeedback = true;
|
|
}
|
|
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]);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
// Since the first feedback is taken as correct answer feedback by non-choice problems,
|
|
// we insert an empty array to preserve feedback order.
|
|
if (!hasCorrectAnswerFeedback) {
|
|
preservedAnswers.unshift([]);
|
|
}
|
|
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
|
|
* 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 preservedAnswers = this.getPreservedAnswersAndFeedback(
|
|
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');
|
|
}
|
|
if (get(this.problem, `${problemType}.@_partial_credit`)) {
|
|
throw new Error('Partial credit not supported by GUI, reverting to Advanced Editor');
|
|
}
|
|
const choice = get(widget, option);
|
|
const isComplexAnswer = RichTextProblems.includes(problemType);
|
|
if (isEmpty(choice)) {
|
|
answers.push(
|
|
{
|
|
id: indexToLetterMap[answers.length],
|
|
title: '',
|
|
correct: true,
|
|
},
|
|
);
|
|
} else if (isArray(choice)) {
|
|
choice.forEach((element, index) => {
|
|
const preservedAnswer = preservedAnswers[index].filter(answer => !Object.keys(answer).includes(`${option}hint`));
|
|
const preservedFeedback = preservedAnswers[index].filter(answer => Object.keys(answer).includes(`${option}hint`));
|
|
let title = String(element['#text']);
|
|
|
|
if (isComplexAnswer && preservedAnswer) {
|
|
title = this.richTextBuilder.build(preservedAnswer);
|
|
}
|
|
const correct = eval(element['@_correct'].toLowerCase());
|
|
const id = indexToLetterMap[index];
|
|
const feedback = this.getAnswerFeedback(preservedFeedback, `${option}hint`);
|
|
answers.push(
|
|
{
|
|
id,
|
|
correct,
|
|
title,
|
|
...feedback,
|
|
},
|
|
);
|
|
});
|
|
} else {
|
|
const preservedAnswer = preservedAnswers[0].filter(answer => !Object.keys(answer).includes(`${option}hint`));
|
|
const preservedFeedback = preservedAnswers[0].filter(answer => Object.keys(answer).includes(`${option}hint`));
|
|
let title = String(choice['#text']);
|
|
|
|
if (isComplexAnswer && preservedAnswer) {
|
|
title = this.richTextBuilder.build(preservedAnswer);
|
|
}
|
|
const feedback = this.getAnswerFeedback(preservedFeedback, `${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(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 {array} preservedFeedback - array of feedback objects
|
|
* @param {string} hintKey - string of the wrapping tag name (optionhint or choicehint)
|
|
* @return {object} object containing selected and unselected feedback
|
|
*/
|
|
getAnswerFeedback(preservedFeedback, hintKey) {
|
|
const feedback = {};
|
|
let feedbackKeys = 'selectedFeedback';
|
|
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.richTextBuilder.build(feedbackArr[hintKey]);
|
|
}
|
|
});
|
|
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 [firstCorrectFeedback, ...preservedFeedback] = this.getPreservedAnswersAndFeedback(
|
|
ProblemTypeKeys.TEXTINPUT,
|
|
'additional_answer',
|
|
['correcthint', 'stringequalhint'],
|
|
);
|
|
const { stringresponse } = this.problem;
|
|
const answers = [];
|
|
let answerFeedback = '';
|
|
let additionalStringAttributes = {};
|
|
let data = {};
|
|
const firstFeedback = this.getFeedback(firstCorrectFeedback);
|
|
answers.push({
|
|
id: indexToLetterMap[answers.length],
|
|
title: stringresponse['@_answer'],
|
|
correct: true,
|
|
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, indx) => {
|
|
answerFeedback = this.getFeedback(additionalAnswerFeedback[indx]);
|
|
answers.push({
|
|
id: indexToLetterMap[answers.length],
|
|
title: newAnswer['@_answer'],
|
|
correct: true,
|
|
selectedFeedback: answerFeedback,
|
|
});
|
|
});
|
|
} else {
|
|
answerFeedback = this.getFeedback(additionalAnswerFeedback[0]);
|
|
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, indx) => {
|
|
answerFeedback = this.getFeedback(stringEqualHintFeedback[indx]?.stringequalhint);
|
|
answers.push({
|
|
id: indexToLetterMap[answers.length],
|
|
title: newAnswer['@_answer'],
|
|
correct: false,
|
|
selectedFeedback: answerFeedback,
|
|
});
|
|
});
|
|
} else {
|
|
answerFeedback = this.getFeedback(stringEqualHintFeedback[0]?.stringequalhint);
|
|
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 [firstCorrectFeedback, ...preservedFeedback] = this.getPreservedAnswersAndFeedback(
|
|
ProblemTypeKeys.NUMERIC,
|
|
'additional_answer',
|
|
'correcthint',
|
|
);
|
|
const { numericalresponse } = this.problem;
|
|
if (get(numericalresponse, '@_partial_credit')) {
|
|
throw new Error('Partial credit not supported by GUI, reverting to Advanced Editor');
|
|
}
|
|
let answerFeedback = '';
|
|
const answers = [];
|
|
let responseParam = {};
|
|
const feedback = this.getFeedback(firstCorrectFeedback);
|
|
if (has(numericalresponse, 'responseparam')) {
|
|
const type = get(numericalresponse, 'responseparam.@_type');
|
|
const defaultValue = get(numericalresponse, 'responseparam.@_default');
|
|
responseParam = {
|
|
[type]: defaultValue,
|
|
};
|
|
}
|
|
const isAnswerRange = /[([]\s*\d*,\s*\d*\s*[)\]]/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, indx) => {
|
|
answerFeedback = this.getFeedback(preservedFeedback[indx]);
|
|
answers.push({
|
|
id: indexToLetterMap[answers.length],
|
|
title: newAnswer['@_answer'],
|
|
correct: true,
|
|
selectedFeedback: answerFeedback,
|
|
});
|
|
});
|
|
} else {
|
|
answerFeedback = this.getFeedback(preservedFeedback[0]);
|
|
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 problemArray = get(this.richTextProblem[0], problemType) || this.richTextProblem;
|
|
|
|
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)) {
|
|
/* Tags that are not used for other parts of the question such as <solution> or <choicegroup>
|
|
should be included in the question. These include but are not limited to tags like <label>,
|
|
<description> and <table> as they often are both valid olx as siblings or children of response
|
|
type tags. */
|
|
tag[tagName].forEach(subTag => {
|
|
const subTagName = Object.keys(subTag)[0];
|
|
if (!nonQuestionKeys.includes(subTagName)) {
|
|
questionArray.push(subTag);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
const questionString = this.richTextBuilder.build(questionArray);
|
|
const res = this.replaceOlxDescriptionTag(questionString);
|
|
return res;
|
|
}
|
|
|
|
/** hasOLXAfterProblemTypeTag(problemType)
|
|
* checkTextAfterProblemTypeTag takes a problemType. The problem type is used to determine
|
|
* if there is olx after the answer choices the problem. Simple problems are not expected
|
|
* to have olx after the answer choices and returns false. In the event that a problem has
|
|
* olx after the answer choices it returns true and will raise an error.
|
|
* @param {string} problemType - string of the olx problem type
|
|
* @return {bool}
|
|
*/
|
|
hasOLXAfterProblemTypeTag(problemType) {
|
|
let problemTagIndex = this.richTextProblem.length - 1;
|
|
let hasExtraOLX = false;
|
|
Object.entries(this.richTextProblem).forEach(([i, value]) => {
|
|
if (Object.keys(value).includes(problemType)) {
|
|
problemTagIndex = i;
|
|
}
|
|
});
|
|
|
|
if (problemTagIndex < this.richTextProblem.length - 1) {
|
|
const olxAfterProblemType = this.richTextProblem.slice(problemTagIndex + 1);
|
|
Object.values(olxAfterProblemType).forEach(value => {
|
|
const currentKey = Object.keys(value)[0];
|
|
const invalidText = currentKey === '#text' && value[currentKey] !== '\n';
|
|
const invalidKey = !nonQuestionKeys.includes(currentKey) && currentKey !== '#text';
|
|
if (invalidText) {
|
|
hasExtraOLX = true;
|
|
} else if (invalidKey) {
|
|
hasExtraOLX = true;
|
|
}
|
|
});
|
|
}
|
|
return hasExtraOLX;
|
|
}
|
|
|
|
replaceOlxDescriptionTag(questionString) {
|
|
return questionString.replace(/<description>/gm, '<em class="olx_description">').replace(/<\/description>/gm, '</em>');
|
|
}
|
|
|
|
/** getHints()
|
|
* The OLX saved to the class constuctor is parsed for demand hint tags with hint subtags. An empty array is returned
|
|
* if there are no hints in the OLX. Otherwise the hint tag is parsed and appended to the hintsObject arrary. After
|
|
* going through all the hints the hintsObject array is returned.
|
|
* @return {array} array of hint objects
|
|
*/
|
|
getHints() {
|
|
const hintsObject = [];
|
|
if (has(this.problem, 'demandhint.hint')) {
|
|
const preservedProblem = this.richTextProblem;
|
|
preservedProblem.forEach(obj => {
|
|
const objKeys = Object.keys(obj);
|
|
if (objKeys.includes('demandhint')) {
|
|
const currentDemandHint = obj.demandhint;
|
|
currentDemandHint.forEach(hint => {
|
|
if (Object.keys(hint).includes('hint')) {
|
|
const hintValue = this.richTextBuilder.build(hint.hint);
|
|
hintsObject.push({
|
|
id: hintsObject.length,
|
|
value: hintValue,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
return hintsObject;
|
|
}
|
|
|
|
/** 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.
|
|
* @param {string} problemType - string of the olx problem type
|
|
* @return {string} string of OLX
|
|
*/
|
|
getSolutionExplanation(problemType) {
|
|
if (!has(this.problem, `${problemType}.solution`) && !has(this.problem, 'solution')) { return null; }
|
|
const [problemBody] = this.richTextProblem.filter(section => Object.keys(section).includes(problemType));
|
|
const [solutionBody] = problemBody[problemType].filter(section => Object.keys(section).includes('solution'));
|
|
const [divBody] = solutionBody.solution.filter(section => Object.keys(section).includes('div'));
|
|
const solutionArray = [];
|
|
if (divBody && divBody.div) {
|
|
divBody.div.forEach(tag => {
|
|
const tagText = get(Object.values(tag)[0][0], '#text', '');
|
|
if (tagText.toString().trim() !== 'Explanation') {
|
|
solutionArray.push(tag);
|
|
}
|
|
});
|
|
} else {
|
|
solutionBody.solution.forEach(tag => {
|
|
const tagText = get(Object.values(tag)[0][0], '#text', '');
|
|
if (tagText.toString().trim() !== 'Explanation') {
|
|
solutionArray.push(tag);
|
|
}
|
|
});
|
|
}
|
|
const solutionString = this.richTextBuilder.build(solutionArray);
|
|
return solutionString;
|
|
}
|
|
|
|
/** getFeedback(xmlElement)
|
|
* getFeedback takes xmlElement. The xmlElement is searched for the attribute correcthint.
|
|
* An empty string is returned if the parameter is not present. Otherwise a string of the feedback
|
|
* is returned.
|
|
* @param {object} xmlElement - object of answer attributes
|
|
* @return {string} string of feedback
|
|
*/
|
|
getFeedback(xmlElement) {
|
|
if (isEmpty(xmlElement)) { return ''; }
|
|
const feedbackString = this.richTextBuilder.build(xmlElement);
|
|
return feedbackString;
|
|
}
|
|
|
|
/** getProblemType()
|
|
* The OLX saved to the class constuctor is parsed for a valid problem type (referencing problemKeys).
|
|
* For blank problems, it returns null. For OLX problems tags not defined in problemKeys or OLX with
|
|
* multiple problem tags, it returns advanced. For defined, single problem tag, it returns the
|
|
* associated problem type.
|
|
* @return {string} problem type
|
|
*/
|
|
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 })
|
|
* getGeneralFeedback takes answers and problemType. The problem type determines if the problem should be checked
|
|
* for general feedback. The incorrect answers are checked to seee if all of their feedback is the same and
|
|
* returns the first incorrect answer's feedback if true. When conditions are unmet, it returns and empty string.
|
|
* @param {array} answers - array of answer objects
|
|
* @param {string} problemType - string of string of the olx problem type
|
|
* @return {string} text for incorrect feedback
|
|
*/
|
|
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 {};
|
|
}
|
|
|
|
Object.keys(this.problem).forEach((key) => {
|
|
if (key.indexOf('@_') !== -1 && !settingsOlxAttributes.includes(key) && !ignoredOlxAttributes.includes(key)) {
|
|
const plainKey = key.replace(/^@_/, '');
|
|
throw new Error(`Unrecognized attribute "${plainKey}" associated with problem, opening in advanced editor`);
|
|
}
|
|
});
|
|
|
|
const problemType = this.getProblemType();
|
|
|
|
if (this.hasOLXAfterProblemTypeTag(problemType)) {
|
|
throw new Error(`OLX was found after the ${problemType} tags, opening in advanced editor`);
|
|
}
|
|
|
|
let answersObject = {};
|
|
let additionalAttributes = {};
|
|
let groupFeedbackList = [];
|
|
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), 10), type: 'Percent' };
|
|
} else {
|
|
settings.tolerance = { value: parseInt(toleranceValue, 10), type: 'Number' };
|
|
}
|
|
} else {
|
|
settings.tolerance = { value: null, type: 'None' };
|
|
}
|
|
if (solutionExplanation) { settings.solutionExplanation = solutionExplanation; }
|
|
|
|
return {
|
|
question,
|
|
settings,
|
|
answers,
|
|
problemType,
|
|
additionalAttributes,
|
|
generalFeedback,
|
|
groupFeedbackList,
|
|
};
|
|
}
|
|
}
|