diff --git a/amd/build/library.min.js b/amd/build/library.min.js index 1e419161dc2..cb0ee4d2c4f 100644 --- a/amd/build/library.min.js +++ b/amd/build/library.min.js @@ -6,6 +6,6 @@ * @copyright 2024 The University of Edinburgh * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -define("qtype_stack/library",["core/ajax","core_filters/events"],(function(Ajax,CustomEvents){let courseId=null,categoryId=null,libraryDiv=null,rawDiv=null,variablesDiv=null,descriptionDiv=null,importListDiv=null,importSuccessDiv=null,importFailureDiv=null,importSuccessFileDiv=null,importFailureFileDiv=null,displayedDiv=null,quizLink=null,dashLink=null,errorDiv=null,errorDetailsDiv=null,currentPath=null;function libraryRender(e){const filepath=e.target.getAttribute("data-filepath");currentPath=filepath,loading(!0),categoryId=Number(document.getElementById("id_category").value.split(",")[0]),Ajax.call([{methodname:"qtype_stack_library_render",args:{category:categoryId,filepath:filepath},done:function(response){loading(!1),libraryDiv.innerHTML=response.questionrender;for(const iframe of response.iframes)require(["qtype_stack/stackjsvle"],(function(stackjsvle){stackjsvle.create_iframe(iframe.iframeid,iframe.content,iframe.targetdivid,iframe.title,iframe.scrolling,iframe.evil)}));rawDiv.innerText=response.questiontext,descriptionDiv.innerHTML=response.questiondescription,variablesDiv.innerHTML=response.questionvariables.replace(/;/g,";
"),displayedDiv.innerHTML=response.questionname+"
("+filepath.split("/").pop()+")",document.querySelectorAll(".library-secondary-info").forEach((el=>el.removeAttribute("hidden"))),document.querySelector(".library-import-link").removeAttribute("disabled"),filepath.endsWith("_quiz.json")?(document.querySelector(".stack-library-category-holder").setAttribute("hidden",!0),document.querySelector(".stack-library-course").removeAttribute("hidden")):(document.querySelector(".stack-library-course").setAttribute("hidden",!0),document.querySelector(".stack-library-category-holder").removeAttribute("hidden"),document.querySelector(".library-import-link-folder").removeAttribute("disabled")),CustomEvents.notifyFilterContentUpdated(libraryDiv)},fail:function(response){loading(!1),errorDetailsDiv.innerHTML=response.message?response.message:"",errorDiv.hidden=!1}}])}function libraryImport(isFolder){if(!currentPath)return;const filepath=currentPath;loading(!0),categoryId=Number(document.getElementById("id_category").value.split(",")[0]),Ajax.call([{methodname:"qtype_stack_library_import",args:{courseid:courseId,category:categoryId,filepath:filepath,isfolder:isFolder?1:0},done:function(response){loading(!1);for(const currentQuestion of response)if(currentQuestion.success){let currentDashLink=dashLink+currentQuestion.questionid;currentQuestion.isstack?importListDiv.innerHTML+='
'+currentQuestion.questionname+"":currentQuestion.filename.endsWith("_quiz.json")?importListDiv.innerHTML+='
'+currentQuestion.questionname+"":importListDiv.innerHTML+="
"+currentQuestion.questionname,importSuccessFileDiv.innerHTML+="
"+currentQuestion.filename.split("/").pop()+" --\x3e "+currentQuestion.questionname,importSuccessDiv.removeAttribute("hidden")}else importFailureFileDiv.innerHTML+="
"+currentQuestion.filename.split("/").pop(),importFailureDiv.removeAttribute("hidden")},fail:function(response){loading(!1),errorDetailsDiv.innerHTML=response.message?response.message:"",errorDiv.hidden=!1}}])}function loading(isLoading){errorDiv.hidden=!0,isLoading?(document.querySelector(".loading-display").removeAttribute("hidden"),document.querySelector(".library-import-link").setAttribute("disabled","disabled"),document.querySelector(".library-import-link-folder").setAttribute("disabled","disabled"),document.querySelectorAll(".library-file-link").forEach((el=>el.setAttribute("disabled","disabled"))),importSuccessFileDiv.innerHTML="",importSuccessDiv.setAttribute("hidden",!0),importFailureFileDiv.innerHTML="",importFailureDiv.setAttribute("hidden",!0)):(document.querySelector(".loading-display").setAttribute("hidden",!0),document.querySelectorAll(".library-file-link").forEach((el=>el.removeAttribute("disabled"))))}return{setup:function(){libraryDiv=document.querySelector(".stack_library_display"),rawDiv=document.querySelector(".stack_library_raw_display"),variablesDiv=document.querySelector(".stack_library_variables_display"),importListDiv=document.querySelector(".stack-library-imported-list"),displayedDiv=document.querySelector(".stack_library_selected_question"),descriptionDiv=document.querySelector(".stack_library_description_display"),errorDiv=document.querySelector(".stack-library-error"),errorDetailsDiv=document.querySelector(".stack-library-error-details"),importSuccessDiv=document.querySelector(".stack-library-import-success"),importFailureDiv=document.querySelector(".stack-library-import-failure"),importSuccessFileDiv=document.querySelector(".stack-library-import-success-file"),importFailureFileDiv=document.querySelector(".stack-library-import-failure-file"),dashLink=document.querySelector("#dashboard-link-holder").innerHTML.trim(),quizLink=document.querySelector("#quiz-link-holder").innerHTML.trim(),dashLink=dashLink.includes("?")?dashLink+="&questionid=":dashLink+="?questionid=",loading(!0),document.querySelectorAll(".library-file-link").forEach((function(elem){elem.addEventListener("click",libraryRender)})),courseId=document.querySelector('[data-id="stack_library_course_id"]').getAttribute("data-value"),document.querySelector(".library-import-link").addEventListener("click",(()=>libraryImport(!1))),document.querySelector(".library-import-link-folder").addEventListener("click",(()=>libraryImport(!0)));const catOptions=document.querySelectorAll("#id_category option");for(let option of catOptions){const sections=option.text.split("(");sections.length>1&&(sections[0]||sections.length>2)&&(sections.pop(),option.text=sections.join("("))}loading(!1)}}})); +define("qtype_stack/library",["core/ajax","core_filters/events"],(function(Ajax,CustomEvents){let courseId=null,categoryId=null,cacheId=null,libraryDiv=null,rawDiv=null,variablesDiv=null,descriptionDiv=null,importListDiv=null,importSuccessDiv=null,importFailureDiv=null,importSuccessFileDiv=null,importFailureFileDiv=null,displayedDiv=null,quizLink=null,dashLink=null,errorDiv=null,errorDetailsDiv=null,currentPath=null;function escapeHtml(text){return String(text||"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function libraryRender(e){let filepath=e.target.getAttribute("data-filepath");currentPath=filepath,loading(!0),categoryId=Number(document.getElementById("id_category").value.split(",")[0]),Ajax.call([{methodname:"qtype_stack_library_render",args:{category:categoryId,filepath:filepath,cacheid:cacheId,apikey:""},done:function(response){loading(!1),libraryDiv.innerHTML=response.questionrender;for(const iframe of response.iframes)require(["qtype_stack/stackjsvle"],(function(stackjsvle){stackjsvle.create_iframe(iframe.iframeid,iframe.content,iframe.targetdivid,iframe.title,iframe.scrolling,iframe.evil)}));rawDiv.innerText=response.questiontext,descriptionDiv.textContent=response.questiondescription,variablesDiv.innerHTML=response.questionvariables.split(";").map(escapeHtml).join(";
"),displayedDiv.innerHTML=escapeHtml(response.questionname)+"
("+escapeHtml(filepath.split("/").pop())+")",document.querySelectorAll(".library-secondary-info").forEach((el=>el.removeAttribute("hidden"))),document.querySelector(".library-import-link").removeAttribute("disabled"),filepath.endsWith("_quiz.json")?(document.querySelector(".stack-library-category-holder").setAttribute("hidden",!0),document.querySelector(".stack-library-course").removeAttribute("hidden")):(document.querySelector(".stack-library-course").setAttribute("hidden",!0),document.querySelector(".stack-library-category-holder").removeAttribute("hidden"),"nrwsearch"!==cacheId&&document.querySelector(".library-import-link-folder").removeAttribute("disabled")),CustomEvents.notifyFilterContentUpdated(libraryDiv)},fail:function(response){loading(!1),errorDetailsDiv.textContent=response.message||"",errorDiv.hidden=!1}}])}function libraryImport(isFolder){if(!currentPath)return;const filepath=currentPath;loading(!0),categoryId=Number(document.getElementById("id_category").value.split(",")[0]),Ajax.call([{methodname:"qtype_stack_library_import",args:{courseid:courseId,category:categoryId,filepath:filepath,isfolder:isFolder?1:0,cacheid:cacheId,apikey:""},done:function(response){loading(!1);for(const currentQuestion of response)if(currentQuestion.success){let currentDashLink=dashLink+currentQuestion.questionid;currentQuestion.isstack?importListDiv.innerHTML+='
'+escapeHtml(currentQuestion.questionname)+"":currentQuestion.filename.endsWith("_quiz.json")?importListDiv.innerHTML+='
'+escapeHtml(currentQuestion.questionname)+"":importListDiv.innerHTML+="
"+escapeHtml(currentQuestion.questionname),importSuccessFileDiv.innerHTML+="
"+escapeHtml(currentQuestion.filename.split("/").pop())+" --\x3e "+escapeHtml(currentQuestion.questionname),importSuccessDiv.removeAttribute("hidden")}else importFailureFileDiv.innerHTML+="
"+escapeHtml(currentQuestion.filename.split("/").pop()),importFailureDiv.removeAttribute("hidden")},fail:function(response){loading(!1),errorDetailsDiv.textContent=response.message||"",errorDiv.hidden=!1}}])}function loading(isLoading){errorDiv.hidden=!0,isLoading?(document.querySelector(".loading-display").removeAttribute("hidden"),document.querySelector(".library-import-link").setAttribute("disabled","disabled"),document.querySelector(".library-import-link-folder").setAttribute("disabled","disabled"),document.querySelectorAll(".library-file-link").forEach((el=>el.setAttribute("disabled","disabled"))),importSuccessFileDiv.innerHTML="",importSuccessDiv.setAttribute("hidden",!0),importFailureFileDiv.innerHTML="",importFailureDiv.setAttribute("hidden",!0)):(document.querySelector(".loading-display").setAttribute("hidden",!0),document.querySelectorAll(".library-file-link").forEach((el=>el.removeAttribute("disabled"))))}return{setup:function(){libraryDiv=document.querySelector(".stack_library_display"),rawDiv=document.querySelector(".stack_library_raw_display"),variablesDiv=document.querySelector(".stack_library_variables_display"),importListDiv=document.querySelector(".stack-library-imported-list"),displayedDiv=document.querySelector(".stack_library_selected_question"),descriptionDiv=document.querySelector(".stack_library_description_display"),errorDiv=document.querySelector(".stack-library-error"),errorDetailsDiv=document.querySelector(".stack-library-error-details"),importSuccessDiv=document.querySelector(".stack-library-import-success"),importFailureDiv=document.querySelector(".stack-library-import-failure"),importSuccessFileDiv=document.querySelector(".stack-library-import-success-file"),importFailureFileDiv=document.querySelector(".stack-library-import-failure-file"),dashLink=document.querySelector("#dashboard-link-holder").innerHTML.trim(),quizLink=document.querySelector("#quiz-link-holder").innerHTML.trim(),dashLink=dashLink.includes("?")?dashLink+="&questionid=":dashLink+="?questionid=",loading(!0),document.querySelectorAll(".library-file-link").forEach((function(elem){elem.addEventListener("click",libraryRender)})),courseId=document.querySelector('[data-id="stack_library_course_id"]').getAttribute("data-value"),cacheId=document.querySelector('[data-id="stack_cache_id"]').getAttribute("data-value"),document.querySelector(".library-import-link").addEventListener("click",(()=>libraryImport(!1))),document.querySelector(".library-import-link-folder").addEventListener("click",(()=>libraryImport(!0)));const catOptions=document.querySelectorAll("#id_category option");for(let option of catOptions){const sections=option.text.split("(");sections.length>1&&(sections[0]||sections.length>2)&&(sections.pop(),option.text=sections.join("("))}loading(!1)}}})); //# sourceMappingURL=library.min.js.map \ No newline at end of file diff --git a/amd/build/library.min.js.map b/amd/build/library.min.js.map index f4c77de3482..1e3293d3068 100644 --- a/amd/build/library.min.js.map +++ b/amd/build/library.min.js.map @@ -1 +1 @@ -{"version":3,"file":"library.min.js","sources":["../src/library.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A javascript module to handle requests for library question info\n * and to import questions.\n *\n * @module qtype_stack/library\n * @copyright 2024 The University of Edinburgh\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([\n 'core/ajax',\n 'core_filters/events'\n], function(\n Ajax,\n CustomEvents\n) {\n\n let courseId = null;\n let categoryId = null;\n let libraryDiv = null;\n let rawDiv = null;\n let variablesDiv = null;\n let descriptionDiv = null;\n let importListDiv = null;\n let importSuccessDiv = null;\n let importFailureDiv = null;\n let importSuccessFileDiv = null;\n let importFailureFileDiv = null;\n let displayedDiv = null;\n let quizLink = null;\n let dashLink = null;\n let errorDiv = null;\n let errorDetailsDiv = null;\n let currentPath = null;\n\n /**\n * Sets up event listeners.\n *\n */\n function setup() {\n libraryDiv = document.querySelector('.stack_library_display');\n rawDiv = document.querySelector('.stack_library_raw_display');\n variablesDiv = document.querySelector('.stack_library_variables_display');\n importListDiv = document.querySelector('.stack-library-imported-list');\n displayedDiv = document.querySelector('.stack_library_selected_question');\n descriptionDiv = document.querySelector('.stack_library_description_display');\n errorDiv = document.querySelector('.stack-library-error');\n errorDetailsDiv = document.querySelector('.stack-library-error-details');\n importSuccessDiv = document.querySelector('.stack-library-import-success');\n importFailureDiv = document.querySelector('.stack-library-import-failure');\n importSuccessFileDiv = document.querySelector('.stack-library-import-success-file');\n importFailureFileDiv = document.querySelector('.stack-library-import-failure-file');\n dashLink = document.querySelector('#dashboard-link-holder').innerHTML.trim();\n quizLink = document.querySelector('#quiz-link-holder').innerHTML.trim();\n dashLink = dashLink.includes('?') ? dashLink = dashLink + '&questionid=' : dashLink = dashLink + '?questionid=';\n loading(true);\n const linksArray = document.querySelectorAll('.library-file-link');\n linksArray.forEach(function(elem) {\n elem.addEventListener('click', libraryRender);\n });\n courseId = document.querySelector('[data-id=\"stack_library_course_id\"]').getAttribute('data-value');\n const importButton = document.querySelector('.library-import-link');\n importButton.addEventListener('click', ()=>libraryImport(false));\n const importFolderButton = document.querySelector('.library-import-link-folder');\n importFolderButton.addEventListener('click', ()=>libraryImport(true));\n // Remove number of questions from category dropdown as we're not\n // updating them and that will confuse users.\n const catOptions = document.querySelectorAll('#id_category option');\n for (let option of catOptions) {\n let optionText = option.text;\n const sections = optionText.split('(');\n if (sections.length > 1) {\n if (sections[0] || sections.length > 2) {\n sections.pop();\n option.text = sections.join('(');\n }\n }\n }\n loading(false);\n }\n\n /**\n * Performs AJAX call to Moodle to get info on a question when\n * a link containing the questions filename is clicked.\n *\n * @param {object} e the click event triggering the function call.\n */\n function libraryRender(e) {\n const filepath = e.target.getAttribute('data-filepath');\n currentPath = filepath;\n loading(true);\n categoryId = Number(document.getElementById('id_category').value.split(',')[0]);\n Ajax.call([{\n methodname: 'qtype_stack_library_render',\n args: {category: categoryId, filepath: filepath},\n done: function(response) {\n loading(false);\n libraryDiv.innerHTML = response.questionrender;\n for (const iframe of response.iframes) {\n require(['qtype_stack/stackjsvle'],\n function(stackjsvle,) {\n stackjsvle.create_iframe(\n iframe.iframeid,\n iframe.content,\n iframe.targetdivid,\n iframe.title,\n iframe.scrolling,\n iframe.evil\n );\n });\n }\n rawDiv.innerText = response.questiontext;\n descriptionDiv.innerHTML = response.questiondescription;\n variablesDiv.innerHTML = response.questionvariables.replace(/;/g, \";
\");\n displayedDiv.innerHTML = response.questionname + '
(' + filepath.split('/').pop() + ')';\n document.querySelectorAll('.library-secondary-info')\n .forEach(el => el.removeAttribute('hidden'));\n document.querySelector('.library-import-link').removeAttribute('disabled');\n if (filepath.endsWith('_quiz.json')) {\n document.querySelector('.stack-library-category-holder').setAttribute('hidden', true);\n document.querySelector('.stack-library-course').removeAttribute('hidden');\n } else {\n document.querySelector('.stack-library-course').setAttribute('hidden', true);\n document.querySelector('.stack-library-category-holder').removeAttribute('hidden');\n document.querySelector('.library-import-link-folder').removeAttribute('disabled');\n }\n // This fires the Maths filters for content in the validation div.\n CustomEvents.notifyFilterContentUpdated(libraryDiv);\n },\n fail: function(response) {\n loading(false);\n errorDetailsDiv.innerHTML = (response.message) ? response.message : '';\n errorDiv.hidden = false;\n }\n }]);\n }\n\n /**\n * Performs AJAX call to Moodle to import a question.\n *\n * @param {boolean} isFolder is this a request to load the whole folder\n */\n function libraryImport(isFolder) {\n if (!currentPath) {\n return;\n }\n const filepath = currentPath;\n loading(true);\n categoryId = Number(document.getElementById('id_category').value.split(',')[0]);\n Ajax.call([{\n methodname: 'qtype_stack_library_import',\n args: {courseid: courseId, category: categoryId, filepath: filepath, isfolder: (isFolder) ? 1 : 0},\n done: function(response) {\n loading(false);\n for (const currentQuestion of response) {\n if (currentQuestion.success) {\n let currentDashLink = dashLink + currentQuestion.questionid;\n if (currentQuestion.isstack) {\n importListDiv.innerHTML += '
' + '' + currentQuestion.questionname + '';\n } else if (currentQuestion.filename.endsWith('_quiz.json')) {\n importListDiv.innerHTML += '
' + ''\n + currentQuestion.questionname + '';\n } else {\n importListDiv.innerHTML += '
' + currentQuestion.questionname;\n }\n importSuccessFileDiv.innerHTML += '
' +\n currentQuestion.filename.split('/').pop() + ' --> ' + currentQuestion.questionname;\n importSuccessDiv.removeAttribute('hidden');\n } else {\n importFailureFileDiv.innerHTML += '
' +\n currentQuestion.filename.split('/').pop();\n importFailureDiv.removeAttribute('hidden');\n }\n }\n },\n fail: function(response) {\n loading(false);\n errorDetailsDiv.innerHTML = (response.message) ? response.message : '';\n errorDiv.hidden = false;\n }\n }]);\n }\n\n /**\n * Disable/enable features before/after loading.\n *\n * @param {boolean} isLoading Is an AJAX call taking place?\n */\n function loading(isLoading) {\n errorDiv.hidden = true;\n if (isLoading) {\n document.querySelector('.loading-display').removeAttribute('hidden');\n document.querySelector('.library-import-link').setAttribute('disabled', 'disabled');\n document.querySelector('.library-import-link-folder').setAttribute('disabled', 'disabled');\n document.querySelectorAll('.library-file-link').forEach(el => el.setAttribute('disabled', 'disabled'));\n importSuccessFileDiv.innerHTML = '';\n importSuccessDiv.setAttribute('hidden', true);\n importFailureFileDiv.innerHTML = '';\n importFailureDiv.setAttribute('hidden', true);\n } else {\n document.querySelector('.loading-display').setAttribute('hidden', true);\n document.querySelectorAll('.library-file-link').forEach(el => el.removeAttribute('disabled'));\n }\n }\n\n /** Export our entry point. */\n return {\n setup: setup\n };\n});\n"],"names":["define","Ajax","CustomEvents","courseId","categoryId","libraryDiv","rawDiv","variablesDiv","descriptionDiv","importListDiv","importSuccessDiv","importFailureDiv","importSuccessFileDiv","importFailureFileDiv","displayedDiv","quizLink","dashLink","errorDiv","errorDetailsDiv","currentPath","libraryRender","e","filepath","target","getAttribute","loading","Number","document","getElementById","value","split","call","methodname","args","category","done","response","innerHTML","questionrender","iframe","iframes","require","stackjsvle","create_iframe","iframeid","content","targetdivid","title","scrolling","evil","innerText","questiontext","questiondescription","questionvariables","replace","questionname","pop","querySelectorAll","forEach","el","removeAttribute","querySelector","endsWith","setAttribute","notifyFilterContentUpdated","fail","message","hidden","libraryImport","isFolder","courseid","isfolder","currentQuestion","success","currentDashLink","questionid","isstack","filename","isLoading","setup","trim","includes","elem","addEventListener","catOptions","option","sections","text","length","join"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"library.min.js","sources":["../src/library.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A javascript module to handle requests for library question info\n * and to import questions.\n *\n * @module qtype_stack/library\n * @copyright 2024 The University of Edinburgh\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([\n 'core/ajax',\n 'core_filters/events'\n], function(\n Ajax,\n CustomEvents\n) {\n\n let courseId = null;\n let categoryId = null;\n let cacheId = null;\n let libraryDiv = null;\n let rawDiv = null;\n let variablesDiv = null;\n let descriptionDiv = null;\n let importListDiv = null;\n let importSuccessDiv = null;\n let importFailureDiv = null;\n let importSuccessFileDiv = null;\n let importFailureFileDiv = null;\n let displayedDiv = null;\n let quizLink = null;\n let dashLink = null;\n let errorDiv = null;\n let errorDetailsDiv = null;\n let currentPath = null;\n\n /**\n * Escape text before inserting it into innerHTML.\n *\n * @param {string} text text to escape\n * @returns {string} escaped text\n */\n function escapeHtml(text) {\n return String(text || '')\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n }\n\n /**\n * Sets up event listeners.\n *\n */\n function setup() {\n libraryDiv = document.querySelector('.stack_library_display');\n rawDiv = document.querySelector('.stack_library_raw_display');\n variablesDiv = document.querySelector('.stack_library_variables_display');\n importListDiv = document.querySelector('.stack-library-imported-list');\n displayedDiv = document.querySelector('.stack_library_selected_question');\n descriptionDiv = document.querySelector('.stack_library_description_display');\n errorDiv = document.querySelector('.stack-library-error');\n errorDetailsDiv = document.querySelector('.stack-library-error-details');\n importSuccessDiv = document.querySelector('.stack-library-import-success');\n importFailureDiv = document.querySelector('.stack-library-import-failure');\n importSuccessFileDiv = document.querySelector('.stack-library-import-success-file');\n importFailureFileDiv = document.querySelector('.stack-library-import-failure-file');\n dashLink = document.querySelector('#dashboard-link-holder').innerHTML.trim();\n quizLink = document.querySelector('#quiz-link-holder').innerHTML.trim();\n dashLink = dashLink.includes('?') ? dashLink = dashLink + '&questionid=' : dashLink = dashLink + '?questionid=';\n loading(true);\n const linksArray = document.querySelectorAll('.library-file-link');\n linksArray.forEach(function(elem) {\n elem.addEventListener('click', libraryRender);\n });\n courseId = document.querySelector('[data-id=\"stack_library_course_id\"]').getAttribute('data-value');\n cacheId = document.querySelector('[data-id=\"stack_cache_id\"]').getAttribute('data-value');\n const importButton = document.querySelector('.library-import-link');\n importButton.addEventListener('click', ()=>libraryImport(false));\n const importFolderButton = document.querySelector('.library-import-link-folder');\n importFolderButton.addEventListener('click', ()=>libraryImport(true));\n // Remove number of questions from category dropdown as we're not\n // updating them and that will confuse users.\n const catOptions = document.querySelectorAll('#id_category option');\n for (let option of catOptions) {\n let optionText = option.text;\n const sections = optionText.split('(');\n if (sections.length > 1) {\n if (sections[0] || sections.length > 2) {\n sections.pop();\n option.text = sections.join('(');\n }\n }\n }\n loading(false);\n }\n\n /**\n * Performs AJAX call to Moodle to get info on a question when\n * a link containing the questions filename is clicked.\n *\n * @param {object} e the click event triggering the function call.\n */\n function libraryRender(e) {\n let filepath = e.target.getAttribute('data-filepath');\n currentPath = filepath;\n loading(true);\n categoryId = Number(document.getElementById('id_category').value.split(',')[0]);\n Ajax.call([{\n methodname: 'qtype_stack_library_render',\n args: {category: categoryId, filepath: filepath, cacheid: cacheId, apikey: ''},\n done: function(response) {\n loading(false);\n libraryDiv.innerHTML = response.questionrender;\n for (const iframe of response.iframes) {\n require(['qtype_stack/stackjsvle'],\n function(stackjsvle,) {\n stackjsvle.create_iframe(\n iframe.iframeid,\n iframe.content,\n iframe.targetdivid,\n iframe.title,\n iframe.scrolling,\n iframe.evil\n );\n });\n }\n rawDiv.innerText = response.questiontext;\n descriptionDiv.textContent = response.questiondescription;\n variablesDiv.innerHTML = response.questionvariables.split(';').map(escapeHtml).join(\";
\");\n displayedDiv.innerHTML = escapeHtml(response.questionname) +\n '
(' + escapeHtml(filepath.split('/').pop()) + ')';\n document.querySelectorAll('.library-secondary-info')\n .forEach(el => el.removeAttribute('hidden'));\n document.querySelector('.library-import-link').removeAttribute('disabled');\n if (filepath.endsWith('_quiz.json')) {\n document.querySelector('.stack-library-category-holder').setAttribute('hidden', true);\n document.querySelector('.stack-library-course').removeAttribute('hidden');\n } else {\n document.querySelector('.stack-library-course').setAttribute('hidden', true);\n document.querySelector('.stack-library-category-holder').removeAttribute('hidden');\n if (cacheId !== 'nrwsearch') {\n document.querySelector('.library-import-link-folder').removeAttribute('disabled');\n }\n }\n // This fires the Maths filters for content in the validation div.\n CustomEvents.notifyFilterContentUpdated(libraryDiv);\n },\n fail: function(response) {\n loading(false);\n errorDetailsDiv.textContent = response.message || '';\n errorDiv.hidden = false;\n }\n }]);\n }\n\n /**\n * Performs AJAX call to Moodle to import a question.\n *\n * @param {boolean} isFolder is this a request to load the whole folder\n */\n function libraryImport(isFolder) {\n if (!currentPath) {\n return;\n }\n const filepath = currentPath;\n loading(true);\n categoryId = Number(document.getElementById('id_category').value.split(',')[0]);\n Ajax.call([{\n methodname: 'qtype_stack_library_import',\n args: {\n courseid: courseId,\n category: categoryId,\n filepath: filepath,\n isfolder: (isFolder) ? 1 : 0,\n cacheid: cacheId,\n apikey: ''\n },\n done: function(response) {\n loading(false);\n for (const currentQuestion of response) {\n if (currentQuestion.success) {\n let currentDashLink = dashLink + currentQuestion.questionid;\n if (currentQuestion.isstack) {\n importListDiv.innerHTML += '
' + '' + escapeHtml(currentQuestion.questionname) + '';\n } else if (currentQuestion.filename.endsWith('_quiz.json')) {\n importListDiv.innerHTML += '
' + ''\n + escapeHtml(currentQuestion.questionname) + '';\n } else {\n importListDiv.innerHTML += '
' + escapeHtml(currentQuestion.questionname);\n }\n importSuccessFileDiv.innerHTML += '
' +\n escapeHtml(currentQuestion.filename.split('/').pop()) + ' --> ' +\n escapeHtml(currentQuestion.questionname);\n importSuccessDiv.removeAttribute('hidden');\n } else {\n importFailureFileDiv.innerHTML += '
' +\n escapeHtml(currentQuestion.filename.split('/').pop());\n importFailureDiv.removeAttribute('hidden');\n }\n }\n },\n fail: function(response) {\n loading(false);\n errorDetailsDiv.textContent = response.message || '';\n errorDiv.hidden = false;\n }\n }]);\n }\n\n /**\n * Disable/enable features before/after loading.\n *\n * @param {boolean} isLoading Is an AJAX call taking place?\n */\n function loading(isLoading) {\n errorDiv.hidden = true;\n if (isLoading) {\n document.querySelector('.loading-display').removeAttribute('hidden');\n document.querySelector('.library-import-link').setAttribute('disabled', 'disabled');\n document.querySelector('.library-import-link-folder').setAttribute('disabled', 'disabled');\n document.querySelectorAll('.library-file-link').forEach(el => el.setAttribute('disabled', 'disabled'));\n importSuccessFileDiv.innerHTML = '';\n importSuccessDiv.setAttribute('hidden', true);\n importFailureFileDiv.innerHTML = '';\n importFailureDiv.setAttribute('hidden', true);\n } else {\n document.querySelector('.loading-display').setAttribute('hidden', true);\n document.querySelectorAll('.library-file-link').forEach(el => el.removeAttribute('disabled'));\n }\n }\n\n /** Export our entry point. */\n return {\n setup: setup\n };\n});\n"],"names":["define","Ajax","CustomEvents","courseId","categoryId","cacheId","libraryDiv","rawDiv","variablesDiv","descriptionDiv","importListDiv","importSuccessDiv","importFailureDiv","importSuccessFileDiv","importFailureFileDiv","displayedDiv","quizLink","dashLink","errorDiv","errorDetailsDiv","currentPath","escapeHtml","text","String","replace","libraryRender","e","filepath","target","getAttribute","loading","Number","document","getElementById","value","split","call","methodname","args","category","cacheid","apikey","done","response","innerHTML","questionrender","iframe","iframes","require","stackjsvle","create_iframe","iframeid","content","targetdivid","title","scrolling","evil","innerText","questiontext","textContent","questiondescription","questionvariables","map","join","questionname","pop","querySelectorAll","forEach","el","removeAttribute","querySelector","endsWith","setAttribute","notifyFilterContentUpdated","fail","message","hidden","libraryImport","isFolder","courseid","isfolder","currentQuestion","success","currentDashLink","questionid","isstack","filename","isLoading","setup","trim","includes","elem","addEventListener","catOptions","option","sections","length"],"mappings":"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"} \ No newline at end of file diff --git a/amd/src/library.js b/amd/src/library.js index 0a4d685fbaf..51ce927981e 100644 --- a/amd/src/library.js +++ b/amd/src/library.js @@ -31,6 +31,7 @@ define([ let courseId = null; let categoryId = null; + let cacheId = null; let libraryDiv = null; let rawDiv = null; let variablesDiv = null; @@ -47,6 +48,21 @@ define([ let errorDetailsDiv = null; let currentPath = null; + /** + * Escape text before inserting it into innerHTML. + * + * @param {string} text text to escape + * @returns {string} escaped text + */ + function escapeHtml(text) { + return String(text || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + /** * Sets up event listeners. * @@ -73,6 +89,7 @@ define([ elem.addEventListener('click', libraryRender); }); courseId = document.querySelector('[data-id="stack_library_course_id"]').getAttribute('data-value'); + cacheId = document.querySelector('[data-id="stack_cache_id"]').getAttribute('data-value'); const importButton = document.querySelector('.library-import-link'); importButton.addEventListener('click', ()=>libraryImport(false)); const importFolderButton = document.querySelector('.library-import-link-folder'); @@ -100,13 +117,13 @@ define([ * @param {object} e the click event triggering the function call. */ function libraryRender(e) { - const filepath = e.target.getAttribute('data-filepath'); + let filepath = e.target.getAttribute('data-filepath'); currentPath = filepath; loading(true); categoryId = Number(document.getElementById('id_category').value.split(',')[0]); Ajax.call([{ methodname: 'qtype_stack_library_render', - args: {category: categoryId, filepath: filepath}, + args: {category: categoryId, filepath: filepath, cacheid: cacheId, apikey: ''}, done: function(response) { loading(false); libraryDiv.innerHTML = response.questionrender; @@ -124,9 +141,10 @@ define([ }); } rawDiv.innerText = response.questiontext; - descriptionDiv.innerHTML = response.questiondescription; - variablesDiv.innerHTML = response.questionvariables.replace(/;/g, ";
"); - displayedDiv.innerHTML = response.questionname + '
(' + filepath.split('/').pop() + ')'; + descriptionDiv.textContent = response.questiondescription; + variablesDiv.innerHTML = response.questionvariables.split(';').map(escapeHtml).join(";
"); + displayedDiv.innerHTML = escapeHtml(response.questionname) + + '
(' + escapeHtml(filepath.split('/').pop()) + ')'; document.querySelectorAll('.library-secondary-info') .forEach(el => el.removeAttribute('hidden')); document.querySelector('.library-import-link').removeAttribute('disabled'); @@ -136,14 +154,16 @@ define([ } else { document.querySelector('.stack-library-course').setAttribute('hidden', true); document.querySelector('.stack-library-category-holder').removeAttribute('hidden'); - document.querySelector('.library-import-link-folder').removeAttribute('disabled'); + if (cacheId !== 'nrwsearch') { + document.querySelector('.library-import-link-folder').removeAttribute('disabled'); + } } // This fires the Maths filters for content in the validation div. CustomEvents.notifyFilterContentUpdated(libraryDiv); }, fail: function(response) { loading(false); - errorDetailsDiv.innerHTML = (response.message) ? response.message : ''; + errorDetailsDiv.textContent = response.message || ''; errorDiv.hidden = false; } }]); @@ -163,7 +183,14 @@ define([ categoryId = Number(document.getElementById('id_category').value.split(',')[0]); Ajax.call([{ methodname: 'qtype_stack_library_import', - args: {courseid: courseId, category: categoryId, filepath: filepath, isfolder: (isFolder) ? 1 : 0}, + args: { + courseid: courseId, + category: categoryId, + filepath: filepath, + isfolder: (isFolder) ? 1 : 0, + cacheid: cacheId, + apikey: '' + }, done: function(response) { loading(false); for (const currentQuestion of response) { @@ -171,27 +198,28 @@ define([ let currentDashLink = dashLink + currentQuestion.questionid; if (currentQuestion.isstack) { importListDiv.innerHTML += '
' + '' + currentQuestion.questionname + ''; + + currentDashLink + '">' + escapeHtml(currentQuestion.questionname) + ''; } else if (currentQuestion.filename.endsWith('_quiz.json')) { importListDiv.innerHTML += '
' + '' - + currentQuestion.questionname + ''; + + escapeHtml(currentQuestion.questionname) + ''; } else { - importListDiv.innerHTML += '
' + currentQuestion.questionname; + importListDiv.innerHTML += '
' + escapeHtml(currentQuestion.questionname); } importSuccessFileDiv.innerHTML += '
' + - currentQuestion.filename.split('/').pop() + ' --> ' + currentQuestion.questionname; + escapeHtml(currentQuestion.filename.split('/').pop()) + ' --> ' + + escapeHtml(currentQuestion.questionname); importSuccessDiv.removeAttribute('hidden'); } else { importFailureFileDiv.innerHTML += '
' + - currentQuestion.filename.split('/').pop(); + escapeHtml(currentQuestion.filename.split('/').pop()); importFailureDiv.removeAttribute('hidden'); } } }, fail: function(response) { loading(false); - errorDetailsDiv.innerHTML = (response.message) ? response.message : ''; + errorDetailsDiv.textContent = response.message || ''; errorDiv.hidden = false; } }]); diff --git a/api/public/sample.php b/api/public/sample.php index 4b5acd4c357..cd3b93a2826 100644 --- a/api/public/sample.php +++ b/api/public/sample.php @@ -27,7 +27,7 @@ require_once(__DIR__ . '/../../stack/questionlibrary.class.php'); // Required to pass Moodle code check. Uses emulation stub. require_login(); -$files = stack_question_library::get_file_list(realpath(__DIR__ . '/../../samplequestions/stackdemo') . '/*'); +$files = stack_question_library::get_file_list(realpath(__DIR__ . '/../../samplequestions/stackdemo')); $questions = []; foreach ($files->children as $file) { diff --git a/api/public/stack.php b/api/public/stack.php index 4b34d8a60da..a3424ada1c0 100644 --- a/api/public/stack.php +++ b/api/public/stack.php @@ -135,7 +135,7 @@ function setQuestion(question) { Choose a STACK sample file: '; foreach ($dirdetails as $file) { diff --git a/api/util/StackQuestionLoader.php b/api/util/StackQuestionLoader.php index c548aea7868..76eb7b5b521 100644 --- a/api/util/StackQuestionLoader.php +++ b/api/util/StackQuestionLoader.php @@ -35,6 +35,12 @@ * Converts question xml into usable format */ class StackQuestionLoader { + /** Maximum accepted bytes in a raw XML or YAML question definition. */ + private const MAX_SOURCE_BYTES = 5242880; + + /** Maximum accepted XML element count after parsing or YAML conversion. */ + private const MAX_XML_NODE_COUNT = 50000; + /** * @var array|null Default values for the question. */ @@ -67,11 +73,9 @@ class StackQuestionLoader { // phpcs:ignore moodle.Commenting.MissingDocblock.Function public static function loadxml($xml, $includetests = false) { try { - if (strpos($xml, 'deployedseeds)) { - throw new \stack_exception(get_string('api_seed_not_in_variants', null)); + throw new \stack_exception(get_string('api_seed_not_in_variants', 'qtype_stack')); } $question->seed = $seed; diff --git a/classes/fake_render.php b/classes/fake_render.php index d340045733a..1f34f8afd90 100644 --- a/classes/fake_render.php +++ b/classes/fake_render.php @@ -35,4 +35,10 @@ class fake_render extends library_render { public static function call_question_render($question) { return '

Hello World

'; } + + // phpcs:ignore moodle.Commenting.MissingDocblock.Function + public static function call_external_request($requestedfile, $external, $apikey) { + return "Fake XML: {$external} {$requestedfile}" . + ''; + } } diff --git a/classes/library_import.php b/classes/library_import.php index 5a9e4a63ea5..dcf5e27a752 100644 --- a/classes/library_import.php +++ b/classes/library_import.php @@ -43,6 +43,7 @@ use qformat_xml; use core_question\local\bank\question_edit_contexts; use mod_quiz\quiz_settings; +use stack_question_library; /** * External API for AJAX calls. @@ -57,8 +58,13 @@ public static function import_execute_parameters() { return new \external_function_parameters([ 'courseid' => new \external_value(PARAM_INT, 'ID of current course.'), 'category' => new \external_value(PARAM_INT, 'Question category where user has edit access'), - 'filepath' => new \external_value(PARAM_RAW, 'File path relative to samplequestions'), + 'filepath' => new \external_value( + PARAM_RAW, + 'File path relative to samplequestions, STACK data directory or top of GitHub library' + ), 'isfolder' => new \external_value(PARAM_BOOL, 'Is import of whole question folder requested?'), + 'cacheid' => new \external_value(PARAM_RAW, 'Library cache id'), + 'apikey' => new \external_value(PARAM_RAW, 'API key'), ]); } @@ -87,13 +93,15 @@ public static function import_execute_returns() { * @param string $filepath File path relative to samplequestions. * @return array Question details. */ - public static function import_execute($courseid, $category, $filepath, $isfolder) { + public static function import_execute($courseid, $category, $filepath, $isfolder, $cacheid, $apikey) { global $CFG, $DB; $params = self::validate_parameters(self::import_execute_parameters(), [ 'courseid' => $courseid, 'category' => $category, 'filepath' => $filepath, 'isfolder' => $isfolder, + 'cacheid' => $cacheid, + 'apikey' => $apikey, ]); // Check parameters and permissions. $thiscontext = null; @@ -106,17 +114,33 @@ public static function import_execute($courseid, $category, $filepath, $isfolder require_capability('moodle/question:add', $thiscontext); $loadingquiz = false; $categories = []; + $external = null; + $externalfiles = null; - if (str_starts_with($params['filepath'], 'sitelibrary/')) { + if (str_starts_with($params['filepath'], stack_question_library::SITELIB)) { $requestedfile = $CFG->dataroot . '/stack/' . $params['filepath']; $basedir = $CFG->dataroot . '/stack/'; + } else if (str_starts_with($params['cacheid'], stack_question_library::GITHUB)) { + $requestedfile = make_request_directory() . "/importq.xml"; + $external = stack_question_library::GITHUB; + $cache = \cache::make('qtype_stack', 'librarycache'); + $externalfiles = $cache->get($params['cacheid'] . '_flat_file_list'); + } else if (str_starts_with($params['cacheid'], stack_question_library::NRWSEARCH)) { + $requestedfile = make_request_directory() . "/importq.xml"; + $external = stack_question_library::NRWSEARCH; + $apikey = get_config('qtype_stack', 'nrwapikey'); } else { $requestedfile = $CFG->dirroot . '/question/type/stack/samplequestions/' . $params['filepath']; $basedir = $CFG->dirroot . '/question/type/stack/samplequestions/'; } + if ($external) { + // External libraries are controlled by the site-level capability. + require_capability('qtype/stack:useexternallibraries', \context_system::instance()); + } if ( !str_starts_with(realpath($requestedfile), "{$CFG->dataroot}/stack/sitelibrary") && - !str_starts_with(realpath($requestedfile), "{$CFG->dirroot}/question/type/stack/samplequestions/") + !str_starts_with(realpath($requestedfile), "{$CFG->dirroot}/question/type/stack/samplequestions/") && + !$external ) { throw new \Exception('Dubious file request.'); } @@ -126,7 +150,12 @@ public static function import_execute($courseid, $category, $filepath, $isfolder && strrpos($params['filepath'], '_quiz.json') !== false ) { // We've got a quiz file. Load JSON and instantiate. - $quizcontents = file_get_contents($requestedfile); + if ($external) { + $url = $externalfiles[$params['filepath']]->url; + $quizcontents = stack_question_library::get_external_file($url, $external, $apikey); + } else { + $quizcontents = file_get_contents($requestedfile); + } $quizdata = json_decode($quizcontents); // We have to create the quiz, import the questions and then add the questions to the quiz. // Create quiz and its default category. This is now our target category which we add to the quiz data. @@ -151,7 +180,7 @@ public static function import_execute($courseid, $category, $filepath, $isfolder } $loadingquiz = true; } else if (!$params['isfolder']) { - // We're only importing one question. Stick the supplied fielpath in an array. + // We're only importing one question. Stick the supplied fieldpath in an array. $files = [$params['filepath']]; } else { // We're importing a folder. @@ -159,15 +188,22 @@ public static function import_execute($courseid, $category, $filepath, $isfolder $fullpath = $requestedfile; $reldirname = dirname($params['filepath']); // List all the files in the same folder. - $files = scandir(dirname($fullpath)); + if ($external) { + $files = array_filter( + array_keys($externalfiles), + fn($file) => dirname($file) === $reldirname + ); + } else { + $files = scandir(dirname($fullpath)); + $files = array_map(function ($file) use ($reldirname) { + return $reldirname . '/' . $file; + }, $files); + } // Discard anything which isn't XML. Also discard category files. $files = array_filter($files, function ($file) { return pathinfo($file, PATHINFO_EXTENSION) === 'xml' && strrpos($file, 'gitsync_category') === false; }); // Convert file names into paths relative to the sample questions folder. - $files = array_map(function ($file) use ($reldirname) { - return $reldirname . '/' . $file; - }, $files); } $response = []; @@ -180,7 +216,13 @@ public static function import_execute($courseid, $category, $filepath, $isfolder $qformat->set_display_progress(false); $qformat->setCategory($thiscategory); $qformat->setCatfromfile(true); - $qformat->setFilename($basedir . $category); + if ($external) { + $url = $externalfiles[$category]->url; + file_put_contents($requestedfile, stack_question_library::get_external_file($url, $external, $apikey)); + $qformat->setFilename($requestedfile); + } else { + $qformat->setFilename($basedir . $category); + } $qformat->setContextfromfile(false); $qformat->setStoponerror(true); $contexts = new question_edit_contexts($thiscontext); @@ -225,8 +267,17 @@ public static function import_execute($courseid, $category, $filepath, $isfolder $qformat->setCategory($thiscategory); } $qformat->setCatfromfile(false); + if ($external === stack_question_library::GITHUB) { + $url = $externalfiles[$file]->url; + file_put_contents($requestedfile, stack_question_library::get_external_file($url, $external, $apikey)); + $qformat->setFilename($requestedfile); + } else if ($external === stack_question_library::NRWSEARCH) { + file_put_contents($requestedfile, stack_question_library::get_external_file($file, $external, $apikey)); + $qformat->setFilename($requestedfile); + } else { + $qformat->setFilename($basedir . $file); + } - $qformat->setFilename($basedir . $file); $qformat->setContextfromfile(false); $qformat->setStoponerror(true); $contexts = new question_edit_contexts($thiscontext); diff --git a/classes/library_render.php b/classes/library_render.php index 53ab52caa0f..a1b47b017ae 100644 --- a/classes/library_render.php +++ b/classes/library_render.php @@ -24,6 +24,8 @@ namespace qtype_stack; +use stack_exception; + defined('MOODLE_INTERNAL') || die(); global $CFG; @@ -57,7 +59,12 @@ class library_render extends \external_api { public static function render_execute_parameters() { return new \external_function_parameters([ 'category' => new \external_value(PARAM_INT, 'Question category where user has edit access'), - 'filepath' => new \external_value(PARAM_RAW, 'File path relative to samplequestions'), + 'filepath' => new \external_value( + PARAM_RAW, + 'File path relative to samplequestions, STACK data directory or top of GitHub library' + ), + 'cacheid' => new \external_value(PARAM_RAW, 'Library cache id'), + 'apikey' => new \external_value(PARAM_RAW, 'API key'), ]); } @@ -95,40 +102,65 @@ public static function render_execute_returns() { * @param string $filepath File path relative to samplequestions. * @return array Array of question render, question text, description and question variables. */ - public static function render_execute($category, $filepath) { + public static function render_execute($category, $filepath, $cacheid, $apikey) { global $CFG, $DB; $params = self::validate_parameters(self::render_execute_parameters(), [ 'category' => $category, 'filepath' => $filepath, + 'cacheid' => $cacheid, + 'apikey' => $apikey, ]); StackIframeHolder::$islibrary = true; // Check parameters and that user has question add capability in the supplied category. $context = $DB->get_field('question_categories', 'contextid', ['id' => $params['category']]); + if (str_starts_with($params['cacheid'], stack_question_library::GITHUB)) { + $external = stack_question_library::GITHUB; + } else if (str_starts_with($params['cacheid'], stack_question_library::NRWSEARCH)) { + $external = stack_question_library::NRWSEARCH; + } else { + $external = null; + } $thiscontext = context::instance_by_id($context); self::validate_context($thiscontext); require_capability('moodle/question:add', $thiscontext); + if ($external) { + // External libraries are controlled by the site-level capability. + require_capability('qtype/stack:useexternallibraries', \context_system::instance()); + } // Check if we've already cached the answer. $cache = cache::make('qtype_stack', 'librarycache'); - $result = $cache->get($params['filepath']); + $result = $cache->get($external ? "{$params['cacheid']}/{$params['filepath']}" : $params['filepath']); $isquiz = (pathinfo($params['filepath'], PATHINFO_EXTENSION) === 'json' && strrpos($params['filepath'], '_quiz.json') !== false) ? true : false; - if (str_starts_with($params['filepath'], 'sitelibrary/')) { + if (str_starts_with($params['filepath'], stack_question_library::SITELIB . '/')) { $requestedfile = $CFG->dataroot . '/stack/' . $params['filepath']; + } else if ($external && $external === stack_question_library::GITHUB) { + $externalfiles = $cache->get($params['cacheid'] . '_flat_file_list'); + $requestedfile = $externalfiles[$params['filepath']]->url; + } else if ($external && $external === stack_question_library::NRWSEARCH) { + $requestedfile = $params['filepath']; + $apikey = get_config('qtype_stack', 'nrwapikey'); } else { $requestedfile = $CFG->dirroot . '/question/type/stack/samplequestions/' . $params['filepath']; } if ( - !str_starts_with(realpath($requestedfile), "{$CFG->dataroot}/stack/sitelibrary") && - !str_starts_with(realpath($requestedfile), "{$CFG->dirroot}/question/type/stack/samplequestions/") + !str_starts_with(realpath($requestedfile), "{$CFG->dataroot}/stack/sitelibrary/") && + !str_starts_with(realpath($requestedfile), "{$CFG->dirroot}/question/type/stack/samplequestions/") && + !$external ) { throw new \Exception('Dubious file request.'); } + if ($external) { + $qcontents = static::call_external_request($requestedfile, $external, $apikey); + } else { + $qcontents = file_get_contents($requestedfile); + } + if (!$result && !$isquiz) { // Get contents of file and run through API question loader to render. - $qcontents = file_get_contents($requestedfile); try { $question = StackQuestionLoader::loadxml($qcontents)['question']; $render = static::call_question_render($question); @@ -152,7 +184,7 @@ public static function render_execute($category, $filepath) { 'questiondescription' => $question->questiondescription, 'isstack' => true, ]; - $cache->set($params['filepath'], $result); + $cache->set($external ? "{$params['cacheid']}/{$params['filepath']}" : $params['filepath'], $result); } catch (\stack_exception $e) { // If the question is not a STACK question we can't render it // but we still want users to be able to import it. @@ -176,9 +208,9 @@ public static function render_execute($category, $filepath) { } } } + if (!$result && $isquiz) { - $quizcontents = file_get_contents($requestedfile); - $json = json_decode($quizcontents); + $json = json_decode($qcontents); $quiz = $json->quiz; $questions = $json->questions; $sections = $json->sections; @@ -188,7 +220,7 @@ public static function render_execute($category, $filepath) { $sectionno = 0; for ($questionno = 0; $questionno < $numquestions; $questionno++) { $slot = $questions[$questionno]->slot; - if ($sections[$sectionno]->firstslot === $slot) { + if (!empty($sections[$sectionno]) && $sections[$sectionno]->firstslot === $slot) { $quiztext .= '
' . $sections[$sectionno]->heading . '
'; $sectionno++; } @@ -218,4 +250,14 @@ public static function render_execute($category, $filepath) { public static function call_question_render($question) { return stack_question_library::render_question($question); } + + /** + * Separate out to mock in unit testing. + * @param string $requestedfile + * @param string $external + * @return string XML of question + */ + public static function call_external_request($requestedfile, $external, $apikey) { + return stack_question_library::get_external_file($requestedfile, $external, $apikey); + } } diff --git a/db/access.php b/db/access.php index 4db1be58f08..be582242907 100644 --- a/db/access.php +++ b/db/access.php @@ -35,4 +35,24 @@ 'manager' => CAP_ALLOW, ], ], + // Users with this in the system context can use external question libraries. + 'qtype/stack:useexternallibraries' => [ + 'captype' => 'write', + 'contextlevel' => CONTEXT_SYSTEM, + 'archetypes' => [ + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW, + ], + 'clonepermissionsfrom' => 'moodle/question:add', + ], + // Users with this in the system context can export to external question libraries. + 'qtype/stack:exporttoexternallibraries' => [ + 'captype' => 'write', + 'contextlevel' => CONTEXT_SYSTEM, + 'archetypes' => [ + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW, + ], + 'clonepermissionsfrom' => 'moodle/question:add', + ], ]; diff --git a/lang/en/qtype_stack.php b/lang/en/qtype_stack.php index d0879380791..f705dd5ca53 100644 --- a/lang/en/qtype_stack.php +++ b/lang/en/qtype_stack.php @@ -51,6 +51,8 @@ // Capability names. $string['stack:usediagnostictools'] = 'Use the STACK tools'; +$string['stack:useexternallibraries'] = 'Use external libraries'; +$string['stack:exporttoexternallibraries'] = 'Allow export to external libraries'; // Versions of STACK. $string['stackversionedited'] = 'This question was authored with STACK version {$a}.'; @@ -510,6 +512,22 @@ $string['settingmaximalibraries_desc'] = 'This is a comma separated list of Maxima library names which will be automatically loaded into Maxima. Only supported library names can be used: "stats, distrib, descriptive, simplex". When you change the listed libraties you must rebuild the Maxima optimised image.'; $string['settingmaximalibraries_error'] = 'Please edit the STACK plugin setting qtype_stack | maximalibraries. The following package is not supported: {$a}'; $string['settingmaximalibraries_failed'] = 'It appears as if some of the Maxima packages you have asked for have failed to load.'; +$string['settingexternallibraries'] = 'External question libraries'; +$string['settingexternallibraries_desc'] = 'JSON object listing display names and URL locations of allowed external GitHub libraries with a short identifier for each. Example: +
{ +
  "EIT": { +
    "url": "https:\/\/github.com\/maths\/moodle-qtype_stack\/tree\/master\/samplequestions\/importtest", +
    "name": "External: import test" +
  }, +
  "ESQ": { +
    "url": "https:\/\/github.com\/maths\/moodle-qtype_stack\/tree\/master\/samplequestions\/stacklibrary", +
    "name": "External: sample library" +
  } +
}'; +$string['settingnrwapikey'] = 'STACK.nrw databse API key'; +$string['settingnrwapikey_desc'] = 'Allows access to the STACK.nrw question database via the STACK library page. Access can be restricted on a per user basis using Moodle permission qtype/stack:useexternallibraries.'; +$string['settingnrwupload'] = 'STACK.nrw upload allowed'; +$string['settingnrwupload_desc'] = 'Allows questions to be uploaded to the STACK.nrw question database via the STACK question dashboard. Access can be restricted on a per user basis using Moodle permission qtype/stack:exporttoexternallibraries.'; // Strings used by replace dollars script. $string['replacedollarscount'] = 'This category contains {$a} STACK questions.'; @@ -666,6 +684,7 @@ $string['seethisquestioninthequestionbank'] = ' Show in question bank'; $string['exportthisquestion'] = ' Export as Moodle XML'; $string['exportthisquestion_help'] = 'This will create a Moodle XML export file containing just this one question. One example of when this is useful if you think this question demonstrates a bug in STACK that you would like to report to the developers.'; +$string['exporttonrw'] = ' Export to STACK.nrw'; $string['tidyquestion'] = ' Tidy inputs and PRTs'; $string['tidyquestion_txt'] = 'Tidy inputs and PRTs'; $string['sendgeneralfeedback'] = ' Send general feedback to the CAS'; @@ -678,6 +697,19 @@ $string['bulktestquiz'] = ' Bulk test quiz'; $string['bulktestquiznotes'] = 'Bulk test the latest version of all the questions in a quiz containing this question.'; $string['history'] = ' Question history'; +$string['questionexportplaceholder'] = 'This page is a placeholder for the NRW export workflow.'; +$string['nrwuploadbutton'] = 'Upload to STACK.nrw'; +$string['nrwuploadcreated'] = 'Question uploaded to NRW successfully. ATLAS ID: {$a}.'; +$string['nrwuploadduplicate'] = 'This XML already exists in NRW. Existing ATLAS ID: {$a}.'; +$string['nrwuploadvalidationerror'] = 'NRW rejected the upload: XML failed validation.'; +$string['nrwuploadfailed'] = 'NRW upload failed.'; +$string['nrwuploadunexpected'] = 'Unexpected response from NRW upload endpoint.'; +$string['nrwuploadapikeymissing'] = 'Cannot upload: NRW API key is not configured.'; +$string['nrwuploadxmlerror'] = 'Could not export this question to XML for upload.'; +$string['nrwuploadpayloadencodeerror'] = 'Could not encode upload payload.'; +$string['nrwuploadduplicateapi'] = 'Duplicate question already exists.'; +$string['nrwuploadcreatedapi'] = 'Question uploaded successfully.'; +$string['nrwuploadxmlvalidationapifailed'] = 'XML failed validation.'; $string['bulktestquizselect'] = 'Select a quiz'; $string['basicquestionreport'] = ' Analyze responses'; @@ -1979,6 +2011,7 @@ // Strings used by question library. $string['stack_library'] = 'STACK question library'; $string['stack_library_destination'] = 'Questions will be imported into the following category:'; +$string['stack_library_connection_error'] = 'Something went wrong contacting an external question source'; $string['stack_library_error'] = 'Something went wrong. Please refresh the page and try again.'; $string['stack_library_failure'] = 'Failed import of:'; $string['stack_library_help'] = 'Rather than creating your own question, follow this link to go to the STACK question library. The STACK question library contains many pre-made STACK questions ready for you to import into Moodle. You can then use them as they are or edit them to fit your needs.'; @@ -1990,15 +2023,21 @@ $string['stack_library_import'] = 'Import'; $string['stack_library_importlist'] = 'Imported questions:'; $string['stack_library_import_folder'] = 'Import folder'; +$string['stack_library_license'] = 'License'; $string['stack_library_quiz'] = 'This is a quiz:'; $string['stack_library_quiz_course'] = 'The quiz will be imported into course: '; $string['stack_library_quiz_prefix'] = 'Quiz:'; +$string['stack_library_refresh'] = 'Refresh library contents'; $string['stack_library_selected'] = 'Displayed question:'; $string['stack_library_select'] = 'Select library:'; +$string['stack_library_source'] = 'Source'; +$string['stack_library_nrw'] = 'Search NRW database'; +$string['stack_library_subject'] = 'Subject'; $string['stack_library_success'] = 'Successful import of:'; $string['stack_library_not_stack'] = 'This is not a STACK question and so cannot be fully rendered here but you can still import it.'; $string['stack_library_quiz_return'] = 'Return to quiz'; $string['stack_library_qb_return'] = 'Return to question bank'; +$string['stack_library_nothing'] = 'No results found'; // API strings. $string['api_advance_variant'] = 'Next Variant'; $string['api_choose_file'] = 'Please select a question file'; diff --git a/questionexport.php b/questionexport.php new file mode 100644 index 00000000000..2de2b3c4987 --- /dev/null +++ b/questionexport.php @@ -0,0 +1,90 @@ +. + +/** + * Placeholder export page for external library exports. + * + * @package qtype_stack + * @copyright 2026 The University of Edinburgh + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(__DIR__ . '/../../../config.php'); + +require_once($CFG->libdir . '/questionlib.php'); +require_once(__DIR__ . '/vle_specific.php'); +require_once(__DIR__ . '/locallib.php'); +require_once(__DIR__ . '/stack/questionlibrary.class.php'); + +$questionid = required_param('questionid', PARAM_INT); +[$qversion, $questionid] = get_latest_question_version($questionid); + +$questiondata = question_bank::load_question_data($questionid); +if (!$questiondata) { + throw new stack_exception('questiondoesnotexist'); +} +$question = question_bank::load_question($questionid); + +[$context, , $urlparams] = qtype_stack_setup_question_test_page($question); + +question_require_capability_on($questiondata, 'view'); +require_capability('qtype/stack:exporttoexternallibraries', $context); + +if (!get_config('qtype_stack', 'nrwupload')) { + throw new moodle_exception('nopermissions', 'error', '', 'export to NRW'); +} + +$PAGE->set_context($context); +$PAGE->set_url('/question/type/stack/questionexport.php', $urlparams); +$title = stack_string('exporttonrw'); +$PAGE->set_title($title); +$PAGE->set_heading($title); +$PAGE->set_pagelayout('popup'); + +require_login(); + +$dashboardlink = new moodle_url('/question/type/stack/questiontestrun.php', $urlparams); +$previewquestionlink = qbank_previewquestion\helper::question_preview_url($questionid, null, null, null, null, $context); +$editparams = $urlparams; +unset($editparams['questionid']); +unset($editparams['seed']); +$editparams['id'] = $question->id; +$editquestionlink = new moodle_url('/question/type/stack/questioneditlatest.php', $editparams); + +$uploadresult = null; +if (optional_param('uploadtonrw', false, PARAM_BOOL)) { + require_sesskey(); + $apikey = get_config('qtype_stack', 'nrwapikey'); + $uploadresult = stack_question_library::upload_nrw_question($questiondata, $apikey); +} + +echo $OUTPUT->header(); + +$outputdata = new stdClass(); +$outputdata->question = new stdClass(); +$outputdata->question->name = format_string($question->name); +$outputdata->question->version = $qversion; +$outputdata->general = new stdClass(); +$outputdata->general->testquestionlink = $dashboardlink->out(); +$outputdata->general->previewquestionlink = $previewquestionlink->out(); +$outputdata->general->editquestionlink = $editquestionlink->out(); +$outputdata->general->uploadurl = $PAGE->url->out(false); +$outputdata->general->sesskey = sesskey(); +$outputdata->uploadresult = $uploadresult; + +echo $OUTPUT->render_from_template('qtype_stack/questionexport', $outputdata); + +echo $OUTPUT->footer(); diff --git a/questionlibrary.php b/questionlibrary.php index 55fd6639e63..43e61c5319e 100644 --- a/questionlibrary.php +++ b/questionlibrary.php @@ -52,6 +52,7 @@ $urlparams['courseid'] = $courseid; $returntext = get_string('stack_library_qb_return', 'qtype_stack'); } +$isrefresh = optional_param('refresh', 0, PARAM_INT) === 1 ? true : false; // Check user has add capability for the required context. require_capability('moodle/question:add', $thiscontext); @@ -88,27 +89,88 @@ // Make sure we're only listing contents of STACK library or site library. $location = optional_param('location', '', PARAM_RAW); -$cacheid = 'library_file_list'; +// Location parameter will be in form: +// samplequestions/stacklibrary +// sitelibrary/foldername +// githublibrary/id +// nrwsearch. +// Corresponding cache ids are: +// library +// sitelibrary_foldername +// githublibrary_id +// null. +$cacheid = stack_question_library::STACKLIB; $libraryname = stack_string('stack_library'); -if (str_starts_with($location, 'sitelibrary')) { +$external = null; +$allowedlibraries = get_config('qtype_stack', 'libraries'); +$allowedlibraries = json_decode($allowedlibraries); +$allowedlibraries = $allowedlibraries ? $allowedlibraries : new StdClass(); +$search = optional_param('search', '', PARAM_RAW); +$apikey = optional_param('apikey', '', PARAM_RAW); + +if (str_starts_with($location, stack_question_library::SITELIB)) { $libraryname = explode('/', $location)[1]; - $cacheid = 'sitelibrary_' . $libraryname . '_file_list'; + $cacheid = stack_question_library::SITELIB . "_{$libraryname}"; $location = "{$CFG->dataroot}/stack/{$location}"; if (!str_starts_with(realpath($location), "{$CFG->dataroot}/stack/sitelibrary")) { - $location = __DIR__ . '/samplequestions/stacklibrary/*'; + $location = __DIR__ . '/samplequestions/stacklibrary'; $libraryname = stack_string('stack_library'); - $cacheid = 'library_file_list'; + $cacheid = stack_question_library::STACKLIB; + } +} else if (str_starts_with($location, stack_question_library::GITHUB)) { + require_capability('qtype/stack:useexternallibraries', \context_system::instance()); + $libparts = explode('/', $location); + $librarytype = $libparts[0]; + $libraryid = $libparts[1]; + $cacheid = $librarytype . "_{$libraryid}"; + $libraryname = $allowedlibraries->{$libraryid}->name ?? null; + if (!$libraryname) { + $location = __DIR__ . '/samplequestions/stacklibrary'; + $libraryname = stack_string('stack_library'); + $cacheid = stack_question_library::STACKLIB; } else { - $location .= '/*'; + $external = $librarytype; + $externaldetail = $allowedlibraries->{$libraryid}->url; } +} else if (str_starts_with($location, stack_question_library::NRWSEARCH)) { + require_capability('qtype/stack:useexternallibraries', \context_system::instance()); + $cacheid = stack_question_library::NRWSEARCH; + $libraryname = null; + $external = stack_question_library::NRWSEARCH; + $externaldetail = ['search' => $search, 'apikey' => get_config('qtype_stack', 'nrwapikey')]; } else { - $location = __DIR__ . '/samplequestions/stacklibrary/*'; + $location = __DIR__ . '/samplequestions/stacklibrary'; +} + +if ($isrefresh) { + $refreshfiles = $cache->get($cacheid . '_flat_file_list'); + if ($refreshfiles) { + $refreshfiles = array_keys($refreshfiles); + $refreshfiles = array_map(function ($file) use ($cacheid) { + return "{$cacheid}/{$file}"; + }, $refreshfiles); + } + $refreshfiles[] = $cacheid . '_flat_file_list'; + $refreshfiles[] = $cacheid . '_file_list'; + $cache->delete_many($refreshfiles); } -$files = $cache->get($cacheid); -if (!$files) { - $files = stack_question_library::get_file_list($location); - $cache->set($cacheid, $files); +$files = new StdClass(); + +if ($cacheid && $cacheid != stack_question_library::NRWSEARCH) { + $files = $cache->get($cacheid . '_file_list'); + if (!$files) { + if ($external) { + [$files, $flatfiles] = stack_question_library::get_file_list_from_repo($externaldetail, $external); + $cache->set($cacheid . '_flat_file_list', $flatfiles); + } else { + $files = stack_question_library::get_file_list($location); + } + $cache->set($cacheid . '_file_list', $files); + } +} +if ($cacheid && $cacheid === stack_question_library::NRWSEARCH) { + [$files, $flatfiles] = stack_question_library::get_file_list_from_repo($externaldetail, $external); } $mform = new qtype_stack\form\category_form(null, ['qcontext' => $contexts]); @@ -118,19 +180,28 @@ $outputdata->dashboardlink = $dashboardlink->out(); $outputdata->quizlink = $quizlink->out(); $outputdata->returntext = $returntext; -$outputdata->files = $files->children; +$outputdata->files = (isset($files->children)) ? $files->children : []; +if (isset($files->error)) { + $outputdata->fileserror = $files->error; +} $outputdata->category = $mform->render(); $outputdata->coursename = $coursename; $outputdata->courseid = $courseid; +$outputdata->isnrwapikey = (get_config('qtype_stack', 'nrwapikey')) ? true : false; +$outputdata->search = $search; $outputdata->libraries = new StdClass(); $outputdata->libraries->items = []; $outputdata->libraries->hasitems = false; +$outputdata->libraries->current = $cacheid; +if ($external === stack_question_library::GITHUB) { + $outputdata->libraries->isgithub = true; +} $libraries = glob("{$CFG->dataroot}/stack/sitelibrary/*"); if ($libraries) { $libentry = new StdClass(); $libentry->name = stack_string('stack_library'); - $urlparams['location'] = "/samplequestions/stacklibrary"; + $urlparams['location'] = "samplequestions/stacklibrary"; $libentry->url = new moodle_url('/question/type/stack/questionlibrary.php', $urlparams); $libentry->url = $libentry->url->out(); $libentry->active = ($libentry->name === $libraryname) ? true : false; @@ -141,13 +212,34 @@ $libentry = new StdClass(); $parts = explode('/', $library); $libentry->name = end($parts); - $urlparams['location'] = "sitelibrary/{$libentry->name}"; + $urlparams['location'] = stack_question_library::SITELIB . "/{$libentry->name}"; + $libentry->url = new moodle_url('/question/type/stack/questionlibrary.php', $urlparams); + $libentry->url = $libentry->url->out(); + $libentry->active = ($libentry->name === $libraryname) ? true : false; + $outputdata->libraries->items[] = $libentry; +} + +foreach ($allowedlibraries as $id => $lib) { + $libentry = new StdClass(); + $libentry->name = $lib->name; + if (str_starts_with($lib->url, 'https://github.com/')) { + $urlparams['location'] = stack_question_library::GITHUB . "/{$id}"; + } else { + $urlparams['location'] = 'invalid'; + } + $urlparams['name'] = $lib->name; $libentry->url = new moodle_url('/question/type/stack/questionlibrary.php', $urlparams); $libentry->url = $libentry->url->out(); $libentry->active = ($libentry->name === $libraryname) ? true : false; $outputdata->libraries->items[] = $libentry; + $outputdata->libraries->hasitems = true; } +unset($urlparams['name']); +$urlparams['location'] = stack_question_library::NRWSEARCH; +$outputdata->searchurl = new moodle_url('/question/type/stack/questionlibrary.php', $urlparams); +$outputdata->searchurl = $outputdata->searchurl->out(); + echo $OUTPUT->render_from_template('qtype_stack/questionlibrary', $outputdata); // Finish output. diff --git a/questiontestrun.php b/questiontestrun.php index ae78b395ee4..4fdb1464bc2 100644 --- a/questiontestrun.php +++ b/questiontestrun.php @@ -70,6 +70,8 @@ question_require_capability_on($questiondata, 'view'); $caneditpermission = question_has_capability_on($questiondata, 'edit'); $canedit = $caneditpermission && !$historic; +$canexporttonrw = get_config('qtype_stack', 'nrwupload') + && has_capability('qtype/stack:exporttoexternallibraries', $context); // Initialise $PAGE. $PAGE->set_url('/question/type/stack/questiontestrun.php', $urlparams); @@ -110,6 +112,7 @@ $reportlink = new moodle_url('/question/type/stack/questiontestreport.php', $urlparams); $bulktestlink = new moodle_url('/question/type/stack/questionbulktest.php', $urlparams); $pagelink = new moodle_url('/question/type/stack/questiontestrun.php', $urlparams); +$exportnrwlink = new moodle_url('/question/type/stack/questionexport.php', $urlparams); $historyparams = $urlparams; unset($historyparams['questionid']); $historyparams['entryid'] = $qbeid; @@ -167,8 +170,10 @@ $initialdata->general->todolink = $todolink->out(); $initialdata->general->bulktestlink = $bulktestlink->out(); $initialdata->general->historylink = $historylink->out(); +$initialdata->general->exportnrwlink = $exportnrwlink->out(); $initialdata->general->caneditpermission = $caneditpermission; $initialdata->general->canedit = $canedit; +$initialdata->general->canexporttonrw = $canexporttonrw; $initialdata->general->courseid = $courseid; $initialdata->general->cmid = $cmid; $initialdata->general->questionid = $questionid; diff --git a/settings.php b/settings.php index d2b10ebfafd..a0e28c1fba1 100644 --- a/settings.php +++ b/settings.php @@ -243,6 +243,32 @@ 3 )); +$settings->add(new admin_setting_configtextarea( + 'qtype_stack/libraries', + get_string('settingexternallibraries', 'qtype_stack'), + get_string('settingexternallibraries_desc', 'qtype_stack'), + '', + PARAM_RAW, + 60, + 5 +)); + +$settings->add(new admin_setting_configtext( + 'qtype_stack/nrwapikey', + get_string('settingnrwapikey', 'qtype_stack'), + get_string('settingnrwapikey_desc', 'qtype_stack'), + '', + PARAM_TEXT, + 60 +)); + +$settings->add(new admin_setting_configcheckbox( + 'qtype_stack/nrwupload', + get_string('settingnrwupload', 'qtype_stack'), + get_string('settingnrwupload_desc', 'qtype_stack'), + 0 +)); + // Options for maths display. $settings->add(new admin_setting_heading( 'mathsdisplayheading', diff --git a/stack/maxima/stackmaxima.mac b/stack/maxima/stackmaxima.mac index c134a896a38..e612ebc4a8a 100644 --- a/stack/maxima/stackmaxima.mac +++ b/stack/maxima/stackmaxima.mac @@ -1045,7 +1045,7 @@ plot(ex, [ra]) := /*stack_web_plot*/ /* Check for box. */ plotbox:sublist(ra, lambda([ex], if listp(ex) then is(first(ex) = box) else false)), if emptyp(plotbox) then plotbox:true else block( - ra:delete(first(plotbox), ra), + ra:delete(first(plotbox), ra), if not(second(first(plotbox))) then plotbox:false ), /* For new versions. */ @@ -1140,7 +1140,7 @@ set output ", afn), if plotgrid2d and MAXIMA_VERSION_NUM>34 then ral: append(ral, [grid2d]), /* Add in the command for the nobox. */ - if not(plotbox) then if MAXIMA_VERSION_NUM>47 then + if not(plotbox) then if MAXIMA_VERSION_NUM>47 then ral:append(ral, [nobox]) else ral:append(ral, [[box,false]]), if plotdebug then print(preamble), if PLOT_TERMINAL="svg" then set_plot_option([svg_file, afn]), @@ -3564,4 +3564,4 @@ is_lang(code):=ev(is(%_STACK_LANG=code),simp=true)$ /* Stack expects some output with the version number the output happens at */ /* maximalocal.mac after additional library loading */ -stackmaximaversion:2026063000$ +stackmaximaversion:2026071800$ diff --git a/stack/questionlibrary.class.php b/stack/questionlibrary.class.php index 4af79db8f5d..4c8e8241138 100644 --- a/stack/questionlibrary.class.php +++ b/stack/questionlibrary.class.php @@ -26,8 +26,10 @@ require_once(__DIR__ . '../../api/util/StackSeedHelper.php'); require_once(__DIR__ . '../../api/util/StackPlotReplacer.php'); + use core_question\local\bank\question_edit_contexts; use api\util\StackSeedHelper; use api\util\StackPlotReplacer; + /** * Functions required to display the STACK question library * @package qtype_stack @@ -35,6 +37,52 @@ class stack_question_library { /** @var int increments unique folder ids */ public static $dircount = 1; + /** + * GITHUB library identifier + * @var string + */ + public const GITHUB = 'githublibrary'; + /** + * Site library identifier + * @var string + */ + public const SITELIB = 'sitelibrary'; + /** + * STACK library identifier + * @var string + */ + public const STACKLIB = 'stacklibrary'; + /** + * NRW API identifier + * @var string + */ + public const NRWSEARCH = 'nrwsearch'; + /** + * NRW API base URL + * @var string + */ + public const NRWAPIBASE = 'https://vmits1614.vm.ruhr-uni-bochum.de/dev/api'; + + /** + * Wrapper for curl_exec to allow mocking in unit tests. + * + * @param resource $ch cURL handle + * @return string|bool + */ + protected static function execute_curl_request($ch) { + return curl_exec($ch); + } + + /** + * Wrapper for curl_getinfo to allow mocking in unit tests. + * + * @param resource $ch cURL handle + * @param int $opt cURL info option + * @return mixed + */ + protected static function get_curl_info($ch, int $opt) { + return curl_getinfo($ch, $opt); + } /** * Summary of render_question @@ -44,7 +92,12 @@ class stack_question_library { */ public static function render_question(object $question): string { global $CFG; - StackSeedHelper::initialize_seed($question, null); + try { + StackSeedHelper::initialize_seed($question, null); + } catch (\stack_exception $e) { + // XML has no deployed seeds but we don't care in the library. + $question->seed = 0; + } // Handle Pluginfiles. $storeprefix = uniqid(); @@ -134,66 +187,296 @@ public static function render_question(object $question): string { } /** - * Gets the structure of folders and files within a given directory + * Gets the structure of folders and files within a given directory on the server. * See questionfolder.mustache for output and usage. - * We sanitise the structure a bit to remove gitsync files and folders. - * @param string sanitised search string e.g. '/srv/stack/samplequestions/stacklibrary/*' - * with the full real path of the folder and search criteria. + * @param string $dir sanitised full real path of library e.g. '/srv/stack/samplequestions/stacklibrary' * @return object StdClass Representation of the file system */ public static function get_file_list(string $dir): object { global $CFG; - $files = glob($dir); + $directoryiterator = new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS); + $files = new RecursiveIteratorIterator($directoryiterator, RecursiveIteratorIterator::SELF_FIRST); + $result = []; + foreach ($files as $item) { + $pathfromsq = str_replace(dirname(__DIR__) . '/samplequestions/', '', $item->getPathname()); + $pathfromsq = str_replace("{$CFG->dataroot}/stack/", '', $pathfromsq); + $result[] = (object)[ + 'label' => $item->getFilename(), + 'relpath' => $pathfromsq, + 'isdirectory' => $item->isDir(), + 'url' => '', + ]; + } + + return self::format_file_list($result); + } + + /** + * Gets the structure of folders and files within a given remote repo. + * Two versions are returned. The first is a structured object for feeding into the mustache template + * for displaying the folder structure. The second is a flat array keyed by file path for easy + * retrieval of file info, paerticularly the file URL. + * This is a wrapper function to make it easier to support different repo types. + * See questionfolder.mustache for output and usage. + * @param string|array $detail URL of the directory required or search term and apikey + * @param string $repotype The type of repo being searched. + * @return array [object StdClass structured representation of the file system, array flat array of file objects] + */ + public static function get_file_list_from_repo($detail, $repotype) { + switch ($repotype) { + case self::GITHUB: + return self::list_github_repo($detail); + case self::NRWSEARCH: + return self::list_nrw_search($detail); + default: + return [new StdClass(), []]; + } + } + + /** + * Gets a file from an external repo. + * This is a wrapper function to make it easier to support different repo types. + * + * @param string $requestedfile URL + * @param string $repotype + * @return void + */ + public static function get_external_file($requestedfile, $repotype, $apikey) { + switch ($repotype) { + case self::GITHUB: + return self::get_external_github_file($requestedfile); + case self::NRWSEARCH: + return self::get_external_nrw_file($requestedfile, $apikey); + default: + return null; + } + } + + /** + * Retrieves a list of all the files in a GitHub repo via API + * @param string $githuburl + * @return array [object StdClass structured representation of the file system, array flat array of file objects] + */ + public static function list_github_repo(string $githuburl) { + // Parse github URL like: + // https://github.com/{owner}/{repo}/tree/{branch}/{path...}. + $parts = parse_url($githuburl); + if (empty($parts['host']) || strpos($parts['host'], 'github.com') === false) { + return []; + } + $path = isset($parts['path']) ? trim($parts['path'], '/') : ''; + $segments = explode('/', $path); + if (count($segments) < 2) { + return []; + } + $owner = $segments[0]; + $repo = $segments[1]; + + // Default values. + $branch = 'main'; + $subpath = ''; + + // If URL uses the tree layout, extract branch and subpath. + // Expected segments: owner, repo, tree, branch, ...subpath. + if (isset($segments[2]) && $segments[2] === 'tree' && isset($segments[3])) { + $branch = $segments[3]; + if (count($segments) > 4) { + $subpath = implode('/', array_slice($segments, 4)); + } else { + $subpath = ''; + } + } + + $apibase = "https://api.github.com/repos/{$owner}/{$repo}"; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_USERAGENT, 'Moodle-STACK'); // GitHub requires a user agent. + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/vnd.github.v3+json']); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/vnd.github.v3+json']); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + + $files = []; + $errorresponse = new StdClass(); + $errorresponse->error = stack_string('stack_library_connection_error'); + + // Always use the git/trees API with recursive=1, then filter by subpath. + $apiurl = "{$apibase}/git/trees/" . rawurlencode($branch) . "?recursive=1"; + curl_setopt($ch, CURLOPT_URL, $apiurl); + $response = static::execute_curl_request($ch); + $httpcode = static::get_curl_info($ch, CURLINFO_HTTP_CODE); + if ($response === false || $httpcode >= 400) { + if ($response) { + $errorresponse->error .= ': ' . $response; + } + return [$errorresponse, []]; + } + $data = json_decode($response, true); + if (empty($data['tree']) || !is_array($data['tree'])) { + return [$errorresponse, []]; + } + $prefix = $subpath === '' ? '' : rtrim($subpath, '/') . '/'; + foreach ($data['tree'] as $item) { + if ($prefix === '' || strpos($item['path'], $prefix) === 0) { + $relpath = ltrim(substr($item['path'], strlen($prefix)), '/'); + $files[] = (object)[ + 'label' => basename($item['path']), + 'relpath' => $relpath, + 'isdirectory' => ($item['type'] === 'tree') ? 1 : 0, + 'url' => ($item['type'] === 'tree') ? '' : $item['url'], + ]; + } + } + + $flatarray = array_column($files, null, 'relpath'); + + return [self::format_file_list($files), $flatarray]; + } + + /** + * Retrieves search results via API + * @param array $details - search term and apikey + * @return array [object StdClass structured representation of the file system, array flat array of file objects] + */ + public static function list_nrw_search(array $details) { + $apibase = self::NRWAPIBASE . "/questions/search?fields=id,data"; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_USERAGENT, 'Moodle-STACK'); // GitHub requires a user agent. + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/vnd.github.v3+json']); + curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer {$details['apikey']}"]); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + + $files = new StdClass(); + $files->divid = 'stack-library-folder-1'; + $files->children = []; + $files->isdirectory = 1; + $files->label = '.'; + + // Always use the git/trees API with recursive=1, then filter by subpath. + $apiurl = "{$apibase}&q={$details['search']}"; + curl_setopt($ch, CURLOPT_URL, $apiurl); + $response = static::execute_curl_request($ch); + $httpcode = static::get_curl_info($ch, CURLINFO_HTTP_CODE); + if ($response === false || $httpcode >= 400) { + $files->error = stack_string('stack_library_connection_error'); + if ($response) { + $files->error .= ': ' . $response; + } + + return [$files, []]; + } + $data = json_decode($response, true); + if (isset($data['results'])) { + foreach ($data['results'] as $item) { + $files->children[] = (object)[ + 'label' => $item['question']['data']['title'], + // Once we have format data use format_text($text, FORMAT_MARKDOWN). + 'description' => $item['question']['data']['description'][0][1] ?? null, + 'license' => $item['question']['data']['license'], + 'source' => $item['question']['data']['source'], + 'subject' => (is_array($item['question']['data']['subject'])) ? + implode(', ', $item['question']['data']['subject']) : $item['question']['data']['subject'], + 'path' => $item['question']['id'], + 'isdirectory' => 0, + 'url' => '', + ]; + } + if (!count($data['results'])) { + $files->error = stack_string('stack_library_nothing'); + } + } else { + $files->error = stack_string('stack_library_connection_error'); + } + + return [$files, []]; + } + + /** + * Take an array of file objects and format into an object with a directory-style structure. + * The file objects should be in the form: + * {'label' => file/directory name, + * 'relpath' => file path relative to top directory, + * 'isdirectory' => boolean, + * 'url' => url for obtaining the file if remote} + * We sanitise the structure a bit to remove gitsync files and folders. + * @param mixed $filelist + * @return object stdClass + */ + public static function format_file_list($filelist) { + usort($filelist, function ($a, $b) { + return strnatcmp($a->relpath, $b->relpath); + }); $results = new stdClass(); - $labels = explode('/', $dir); - $results->label = $labels[count($labels) - 2]; $results->divid = 'stack-library-folder-' . self::$dircount; self::$dircount++; $results->children = []; $results->isdirectory = 1; - foreach ($files as $path) { - if (!is_dir($path)) { + // First file in sorted list will be in the current base directory. If we're on the first pass, we're in overall top + // directory and dirname will return '.' which we convert to '' for our string compare. + $firstfile = $filelist[array_key_first($filelist)]->relpath; + $basedir = dirname($firstfile !== '.') ? dirname($firstfile) : ''; + + $dirparts = explode('/', $basedir); + $results->label = end($dirparts); + foreach ($filelist as $file) { + // We only want children of the current base directory. We ignore more distant descendants. + if ($basedir === '') { + if (str_contains($file->relpath, '/')) { + continue; + } + } else { + if (str_contains(str_replace($basedir . '/', '', $file->relpath), '/')) { + continue; + } + } + + if (!$file->isdirectory) { if ( - (pathinfo($path, PATHINFO_EXTENSION) === 'xml' && strrpos($path, 'gitsync_category') === false) - || (pathinfo($path, PATHINFO_EXTENSION) === 'json' && strrpos($path, '_quiz.json') !== false) + (pathinfo($file->relpath, PATHINFO_EXTENSION) === 'xml' + && strrpos($file->relpath, 'gitsync_category') === false) + || (pathinfo($file->relpath, PATHINFO_EXTENSION) === 'json' && strrpos($file->relpath, '_quiz.json') !== false) ) { $childless = new StdClass(); - // Get the path relative to the samplequestions or stack dataroot folder. - $pathfromsq = str_replace(dirname(__DIR__) . '/samplequestions/', '', $path); - $pathfromsq = str_replace("{$CFG->dataroot}/stack/", '', $pathfromsq); - $childless->path = $pathfromsq; - $labels = explode('/', $path); - $childless->label = end($labels); + $childless->path = $file->relpath; + $childless->url = $file->url; + $childless->label = $file->label; $childless->isdirectory = 0; $results->children[] = $childless; } } else { - if (strrpos($path, 'manifest_backups') === false) { - $children = self::get_file_list($path . '/*'); + if (strrpos($file->relpath, 'manifest_backups') === false) { + $descendants = array_filter($filelist, fn($x) => str_starts_with($x->relpath, $file->relpath . '/')); + if ($descendants) { + $children = self::format_file_list($descendants); + } else { + continue; + } if ($children->label === 'top') { - $topchildren = $children->children; - $topquizzes = []; - $topfolders = []; - foreach ($topchildren as $topchild) { + $childrenoftop = $children->children; + $quizzesintop = []; + $foldersintop = []; + foreach ($childrenoftop as $childoftop) { if ( - isset($topchild->path) && pathinfo($topchild->path, PATHINFO_EXTENSION) === 'json' - && strrpos($topchild->path, '_quiz.json') !== false + isset($childoftop->path) && pathinfo($childoftop->path, PATHINFO_EXTENSION) === 'json' + && strrpos($childoftop->path, '_quiz.json') !== false ) { - $topquizzes[] = $topchild; - } else if ($topchild->isdirectory) { - $topfolders[] = $topchild; + $quizzesintop[] = $childoftop; + } else if ($childoftop->isdirectory) { + $foldersintop[] = $childoftop; } } - if (count($topfolders) === 1 && count($topquizzes) === 0) { + if (count($foldersintop) === 1 && count($quizzesintop) === 0) { // If we have a 'top' folder containing only a single folder (e.g. 'Default for...) // strip out both from file display. - $results->children = array_merge($results->children, $topchildren[0]->children); - } else if (count($topfolders) === 1 && count($topquizzes) > 0) { + $results->children = array_merge($results->children, $foldersintop[0]->children); + } else if (count($foldersintop) === 1 && count($quizzesintop) > 0) { // Quizzes and a single folder. Display quizzes and contents of folder. - $results->children = array_merge($topquizzes, $topfolders[0]->children); + $results->children = array_merge($results->children, $quizzesintop, $foldersintop[0]->children); } else { // Just strip out 'top'. - $results->children = array_merge($results->children, $topchildren); + $results->children = array_merge($results->children, $childrenoftop); } } else { $results->children[] = $children; @@ -206,4 +489,193 @@ public static function get_file_list(string $dir): object { }); return $results; } + + /** + * Fetch a file from GitHub using the api blob URL. + * + * @param string $requestedfile API URL + * @return string XML file contents + */ + public static function get_external_github_file($requestedfile) { + $headers = [ + 'User-Agent: Moodle-STACK', + 'Accept: application/vnd.github.v3+json', + ]; + + $ch = curl_init($requestedfile); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_FAILONERROR => false, + CURLOPT_SSL_VERIFYPEER => true, + ]); + $res = static::execute_curl_request($ch); + $httpcode = static::get_curl_info($ch, CURLINFO_HTTP_CODE); + + if ($res === false || $httpcode !== 200) { + throw new \stack_exception('File unavailable.'); + } + + $json = json_decode($res, true); + if (!is_array($json) || empty($json['content']) || empty($json['encoding'])) { + throw new \stack_exception('Invalid JSON.'); + } + + if ($json['encoding'] !== 'base64') { + throw new \stack_exception('Wrongly encoded.'); + } + + $filecontents = base64_decode($json['content'], true); + if ($filecontents === false) { + throw new \stack_exception('Could not decode.'); + } + + return $filecontents; + } + + /** + * Fetch a file from GitHub using the api blob URL. + * + * @param string $requestedfile API URL + * @return string XML file contents + */ + public static function get_external_nrw_file($requestedid, $apikey) { + $apibase = self::NRWAPIBASE . "/questions/get/"; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_USERAGENT, 'Moodle-STACK'); // GitHub requires a user agent. + curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer {$apikey}"]); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + $apiurl = "{$apibase}{$requestedid}?fields=xml"; + curl_setopt($ch, CURLOPT_URL, $apiurl); + $response = static::execute_curl_request($ch); + $httpcode = static::get_curl_info($ch, CURLINFO_HTTP_CODE); + if ($response === false || $httpcode >= 400) { + throw new \stack_exception('File unavailable.'); + } + + $json = json_decode($response, true); + if (!$json) { + throw new \stack_exception('Invalid JSON.'); + } + + return $json['xml']; + } + + /** + * Upload a Moodle question object to the NRW API. + * + * @param object $questiondata Question data from question_bank::load_question_data(). + * @param string $apikey NRW API key. + * @return array Upload result for template output. + */ + public static function upload_nrw_question(object $questiondata, string $apikey): array { + global $CFG, $COURSE; + require_once($CFG->libdir . '/questionlib.php'); + require_once($CFG->dirroot . '/question/format/xml/format.php'); + + if (empty($apikey)) { + return [ + 'iserror' => true, + 'message' => stack_string('nrwuploadapikeymissing'), + ]; + } + + // Preflight the same capability exportprocess(true) will enforce, so we can + // fail cleanly instead of falling into a zero-question export edge case. + if (!question_has_capability_on($questiondata, 'view')) { + return [ + 'iserror' => true, + 'message' => get_string('nopermissions', 'error', get_string('stack:exporttoexternallibraries', 'qtype_stack')), + ]; + } + + $qformat = new \qformat_xml(); + $qformat->setQuestions([$questiondata]); + $thiscontext = context::instance_by_id($questiondata->contextid); + $contexts = new question_edit_contexts($thiscontext); + // Checks user has export permission for the supplied context. + $qformat->setContexts($contexts->having_one_edit_tab_cap('export')); + $qformat->setCattofile(false); + $qformat->setContexttofile(false); + $qformat->setCourse($COURSE); + + if (!$qformat->exportpreprocess()) { + return [ + 'iserror' => true, + 'message' => stack_string('nrwuploadxmlerror'), + ]; + } + $xmlstring = $qformat->exportprocess(true); + if (!$xmlstring) { + return [ + 'iserror' => true, + 'message' => stack_string('nrwuploadxmlerror'), + ]; + } + + $apiurl = self::NRWAPIBASE . '/questions/upload'; + $payload = json_encode(['xml' => $xmlstring]); + if ($payload === false) { + return [ + 'iserror' => true, + 'message' => stack_string('nrwuploadpayloadencodeerror'), + ]; + } + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $apiurl); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_USERAGENT, 'Moodle-STACK'); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $apikey, + 'Content-Type: application/json', + ]); + curl_setopt($ch, CURLOPT_TIMEOUT, 20); + $response = static::execute_curl_request($ch); + $httpcode = static::get_curl_info($ch, CURLINFO_HTTP_CODE); + if ($response === false) { + return [ + 'iserror' => true, + 'message' => stack_string('nrwuploadfailed') . ': ' . curl_error($ch), + ]; + } + + $json = json_decode($response, true); + if (!is_array($json)) { + return [ + 'iserror' => true, + 'message' => stack_string('nrwuploadfailed') . ' HTTP ' . $httpcode . ': ' . $response, + ]; + } + $id = $json['id'] ?? null; + + if ($httpcode === 201) { + if ($json['status'] === 'duplicate') { + return [ + 'iswarning' => true, + 'message' => stack_string('nrwuploadduplicate', $id ?? '-'), + ]; + } + return [ + 'issuccess' => true, + 'message' => stack_string('nrwuploadcreated', $id ?? '-'), + ]; + } + + if ($httpcode === 400) { + return [ + 'iserror' => true, + 'message' => stack_string('nrwuploadvalidationerror') . ' ' . ($json['detail']['error_message'] ?? ''), + ]; + } + + return [ + 'iserror' => true, + 'message' => stack_string('nrwuploadfailed') . ' HTTP ' . $httpcode . ': ' . $response, + ]; + } } diff --git a/templates/questionexport.mustache b/templates/questionexport.mustache new file mode 100644 index 00000000000..d8563f058ac --- /dev/null +++ b/templates/questionexport.mustache @@ -0,0 +1,80 @@ +{{! + This file is part of Moodle - https://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template qtype_stack/questionexport + + Generic export page scaffold for question exports. + + Context variables required for this template: + * question.name + * question.version + * general.testquestionlink + * general.previewquestionlink + * general.editquestionlink + * general.uploadurl + * general.sesskey + * uploadresult + + Example context (json): + { + "question": { + "name": "Question name", + "version": "2" + }, + "general": { + "testquestionlink": "DashboardURL", + "previewquestionlink": "PreviewURL", + "editquestionlink": "EditURL", + "uploadurl": "UploadURL", + "sesskey": "abc123" + }, + "uploadresult": { + "issuccess": 1, + "iswarning": 0, + "iserror": 0, + "message": "Question uploaded to NRW." + } + } +}} + +
+ +

{{#str}} exporttonrw, qtype_stack {{/str}}

+

{{question.name}}

+

{{#str}} version, qtype_stack {{/str}} {{question.version}}

+

+ + + +
+ {{#uploadresult}} + + {{/uploadresult}} +

{{#str}} questionexportplaceholder, qtype_stack {{/str}}

+
diff --git a/templates/questionfolder.mustache b/templates/questionfolder.mustache index 5bf29293568..e2d1a934034 100644 --- a/templates/questionfolder.mustache +++ b/templates/questionfolder.mustache @@ -72,5 +72,21 @@ + {{#subject}} +
+ {{#str}} stack_library_subject, qtype_stack {{/str}}: {{subject}} + {{/subject}} + {{#source}} +
+ {{#str}} stack_library_source, qtype_stack {{/str}}: {{source}} + {{/source}} + {{#license}} +
+ {{#str}} stack_library_license, qtype_stack {{/str}}: {{license}} + {{/license}} + {{#description}} +
+ {{description}} + {{/description}} {{/isdirectory}} \ No newline at end of file diff --git a/templates/questionlibrary.mustache b/templates/questionlibrary.mustache index fddced750c6..b837aa5cb15 100644 --- a/templates/questionlibrary.mustache +++ b/templates/questionlibrary.mustache @@ -97,17 +97,35 @@ {{#str}} stack_library_select, qtype_stack {{/str}} + {{#libraries.isgithub}} + + {{/libraries.isgithub}}

{{/libraries.hasitems}} + {{#isnrwapikey}} + +
+ {{/isnrwapikey}}
{{#str}} stack_library_destination, qtype_stack {{/str}} @@ -153,6 +171,11 @@
+ {{#fileserror}} +
+ {{fileserror}} +
+ {{/fileserror}} {{#files}} {{>qtype_stack/questionfolder}} {{/files}} @@ -188,4 +211,6 @@
+ diff --git a/templates/questiontestrun.mustache b/templates/questiontestrun.mustache index a1cfcb0ff3c..510f2955bf8 100644 --- a/templates/questiontestrun.mustache +++ b/templates/questiontestrun.mustache @@ -128,6 +128,11 @@ {{#str}} exportthisquestion, qtype_stack {{/str}} {{/general.caneditpermission}} + {{#general.canexporttonrw}} + + {{/general.canexporttonrw}} diff --git a/tests/api_stackquestionloader_test.php b/tests/api_stackquestionloader_test.php index 0e07c40212b..80f9892efba 100644 --- a/tests/api_stackquestionloader_test.php +++ b/tests/api_stackquestionloader_test.php @@ -736,6 +736,40 @@ public function test_detect_difference_yml(): void { set_config('stackapi', false, 'qtype_stack'); } + public function test_question_loader_rejects_xml_doctype(): void { + $xml = <<<'XML' + + +]> + + + Unsafe + + +XML; + + $this->expectException(\stack_exception::class); + $this->expectExceptionMessage('forbidden DOCTYPE'); + StackQuestionLoader::loadXML($xml); + } + + public function test_question_loader_rejects_oversized_yaml(): void { + $yaml = "name: Oversized\nquestiontext: |\n " . str_repeat('a', 5242881); + + $this->expectException(\stack_exception::class); + $this->expectExceptionMessage('maximum allowed size'); + StackQuestionLoader::loadXML($yaml); + } + + public function test_question_loader_rejects_excessive_node_count(): void { + $xml = $this->get_minimal_question_xml(str_repeat("1\n", 50001)); + + $this->expectException(\stack_exception::class); + $this->expectExceptionMessage('maximum allowed node count'); + StackQuestionLoader::loadXML($xml); + } + public function test_split_answertest_basic(): void { $input = 'ATAlgEquiv(x^2+2x+1, (x+1)^2, 1, ignoreorder)'; $expected = [ @@ -868,4 +902,14 @@ public function test_question_loader_default_noinputnospecific(): void { $this->assertEquals(0, count($question->prts)); $this->assertEquals(0, count($question->inputs)); } + + private function get_minimal_question_xml(string $extra = ''): string { + return "\n" . + "\n" . + " \n" . + " Test\n" . + $extra . + " \n" . + "\n"; + } } diff --git a/tests/jest/library.test.js b/tests/jest/library.test.js index 31e444931e1..8c10102effd 100644 --- a/tests/jest/library.test.js +++ b/tests/jest/library.test.js @@ -43,7 +43,7 @@ function setupLibraryDom() { Quiz
- +