const timeOutHandler = new Object(); const inputPrefix = 'stackapi_input_'; const feedbackPrefix = 'stackapi_fb_'; const validationPrefix = 'stackapi_val_'; // const stack_api_url = // This is pulled from the publication file const stackstring = { "teacheranswershow_mcq":"A correct answer is: {$a->display}", "api_which_typed":"which can be typed as follows", "api_valid_all_parts":"Please enter valid answers for all parts of the question.", "api_out_of":"out of", "api_marks_sub":"Marks for this submission", "api_submit":"Submit Answers", "generalfeedback":"General feedback", "score":"Score", "api_response":"Response summary", "api_correct":"Correct answers" }; function wrap_math(content) { // Wrap instances of \[ ... \] and \( ... \) into the tags configured to be processed by MathJax // Here we make sure that the backslashes are not escaped like \\[ content = content.replace(/(?$1"); return content.replace(/(?$1"); } // Create data for call to API. async function collectData(qfile, qname, qprefix) { let res = ""; await getQuestionFile(qfile, qname).then((response)=>{ if (response.questionxml != "\nnull\n") { res = { questionDefinition: response.questionxml, answers: collectAnswer(qprefix), seed: response.seed, renderInputs: qprefix + inputPrefix, readOnly: false, }; }; }); // } return res; } // Get the different input elements by tag and return object with values. function collectAnswer(qprefix) { const inputs = document.getElementsByTagName('input'); const textareas = document.getElementsByTagName('textarea'); const selects = document.getElementsByTagName('select'); let res = {}; res = processNodes(res, inputs, qprefix); res = processNodes(res, textareas, qprefix); res = processNodes(res, selects, qprefix); return res; } // Return object of values of valid entries in an HTMLCollection. function processNodes(res, nodes, qprefix) { for (let i = 0; i < nodes.length; i++) { const element = nodes[i]; if (element.name.indexOf(qprefix+inputPrefix) === 0 && element.name.indexOf('_val') === -1) { if (element.type === 'checkbox' || element.type === 'radio') { if (element.checked) { res[element.name.slice((qprefix+inputPrefix).length)] = element.value; } } else { res[element.name.slice((qprefix+inputPrefix).length)] = element.value; } } } return res; } // Display rendered question and solution. function send(qfile, qname, qprefix) { const http = new XMLHttpRequest(); const url = stack_api_url + '/render'; http.open("POST", url, true); http.setRequestHeader('Content-Type', 'application/json'); http.onreadystatechange = function() { if(http.readyState == 4) { try { const json = JSON.parse(http.responseText); if (json.message) { console.log(json); document.getElementById(`${qprefix+"errors"}`).innerText = json.message; return; } else { document.getElementById(`${qprefix+"errors"}`).innerText = ''; } renameIframeHolders(); let question = json.questionrender; const inputs = json.questioninputs; const seed = json.questionseed; let correctAnswers = ''; // Show correct answers. for (const [name, input] of Object.entries(inputs)) { question = question.replace(`[[input:${name}]]`, input.render); // question = question.replaceAll(`${inputPrefix}`,`${qprefix+inputPrefix}`); question = question.replace(`[[validation:${name}]]`, ``); // This is a bit of a hack. The question render returns an calling the download function with // two arguments. We add the additional arguments that we need for context (question definition) here. question = question.replace(/javascript:download\(([^,]+?),([^,]+?)\)/, `javascript:download($1,$2, '${qfile}', '${qname}', '${qprefix}', ${seed})`); question = wrap_math(question); if (input.samplesolutionrender && name !== 'remember') { // Display render of answer and matching user input to produce the answer. correctAnswers += `

${stackstring['teacheranswershow_mcq']} \\[{${input.samplesolutionrender}}\\], ${stackstring['api_which_typed']}: `; for (const [name, solution] of Object.entries(input.samplesolution)) { if (name.indexOf('_val') === -1) { correctAnswers += `${wrap_math(solution)}`; } } correctAnswers += '.

'; } else if (name !== 'remember') { // For dropdowns, radio buttons, etc, only the correct option is displayed. for (const solution of Object.values(input.samplesolution)) { if (input.configuration.options) { correctAnswers += `

${input.configuration.options[solution]}

