diff --git a/amd/build/library.min.js b/amd/build/library.min.js
index 1e419161dc2..6b8bb8b2ec4 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 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},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,cacheid:cacheId},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"),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..08b3f38ff08 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 * 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},\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: {\n courseid: courseId,\n category: categoryId,\n filepath: filepath,\n isfolder: (isFolder) ? 1 : 0,\n cacheid: cacheId\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 += '
' + '' + 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","cacheId","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","cacheid","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
diff --git a/amd/src/library.js b/amd/src/library.js
index 0a4d685fbaf..7034ae5c3a5 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;
@@ -73,6 +74,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 +102,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},
done: function(response) {
loading(false);
libraryDiv.innerHTML = response.questionrender;
@@ -163,7 +165,13 @@ 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
+ },
done: function(response) {
loading(false);
for (const currentQuestion of response) {
diff --git a/api/public/sample.php b/api/public/sample.php
index 03c80ec6478..4896d98f875 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 eb2acc22264..b3863f90edb 100644
--- a/api/public/stack.php
+++ b/api/public/stack.php
@@ -134,7 +134,7 @@ function setQuestion(question) {
Choose a STACK sample file:
';
foreach ($dirdetails as $file) {
diff --git a/classes/fake_render.php b/classes/fake_render.php
index d340045733a..56d949b74a6 100644
--- a/classes/fake_render.php
+++ b/classes/fake_render.php
@@ -35,4 +35,9 @@ 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) {
+ return "Fake XML: {$external} {$requestedfile}";
+ }
}
diff --git a/classes/library_import.php b/classes/library_import.php
index 5a9e4a63ea5..671a7ec4f30 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,12 @@ 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'),
]);
}
@@ -87,13 +92,14 @@ 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) {
global $CFG, $DB;
$params = self::validate_parameters(self::import_execute_parameters(), [
'courseid' => $courseid,
'category' => $category,
'filepath' => $filepath,
'isfolder' => $isfolder,
+ 'cacheid' => $cacheid,
]);
// Check parameters and permissions.
$thiscontext = null;
@@ -106,17 +112,25 @@ 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 = explode('_', $params['cacheid'])[0];
+ $cache = \cache::make('qtype_stack', 'librarycache');
+ $externalfiles = $cache->get($params['cacheid'] . '_flat_file_list');
} else {
$requestedfile = $CFG->dirroot . '/question/type/stack/samplequestions/' . $params['filepath'];
$basedir = $CFG->dirroot . '/question/type/stack/samplequestions/';
}
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 +140,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);
+ } 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 +170,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 +178,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 +206,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));
+ $qformat->setFilename($requestedfile);
+ } else {
+ $qformat->setFilename($basedir . $category);
+ }
$qformat->setContextfromfile(false);
$qformat->setStoponerror(true);
$contexts = new question_edit_contexts($thiscontext);
@@ -225,8 +257,14 @@ public static function import_execute($courseid, $category, $filepath, $isfolder
$qformat->setCategory($thiscategory);
}
$qformat->setCatfromfile(false);
+ if ($external) {
+ $url = $externalfiles[$file]->url;
+ file_put_contents($requestedfile, stack_question_library::get_external_file($url, $external));
+ $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..7776b456d90 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,11 @@ 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'),
]);
}
@@ -95,40 +101,55 @@ 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) {
global $CFG, $DB;
$params = self::validate_parameters(self::render_execute_parameters(), [
'category' => $category,
'filepath' => $filepath,
+ 'cacheid' => $cacheid,
]);
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 {
+ $external = null;
+ }
$thiscontext = context::instance_by_id($context);
self::validate_context($thiscontext);
require_capability('moodle/question:add', $thiscontext);
// 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) {
+ $externalfiles = $cache->get($params['cacheid'] . '_flat_file_list');
+ $requestedfile = $externalfiles[$params['filepath']]->url;
} 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);
+ } 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 +173,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 +197,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 +209,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 +239,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) {
+ return stack_question_library::get_external_file($requestedfile, $external);
+ }
}
diff --git a/lang/en/qtype_stack.php b/lang/en/qtype_stack.php
index a796afd61fa..39c6f39fc85 100644
--- a/lang/en/qtype_stack.php
+++ b/lang/en/qtype_stack.php
@@ -506,6 +506,18 @@
$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"
+
}
+
}';
// Strings used by replace dollars script.
$string['replacedollarscount'] = 'This category contains {$a} STACK questions.';
@@ -1857,6 +1869,7 @@
$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_success'] = 'Successful import of:';
diff --git a/questionlibrary.php b/questionlibrary.php
index 44d27070227..45e6f309804 100644
--- a/questionlibrary.php
+++ b/questionlibrary.php
@@ -54,6 +54,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);
@@ -90,27 +91,69 @@
// 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.
+// Corresponding cache ids are:
+// library
+// sitelibrary_foldername
+// githublibrary_id.
+$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();
+
+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 = stack_question_library::STACKLIB;
+ }
+} else if (str_starts_with($location, stack_question_library::GITHUB)) {
+ $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 = 'library_file_list';
+ $cacheid = stack_question_library::STACKLIB;
} else {
- $location .= '/*';
+ $external = $librarytype;
}
} 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);
+$files = $cache->get($cacheid . '_file_list');
if (!$files) {
- $files = stack_question_library::get_file_list($location);
- $cache->set($cacheid, $files);
+ if ($external) {
+ [$files, $flatfiles] = stack_question_library::get_file_list_from_repo($allowedlibraries->{$libraryid}->url, $external);
+ $cache->set($cacheid . '_flat_file_list', $flatfiles);
+ } else {
+ $files = stack_question_library::get_file_list($location);
+ }
+ $cache->set($cacheid . '_file_list', $files);
}
$mform = new category_form(null, ['qcontext' => $contexts]);
@@ -127,12 +170,16 @@
$outputdata->libraries = new StdClass();
$outputdata->libraries->items = [];
$outputdata->libraries->hasitems = false;
+$outputdata->libraries->current = $cacheid;
+if ($external) {
+ $outputdata->libraries->external = $cacheid;
+}
$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;
@@ -143,13 +190,29 @@
$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;
+}
+
echo $OUTPUT->render_from_template('qtype_stack/questionlibrary', $outputdata);
// Finish output.
diff --git a/settings.php b/settings.php
index 58d2000e9a8..dbaa4626610 100644
--- a/settings.php
+++ b/settings.php
@@ -237,6 +237,16 @@
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
+));
+
// Options for maths display.
$settings->add(new admin_setting_heading(
'mathsdisplayheading',
diff --git a/stack/maxima/stackmaxima.mac b/stack/maxima/stackmaxima.mac
index 18b68474406..773469147fa 100644
--- a/stack/maxima/stackmaxima.mac
+++ b/stack/maxima/stackmaxima.mac
@@ -3548,4 +3548,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:2026012900$
+stackmaximaversion:2026022302$
diff --git a/stack/questionlibrary.class.php b/stack/questionlibrary.class.php
index 4af79db8f5d..8f0bbc2cf09 100644
--- a/stack/questionlibrary.class.php
+++ b/stack/questionlibrary.class.php
@@ -28,6 +28,7 @@
use api\util\StackSeedHelper;
use api\util\StackPlotReplacer;
+
/**
* Functions required to display the STACK question library
* @package qtype_stack
@@ -35,6 +36,21 @@
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';
/**
* Summary of render_question
@@ -134,66 +150,226 @@ 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 $url URL of the directory required
+ * @param string $repotype The type of repo being searched. (Currently only GitHub is supported)
+ * @return array [object StdClass structured representation of the file system, array flat array of file objects]
+ */
+ public static function get_file_list_from_repo($url, $repotype) {
+ switch ($repotype) {
+ case self::GITHUB:
+ return self::list_github_repo($url);
+ 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) {
+ switch ($repotype) {
+ case self::GITHUB:
+ return self::get_external_github_file($requestedfile);
+ 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_TIMEOUT, 10);
+
+ $files = [];
+
+ // 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 = curl_exec($ch);
+ $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ if ($response === false || $httpcode >= 400) {
+ return [];
+ }
+ $data = json_decode($response, true);
+ if (empty($data['tree']) || !is_array($data['tree'])) {
+ return [];
+ }
+ $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];
+ }
+
+ /**
+ * 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 +382,47 @@ 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: PHP',
+ '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 = curl_exec($ch);
+ $httpcode = curl_getinfo($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;
+ }
}
diff --git a/templates/questionlibrary.mustache b/templates/questionlibrary.mustache
index fddced750c6..c4a80d60be1 100644
--- a/templates/questionlibrary.mustache
+++ b/templates/questionlibrary.mustache
@@ -103,6 +103,12 @@
{{/libraries.items}}
+ {{#libraries.external}}
+
+ {{/libraries.external}}
{{#pix}}y/loading, core, {{#str}}loading, tool_lp{{/str}}{{/pix}}
@@ -188,4 +194,6 @@
+
+
diff --git a/tests/library_import_test.php b/tests/library_import_test.php
index eb41967d2f3..c437073006e 100644
--- a/tests/library_import_test.php
+++ b/tests/library_import_test.php
@@ -31,7 +31,9 @@
require_once($CFG->dirroot . '/webservice/tests/helpers.php');
require_once($CFG->dirroot . '/question/type/stack/tests/fixtures/test_maxima_configuration.php');
require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
+require_once($CFG->dirroot . '/question/type/stack/stack/questionlibrary.class.php');
+use cache;
use context_course;
use externallib_advanced_testcase;
use external_api;
@@ -77,6 +79,8 @@ public function setUp(): void {
}
\stack_utils::clear_config_cache();
\qtype_stack_test_config::setup_test_maxima_connection();
+ $cache = cache::make('qtype_stack', 'librarycache');
+ $cache->purge();
$this->resetAfterTest();
}
@@ -90,7 +94,13 @@ public function test_capabilities(): void {
$managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
role_assign($managerroleid, $this->user->id, $context->id);
- $returnvalue = library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false);
+ $returnvalue = library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ $this->filepath,
+ false,
+ \stack_question_library::STACKLIB
+ );
// We need to execute the return values cleaning process to simulate
// the web service server.
@@ -113,7 +123,13 @@ public function test_not_logged_in(): void {
$this->expectException(require_login_exception::class);
// Exception messages don't seem to get translated.
$this->expectExceptionMessage('not logged in');
- library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false);
+ library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ $this->filepath,
+ false,
+ \stack_question_library::STACKLIB
+ );
}
/**
@@ -127,7 +143,13 @@ public function test_no_access(): void {
$this->getDataGenerator()->enrol_user($this->user->id, $this->course->id);
$this->expectException(required_capability_exception::class);
$this->expectExceptionMessage('you do not currently have permissions to do that (Add new questions).');
- library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false);
+ library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ $this->filepath,
+ false,
+ \stack_question_library::STACKLIB
+ );
}
/**
@@ -136,7 +158,13 @@ public function test_no_access(): void {
public function test_export_capability(): void {
$this->expectException(require_login_exception::class);
$this->expectExceptionMessage('Not enrolled');
- library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false);
+ library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ $this->filepath,
+ false,
+ \stack_question_library::STACKLIB
+ );
}
/**
@@ -150,7 +178,13 @@ public function test_library_import(): void {
role_assign($managerroleid, $this->user->id, $context->id);
$sink = $this->redirectEvents();
- $returnvalue = library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false);
+ $returnvalue = library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ $this->filepath,
+ false,
+ \stack_question_library::STACKLIB
+ );
// We need to execute the return values cleaning process to simulate
// the web service server.
@@ -185,7 +219,13 @@ public function test_library_import_folder(): void {
role_assign($managerroleid, $this->user->id, $context->id);
$sink = $this->redirectEvents();
- $returnvalue = library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, true);
+ $returnvalue = library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ $this->filepath,
+ true,
+ \stack_question_library::STACKLIB
+ );
// We need to execute the return values cleaning process to simulate
// the web service server.
@@ -224,7 +264,13 @@ public function test_library_import_quiz(): void {
role_assign($managerroleid, $this->user->id, $context->id);
$sink = $this->redirectEvents();
- $returnvalue = library_import::import_execute($this->course->id, $this->qcategory->id, $quizfilepath, true);
+ $returnvalue = library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ $quizfilepath,
+ true,
+ \stack_question_library::STACKLIB
+ );
// We need to execute the return values cleaning process to simulate
// the web service server.
@@ -330,7 +376,13 @@ public function test_quiz_import_without_sections_and_feedback(): void {
$managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
role_assign($managerroleid, $this->user->id, $context->id);
- library_import::import_execute($this->course->id, $this->qcategory->id, $quizfilepath, true);
+ library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ $quizfilepath,
+ true,
+ \stack_question_library::STACKLIB
+ );
$sections = $DB->get_records('quiz_sections');
$this->assertEquals(1, count($sections));
@@ -360,7 +412,13 @@ public function test_import_with_require_previous(): void {
$managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
role_assign($managerroleid, $this->user->id, $context->id);
- library_import::import_execute($this->course->id, $this->qcategory->id, $quizfilepath, true);
+ library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ $quizfilepath,
+ true,
+ \stack_question_library::STACKLIB
+ );
$slots = $DB->get_records('quiz_slots');
$this->assertEquals(4, count($slots));
@@ -371,4 +429,523 @@ public function test_import_with_require_previous(): void {
$this->assertEquals(1, $slot2->requireprevious);
$this->assertEquals(0, $slot3->requireprevious);
}
+
+ /**
+ * Test output of library_render function when accessing site library.
+ */
+ public function test_dubious_file_check(): void {
+ global $DB;
+ $cache = cache::make('qtype_stack', 'librarycache');
+ $cache->purge();
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $iserror = false;
+ try {
+ library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ 'sitelibrary/libtest/../../testq.xml',
+ false,
+ \stack_question_library::SITELIB
+ );
+ } catch (\Exception $e) {
+ $this->assertEquals('Dubious file request.', $e->getMessage());
+ $iserror = true;
+ }
+ $this->assertEquals(true, $iserror);
+
+ $iserror = false;
+ try {
+ library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ 'sitelibrary/libtest/../../testq.xml',
+ false,
+ 'fake'
+ );
+ } catch (\Exception $e) {
+ $this->assertEquals('Dubious file request.', $e->getMessage());
+ $iserror = true;
+ }
+ $this->assertEquals(true, $iserror);
+
+ $iserror = false;
+ try {
+ library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ 'otherlib/libtest/../../testq.xml',
+ false,
+ 'fake'
+ );
+ } catch (\Exception $e) {
+ $this->assertEquals('Dubious file request.', $e->getMessage());
+ $iserror = true;
+ }
+ $this->assertEquals(true, $iserror);
+ }
+
+ /**
+ * Set up files for site library tests.
+ * @return void
+ */
+ public function copy_directory(): void {
+ global $CFG;
+
+ $dest = $CFG->dataroot . '/stack/sitelibrary/importtest';
+ $source = $CFG->dirroot . '/question/type/stack/samplequestions/importtest';
+ $this->filepath = 'sitelibrary/importtest/Course1/top/CR_Diff_01/CR-Diff-01-basic-1-e.xml';
+
+ mkdir($dest, 0777, true);
+ foreach (
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator(
+ $source,
+ \RecursiveDirectoryIterator::SKIP_DOTS
+ ),
+ \RecursiveIteratorIterator::SELF_FIRST
+ ) as $item
+ ) {
+ if ($item->isDir()) {
+ mkdir($dest . DIRECTORY_SEPARATOR . $iterator->getSubPathname());
+ } else {
+ copy($item, $dest . DIRECTORY_SEPARATOR . $iterator->getSubPathname());
+ }
+ }
+ }
+
+ /**
+ * Test output of library_import function for site library.
+ */
+ public function test_site_library_import(): void {
+ global $DB;
+
+ $this->copy_directory();
+
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $sink = $this->redirectEvents();
+
+ $returnvalue = library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ $this->filepath,
+ false,
+ \stack_question_library::SITELIB
+ );
+
+ // We need to execute the return values cleaning process to simulate
+ // the web service server.
+ $returnvalue = external_api::clean_returnvalue(
+ library_import::import_execute_returns(),
+ $returnvalue
+ );
+
+ $this->assertEquals(1, count($returnvalue));
+ $this->assertEquals(true, $returnvalue[0]['success']);
+ $this->assertEquals('CR-Diff-01-basic-1.e', $returnvalue[0]['questionname']);
+ $this->assertEquals(basename($this->filepath), $returnvalue[0]['filename']);
+ $this->assertEquals(true, $returnvalue[0]['isstack']);
+
+ $events = $sink->get_events();
+ $this->assertEquals(count($events), 2);
+ $this->assertInstanceOf('\core\event\question_created', $events['0']);
+ $this->assertInstanceOf('\core\event\questions_imported', $events['1']);
+
+ $dbquestion = $DB->get_record('question', ['name' => 'CR-Diff-01-basic-1.e'], '*', MUST_EXIST);
+ $this->assertEquals($dbquestion->id, $returnvalue[0]['questionid']);
+ }
+
+ /**
+ * Test output of library_import function for an entire folder for site library.
+ */
+ public function test_site_library_import_folder(): void {
+ global $DB, $CFG;
+ $this->copy_directory();
+ // Add in an extra question just to make sure we're getting questions from the correct place.
+ file_put_contents(
+ $CFG->dataroot . '/stack/sitelibrary/importtest/Course1/top/CR_Diff_01/testq.xml',
+ 'Test'
+ );
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $sink = $this->redirectEvents();
+
+ $returnvalue = library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ $this->filepath,
+ true,
+ \stack_question_library::SITELIB
+ );
+
+ // We need to execute the return values cleaning process to simulate
+ // the web service server.
+ $returnvalue = external_api::clean_returnvalue(
+ library_import::import_execute_returns(),
+ $returnvalue
+ );
+
+ $this->assertEquals(19, count($returnvalue));
+ $this->assertEquals(true, $returnvalue[0]['success']);
+ $this->assertEquals('CR-Diff-01-basic-1.b', $returnvalue[0]['questionname']);
+ $this->assertEquals('CR-Diff-01-basic-1-b.xml', $returnvalue[0]['filename']);
+ $this->assertEquals(true, $returnvalue[0]['isstack']);
+ $this->assertEquals('Test', $returnvalue[18]['questionname']);
+ $this->assertEquals('testq.xml', $returnvalue[18]['filename']);
+ $this->assertEquals(true, $returnvalue[18]['isstack']);
+
+ $events = $sink->get_events();
+ $this->assertEquals(count($events), 20);
+ $this->assertInstanceOf('\core\event\question_created', $events['0']);
+ $this->assertInstanceOf('\core\event\question_created', $events['18']);
+ $this->assertInstanceOf('\core\event\questions_imported', $events['19']);
+
+ $dbquestion = $DB->get_record('question', ['name' => 'CR-Diff-01-basic-1.b'], '*', MUST_EXIST);
+ $this->assertEquals($dbquestion->id, $returnvalue[0]['questionid']);
+ $dbquestions = $DB->get_records('question', ['qtype' => 'stack'], '', 'id');
+ $this->assertEquals(19, count($dbquestions));
+ }
+
+ /**
+ * Test output of library_import function for an entire folder for site library.
+ */
+ public function test_site_library_import_quiz(): void {
+ global $DB, $CFG;
+ $this->copy_directory();
+ $quizfolder = $CFG->dataroot . '/stack/sitelibrary/importtest/Course1_quiz_quiz-1';
+ // Rename quiz to make sure we're getting it from the correct place.
+ rename("{$quizfolder}/quiz-1_quiz.json", "{$quizfolder}/quiz-2_quiz.json");
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $quizfilepath = 'sitelibrary/importtest/Course1_quiz_quiz-1/quiz-2_quiz.json';
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $sink = $this->redirectEvents();
+
+ $returnvalue = library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ $quizfilepath,
+ true,
+ \stack_question_library::SITELIB
+ );
+
+ // We need to execute the return values cleaning process to simulate
+ // the web service server.
+ $returnvalue = external_api::clean_returnvalue(
+ library_import::import_execute_returns(),
+ $returnvalue
+ );
+
+ // Four questions plus the quiz itself.
+ $this->assertEquals(5, count($returnvalue));
+ $this->assertEquals(true, $returnvalue[0]['success']);
+ $this->assertEquals('Checkbox', $returnvalue[0]['questionname']);
+ $this->assertEquals('Checkbox.xml', $returnvalue[0]['filename']);
+ $this->assertEquals(true, $returnvalue[0]['isstack']);
+ $this->assertEquals('Algebraic input', $returnvalue[1]['questionname']);
+ $this->assertEquals('Algebraic-input.xml', $returnvalue[1]['filename']);
+ $this->assertEquals(true, $returnvalue[1]['isstack']);
+ $this->assertEquals('Dropdown (shuffle)', $returnvalue[2]['questionname']);
+ $this->assertEquals('Dropdown-(shuffle).xml', $returnvalue[2]['filename']);
+ $this->assertEquals(true, $returnvalue[2]['isstack']);
+ $this->assertEquals('Matrix', $returnvalue[3]['questionname']);
+ $this->assertEquals('Matrix.xml', $returnvalue[3]['filename']);
+ $this->assertEquals(true, $returnvalue[3]['isstack']);
+ $this->assertEquals('Quiz: Quiz 1', $returnvalue[4]['questionname']);
+ $this->assertEquals('quiz-2_quiz.json', $returnvalue[4]['filename']);
+ $this->assertEquals(false, $returnvalue[4]['isstack']);
+
+ $events = $sink->get_events();
+ $categoriescreated = 0;
+ $questionscreated = 0;
+ $questionsimported = 0;
+ foreach ($events as $currentevent) {
+ $eventclass = get_class($currentevent);
+ switch ($eventclass) {
+ case 'core\event\question_category_created':
+ $categoriescreated++;
+ break;
+ case 'core\event\question_created':
+ $questionscreated++;
+ break;
+ case 'core\event\questions_imported':
+ // Fired once for each question category.
+ $questionsimported++;
+ break;
+ }
+ }
+ $this->assertEquals(1, $categoriescreated);
+ $this->assertEquals(4, $questionscreated);
+ $this->assertEquals(2, $questionsimported);
+
+ $dbquestion = $DB->get_record('question', ['name' => 'Checkbox'], '*', MUST_EXIST);
+ $this->assertEquals($dbquestion->id, $returnvalue[0]['questionid']);
+ $dbquestions = $DB->get_records('question', ['qtype' => 'stack'], '', 'id');
+ $this->assertEquals(4, count($dbquestions));
+
+ $quizzes = $DB->get_records('quiz');
+ $quiz = array_shift($quizzes);
+ $this->assertEquals('Quiz 1', $quiz->name);
+ $this->assertEquals('A highly interesting quiz involving maths.', $quiz->intro);
+ $this->assertEquals(1, $quiz->questionsperpage);
+ $this->assertEquals('deferredfeedback', $quiz->preferredbehaviour);
+ $this->assertEquals(2, $quiz->decimalpoints);
+ $this->assertEquals(4352, $quiz->reviewmarks);
+ $this->assertEquals(10, $quiz->grade);
+
+ $sections = $DB->get_records('quiz_sections');
+ $this->assertEquals(2, count($sections));
+ $section1 = array_shift($sections);
+ $section2 = array_shift($sections);
+ $this->assertEquals('', $section1->heading);
+ $this->assertEquals(1, $section1->firstslot);
+ $this->assertEquals('New heading 1', $section2->heading);
+ $this->assertEquals(3, $section2->firstslot);
+
+ $slots = $DB->get_records('quiz_slots');
+ $this->assertEquals(4, count($slots));
+ $slot1 = array_shift($slots);
+ $slot2 = array_shift($slots);
+ $this->assertEquals(0, $slot1->requireprevious);
+ $this->assertEquals(1, $slot1->page);
+ $this->assertEquals(1, $slot1->maxmark);
+ $this->assertEquals(0, $slot2->requireprevious);
+ $this->assertEquals(2, $slot2->page);
+ $this->assertEquals(1, $slot2->maxmark);
+
+ $feedback = $DB->get_records('quiz_feedback');
+ $this->assertEquals(1, count($feedback));
+ $feedback1 = array_shift($feedback);
+ $this->assertEquals('Low score', $feedback1->feedbacktext);
+ $this->assertEquals(1, $feedback1->feedbacktextformat);
+ $this->assertEquals(0, $feedback1->mingrade);
+ $this->assertEquals(6, $feedback1->maxgrade);
+ }
+
+ /**
+ * Set cache for external import tests.
+ * @return void
+ */
+ public function set_external(): void {
+ $cache = cache::make('qtype_stack', 'librarycache');
+ $cache->purge();
+ [$files, $flatfiles ] =
+ \stack_question_library::get_file_list_from_repo(
+ 'https://github.com/maths/moodle-qtype_stack/tree/master/samplequestions/importtest',
+ \stack_question_library::GITHUB
+ );
+
+ $cache->set(\stack_question_library::GITHUB . '_TEST_flat_file_list', $flatfiles);
+ $this->filepath = 'Course1/top/CR_Diff_01/CR-Diff-01-basic-1-e.xml';
+ }
+
+ /**
+ * Test output of library_import function for GitHub.
+ */
+ public function test_external_library_import(): void {
+ global $DB;
+ $this->set_external();
+
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $sink = $this->redirectEvents();
+
+ $returnvalue = library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ $this->filepath,
+ false,
+ \stack_question_library::GITHUB . '_TEST'
+ );
+
+ // We need to execute the return values cleaning process to simulate
+ // the web service server.
+ $returnvalue = external_api::clean_returnvalue(
+ library_import::import_execute_returns(),
+ $returnvalue
+ );
+
+ $this->assertEquals(1, count($returnvalue));
+ $this->assertEquals(true, $returnvalue[0]['success']);
+ $this->assertEquals('CR-Diff-01-basic-1.e', $returnvalue[0]['questionname']);
+ $this->assertEquals(basename($this->filepath), $returnvalue[0]['filename']);
+ $this->assertEquals(true, $returnvalue[0]['isstack']);
+
+ $events = $sink->get_events();
+ $this->assertEquals(count($events), 2);
+ $this->assertInstanceOf('\core\event\question_created', $events['0']);
+ $this->assertInstanceOf('\core\event\questions_imported', $events['1']);
+
+ $dbquestion = $DB->get_record('question', ['name' => 'CR-Diff-01-basic-1.e'], '*', MUST_EXIST);
+ $this->assertEquals($dbquestion->id, $returnvalue[0]['questionid']);
+ }
+
+ /**
+ * Test output of library_import function for an entire folder for GitHub.
+ */
+ public function test_external_library_import_folder(): void {
+ global $DB;
+ $this->set_external();
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $sink = $this->redirectEvents();
+
+ $returnvalue = library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ $this->filepath,
+ true,
+ \stack_question_library::GITHUB . '_TEST'
+ );
+
+ // We need to execute the return values cleaning process to simulate
+ // the web service server.
+ $returnvalue = external_api::clean_returnvalue(
+ library_import::import_execute_returns(),
+ $returnvalue
+ );
+
+ $this->assertEquals(18, count($returnvalue));
+ $this->assertEquals(true, $returnvalue[0]['success']);
+ $this->assertEquals('CR-Diff-01-basic-1.b', $returnvalue[0]['questionname']);
+ $this->assertEquals('CR-Diff-01-basic-1-b.xml', $returnvalue[0]['filename']);
+ $this->assertEquals(true, $returnvalue[0]['isstack']);
+
+ $events = $sink->get_events();
+ $this->assertEquals(count($events), 19);
+ $this->assertInstanceOf('\core\event\question_created', $events['0']);
+ $this->assertInstanceOf('\core\event\question_created', $events['17']);
+ $this->assertInstanceOf('\core\event\questions_imported', $events['18']);
+
+ $dbquestion = $DB->get_record('question', ['name' => 'CR-Diff-01-basic-1.b'], '*', MUST_EXIST);
+ $this->assertEquals($dbquestion->id, $returnvalue[0]['questionid']);
+ $dbquestions = $DB->get_records('question', ['qtype' => 'stack'], '', 'id');
+ $this->assertEquals(18, count($dbquestions));
+ }
+
+ /**
+ * Test output of library_import function for an entire folder for GitHub.
+ */
+ public function test_external_library_import_quiz(): void {
+ global $DB;
+ $this->set_external();
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $quizfilepath = 'Course1_quiz_quiz-1/quiz-1_quiz.json';
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $sink = $this->redirectEvents();
+
+ $returnvalue = library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ $quizfilepath,
+ true,
+ \stack_question_library::GITHUB . '_TEST'
+ );
+
+ // We need to execute the return values cleaning process to simulate
+ // the web service server.
+ $returnvalue = external_api::clean_returnvalue(
+ library_import::import_execute_returns(),
+ $returnvalue
+ );
+
+ // Four questions plus the quiz itself.
+ $this->assertEquals(5, count($returnvalue));
+ $this->assertEquals(true, $returnvalue[0]['success']);
+ $this->assertEquals('Checkbox', $returnvalue[0]['questionname']);
+ $this->assertEquals('Checkbox.xml', $returnvalue[0]['filename']);
+ $this->assertEquals(true, $returnvalue[0]['isstack']);
+ $this->assertEquals('Algebraic input', $returnvalue[1]['questionname']);
+ $this->assertEquals('Algebraic-input.xml', $returnvalue[1]['filename']);
+ $this->assertEquals(true, $returnvalue[1]['isstack']);
+ $this->assertEquals('Dropdown (shuffle)', $returnvalue[2]['questionname']);
+ $this->assertEquals('Dropdown-(shuffle).xml', $returnvalue[2]['filename']);
+ $this->assertEquals(true, $returnvalue[2]['isstack']);
+ $this->assertEquals('Matrix', $returnvalue[3]['questionname']);
+ $this->assertEquals('Matrix.xml', $returnvalue[3]['filename']);
+ $this->assertEquals(true, $returnvalue[3]['isstack']);
+ $this->assertEquals('Quiz: Quiz 1', $returnvalue[4]['questionname']);
+ $this->assertEquals('quiz-1_quiz.json', $returnvalue[4]['filename']);
+ $this->assertEquals(false, $returnvalue[4]['isstack']);
+
+ $events = $sink->get_events();
+ $categoriescreated = 0;
+ $questionscreated = 0;
+ $questionsimported = 0;
+ foreach ($events as $currentevent) {
+ $eventclass = get_class($currentevent);
+ switch ($eventclass) {
+ case 'core\event\question_category_created':
+ $categoriescreated++;
+ break;
+ case 'core\event\question_created':
+ $questionscreated++;
+ break;
+ case 'core\event\questions_imported':
+ // Fired once for each question category.
+ $questionsimported++;
+ break;
+ }
+ }
+ $this->assertEquals(1, $categoriescreated);
+ $this->assertEquals(4, $questionscreated);
+ $this->assertEquals(2, $questionsimported);
+
+ $dbquestion = $DB->get_record('question', ['name' => 'Checkbox'], '*', MUST_EXIST);
+ $this->assertEquals($dbquestion->id, $returnvalue[0]['questionid']);
+ $dbquestions = $DB->get_records('question', ['qtype' => 'stack'], '', 'id');
+ $this->assertEquals(4, count($dbquestions));
+
+ $quizzes = $DB->get_records('quiz');
+ $quiz = array_shift($quizzes);
+ $this->assertEquals('Quiz 1', $quiz->name);
+ $this->assertEquals('A highly interesting quiz involving maths.', $quiz->intro);
+ $this->assertEquals(1, $quiz->questionsperpage);
+ $this->assertEquals('deferredfeedback', $quiz->preferredbehaviour);
+ $this->assertEquals(2, $quiz->decimalpoints);
+ $this->assertEquals(4352, $quiz->reviewmarks);
+ $this->assertEquals(10, $quiz->grade);
+
+ $sections = $DB->get_records('quiz_sections');
+ $this->assertEquals(2, count($sections));
+ $section1 = array_shift($sections);
+ $section2 = array_shift($sections);
+ $this->assertEquals('', $section1->heading);
+ $this->assertEquals(1, $section1->firstslot);
+ $this->assertEquals('New heading 1', $section2->heading);
+ $this->assertEquals(3, $section2->firstslot);
+
+ $slots = $DB->get_records('quiz_slots');
+ $this->assertEquals(4, count($slots));
+ $slot1 = array_shift($slots);
+ $slot2 = array_shift($slots);
+ $this->assertEquals(0, $slot1->requireprevious);
+ $this->assertEquals(1, $slot1->page);
+ $this->assertEquals(1, $slot1->maxmark);
+ $this->assertEquals(0, $slot2->requireprevious);
+ $this->assertEquals(2, $slot2->page);
+ $this->assertEquals(1, $slot2->maxmark);
+
+ $feedback = $DB->get_records('quiz_feedback');
+ $this->assertEquals(1, count($feedback));
+ $feedback1 = array_shift($feedback);
+ $this->assertEquals('Low score', $feedback1->feedbacktext);
+ $this->assertEquals(1, $feedback1->feedbacktextformat);
+ $this->assertEquals(0, $feedback1->mingrade);
+ $this->assertEquals(6, $feedback1->maxgrade);
+ }
}
diff --git a/tests/library_render_test.php b/tests/library_render_test.php
index 29853772305..8947f389b9d 100644
--- a/tests/library_render_test.php
+++ b/tests/library_render_test.php
@@ -29,6 +29,7 @@
global $CFG;
require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+require_once($CFG->dirroot . '/question/type/stack/stack/questionlibrary.class.php');
use cache;
use context_course;
@@ -82,7 +83,7 @@ public function test_capabilities(): void {
$managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
role_assign($managerroleid, $this->user->id, $context->id);
- $returnvalue = fake_render::render_execute($this->qcategory->id, $this->filepath);
+ $returnvalue = fake_render::render_execute($this->qcategory->id, $this->filepath, \stack_question_library::STACKLIB);
// We need to execute the return values cleaning process to simulate
// the web service server.
@@ -105,7 +106,7 @@ public function test_not_logged_in(): void {
$this->expectException(require_login_exception::class);
// Exception messages don't seem to get translated.
$this->expectExceptionMessage('not logged in');
- library_render::render_execute($this->qcategory->id, $this->filepath);
+ library_render::render_execute($this->qcategory->id, $this->filepath, \stack_question_library::STACKLIB);
}
/**
@@ -119,7 +120,7 @@ public function test_no_webservice_access(): void {
$this->getDataGenerator()->enrol_user($this->user->id, $this->course->id);
$this->expectException(required_capability_exception::class);
$this->expectExceptionMessage('you do not currently have permissions to do that (Add new questions).');
- library_render::render_execute($this->qcategory->id, $this->filepath);
+ library_render::render_execute($this->qcategory->id, $this->filepath, \stack_question_library::STACKLIB);
}
/**
@@ -128,7 +129,7 @@ public function test_no_webservice_access(): void {
public function test_library_render_capability(): void {
$this->expectException(require_login_exception::class);
$this->expectExceptionMessage('Not enrolled');
- library_render::render_execute($this->qcategory->id, $this->filepath);
+ library_render::render_execute($this->qcategory->id, $this->filepath, \stack_question_library::STACKLIB);
}
/**
@@ -143,7 +144,7 @@ public function test_library_render(): void {
$managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
role_assign($managerroleid, $this->user->id, $context->id);
- $returnvalue = fake_render::render_execute($this->qcategory->id, $this->filepath);
+ $returnvalue = fake_render::render_execute($this->qcategory->id, $this->filepath, \stack_question_library::STACKLIB);
// We need to execute the return values cleaning process to simulate
// the web service server.
@@ -162,4 +163,129 @@ public function test_library_render(): void {
);
$this->assertStringContainsString('rdm:-(2+rand(8))', $returnvalue['questionvariables']);
}
+
+ /**
+ * Test output of library_render function when accessing external library.
+ */
+ public function test_external_library_render(): void {
+ global $DB;
+ $cache = cache::make('qtype_stack', 'librarycache');
+ $cache->purge();
+ $file = new \StdClass();
+ $file->url = 'fakeURL';
+ $cache->set(\stack_question_library::GITHUB . '_flat_file_list', ['file' => $file]);
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+
+ $returnvalue = fake_render::render_execute($this->qcategory->id, 'file', \stack_question_library::GITHUB);
+
+ // We need to execute the return values cleaning process to simulate
+ // the web service server.
+ $returnvalue = external_api::clean_returnvalue(
+ fake_render::render_execute_returns(),
+ $returnvalue
+ );
+
+ $this->assertStringContainsString(
+ 'Hello World
',
+ $returnvalue['questionrender']
+ );
+ $this->assertEquals(
+ 'Fake XML: githublibrary fakeURL',
+ $returnvalue['questiontext']
+ );
+ }
+
+ /**
+ * Test output of library_render function when accessing site library.
+ */
+ public function test_site_library_render(): void {
+ global $DB, $CFG;
+ $cache = cache::make('qtype_stack', 'librarycache');
+ $cache->purge();
+ mkdir($CFG->dataroot . '/stack/sitelibrary/libtest', 0777, true);
+ file_put_contents(
+ $CFG->dataroot . '/stack/sitelibrary/libtest/testq.xml',
+ 'Fake XML: Site'
+ );
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+
+ $returnvalue = fake_render::render_execute(
+ $this->qcategory->id,
+ 'sitelibrary/libtest/testq.xml',
+ \stack_question_library::SITELIB
+ );
+
+ // We need to execute the return values cleaning process to simulate
+ // the web service server.
+ $returnvalue = external_api::clean_returnvalue(
+ fake_render::render_execute_returns(),
+ $returnvalue
+ );
+
+ $this->assertStringContainsString(
+ 'Hello World
',
+ $returnvalue['questionrender']
+ );
+ $this->assertEquals(
+ 'Fake XML: Site',
+ $returnvalue['questiontext']
+ );
+ }
+
+ /**
+ * Test output of library_render function when accessing site library.
+ */
+ public function test_dubious_file_check(): void {
+ global $DB;
+ $cache = cache::make('qtype_stack', 'librarycache');
+ $cache->purge();
+ // Set the required capabilities - webservice access and export rights on course.
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+ $iserror = false;
+ try {
+ fake_render::render_execute(
+ $this->qcategory->id,
+ 'sitelibrary/libtest/../../testq.xml',
+ \stack_question_library::SITELIB
+ );
+ } catch (\Exception $e) {
+ $this->assertEquals('Dubious file request.', $e->getMessage());
+ $iserror = true;
+ }
+ $this->assertEquals(true, $iserror);
+
+ $iserror = false;
+ try {
+ fake_render::render_execute(
+ $this->qcategory->id,
+ 'sitelibrary/libtest/../../testq.xml',
+ 'fake'
+ );
+ } catch (\Exception $e) {
+ $this->assertEquals('Dubious file request.', $e->getMessage());
+ $iserror = true;
+ }
+ $this->assertEquals(true, $iserror);
+
+ $iserror = false;
+ try {
+ fake_render::render_execute(
+ $this->qcategory->id,
+ 'otherlib/libtest/../../testq.xml',
+ 'fake'
+ );
+ } catch (\Exception $e) {
+ $this->assertEquals('Dubious file request.', $e->getMessage());
+ $iserror = true;
+ }
+ $this->assertEquals(true, $iserror);
+ }
}
diff --git a/tests/questionlibrary_test.php b/tests/questionlibrary_test.php
index 2b7efc770d0..6e8faabdfe4 100644
--- a/tests/questionlibrary_test.php
+++ b/tests/questionlibrary_test.php
@@ -64,7 +64,7 @@ public function test_render_question(): void {
*/
public function test_get_file_list(): void {
global $CFG;
- $files = stack_question_library::get_file_list($CFG->dirroot . '/question/type/stack/samplequestions/stacklibrary/*');
+ $files = stack_question_library::get_file_list($CFG->dirroot . '/question/type/stack/samplequestions/stacklibrary');
$folder = null;
foreach ($files->children as $currentfolder) {
if ($currentfolder->label === 'Calculus-Refresher') {
@@ -102,7 +102,7 @@ public function test_get_file_list(): void {
*/
public function test_get_file_list_quizzes(): void {
global $CFG;
- $files = stack_question_library::get_file_list($CFG->dirroot . '/question/type/stack/samplequestions/importtest/*');
+ $files = stack_question_library::get_file_list($CFG->dirroot . '/question/type/stack/samplequestions/importtest');
$this->assertEquals(2, count($files->children));
$this->assertEquals('Course1', $files->children[0]->label);
$this->assertEquals('Course1_quiz_quiz-1', $files->children[1]->label);
@@ -132,4 +132,48 @@ public function test_get_file_list_quizzes(): void {
}
}
}
+
+ /**
+ * Test getting external files.
+ */
+ public function test_get_external_files(): void {
+ [$files, $flatfiles ] =
+ stack_question_library::get_file_list_from_repo(
+ 'https://github.com/maths/moodle-qtype_stack/tree/master/samplequestions/importtest',
+ stack_question_library::GITHUB
+ );
+ $this->assertEquals(2, count($files->children));
+ $this->assertEquals('Course1', $files->children[0]->label);
+ $this->assertEquals('Course1_quiz_quiz-1', $files->children[1]->label);
+
+ // Ignore top and lone folder.
+ $this->assertEquals(18, count($files->children[0]->children));
+
+ // Seven questions, sub-category and 3 quizzes.
+ $this->assertEquals(11, count($files->children[1]->children));
+ $labels = ['Sub-for-quiz-1', 'Algebraic-input-(with-simplification).xml', 'Checkbox-(no-body-LaTeX).xml',
+ 'Dropdown-(shuffle).xml', 'Equiv-input-test-(compact).xml', 'Matrix.xml',
+ 'Radio-(compact).xml', 'Single-char.xml', 'quiz-1_quiz.json', 'quiz-no-sections_quiz.json',
+ 'quiz-require-prev_quiz.json'];
+ foreach ($labels as $label) {
+ $index = null;
+ foreach ($files->children[1]->children as $childkey => $child) {
+ if ($child->label === $label) {
+ $index = $childkey;
+ break;
+ }
+ }
+ $this->assertEquals(true, isset($index), $label);
+ if ($label === 'Sub-for-quiz-1') {
+ $this->assertEquals(1, $files->children[1]->children[$index]->isdirectory, $label);
+ } else {
+ $this->assertEquals(0, $files->children[1]->children[$index]->isdirectory, $label);
+ }
+ }
+
+ $url = $flatfiles['Course1/top/CR_Diff_01/CR-Diff-01-basic-1-e.xml']->url;
+
+ $file = stack_question_library::get_external_github_file($url);
+ $this->assertStringContainsString('Differentiate \({@v@}^{@rdm@}\) with respect to {@v@}', $file);
+ }
}
diff --git a/version.php b/version.php
index 0705b6648a4..fdd21795091 100644
--- a/version.php
+++ b/version.php
@@ -24,7 +24,7 @@
defined('MOODLE_INTERNAL') || die();
-$plugin->version = 2026012900;
+$plugin->version = 2026022302;
$plugin->requires = 2022041900;
$plugin->cron = 0;
$plugin->component = 'qtype_stack';