`; } } } } // Convert Moodle plot filenames to API filenames. for (const [name, file] of Object.entries(json.questionassets)) { const plotUrl = getPlotUrl(file); question = question.replace(name, plotUrl); json.questionsamplesolutiontext = json.questionsamplesolutiontext.replace(name, plotUrl); correctAnswers = correctAnswers.replace(name, plotUrl); } question = replaceFeedbackTags(question,qprefix); qoutput = document.getElementById(`${qprefix+'output'}`); qoutput.innerHTML = question; // Only display results sections once question retrieved. document.getElementById(`${qprefix+'stackapi_qtext'}`).style.display = 'block'; document.getElementById(`${qprefix+'stackapi_correct'}`).style.display = 'block'; // Setup a validation call on inputs. Timeout length is reset if the input is updated // before the validation call is made. for (const inputName of Object.keys(inputs)) { const inputElements = document.querySelectorAll(`[name^=${qprefix+inputPrefix + inputName}]`); for (const inputElement of Object.values(inputElements)) { inputElement.oninput = (event) => { const currentTimeout = timeOutHandler[event.target.id]; if (currentTimeout) { window.clearTimeout(currentTimeout); } timeOutHandler[event.target.id] = window.setTimeout(validate.bind(null, event.target, qfile, qname, qprefix), 1000); }; } } let sampleText = json.questionsamplesolutiontext; if (sampleText) { sampleText = replaceFeedbackTags(sampleText,qprefix); document.getElementById(`${qprefix+'generalfeedback'}`).innerHTML = wrap_math(sampleText); document.getElementById(`${qprefix+'stackapi_generalfeedback'}`).style.display = 'block'; } else { // If the question is updated, there may no longer be general feedback. document.getElementById(`${qprefix+'stackapi_generalfeedback'}`).style.display = 'none'; } document.getElementById(`${qprefix+'stackapi_score'}`).style.display = 'none'; document.getElementById(`${qprefix+'stackapi_validity'}`).innerText = ''; const innerFeedback = document.getElementById(`${qprefix+'specificfeedback'}`); innerFeedback.innerHTML = ''; innerFeedback.classList.remove('feedback'); document.getElementById(`${qprefix+'formatcorrectresponse'}`).innerHTML = correctAnswers; // Hide General feedback and correct answers for now document.getElementById(`${qprefix+'stackapi_generalfeedback'}`).style.display = 'none'; document.getElementById(`${qprefix+'stackapi_correct'}`).style.display = 'none'; createIframes(json.iframes); MathJax.typeset(); } catch(e) { console.log(e); document.getElementById(`${qprefix+'errors'}`).innerText = http.responseText; return; } } }; collectData(qfile, qname, qprefix).then((data)=>{ let submitbutton = document.getElementById(`${qprefix + 'stackapi_qtext'}`).querySelector('input[type="button"]'); submitbutton.addEventListener('click', function() {answer(qfile, qname, qprefix, data.seed)}); http.send(JSON.stringify(data)); let questioncontainer = document.getElementById(`${qprefix+'stack'}`).parentElement; if (questioncontainer.getBoundingClientRect().top<0){ questioncontainer.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; }); } // Validate an input. Called a set amount of time after an input is last updated. function validate(element, qfile, qname, qprefix) { const http = new XMLHttpRequest(); const url = stack_api_url + '/validate'; http.open("POST", url, true); // Remove API prefix and subanswer id. const answerNamePrefixTrim = (qprefix+inputPrefix).length; const answerName = element.name.slice(answerNamePrefixTrim).split('_', 1)[0]; http.setRequestHeader('Content-Type', 'application/json'); http.onreadystatechange = function() { if(http.readyState == 4) { try { const json = JSON.parse(http.responseText); if (json.message) { document.getElementById(`${qprefix+'errors'}`).innerText = json.message; return; } else { document.getElementById(`${qprefix+'errors'}`).innerText = ''; } renameIframeHolders(); const validationHTML = json.validation; const element = document.getElementsByName(`${qprefix+validationPrefix + answerName}`)[0]; element.innerHTML = wrap_math(validationHTML); if (validationHTML) { element.classList.add('validation'); } else { element.classList.remove('validation'); } createIframes(json.iframes); MathJax.typeset(); } catch(e) { document.getElementById(`${qprefix+'errors'}`).innerText = http.responseText; return; } } }; collectData(qfile, qname, qprefix).then((data)=>{ data.inputName = answerName; http.send(JSON.stringify(data)); }); } // Submit answers. function answer(qfile, qname, qprefix, seed) { const http = new XMLHttpRequest(); const url = stack_api_url + '/grade'; http.open("POST", url, true); if (!document.getElementById(`${qprefix+'output'}`).innerText) { return; } http.setRequestHeader('Content-Type', 'application/json'); http.onreadystatechange = function() { if(http.readyState == 4) { try { const json = JSON.parse(http.responseText); if (json.message) { document.getElementById(`${qprefix+'errors'}`).innerText = json.message; return; } else { document.getElementById(`${qprefix+'errors'}`).innerText = ''; } if (!json.isgradable) { document.getElementById(`${qprefix+'stackapi_validity'}`).innerText = ' ' + stackstring["api_valid_all_parts"]; return; } renameIframeHolders(); document.getElementById(`${qprefix+'score'}`).innerText = (json.score * json.scoreweights.total).toFixed(2) + ' ' + stackstring["api_out_of"] + ' ' + json.scoreweights.total; document.getElementById(`${qprefix+'stackapi_score'}`).style.display = 'block'; document.getElementById(`${qprefix+'response_summary'}`).innerText = json.responsesummary; // Show General feedback and correct answers, hide summary document.getElementById(`${qprefix+'stackapi_generalfeedback'}`).style.display = 'block'; document.getElementById(`${qprefix+'stackapi_summary'}`).style.display = 'none'; const feedback = json.prts; const specificFeedbackElement = document.getElementById(`${qprefix+'specificfeedback'}`); // Replace tags and plots in specific feedback and then display. if (json.specificfeedback) { for (const [name, file] of Object.entries(json.gradingassets)) { json.specificfeedback = json.specificfeedback.replace(name, getPlotUrl(file)); } json.specificfeedback = replaceFeedbackTags(json.specificfeedback,qprefix); specificFeedbackElement.innerHTML = wrap_math(json.specificfeedback); specificFeedbackElement.classList.add('feedback'); } else { specificFeedbackElement.classList.remove('feedback'); } // Replace plots in tagged feedback and then display. for (let [name, fb] of Object.entries(feedback)) { for (const [name, file] of Object.entries(json.gradingassets)) { fb = fb.replace(name, getPlotUrl(file)); } const elements = document.getElementsByName(`${qprefix+feedbackPrefix + name}`); if (elements.length > 0) { const element = elements[0]; if (json.scores[name] !== undefined) { fb = fb + `
${stackstring['api_marks_sub']}: ${(json.scores[name] * json.scoreweights[name] * json.scoreweights.total).toFixed(2)} / ${(json.scoreweights[name] * json.scoreweights.total).toFixed(2)}.
`; } element.innerHTML = wrap_math(fb); // if (fb) { // element.classList.add('feedback'); // } else { // element.classList.remove('feedback'); // } } } createIframes(json.iframes); MathJax.typeset(); } catch(e) { console.log(e); document.getElementById(`${qprefix+'errors'}`).innerText = http.responseText; return; } } }; // Clear previous answers and score. const specificFeedbackElement = document.getElementById(`${qprefix+'specificfeedback'}`); specificFeedbackElement.innerHTML = ""; specificFeedbackElement.classList.remove('feedback'); document.getElementById(`${qprefix+'response_summary'}`).innerText = ""; document.getElementById(`${qprefix+'stackapi_summary'}`).style.display = 'none'; const inputElements = document.querySelectorAll(`[name^=${qprefix+feedbackPrefix}]`); for (const inputElement of Object.values(inputElements)) { inputElement.innerHTML = ""; inputElement.classList.remove('feedback'); } document.getElementById(`${qprefix+'stackapi_score'}`).style.display = 'none'; document.getElementById(`${qprefix+'stackapi_validity'}`).innerText = ''; collectData(qfile, qname, qprefix).then((data) => { data.seed = seed; http.send(JSON.stringify(data)); }); } function download(filename, fileid, qfile, qname, qprefix, seed) { const http = new XMLHttpRequest(); const url = stack_api_url + '/download'; http.open("POST", url, true); http.setRequestHeader('Content-Type', 'application/json'); // Something funky going on with closures and callbacks. This seems // to be the easiest way to pass through the file details. http.filename = filename; http.fileid = fileid; http.onreadystatechange = function() { if(http.readyState == 4) { try { // Only download the file once. Replace call to download controller with link // to downloaded file. const blob = new Blob([http.responseText], {type: 'application/octet-binary', endings: 'native'}); // We're matching the three additional arguments that are added in the send function here. const selector = CSS.escape(`javascript\:download\(\'${http.filename}\'\, ${http.fileid}\, \'${qfile}\'\, \'${qname}\'\, \'${qprefix}\'\, ${seed}\)`); const linkElements = document.querySelectorAll(`a[href^=${selector}]`); const link = linkElements[0]; link.setAttribute('href', URL.createObjectURL(blob)); link.setAttribute('download', filename); link.click(); } catch(e) { document.getElementById('errors').innerText = http.responseText; return; } } }; collectData(qfile, qname, qprefix).then((data)=>{ data.filename = filename; data.fileid = fileid; data.seed = seed; http.send(JSON.stringify(data)); }); } // Save contents of question editor locally. function saveState(key, value) { if (typeof(Storage) !== "undefined") { localStorage.setItem(key, value); } } // Load locally stored question on page refresh. function loadState(key) { if (typeof(Storage) !== "undefined") { return localStorage.getItem(key) || ''; } return ''; } function renameIframeHolders() { // Each call to STACK restarts numbering of iframe holders so we need to rename // any old ones to make sure new iframes end up in the correct place. for (const iframe of document.querySelectorAll(`[id^=stack-iframe-holder]:not([id$=old]`)) { iframe.id = iframe.id + '_old'; } } function createIframes (iframes) { const corsFragment = "/cors.php?name="; for (const iframe of iframes) { create_iframe( iframe[0], iframe[1].replaceAll(corsFragment, stack_api_url + corsFragment), ...iframe.slice(2) ); } } // Replace feedback tags in some text with an approproately named HTML div. function replaceFeedbackTags(text, qprefix) { let result = text; const feedbackTags = text.match(/\[\[feedback:.*?\]\]/g); if (feedbackTags) { for (const tag of feedbackTags) { // Part name is between '[[feedback:' and ']]'. result = result.replace(tag, `
`); } } return result; } async function getQuestionFile(questionURL, questionName) { let res = ""; if (questionURL) { await fetch(questionURL) .then(result => result.text()) .then((result) => { res = loadQuestionFromFile(result, questionName); }); } return res; } function loadQuestionFromFile(fileContents, questionName) { const parser = new DOMParser(); const xmlDoc = parser.parseFromString(fileContents, "text/xml"); let thequestion = null; let randSeed = ""; for (const question of xmlDoc.getElementsByTagName("question")) { if (question.getAttribute('type').toLowerCase() === 'stack' && (!questionName || question.querySelectorAll("name text")[0].textContent === questionName)) { thequestion = question.outerHTML; let seeds = question.querySelectorAll('deployedseed'); if (seeds.length) { randSeed = parseInt(seeds[Math.floor(Math.random()*seeds.length)].textContent); } break; } } return {questionxml:setQuestion(thequestion),seed:randSeed}; } function setQuestion(question) { return '\n' + question + '\n'; } function createQuestionBlocks() { questionBlocks = document.getElementsByClassName("que stack"); let i=0; for (questionblock of questionBlocks){ i++; let questionPrefix = "q" + i.toString() + "_"; var qfile = questionblock.dataset.qfile; var qname = questionblock.dataset.qname || ""; questionblock.innerHTML = `
`; } } function addCollapsibles(){ var collapsibles = document.querySelectorAll(".level2>h2, .stack>h2"); for (let i=0; i collapseFunc(this)); } } function collapseFunc(e){ e.classList.toggle("collapsed"); } function stackSetup(){ createQuestionBlocks(); addCollapsibles(); } function getPlotUrl(file) { return `${stack_api_url}/plots/${file}`; }