diff --git a/amd/build/library.min.js b/amd/build/library.min.js
index 1e419161dc2..cb0ee4d2c4f 100644
--- a/amd/build/library.min.js
+++ b/amd/build/library.min.js
@@ -6,6 +6,6 @@
* @copyright 2024 The University of Edinburgh
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-define("qtype_stack/library",["core/ajax","core_filters/events"],(function(Ajax,CustomEvents){let courseId=null,categoryId=null,libraryDiv=null,rawDiv=null,variablesDiv=null,descriptionDiv=null,importListDiv=null,importSuccessDiv=null,importFailureDiv=null,importSuccessFileDiv=null,importFailureFileDiv=null,displayedDiv=null,quizLink=null,dashLink=null,errorDiv=null,errorDetailsDiv=null,currentPath=null;function libraryRender(e){const filepath=e.target.getAttribute("data-filepath");currentPath=filepath,loading(!0),categoryId=Number(document.getElementById("id_category").value.split(",")[0]),Ajax.call([{methodname:"qtype_stack_library_render",args:{category:categoryId,filepath:filepath},done:function(response){loading(!1),libraryDiv.innerHTML=response.questionrender;for(const iframe of response.iframes)require(["qtype_stack/stackjsvle"],(function(stackjsvle){stackjsvle.create_iframe(iframe.iframeid,iframe.content,iframe.targetdivid,iframe.title,iframe.scrolling,iframe.evil)}));rawDiv.innerText=response.questiontext,descriptionDiv.innerHTML=response.questiondescription,variablesDiv.innerHTML=response.questionvariables.replace(/;/g,"; Hello World {{#str}} version, qtype_stack {{/str}} {{question.version}}
+ {{#str}} questionexportplaceholder, qtype_stack {{/str}}
"),displayedDiv.innerHTML=response.questionname+"
("+filepath.split("/").pop()+")",document.querySelectorAll(".library-secondary-info").forEach((el=>el.removeAttribute("hidden"))),document.querySelector(".library-import-link").removeAttribute("disabled"),filepath.endsWith("_quiz.json")?(document.querySelector(".stack-library-category-holder").setAttribute("hidden",!0),document.querySelector(".stack-library-course").removeAttribute("hidden")):(document.querySelector(".stack-library-course").setAttribute("hidden",!0),document.querySelector(".stack-library-category-holder").removeAttribute("hidden"),document.querySelector(".library-import-link-folder").removeAttribute("disabled")),CustomEvents.notifyFilterContentUpdated(libraryDiv)},fail:function(response){loading(!1),errorDetailsDiv.innerHTML=response.message?response.message:"",errorDiv.hidden=!1}}])}function libraryImport(isFolder){if(!currentPath)return;const filepath=currentPath;loading(!0),categoryId=Number(document.getElementById("id_category").value.split(",")[0]),Ajax.call([{methodname:"qtype_stack_library_import",args:{courseid:courseId,category:categoryId,filepath:filepath,isfolder:isFolder?1:0},done:function(response){loading(!1);for(const currentQuestion of response)if(currentQuestion.success){let currentDashLink=dashLink+currentQuestion.questionid;currentQuestion.isstack?importListDiv.innerHTML+='
'+currentQuestion.questionname+"":currentQuestion.filename.endsWith("_quiz.json")?importListDiv.innerHTML+='
'+currentQuestion.questionname+"":importListDiv.innerHTML+="
"+currentQuestion.questionname,importSuccessFileDiv.innerHTML+="
"+currentQuestion.filename.split("/").pop()+" --\x3e "+currentQuestion.questionname,importSuccessDiv.removeAttribute("hidden")}else importFailureFileDiv.innerHTML+="
"+currentQuestion.filename.split("/").pop(),importFailureDiv.removeAttribute("hidden")},fail:function(response){loading(!1),errorDetailsDiv.innerHTML=response.message?response.message:"",errorDiv.hidden=!1}}])}function loading(isLoading){errorDiv.hidden=!0,isLoading?(document.querySelector(".loading-display").removeAttribute("hidden"),document.querySelector(".library-import-link").setAttribute("disabled","disabled"),document.querySelector(".library-import-link-folder").setAttribute("disabled","disabled"),document.querySelectorAll(".library-file-link").forEach((el=>el.setAttribute("disabled","disabled"))),importSuccessFileDiv.innerHTML="",importSuccessDiv.setAttribute("hidden",!0),importFailureFileDiv.innerHTML="",importFailureDiv.setAttribute("hidden",!0)):(document.querySelector(".loading-display").setAttribute("hidden",!0),document.querySelectorAll(".library-file-link").forEach((el=>el.removeAttribute("disabled"))))}return{setup:function(){libraryDiv=document.querySelector(".stack_library_display"),rawDiv=document.querySelector(".stack_library_raw_display"),variablesDiv=document.querySelector(".stack_library_variables_display"),importListDiv=document.querySelector(".stack-library-imported-list"),displayedDiv=document.querySelector(".stack_library_selected_question"),descriptionDiv=document.querySelector(".stack_library_description_display"),errorDiv=document.querySelector(".stack-library-error"),errorDetailsDiv=document.querySelector(".stack-library-error-details"),importSuccessDiv=document.querySelector(".stack-library-import-success"),importFailureDiv=document.querySelector(".stack-library-import-failure"),importSuccessFileDiv=document.querySelector(".stack-library-import-success-file"),importFailureFileDiv=document.querySelector(".stack-library-import-failure-file"),dashLink=document.querySelector("#dashboard-link-holder").innerHTML.trim(),quizLink=document.querySelector("#quiz-link-holder").innerHTML.trim(),dashLink=dashLink.includes("?")?dashLink+="&questionid=":dashLink+="?questionid=",loading(!0),document.querySelectorAll(".library-file-link").forEach((function(elem){elem.addEventListener("click",libraryRender)})),courseId=document.querySelector('[data-id="stack_library_course_id"]').getAttribute("data-value"),document.querySelector(".library-import-link").addEventListener("click",(()=>libraryImport(!1))),document.querySelector(".library-import-link-folder").addEventListener("click",(()=>libraryImport(!0)));const catOptions=document.querySelectorAll("#id_category option");for(let option of catOptions){const sections=option.text.split("(");sections.length>1&&(sections[0]||sections.length>2)&&(sections.pop(),option.text=sections.join("("))}loading(!1)}}}));
+define("qtype_stack/library",["core/ajax","core_filters/events"],(function(Ajax,CustomEvents){let courseId=null,categoryId=null,cacheId=null,libraryDiv=null,rawDiv=null,variablesDiv=null,descriptionDiv=null,importListDiv=null,importSuccessDiv=null,importFailureDiv=null,importSuccessFileDiv=null,importFailureFileDiv=null,displayedDiv=null,quizLink=null,dashLink=null,errorDiv=null,errorDetailsDiv=null,currentPath=null;function escapeHtml(text){return String(text||"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function libraryRender(e){let filepath=e.target.getAttribute("data-filepath");currentPath=filepath,loading(!0),categoryId=Number(document.getElementById("id_category").value.split(",")[0]),Ajax.call([{methodname:"qtype_stack_library_render",args:{category:categoryId,filepath:filepath,cacheid:cacheId,apikey:""},done:function(response){loading(!1),libraryDiv.innerHTML=response.questionrender;for(const iframe of response.iframes)require(["qtype_stack/stackjsvle"],(function(stackjsvle){stackjsvle.create_iframe(iframe.iframeid,iframe.content,iframe.targetdivid,iframe.title,iframe.scrolling,iframe.evil)}));rawDiv.innerText=response.questiontext,descriptionDiv.textContent=response.questiondescription,variablesDiv.innerHTML=response.questionvariables.split(";").map(escapeHtml).join(";
"),displayedDiv.innerHTML=escapeHtml(response.questionname)+"
("+escapeHtml(filepath.split("/").pop())+")",document.querySelectorAll(".library-secondary-info").forEach((el=>el.removeAttribute("hidden"))),document.querySelector(".library-import-link").removeAttribute("disabled"),filepath.endsWith("_quiz.json")?(document.querySelector(".stack-library-category-holder").setAttribute("hidden",!0),document.querySelector(".stack-library-course").removeAttribute("hidden")):(document.querySelector(".stack-library-course").setAttribute("hidden",!0),document.querySelector(".stack-library-category-holder").removeAttribute("hidden"),"nrwsearch"!==cacheId&&document.querySelector(".library-import-link-folder").removeAttribute("disabled")),CustomEvents.notifyFilterContentUpdated(libraryDiv)},fail:function(response){loading(!1),errorDetailsDiv.textContent=response.message||"",errorDiv.hidden=!1}}])}function libraryImport(isFolder){if(!currentPath)return;const filepath=currentPath;loading(!0),categoryId=Number(document.getElementById("id_category").value.split(",")[0]),Ajax.call([{methodname:"qtype_stack_library_import",args:{courseid:courseId,category:categoryId,filepath:filepath,isfolder:isFolder?1:0,cacheid:cacheId,apikey:""},done:function(response){loading(!1);for(const currentQuestion of response)if(currentQuestion.success){let currentDashLink=dashLink+currentQuestion.questionid;currentQuestion.isstack?importListDiv.innerHTML+='
'+escapeHtml(currentQuestion.questionname)+"":currentQuestion.filename.endsWith("_quiz.json")?importListDiv.innerHTML+='
'+escapeHtml(currentQuestion.questionname)+"":importListDiv.innerHTML+="
"+escapeHtml(currentQuestion.questionname),importSuccessFileDiv.innerHTML+="
"+escapeHtml(currentQuestion.filename.split("/").pop())+" --\x3e "+escapeHtml(currentQuestion.questionname),importSuccessDiv.removeAttribute("hidden")}else importFailureFileDiv.innerHTML+="
"+escapeHtml(currentQuestion.filename.split("/").pop()),importFailureDiv.removeAttribute("hidden")},fail:function(response){loading(!1),errorDetailsDiv.textContent=response.message||"",errorDiv.hidden=!1}}])}function loading(isLoading){errorDiv.hidden=!0,isLoading?(document.querySelector(".loading-display").removeAttribute("hidden"),document.querySelector(".library-import-link").setAttribute("disabled","disabled"),document.querySelector(".library-import-link-folder").setAttribute("disabled","disabled"),document.querySelectorAll(".library-file-link").forEach((el=>el.setAttribute("disabled","disabled"))),importSuccessFileDiv.innerHTML="",importSuccessDiv.setAttribute("hidden",!0),importFailureFileDiv.innerHTML="",importFailureDiv.setAttribute("hidden",!0)):(document.querySelector(".loading-display").setAttribute("hidden",!0),document.querySelectorAll(".library-file-link").forEach((el=>el.removeAttribute("disabled"))))}return{setup:function(){libraryDiv=document.querySelector(".stack_library_display"),rawDiv=document.querySelector(".stack_library_raw_display"),variablesDiv=document.querySelector(".stack_library_variables_display"),importListDiv=document.querySelector(".stack-library-imported-list"),displayedDiv=document.querySelector(".stack_library_selected_question"),descriptionDiv=document.querySelector(".stack_library_description_display"),errorDiv=document.querySelector(".stack-library-error"),errorDetailsDiv=document.querySelector(".stack-library-error-details"),importSuccessDiv=document.querySelector(".stack-library-import-success"),importFailureDiv=document.querySelector(".stack-library-import-failure"),importSuccessFileDiv=document.querySelector(".stack-library-import-success-file"),importFailureFileDiv=document.querySelector(".stack-library-import-failure-file"),dashLink=document.querySelector("#dashboard-link-holder").innerHTML.trim(),quizLink=document.querySelector("#quiz-link-holder").innerHTML.trim(),dashLink=dashLink.includes("?")?dashLink+="&questionid=":dashLink+="?questionid=",loading(!0),document.querySelectorAll(".library-file-link").forEach((function(elem){elem.addEventListener("click",libraryRender)})),courseId=document.querySelector('[data-id="stack_library_course_id"]').getAttribute("data-value"),cacheId=document.querySelector('[data-id="stack_cache_id"]').getAttribute("data-value"),document.querySelector(".library-import-link").addEventListener("click",(()=>libraryImport(!1))),document.querySelector(".library-import-link-folder").addEventListener("click",(()=>libraryImport(!0)));const catOptions=document.querySelectorAll("#id_category option");for(let option of catOptions){const sections=option.text.split("(");sections.length>1&&(sections[0]||sections.length>2)&&(sections.pop(),option.text=sections.join("("))}loading(!1)}}}));
//# sourceMappingURL=library.min.js.map
\ No newline at end of file
diff --git a/amd/build/library.min.js.map b/amd/build/library.min.js.map
index f4c77de3482..1e3293d3068 100644
--- a/amd/build/library.min.js.map
+++ b/amd/build/library.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"library.min.js","sources":["../src/library.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see
\");\n 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 displayedDiv.innerHTML = escapeHtml(response.questionname) +\n '
(' + escapeHtml(filepath.split('/').pop()) + ')';\n document.querySelectorAll('.library-secondary-info')\n .forEach(el => el.removeAttribute('hidden'));\n document.querySelector('.library-import-link').removeAttribute('disabled');\n if (filepath.endsWith('_quiz.json')) {\n document.querySelector('.stack-library-category-holder').setAttribute('hidden', true);\n document.querySelector('.stack-library-course').removeAttribute('hidden');\n } else {\n document.querySelector('.stack-library-course').setAttribute('hidden', true);\n document.querySelector('.stack-library-category-holder').removeAttribute('hidden');\n if (cacheId !== 'nrwsearch') {\n document.querySelector('.library-import-link-folder').removeAttribute('disabled');\n }\n }\n // This fires the Maths filters for content in the validation div.\n CustomEvents.notifyFilterContentUpdated(libraryDiv);\n },\n fail: function(response) {\n loading(false);\n errorDetailsDiv.textContent = response.message || '';\n errorDiv.hidden = false;\n }\n }]);\n }\n\n /**\n * Performs AJAX call to Moodle to import a question.\n *\n * @param {boolean} isFolder is this a request to load the whole folder\n */\n function libraryImport(isFolder) {\n if (!currentPath) {\n return;\n }\n const filepath = currentPath;\n loading(true);\n categoryId = Number(document.getElementById('id_category').value.split(',')[0]);\n Ajax.call([{\n methodname: 'qtype_stack_library_import',\n args: {\n courseid: courseId,\n category: categoryId,\n filepath: filepath,\n isfolder: (isFolder) ? 1 : 0,\n cacheid: cacheId,\n apikey: ''\n },\n done: function(response) {\n loading(false);\n for (const currentQuestion of response) {\n if (currentQuestion.success) {\n let currentDashLink = dashLink + currentQuestion.questionid;\n if (currentQuestion.isstack) {\n importListDiv.innerHTML += '
' + '' + escapeHtml(currentQuestion.questionname) + '';\n } else if (currentQuestion.filename.endsWith('_quiz.json')) {\n importListDiv.innerHTML += '
' + ''\n + escapeHtml(currentQuestion.questionname) + '';\n } else {\n importListDiv.innerHTML += '
' + escapeHtml(currentQuestion.questionname);\n }\n importSuccessFileDiv.innerHTML += '
' +\n escapeHtml(currentQuestion.filename.split('/').pop()) + ' --> ' +\n escapeHtml(currentQuestion.questionname);\n importSuccessDiv.removeAttribute('hidden');\n } else {\n importFailureFileDiv.innerHTML += '
' +\n escapeHtml(currentQuestion.filename.split('/').pop());\n importFailureDiv.removeAttribute('hidden');\n }\n }\n },\n fail: function(response) {\n loading(false);\n errorDetailsDiv.textContent = response.message || '';\n errorDiv.hidden = false;\n }\n }]);\n }\n\n /**\n * Disable/enable features before/after loading.\n *\n * @param {boolean} isLoading Is an AJAX call taking place?\n */\n function loading(isLoading) {\n errorDiv.hidden = true;\n if (isLoading) {\n document.querySelector('.loading-display').removeAttribute('hidden');\n document.querySelector('.library-import-link').setAttribute('disabled', 'disabled');\n document.querySelector('.library-import-link-folder').setAttribute('disabled', 'disabled');\n document.querySelectorAll('.library-file-link').forEach(el => el.setAttribute('disabled', 'disabled'));\n importSuccessFileDiv.innerHTML = '';\n importSuccessDiv.setAttribute('hidden', true);\n importFailureFileDiv.innerHTML = '';\n importFailureDiv.setAttribute('hidden', true);\n } else {\n document.querySelector('.loading-display').setAttribute('hidden', true);\n document.querySelectorAll('.library-file-link').forEach(el => el.removeAttribute('disabled'));\n }\n }\n\n /** Export our entry point. */\n return {\n setup: setup\n };\n});\n"],"names":["define","Ajax","CustomEvents","courseId","categoryId","cacheId","libraryDiv","rawDiv","variablesDiv","descriptionDiv","importListDiv","importSuccessDiv","importFailureDiv","importSuccessFileDiv","importFailureFileDiv","displayedDiv","quizLink","dashLink","errorDiv","errorDetailsDiv","currentPath","escapeHtml","text","String","replace","libraryRender","e","filepath","target","getAttribute","loading","Number","document","getElementById","value","split","call","methodname","args","category","cacheid","apikey","done","response","innerHTML","questionrender","iframe","iframes","require","stackjsvle","create_iframe","iframeid","content","targetdivid","title","scrolling","evil","innerText","questiontext","textContent","questiondescription","questionvariables","map","join","questionname","pop","querySelectorAll","forEach","el","removeAttribute","querySelector","endsWith","setAttribute","notifyFilterContentUpdated","fail","message","hidden","libraryImport","isFolder","courseid","isfolder","currentQuestion","success","currentDashLink","questionid","isstack","filename","isLoading","setup","trim","includes","elem","addEventListener","catOptions","option","sections","length"],"mappings":"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"}
\ No newline at end of file
diff --git a/amd/src/library.js b/amd/src/library.js
index 0a4d685fbaf..51ce927981e 100644
--- a/amd/src/library.js
+++ b/amd/src/library.js
@@ -31,6 +31,7 @@ define([
let courseId = null;
let categoryId = null;
+ let cacheId = null;
let libraryDiv = null;
let rawDiv = null;
let variablesDiv = null;
@@ -47,6 +48,21 @@ define([
let errorDetailsDiv = null;
let currentPath = null;
+ /**
+ * Escape text before inserting it into innerHTML.
+ *
+ * @param {string} text text to escape
+ * @returns {string} escaped text
+ */
+ function escapeHtml(text) {
+ return String(text || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
/**
* Sets up event listeners.
*
@@ -73,6 +89,7 @@ define([
elem.addEventListener('click', libraryRender);
});
courseId = document.querySelector('[data-id="stack_library_course_id"]').getAttribute('data-value');
+ cacheId = document.querySelector('[data-id="stack_cache_id"]').getAttribute('data-value');
const importButton = document.querySelector('.library-import-link');
importButton.addEventListener('click', ()=>libraryImport(false));
const importFolderButton = document.querySelector('.library-import-link-folder');
@@ -100,13 +117,13 @@ define([
* @param {object} e the click event triggering the function call.
*/
function libraryRender(e) {
- const filepath = e.target.getAttribute('data-filepath');
+ let filepath = e.target.getAttribute('data-filepath');
currentPath = filepath;
loading(true);
categoryId = Number(document.getElementById('id_category').value.split(',')[0]);
Ajax.call([{
methodname: 'qtype_stack_library_render',
- args: {category: categoryId, filepath: filepath},
+ args: {category: categoryId, filepath: filepath, cacheid: cacheId, apikey: ''},
done: function(response) {
loading(false);
libraryDiv.innerHTML = response.questionrender;
@@ -124,9 +141,10 @@ define([
});
}
rawDiv.innerText = response.questiontext;
- descriptionDiv.innerHTML = response.questiondescription;
- variablesDiv.innerHTML = response.questionvariables.replace(/;/g, ";
");
- displayedDiv.innerHTML = response.questionname + '
(' + filepath.split('/').pop() + ')';
+ descriptionDiv.textContent = response.questiondescription;
+ variablesDiv.innerHTML = response.questionvariables.split(';').map(escapeHtml).join(";
");
+ displayedDiv.innerHTML = escapeHtml(response.questionname) +
+ '
(' + escapeHtml(filepath.split('/').pop()) + ')';
document.querySelectorAll('.library-secondary-info')
.forEach(el => el.removeAttribute('hidden'));
document.querySelector('.library-import-link').removeAttribute('disabled');
@@ -136,14 +154,16 @@ define([
} else {
document.querySelector('.stack-library-course').setAttribute('hidden', true);
document.querySelector('.stack-library-category-holder').removeAttribute('hidden');
- document.querySelector('.library-import-link-folder').removeAttribute('disabled');
+ if (cacheId !== 'nrwsearch') {
+ document.querySelector('.library-import-link-folder').removeAttribute('disabled');
+ }
}
// This fires the Maths filters for content in the validation div.
CustomEvents.notifyFilterContentUpdated(libraryDiv);
},
fail: function(response) {
loading(false);
- errorDetailsDiv.innerHTML = (response.message) ? response.message : '';
+ errorDetailsDiv.textContent = response.message || '';
errorDiv.hidden = false;
}
}]);
@@ -163,7 +183,14 @@ define([
categoryId = Number(document.getElementById('id_category').value.split(',')[0]);
Ajax.call([{
methodname: 'qtype_stack_library_import',
- args: {courseid: courseId, category: categoryId, filepath: filepath, isfolder: (isFolder) ? 1 : 0},
+ args: {
+ courseid: courseId,
+ category: categoryId,
+ filepath: filepath,
+ isfolder: (isFolder) ? 1 : 0,
+ cacheid: cacheId,
+ apikey: ''
+ },
done: function(response) {
loading(false);
for (const currentQuestion of response) {
@@ -171,27 +198,28 @@ define([
let currentDashLink = dashLink + currentQuestion.questionid;
if (currentQuestion.isstack) {
importListDiv.innerHTML += '
' + '' + currentQuestion.questionname + '';
+ + currentDashLink + '">' + escapeHtml(currentQuestion.questionname) + '';
} else if (currentQuestion.filename.endsWith('_quiz.json')) {
importListDiv.innerHTML += '
' + ''
- + currentQuestion.questionname + '';
+ + escapeHtml(currentQuestion.questionname) + '';
} else {
- importListDiv.innerHTML += '
' + currentQuestion.questionname;
+ importListDiv.innerHTML += '
' + escapeHtml(currentQuestion.questionname);
}
importSuccessFileDiv.innerHTML += '
' +
- currentQuestion.filename.split('/').pop() + ' --> ' + currentQuestion.questionname;
+ escapeHtml(currentQuestion.filename.split('/').pop()) + ' --> ' +
+ escapeHtml(currentQuestion.questionname);
importSuccessDiv.removeAttribute('hidden');
} else {
importFailureFileDiv.innerHTML += '
' +
- currentQuestion.filename.split('/').pop();
+ escapeHtml(currentQuestion.filename.split('/').pop());
importFailureDiv.removeAttribute('hidden');
}
}
},
fail: function(response) {
loading(false);
- errorDetailsDiv.innerHTML = (response.message) ? response.message : '';
+ errorDetailsDiv.textContent = response.message || '';
errorDiv.hidden = false;
}
}]);
diff --git a/api/public/sample.php b/api/public/sample.php
index 4b5acd4c357..cd3b93a2826 100644
--- a/api/public/sample.php
+++ b/api/public/sample.php
@@ -27,7 +27,7 @@
require_once(__DIR__ . '/../../stack/questionlibrary.class.php');
// Required to pass Moodle code check. Uses emulation stub.
require_login();
-$files = stack_question_library::get_file_list(realpath(__DIR__ . '/../../samplequestions/stackdemo') . '/*');
+$files = stack_question_library::get_file_list(realpath(__DIR__ . '/../../samplequestions/stackdemo'));
$questions = [];
foreach ($files->children as $file) {
diff --git a/api/public/stack.php b/api/public/stack.php
index 4b34d8a60da..a3424ada1c0 100644
--- a/api/public/stack.php
+++ b/api/public/stack.php
@@ -135,7 +135,7 @@ function setQuestion(question) {
Choose a STACK sample file:
';
foreach ($dirdetails as $file) {
diff --git a/api/util/StackQuestionLoader.php b/api/util/StackQuestionLoader.php
index c548aea7868..76eb7b5b521 100644
--- a/api/util/StackQuestionLoader.php
+++ b/api/util/StackQuestionLoader.php
@@ -35,6 +35,12 @@
* Converts question xml into usable format
*/
class StackQuestionLoader {
+ /** Maximum accepted bytes in a raw XML or YAML question definition. */
+ private const MAX_SOURCE_BYTES = 5242880;
+
+ /** Maximum accepted XML element count after parsing or YAML conversion. */
+ private const MAX_XML_NODE_COUNT = 50000;
+
/**
* @var array|null Default values for the question.
*/
@@ -67,11 +73,9 @@ class StackQuestionLoader {
// phpcs:ignore moodle.Commenting.MissingDocblock.Function
public static function loadxml($xml, $includetests = false) {
try {
- if (strpos($xml, '' . $sections[$sectionno]->heading . '
';
$sectionno++;
}
@@ -218,4 +250,14 @@ public static function render_execute($category, $filepath) {
public static function call_question_render($question) {
return stack_question_library::render_question($question);
}
+
+ /**
+ * Separate out to mock in unit testing.
+ * @param string $requestedfile
+ * @param string $external
+ * @return string XML of question
+ */
+ public static function call_external_request($requestedfile, $external, $apikey) {
+ return stack_question_library::get_external_file($requestedfile, $external, $apikey);
+ }
}
diff --git a/db/access.php b/db/access.php
index 4db1be58f08..be582242907 100644
--- a/db/access.php
+++ b/db/access.php
@@ -35,4 +35,24 @@
'manager' => CAP_ALLOW,
],
],
+ // Users with this in the system context can use external question libraries.
+ 'qtype/stack:useexternallibraries' => [
+ 'captype' => 'write',
+ 'contextlevel' => CONTEXT_SYSTEM,
+ 'archetypes' => [
+ 'editingteacher' => CAP_ALLOW,
+ 'manager' => CAP_ALLOW,
+ ],
+ 'clonepermissionsfrom' => 'moodle/question:add',
+ ],
+ // Users with this in the system context can export to external question libraries.
+ 'qtype/stack:exporttoexternallibraries' => [
+ 'captype' => 'write',
+ 'contextlevel' => CONTEXT_SYSTEM,
+ 'archetypes' => [
+ 'editingteacher' => CAP_ALLOW,
+ 'manager' => CAP_ALLOW,
+ ],
+ 'clonepermissionsfrom' => 'moodle/question:add',
+ ],
];
diff --git a/lang/en/qtype_stack.php b/lang/en/qtype_stack.php
index d0879380791..f705dd5ca53 100644
--- a/lang/en/qtype_stack.php
+++ b/lang/en/qtype_stack.php
@@ -51,6 +51,8 @@
// Capability names.
$string['stack:usediagnostictools'] = 'Use the STACK tools';
+$string['stack:useexternallibraries'] = 'Use external libraries';
+$string['stack:exporttoexternallibraries'] = 'Allow export to external libraries';
// Versions of STACK.
$string['stackversionedited'] = 'This question was authored with STACK version {$a}.';
@@ -510,6 +512,22 @@
$string['settingmaximalibraries_desc'] = 'This is a comma separated list of Maxima library names which will be automatically loaded into Maxima. Only supported library names can be used: "stats, distrib, descriptive, simplex". When you change the listed libraties you must rebuild the Maxima optimised image.';
$string['settingmaximalibraries_error'] = 'Please edit the STACK plugin setting qtype_stack | maximalibraries. The following package is not supported: {$a}';
$string['settingmaximalibraries_failed'] = 'It appears as if some of the Maxima packages you have asked for have failed to load.';
+$string['settingexternallibraries'] = 'External question libraries';
+$string['settingexternallibraries_desc'] = 'JSON object listing display names and URL locations of allowed external GitHub libraries with a short identifier for each. Example:
+
{
+
"EIT": {
+
"url": "https:\/\/github.com\/maths\/moodle-qtype_stack\/tree\/master\/samplequestions\/importtest",
+
"name": "External: import test"
+
},
+
"ESQ": {
+
"url": "https:\/\/github.com\/maths\/moodle-qtype_stack\/tree\/master\/samplequestions\/stacklibrary",
+
"name": "External: sample library"
+
}
+
}';
+$string['settingnrwapikey'] = 'STACK.nrw databse API key';
+$string['settingnrwapikey_desc'] = 'Allows access to the STACK.nrw question database via the STACK library page. Access can be restricted on a per user basis using Moodle permission qtype/stack:useexternallibraries.';
+$string['settingnrwupload'] = 'STACK.nrw upload allowed';
+$string['settingnrwupload_desc'] = 'Allows questions to be uploaded to the STACK.nrw question database via the STACK question dashboard. Access can be restricted on a per user basis using Moodle permission qtype/stack:exporttoexternallibraries.';
// Strings used by replace dollars script.
$string['replacedollarscount'] = 'This category contains {$a} STACK questions.';
@@ -666,6 +684,7 @@
$string['seethisquestioninthequestionbank'] = ' Show in question bank';
$string['exportthisquestion'] = ' Export as Moodle XML';
$string['exportthisquestion_help'] = 'This will create a Moodle XML export file containing just this one question. One example of when this is useful if you think this question demonstrates a bug in STACK that you would like to report to the developers.';
+$string['exporttonrw'] = ' Export to STACK.nrw';
$string['tidyquestion'] = ' Tidy inputs and PRTs';
$string['tidyquestion_txt'] = 'Tidy inputs and PRTs';
$string['sendgeneralfeedback'] = ' Send general feedback to the CAS';
@@ -678,6 +697,19 @@
$string['bulktestquiz'] = ' Bulk test quiz';
$string['bulktestquiznotes'] = 'Bulk test the latest version of all the questions in a quiz containing this question.';
$string['history'] = ' Question history';
+$string['questionexportplaceholder'] = 'This page is a placeholder for the NRW export workflow.';
+$string['nrwuploadbutton'] = 'Upload to STACK.nrw';
+$string['nrwuploadcreated'] = 'Question uploaded to NRW successfully. ATLAS ID: {$a}.';
+$string['nrwuploadduplicate'] = 'This XML already exists in NRW. Existing ATLAS ID: {$a}.';
+$string['nrwuploadvalidationerror'] = 'NRW rejected the upload: XML failed validation.';
+$string['nrwuploadfailed'] = 'NRW upload failed.';
+$string['nrwuploadunexpected'] = 'Unexpected response from NRW upload endpoint.';
+$string['nrwuploadapikeymissing'] = 'Cannot upload: NRW API key is not configured.';
+$string['nrwuploadxmlerror'] = 'Could not export this question to XML for upload.';
+$string['nrwuploadpayloadencodeerror'] = 'Could not encode upload payload.';
+$string['nrwuploadduplicateapi'] = 'Duplicate question already exists.';
+$string['nrwuploadcreatedapi'] = 'Question uploaded successfully.';
+$string['nrwuploadxmlvalidationapifailed'] = 'XML failed validation.';
$string['bulktestquizselect'] = 'Select a quiz';
$string['basicquestionreport'] = ' Analyze responses';
@@ -1979,6 +2011,7 @@
// Strings used by question library.
$string['stack_library'] = 'STACK question library';
$string['stack_library_destination'] = 'Questions will be imported into the following category:';
+$string['stack_library_connection_error'] = 'Something went wrong contacting an external question source';
$string['stack_library_error'] = 'Something went wrong. Please refresh the page and try again.';
$string['stack_library_failure'] = 'Failed import of:';
$string['stack_library_help'] = 'Rather than creating your own question, follow this link to go to the STACK question library. The STACK question library contains many pre-made STACK questions ready for you to import into Moodle. You can then use them as they are or edit them to fit your needs.';
@@ -1990,15 +2023,21 @@
$string['stack_library_import'] = 'Import';
$string['stack_library_importlist'] = 'Imported questions:';
$string['stack_library_import_folder'] = 'Import folder';
+$string['stack_library_license'] = 'License';
$string['stack_library_quiz'] = 'This is a quiz:';
$string['stack_library_quiz_course'] = 'The quiz will be imported into course: ';
$string['stack_library_quiz_prefix'] = 'Quiz:';
+$string['stack_library_refresh'] = 'Refresh library contents';
$string['stack_library_selected'] = 'Displayed question:';
$string['stack_library_select'] = 'Select library:';
+$string['stack_library_source'] = 'Source';
+$string['stack_library_nrw'] = 'Search NRW database';
+$string['stack_library_subject'] = 'Subject';
$string['stack_library_success'] = 'Successful import of:';
$string['stack_library_not_stack'] = 'This is not a STACK question and so cannot be fully rendered here but you can still import it.';
$string['stack_library_quiz_return'] = 'Return to quiz';
$string['stack_library_qb_return'] = 'Return to question bank';
+$string['stack_library_nothing'] = 'No results found';
// API strings.
$string['api_advance_variant'] = 'Next Variant';
$string['api_choose_file'] = 'Please select a question file';
diff --git a/questionexport.php b/questionexport.php
new file mode 100644
index 00000000000..2de2b3c4987
--- /dev/null
+++ b/questionexport.php
@@ -0,0 +1,90 @@
+.
+
+/**
+ * Placeholder export page for external library exports.
+ *
+ * @package qtype_stack
+ * @copyright 2026 The University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(__DIR__ . '/../../../config.php');
+
+require_once($CFG->libdir . '/questionlib.php');
+require_once(__DIR__ . '/vle_specific.php');
+require_once(__DIR__ . '/locallib.php');
+require_once(__DIR__ . '/stack/questionlibrary.class.php');
+
+$questionid = required_param('questionid', PARAM_INT);
+[$qversion, $questionid] = get_latest_question_version($questionid);
+
+$questiondata = question_bank::load_question_data($questionid);
+if (!$questiondata) {
+ throw new stack_exception('questiondoesnotexist');
+}
+$question = question_bank::load_question($questionid);
+
+[$context, , $urlparams] = qtype_stack_setup_question_test_page($question);
+
+question_require_capability_on($questiondata, 'view');
+require_capability('qtype/stack:exporttoexternallibraries', $context);
+
+if (!get_config('qtype_stack', 'nrwupload')) {
+ throw new moodle_exception('nopermissions', 'error', '', 'export to NRW');
+}
+
+$PAGE->set_context($context);
+$PAGE->set_url('/question/type/stack/questionexport.php', $urlparams);
+$title = stack_string('exporttonrw');
+$PAGE->set_title($title);
+$PAGE->set_heading($title);
+$PAGE->set_pagelayout('popup');
+
+require_login();
+
+$dashboardlink = new moodle_url('/question/type/stack/questiontestrun.php', $urlparams);
+$previewquestionlink = qbank_previewquestion\helper::question_preview_url($questionid, null, null, null, null, $context);
+$editparams = $urlparams;
+unset($editparams['questionid']);
+unset($editparams['seed']);
+$editparams['id'] = $question->id;
+$editquestionlink = new moodle_url('/question/type/stack/questioneditlatest.php', $editparams);
+
+$uploadresult = null;
+if (optional_param('uploadtonrw', false, PARAM_BOOL)) {
+ require_sesskey();
+ $apikey = get_config('qtype_stack', 'nrwapikey');
+ $uploadresult = stack_question_library::upload_nrw_question($questiondata, $apikey);
+}
+
+echo $OUTPUT->header();
+
+$outputdata = new stdClass();
+$outputdata->question = new stdClass();
+$outputdata->question->name = format_string($question->name);
+$outputdata->question->version = $qversion;
+$outputdata->general = new stdClass();
+$outputdata->general->testquestionlink = $dashboardlink->out();
+$outputdata->general->previewquestionlink = $previewquestionlink->out();
+$outputdata->general->editquestionlink = $editquestionlink->out();
+$outputdata->general->uploadurl = $PAGE->url->out(false);
+$outputdata->general->sesskey = sesskey();
+$outputdata->uploadresult = $uploadresult;
+
+echo $OUTPUT->render_from_template('qtype_stack/questionexport', $outputdata);
+
+echo $OUTPUT->footer();
diff --git a/questionlibrary.php b/questionlibrary.php
index 55fd6639e63..43e61c5319e 100644
--- a/questionlibrary.php
+++ b/questionlibrary.php
@@ -52,6 +52,7 @@
$urlparams['courseid'] = $courseid;
$returntext = get_string('stack_library_qb_return', 'qtype_stack');
}
+$isrefresh = optional_param('refresh', 0, PARAM_INT) === 1 ? true : false;
// Check user has add capability for the required context.
require_capability('moodle/question:add', $thiscontext);
@@ -88,27 +89,88 @@
// Make sure we're only listing contents of STACK library or site library.
$location = optional_param('location', '', PARAM_RAW);
-$cacheid = 'library_file_list';
+// Location parameter will be in form:
+// samplequestions/stacklibrary
+// sitelibrary/foldername
+// githublibrary/id
+// nrwsearch.
+// Corresponding cache ids are:
+// library
+// sitelibrary_foldername
+// githublibrary_id
+// null.
+$cacheid = stack_question_library::STACKLIB;
$libraryname = stack_string('stack_library');
-if (str_starts_with($location, 'sitelibrary')) {
+$external = null;
+$allowedlibraries = get_config('qtype_stack', 'libraries');
+$allowedlibraries = json_decode($allowedlibraries);
+$allowedlibraries = $allowedlibraries ? $allowedlibraries : new StdClass();
+$search = optional_param('search', '', PARAM_RAW);
+$apikey = optional_param('apikey', '', PARAM_RAW);
+
+if (str_starts_with($location, stack_question_library::SITELIB)) {
$libraryname = explode('/', $location)[1];
- $cacheid = 'sitelibrary_' . $libraryname . '_file_list';
+ $cacheid = stack_question_library::SITELIB . "_{$libraryname}";
$location = "{$CFG->dataroot}/stack/{$location}";
if (!str_starts_with(realpath($location), "{$CFG->dataroot}/stack/sitelibrary")) {
- $location = __DIR__ . '/samplequestions/stacklibrary/*';
+ $location = __DIR__ . '/samplequestions/stacklibrary';
$libraryname = stack_string('stack_library');
- $cacheid = 'library_file_list';
+ $cacheid = stack_question_library::STACKLIB;
+ }
+} else if (str_starts_with($location, stack_question_library::GITHUB)) {
+ require_capability('qtype/stack:useexternallibraries', \context_system::instance());
+ $libparts = explode('/', $location);
+ $librarytype = $libparts[0];
+ $libraryid = $libparts[1];
+ $cacheid = $librarytype . "_{$libraryid}";
+ $libraryname = $allowedlibraries->{$libraryid}->name ?? null;
+ if (!$libraryname) {
+ $location = __DIR__ . '/samplequestions/stacklibrary';
+ $libraryname = stack_string('stack_library');
+ $cacheid = stack_question_library::STACKLIB;
} else {
- $location .= '/*';
+ $external = $librarytype;
+ $externaldetail = $allowedlibraries->{$libraryid}->url;
}
+} else if (str_starts_with($location, stack_question_library::NRWSEARCH)) {
+ require_capability('qtype/stack:useexternallibraries', \context_system::instance());
+ $cacheid = stack_question_library::NRWSEARCH;
+ $libraryname = null;
+ $external = stack_question_library::NRWSEARCH;
+ $externaldetail = ['search' => $search, 'apikey' => get_config('qtype_stack', 'nrwapikey')];
} else {
- $location = __DIR__ . '/samplequestions/stacklibrary/*';
+ $location = __DIR__ . '/samplequestions/stacklibrary';
+}
+
+if ($isrefresh) {
+ $refreshfiles = $cache->get($cacheid . '_flat_file_list');
+ if ($refreshfiles) {
+ $refreshfiles = array_keys($refreshfiles);
+ $refreshfiles = array_map(function ($file) use ($cacheid) {
+ return "{$cacheid}/{$file}";
+ }, $refreshfiles);
+ }
+ $refreshfiles[] = $cacheid . '_flat_file_list';
+ $refreshfiles[] = $cacheid . '_file_list';
+ $cache->delete_many($refreshfiles);
}
-$files = $cache->get($cacheid);
-if (!$files) {
- $files = stack_question_library::get_file_list($location);
- $cache->set($cacheid, $files);
+$files = new StdClass();
+
+if ($cacheid && $cacheid != stack_question_library::NRWSEARCH) {
+ $files = $cache->get($cacheid . '_file_list');
+ if (!$files) {
+ if ($external) {
+ [$files, $flatfiles] = stack_question_library::get_file_list_from_repo($externaldetail, $external);
+ $cache->set($cacheid . '_flat_file_list', $flatfiles);
+ } else {
+ $files = stack_question_library::get_file_list($location);
+ }
+ $cache->set($cacheid . '_file_list', $files);
+ }
+}
+if ($cacheid && $cacheid === stack_question_library::NRWSEARCH) {
+ [$files, $flatfiles] = stack_question_library::get_file_list_from_repo($externaldetail, $external);
}
$mform = new qtype_stack\form\category_form(null, ['qcontext' => $contexts]);
@@ -118,19 +180,28 @@
$outputdata->dashboardlink = $dashboardlink->out();
$outputdata->quizlink = $quizlink->out();
$outputdata->returntext = $returntext;
-$outputdata->files = $files->children;
+$outputdata->files = (isset($files->children)) ? $files->children : [];
+if (isset($files->error)) {
+ $outputdata->fileserror = $files->error;
+}
$outputdata->category = $mform->render();
$outputdata->coursename = $coursename;
$outputdata->courseid = $courseid;
+$outputdata->isnrwapikey = (get_config('qtype_stack', 'nrwapikey')) ? true : false;
+$outputdata->search = $search;
$outputdata->libraries = new StdClass();
$outputdata->libraries->items = [];
$outputdata->libraries->hasitems = false;
+$outputdata->libraries->current = $cacheid;
+if ($external === stack_question_library::GITHUB) {
+ $outputdata->libraries->isgithub = true;
+}
$libraries = glob("{$CFG->dataroot}/stack/sitelibrary/*");
if ($libraries) {
$libentry = new StdClass();
$libentry->name = stack_string('stack_library');
- $urlparams['location'] = "/samplequestions/stacklibrary";
+ $urlparams['location'] = "samplequestions/stacklibrary";
$libentry->url = new moodle_url('/question/type/stack/questionlibrary.php', $urlparams);
$libentry->url = $libentry->url->out();
$libentry->active = ($libentry->name === $libraryname) ? true : false;
@@ -141,13 +212,34 @@
$libentry = new StdClass();
$parts = explode('/', $library);
$libentry->name = end($parts);
- $urlparams['location'] = "sitelibrary/{$libentry->name}";
+ $urlparams['location'] = stack_question_library::SITELIB . "/{$libentry->name}";
+ $libentry->url = new moodle_url('/question/type/stack/questionlibrary.php', $urlparams);
+ $libentry->url = $libentry->url->out();
+ $libentry->active = ($libentry->name === $libraryname) ? true : false;
+ $outputdata->libraries->items[] = $libentry;
+}
+
+foreach ($allowedlibraries as $id => $lib) {
+ $libentry = new StdClass();
+ $libentry->name = $lib->name;
+ if (str_starts_with($lib->url, 'https://github.com/')) {
+ $urlparams['location'] = stack_question_library::GITHUB . "/{$id}";
+ } else {
+ $urlparams['location'] = 'invalid';
+ }
+ $urlparams['name'] = $lib->name;
$libentry->url = new moodle_url('/question/type/stack/questionlibrary.php', $urlparams);
$libentry->url = $libentry->url->out();
$libentry->active = ($libentry->name === $libraryname) ? true : false;
$outputdata->libraries->items[] = $libentry;
+ $outputdata->libraries->hasitems = true;
}
+unset($urlparams['name']);
+$urlparams['location'] = stack_question_library::NRWSEARCH;
+$outputdata->searchurl = new moodle_url('/question/type/stack/questionlibrary.php', $urlparams);
+$outputdata->searchurl = $outputdata->searchurl->out();
+
echo $OUTPUT->render_from_template('qtype_stack/questionlibrary', $outputdata);
// Finish output.
diff --git a/questiontestrun.php b/questiontestrun.php
index ae78b395ee4..4fdb1464bc2 100644
--- a/questiontestrun.php
+++ b/questiontestrun.php
@@ -70,6 +70,8 @@
question_require_capability_on($questiondata, 'view');
$caneditpermission = question_has_capability_on($questiondata, 'edit');
$canedit = $caneditpermission && !$historic;
+$canexporttonrw = get_config('qtype_stack', 'nrwupload')
+ && has_capability('qtype/stack:exporttoexternallibraries', $context);
// Initialise $PAGE.
$PAGE->set_url('/question/type/stack/questiontestrun.php', $urlparams);
@@ -110,6 +112,7 @@
$reportlink = new moodle_url('/question/type/stack/questiontestreport.php', $urlparams);
$bulktestlink = new moodle_url('/question/type/stack/questionbulktest.php', $urlparams);
$pagelink = new moodle_url('/question/type/stack/questiontestrun.php', $urlparams);
+$exportnrwlink = new moodle_url('/question/type/stack/questionexport.php', $urlparams);
$historyparams = $urlparams;
unset($historyparams['questionid']);
$historyparams['entryid'] = $qbeid;
@@ -167,8 +170,10 @@
$initialdata->general->todolink = $todolink->out();
$initialdata->general->bulktestlink = $bulktestlink->out();
$initialdata->general->historylink = $historylink->out();
+$initialdata->general->exportnrwlink = $exportnrwlink->out();
$initialdata->general->caneditpermission = $caneditpermission;
$initialdata->general->canedit = $canedit;
+$initialdata->general->canexporttonrw = $canexporttonrw;
$initialdata->general->courseid = $courseid;
$initialdata->general->cmid = $cmid;
$initialdata->general->questionid = $questionid;
diff --git a/settings.php b/settings.php
index d2b10ebfafd..a0e28c1fba1 100644
--- a/settings.php
+++ b/settings.php
@@ -243,6 +243,32 @@
3
));
+$settings->add(new admin_setting_configtextarea(
+ 'qtype_stack/libraries',
+ get_string('settingexternallibraries', 'qtype_stack'),
+ get_string('settingexternallibraries_desc', 'qtype_stack'),
+ '',
+ PARAM_RAW,
+ 60,
+ 5
+));
+
+$settings->add(new admin_setting_configtext(
+ 'qtype_stack/nrwapikey',
+ get_string('settingnrwapikey', 'qtype_stack'),
+ get_string('settingnrwapikey_desc', 'qtype_stack'),
+ '',
+ PARAM_TEXT,
+ 60
+));
+
+$settings->add(new admin_setting_configcheckbox(
+ 'qtype_stack/nrwupload',
+ get_string('settingnrwupload', 'qtype_stack'),
+ get_string('settingnrwupload_desc', 'qtype_stack'),
+ 0
+));
+
// Options for maths display.
$settings->add(new admin_setting_heading(
'mathsdisplayheading',
diff --git a/stack/maxima/stackmaxima.mac b/stack/maxima/stackmaxima.mac
index c134a896a38..e612ebc4a8a 100644
--- a/stack/maxima/stackmaxima.mac
+++ b/stack/maxima/stackmaxima.mac
@@ -1045,7 +1045,7 @@ plot(ex, [ra]) := /*stack_web_plot*/
/* Check for box. */
plotbox:sublist(ra, lambda([ex], if listp(ex) then is(first(ex) = box) else false)),
if emptyp(plotbox) then plotbox:true else block(
- ra:delete(first(plotbox), ra),
+ ra:delete(first(plotbox), ra),
if not(second(first(plotbox))) then plotbox:false
),
/* For new versions. */
@@ -1140,7 +1140,7 @@ set output ", afn),
if plotgrid2d and MAXIMA_VERSION_NUM>34 then
ral: append(ral, [grid2d]),
/* Add in the command for the nobox. */
- if not(plotbox) then if MAXIMA_VERSION_NUM>47 then
+ if not(plotbox) then if MAXIMA_VERSION_NUM>47 then
ral:append(ral, [nobox]) else ral:append(ral, [[box,false]]),
if plotdebug then print(preamble),
if PLOT_TERMINAL="svg" then set_plot_option([svg_file, afn]),
@@ -3564,4 +3564,4 @@ is_lang(code):=ev(is(%_STACK_LANG=code),simp=true)$
/* Stack expects some output with the version number the output happens at */
/* maximalocal.mac after additional library loading */
-stackmaximaversion:2026063000$
+stackmaximaversion:2026071800$
diff --git a/stack/questionlibrary.class.php b/stack/questionlibrary.class.php
index 4af79db8f5d..4c8e8241138 100644
--- a/stack/questionlibrary.class.php
+++ b/stack/questionlibrary.class.php
@@ -26,8 +26,10 @@
require_once(__DIR__ . '../../api/util/StackSeedHelper.php');
require_once(__DIR__ . '../../api/util/StackPlotReplacer.php');
+ use core_question\local\bank\question_edit_contexts;
use api\util\StackSeedHelper;
use api\util\StackPlotReplacer;
+
/**
* Functions required to display the STACK question library
* @package qtype_stack
@@ -35,6 +37,52 @@
class stack_question_library {
/** @var int increments unique folder ids */
public static $dircount = 1;
+ /**
+ * GITHUB library identifier
+ * @var string
+ */
+ public const GITHUB = 'githublibrary';
+ /**
+ * Site library identifier
+ * @var string
+ */
+ public const SITELIB = 'sitelibrary';
+ /**
+ * STACK library identifier
+ * @var string
+ */
+ public const STACKLIB = 'stacklibrary';
+ /**
+ * NRW API identifier
+ * @var string
+ */
+ public const NRWSEARCH = 'nrwsearch';
+ /**
+ * NRW API base URL
+ * @var string
+ */
+ public const NRWAPIBASE = 'https://vmits1614.vm.ruhr-uni-bochum.de/dev/api';
+
+ /**
+ * Wrapper for curl_exec to allow mocking in unit tests.
+ *
+ * @param resource $ch cURL handle
+ * @return string|bool
+ */
+ protected static function execute_curl_request($ch) {
+ return curl_exec($ch);
+ }
+
+ /**
+ * Wrapper for curl_getinfo to allow mocking in unit tests.
+ *
+ * @param resource $ch cURL handle
+ * @param int $opt cURL info option
+ * @return mixed
+ */
+ protected static function get_curl_info($ch, int $opt) {
+ return curl_getinfo($ch, $opt);
+ }
/**
* Summary of render_question
@@ -44,7 +92,12 @@ class stack_question_library {
*/
public static function render_question(object $question): string {
global $CFG;
- StackSeedHelper::initialize_seed($question, null);
+ try {
+ StackSeedHelper::initialize_seed($question, null);
+ } catch (\stack_exception $e) {
+ // XML has no deployed seeds but we don't care in the library.
+ $question->seed = 0;
+ }
// Handle Pluginfiles.
$storeprefix = uniqid();
@@ -134,66 +187,296 @@ public static function render_question(object $question): string {
}
/**
- * Gets the structure of folders and files within a given directory
+ * Gets the structure of folders and files within a given directory on the server.
* See questionfolder.mustache for output and usage.
- * We sanitise the structure a bit to remove gitsync files and folders.
- * @param string sanitised search string e.g. '/srv/stack/samplequestions/stacklibrary/*'
- * with the full real path of the folder and search criteria.
+ * @param string $dir sanitised full real path of library e.g. '/srv/stack/samplequestions/stacklibrary'
* @return object StdClass Representation of the file system
*/
public static function get_file_list(string $dir): object {
global $CFG;
- $files = glob($dir);
+ $directoryiterator = new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS);
+ $files = new RecursiveIteratorIterator($directoryiterator, RecursiveIteratorIterator::SELF_FIRST);
+ $result = [];
+ foreach ($files as $item) {
+ $pathfromsq = str_replace(dirname(__DIR__) . '/samplequestions/', '', $item->getPathname());
+ $pathfromsq = str_replace("{$CFG->dataroot}/stack/", '', $pathfromsq);
+ $result[] = (object)[
+ 'label' => $item->getFilename(),
+ 'relpath' => $pathfromsq,
+ 'isdirectory' => $item->isDir(),
+ 'url' => '',
+ ];
+ }
+
+ return self::format_file_list($result);
+ }
+
+ /**
+ * Gets the structure of folders and files within a given remote repo.
+ * Two versions are returned. The first is a structured object for feeding into the mustache template
+ * for displaying the folder structure. The second is a flat array keyed by file path for easy
+ * retrieval of file info, paerticularly the file URL.
+ * This is a wrapper function to make it easier to support different repo types.
+ * See questionfolder.mustache for output and usage.
+ * @param string|array $detail URL of the directory required or search term and apikey
+ * @param string $repotype The type of repo being searched.
+ * @return array [object StdClass structured representation of the file system, array flat array of file objects]
+ */
+ public static function get_file_list_from_repo($detail, $repotype) {
+ switch ($repotype) {
+ case self::GITHUB:
+ return self::list_github_repo($detail);
+ case self::NRWSEARCH:
+ return self::list_nrw_search($detail);
+ default:
+ return [new StdClass(), []];
+ }
+ }
+
+ /**
+ * Gets a file from an external repo.
+ * This is a wrapper function to make it easier to support different repo types.
+ *
+ * @param string $requestedfile URL
+ * @param string $repotype
+ * @return void
+ */
+ public static function get_external_file($requestedfile, $repotype, $apikey) {
+ switch ($repotype) {
+ case self::GITHUB:
+ return self::get_external_github_file($requestedfile);
+ case self::NRWSEARCH:
+ return self::get_external_nrw_file($requestedfile, $apikey);
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Retrieves a list of all the files in a GitHub repo via API
+ * @param string $githuburl
+ * @return array [object StdClass structured representation of the file system, array flat array of file objects]
+ */
+ public static function list_github_repo(string $githuburl) {
+ // Parse github URL like:
+ // https://github.com/{owner}/{repo}/tree/{branch}/{path...}.
+ $parts = parse_url($githuburl);
+ if (empty($parts['host']) || strpos($parts['host'], 'github.com') === false) {
+ return [];
+ }
+ $path = isset($parts['path']) ? trim($parts['path'], '/') : '';
+ $segments = explode('/', $path);
+ if (count($segments) < 2) {
+ return [];
+ }
+ $owner = $segments[0];
+ $repo = $segments[1];
+
+ // Default values.
+ $branch = 'main';
+ $subpath = '';
+
+ // If URL uses the tree layout, extract branch and subpath.
+ // Expected segments: owner, repo, tree, branch, ...subpath.
+ if (isset($segments[2]) && $segments[2] === 'tree' && isset($segments[3])) {
+ $branch = $segments[3];
+ if (count($segments) > 4) {
+ $subpath = implode('/', array_slice($segments, 4));
+ } else {
+ $subpath = '';
+ }
+ }
+
+ $apibase = "https://api.github.com/repos/{$owner}/{$repo}";
+
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_USERAGENT, 'Moodle-STACK'); // GitHub requires a user agent.
+ curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/vnd.github.v3+json']);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/vnd.github.v3+json']);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 10);
+
+ $files = [];
+ $errorresponse = new StdClass();
+ $errorresponse->error = stack_string('stack_library_connection_error');
+
+ // Always use the git/trees API with recursive=1, then filter by subpath.
+ $apiurl = "{$apibase}/git/trees/" . rawurlencode($branch) . "?recursive=1";
+ curl_setopt($ch, CURLOPT_URL, $apiurl);
+ $response = static::execute_curl_request($ch);
+ $httpcode = static::get_curl_info($ch, CURLINFO_HTTP_CODE);
+ if ($response === false || $httpcode >= 400) {
+ if ($response) {
+ $errorresponse->error .= ': ' . $response;
+ }
+ return [$errorresponse, []];
+ }
+ $data = json_decode($response, true);
+ if (empty($data['tree']) || !is_array($data['tree'])) {
+ return [$errorresponse, []];
+ }
+ $prefix = $subpath === '' ? '' : rtrim($subpath, '/') . '/';
+ foreach ($data['tree'] as $item) {
+ if ($prefix === '' || strpos($item['path'], $prefix) === 0) {
+ $relpath = ltrim(substr($item['path'], strlen($prefix)), '/');
+ $files[] = (object)[
+ 'label' => basename($item['path']),
+ 'relpath' => $relpath,
+ 'isdirectory' => ($item['type'] === 'tree') ? 1 : 0,
+ 'url' => ($item['type'] === 'tree') ? '' : $item['url'],
+ ];
+ }
+ }
+
+ $flatarray = array_column($files, null, 'relpath');
+
+ return [self::format_file_list($files), $flatarray];
+ }
+
+ /**
+ * Retrieves search results via API
+ * @param array $details - search term and apikey
+ * @return array [object StdClass structured representation of the file system, array flat array of file objects]
+ */
+ public static function list_nrw_search(array $details) {
+ $apibase = self::NRWAPIBASE . "/questions/search?fields=id,data";
+
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_USERAGENT, 'Moodle-STACK'); // GitHub requires a user agent.
+ curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/vnd.github.v3+json']);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer {$details['apikey']}"]);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 10);
+
+ $files = new StdClass();
+ $files->divid = 'stack-library-folder-1';
+ $files->children = [];
+ $files->isdirectory = 1;
+ $files->label = '.';
+
+ // Always use the git/trees API with recursive=1, then filter by subpath.
+ $apiurl = "{$apibase}&q={$details['search']}";
+ curl_setopt($ch, CURLOPT_URL, $apiurl);
+ $response = static::execute_curl_request($ch);
+ $httpcode = static::get_curl_info($ch, CURLINFO_HTTP_CODE);
+ if ($response === false || $httpcode >= 400) {
+ $files->error = stack_string('stack_library_connection_error');
+ if ($response) {
+ $files->error .= ': ' . $response;
+ }
+
+ return [$files, []];
+ }
+ $data = json_decode($response, true);
+ if (isset($data['results'])) {
+ foreach ($data['results'] as $item) {
+ $files->children[] = (object)[
+ 'label' => $item['question']['data']['title'],
+ // Once we have format data use format_text($text, FORMAT_MARKDOWN).
+ 'description' => $item['question']['data']['description'][0][1] ?? null,
+ 'license' => $item['question']['data']['license'],
+ 'source' => $item['question']['data']['source'],
+ 'subject' => (is_array($item['question']['data']['subject'])) ?
+ implode(', ', $item['question']['data']['subject']) : $item['question']['data']['subject'],
+ 'path' => $item['question']['id'],
+ 'isdirectory' => 0,
+ 'url' => '',
+ ];
+ }
+ if (!count($data['results'])) {
+ $files->error = stack_string('stack_library_nothing');
+ }
+ } else {
+ $files->error = stack_string('stack_library_connection_error');
+ }
+
+ return [$files, []];
+ }
+
+ /**
+ * Take an array of file objects and format into an object with a directory-style structure.
+ * The file objects should be in the form:
+ * {'label' => file/directory name,
+ * 'relpath' => file path relative to top directory,
+ * 'isdirectory' => boolean,
+ * 'url' => url for obtaining the file if remote}
+ * We sanitise the structure a bit to remove gitsync files and folders.
+ * @param mixed $filelist
+ * @return object stdClass
+ */
+ public static function format_file_list($filelist) {
+ usort($filelist, function ($a, $b) {
+ return strnatcmp($a->relpath, $b->relpath);
+ });
$results = new stdClass();
- $labels = explode('/', $dir);
- $results->label = $labels[count($labels) - 2];
$results->divid = 'stack-library-folder-' . self::$dircount;
self::$dircount++;
$results->children = [];
$results->isdirectory = 1;
- foreach ($files as $path) {
- if (!is_dir($path)) {
+ // First file in sorted list will be in the current base directory. If we're on the first pass, we're in overall top
+ // directory and dirname will return '.' which we convert to '' for our string compare.
+ $firstfile = $filelist[array_key_first($filelist)]->relpath;
+ $basedir = dirname($firstfile !== '.') ? dirname($firstfile) : '';
+
+ $dirparts = explode('/', $basedir);
+ $results->label = end($dirparts);
+ foreach ($filelist as $file) {
+ // We only want children of the current base directory. We ignore more distant descendants.
+ if ($basedir === '') {
+ if (str_contains($file->relpath, '/')) {
+ continue;
+ }
+ } else {
+ if (str_contains(str_replace($basedir . '/', '', $file->relpath), '/')) {
+ continue;
+ }
+ }
+
+ if (!$file->isdirectory) {
if (
- (pathinfo($path, PATHINFO_EXTENSION) === 'xml' && strrpos($path, 'gitsync_category') === false)
- || (pathinfo($path, PATHINFO_EXTENSION) === 'json' && strrpos($path, '_quiz.json') !== false)
+ (pathinfo($file->relpath, PATHINFO_EXTENSION) === 'xml'
+ && strrpos($file->relpath, 'gitsync_category') === false)
+ || (pathinfo($file->relpath, PATHINFO_EXTENSION) === 'json' && strrpos($file->relpath, '_quiz.json') !== false)
) {
$childless = new StdClass();
- // Get the path relative to the samplequestions or stack dataroot folder.
- $pathfromsq = str_replace(dirname(__DIR__) . '/samplequestions/', '', $path);
- $pathfromsq = str_replace("{$CFG->dataroot}/stack/", '', $pathfromsq);
- $childless->path = $pathfromsq;
- $labels = explode('/', $path);
- $childless->label = end($labels);
+ $childless->path = $file->relpath;
+ $childless->url = $file->url;
+ $childless->label = $file->label;
$childless->isdirectory = 0;
$results->children[] = $childless;
}
} else {
- if (strrpos($path, 'manifest_backups') === false) {
- $children = self::get_file_list($path . '/*');
+ if (strrpos($file->relpath, 'manifest_backups') === false) {
+ $descendants = array_filter($filelist, fn($x) => str_starts_with($x->relpath, $file->relpath . '/'));
+ if ($descendants) {
+ $children = self::format_file_list($descendants);
+ } else {
+ continue;
+ }
if ($children->label === 'top') {
- $topchildren = $children->children;
- $topquizzes = [];
- $topfolders = [];
- foreach ($topchildren as $topchild) {
+ $childrenoftop = $children->children;
+ $quizzesintop = [];
+ $foldersintop = [];
+ foreach ($childrenoftop as $childoftop) {
if (
- isset($topchild->path) && pathinfo($topchild->path, PATHINFO_EXTENSION) === 'json'
- && strrpos($topchild->path, '_quiz.json') !== false
+ isset($childoftop->path) && pathinfo($childoftop->path, PATHINFO_EXTENSION) === 'json'
+ && strrpos($childoftop->path, '_quiz.json') !== false
) {
- $topquizzes[] = $topchild;
- } else if ($topchild->isdirectory) {
- $topfolders[] = $topchild;
+ $quizzesintop[] = $childoftop;
+ } else if ($childoftop->isdirectory) {
+ $foldersintop[] = $childoftop;
}
}
- if (count($topfolders) === 1 && count($topquizzes) === 0) {
+ if (count($foldersintop) === 1 && count($quizzesintop) === 0) {
// If we have a 'top' folder containing only a single folder (e.g. 'Default for...)
// strip out both from file display.
- $results->children = array_merge($results->children, $topchildren[0]->children);
- } else if (count($topfolders) === 1 && count($topquizzes) > 0) {
+ $results->children = array_merge($results->children, $foldersintop[0]->children);
+ } else if (count($foldersintop) === 1 && count($quizzesintop) > 0) {
// Quizzes and a single folder. Display quizzes and contents of folder.
- $results->children = array_merge($topquizzes, $topfolders[0]->children);
+ $results->children = array_merge($results->children, $quizzesintop, $foldersintop[0]->children);
} else {
// Just strip out 'top'.
- $results->children = array_merge($results->children, $topchildren);
+ $results->children = array_merge($results->children, $childrenoftop);
}
} else {
$results->children[] = $children;
@@ -206,4 +489,193 @@ public static function get_file_list(string $dir): object {
});
return $results;
}
+
+ /**
+ * Fetch a file from GitHub using the api blob URL.
+ *
+ * @param string $requestedfile API URL
+ * @return string XML file contents
+ */
+ public static function get_external_github_file($requestedfile) {
+ $headers = [
+ 'User-Agent: Moodle-STACK',
+ 'Accept: application/vnd.github.v3+json',
+ ];
+
+ $ch = curl_init($requestedfile);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_FAILONERROR => false,
+ CURLOPT_SSL_VERIFYPEER => true,
+ ]);
+ $res = static::execute_curl_request($ch);
+ $httpcode = static::get_curl_info($ch, CURLINFO_HTTP_CODE);
+
+ if ($res === false || $httpcode !== 200) {
+ throw new \stack_exception('File unavailable.');
+ }
+
+ $json = json_decode($res, true);
+ if (!is_array($json) || empty($json['content']) || empty($json['encoding'])) {
+ throw new \stack_exception('Invalid JSON.');
+ }
+
+ if ($json['encoding'] !== 'base64') {
+ throw new \stack_exception('Wrongly encoded.');
+ }
+
+ $filecontents = base64_decode($json['content'], true);
+ if ($filecontents === false) {
+ throw new \stack_exception('Could not decode.');
+ }
+
+ return $filecontents;
+ }
+
+ /**
+ * Fetch a file from GitHub using the api blob URL.
+ *
+ * @param string $requestedfile API URL
+ * @return string XML file contents
+ */
+ public static function get_external_nrw_file($requestedid, $apikey) {
+ $apibase = self::NRWAPIBASE . "/questions/get/";
+
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_USERAGENT, 'Moodle-STACK'); // GitHub requires a user agent.
+ curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer {$apikey}"]);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 10);
+ $apiurl = "{$apibase}{$requestedid}?fields=xml";
+ curl_setopt($ch, CURLOPT_URL, $apiurl);
+ $response = static::execute_curl_request($ch);
+ $httpcode = static::get_curl_info($ch, CURLINFO_HTTP_CODE);
+ if ($response === false || $httpcode >= 400) {
+ throw new \stack_exception('File unavailable.');
+ }
+
+ $json = json_decode($response, true);
+ if (!$json) {
+ throw new \stack_exception('Invalid JSON.');
+ }
+
+ return $json['xml'];
+ }
+
+ /**
+ * Upload a Moodle question object to the NRW API.
+ *
+ * @param object $questiondata Question data from question_bank::load_question_data().
+ * @param string $apikey NRW API key.
+ * @return array Upload result for template output.
+ */
+ public static function upload_nrw_question(object $questiondata, string $apikey): array {
+ global $CFG, $COURSE;
+ require_once($CFG->libdir . '/questionlib.php');
+ require_once($CFG->dirroot . '/question/format/xml/format.php');
+
+ if (empty($apikey)) {
+ return [
+ 'iserror' => true,
+ 'message' => stack_string('nrwuploadapikeymissing'),
+ ];
+ }
+
+ // Preflight the same capability exportprocess(true) will enforce, so we can
+ // fail cleanly instead of falling into a zero-question export edge case.
+ if (!question_has_capability_on($questiondata, 'view')) {
+ return [
+ 'iserror' => true,
+ 'message' => get_string('nopermissions', 'error', get_string('stack:exporttoexternallibraries', 'qtype_stack')),
+ ];
+ }
+
+ $qformat = new \qformat_xml();
+ $qformat->setQuestions([$questiondata]);
+ $thiscontext = context::instance_by_id($questiondata->contextid);
+ $contexts = new question_edit_contexts($thiscontext);
+ // Checks user has export permission for the supplied context.
+ $qformat->setContexts($contexts->having_one_edit_tab_cap('export'));
+ $qformat->setCattofile(false);
+ $qformat->setContexttofile(false);
+ $qformat->setCourse($COURSE);
+
+ if (!$qformat->exportpreprocess()) {
+ return [
+ 'iserror' => true,
+ 'message' => stack_string('nrwuploadxmlerror'),
+ ];
+ }
+ $xmlstring = $qformat->exportprocess(true);
+ if (!$xmlstring) {
+ return [
+ 'iserror' => true,
+ 'message' => stack_string('nrwuploadxmlerror'),
+ ];
+ }
+
+ $apiurl = self::NRWAPIBASE . '/questions/upload';
+ $payload = json_encode(['xml' => $xmlstring]);
+ if ($payload === false) {
+ return [
+ 'iserror' => true,
+ 'message' => stack_string('nrwuploadpayloadencodeerror'),
+ ];
+ }
+
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, $apiurl);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_USERAGENT, 'Moodle-STACK');
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, [
+ 'Authorization: Bearer ' . $apikey,
+ 'Content-Type: application/json',
+ ]);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 20);
+ $response = static::execute_curl_request($ch);
+ $httpcode = static::get_curl_info($ch, CURLINFO_HTTP_CODE);
+ if ($response === false) {
+ return [
+ 'iserror' => true,
+ 'message' => stack_string('nrwuploadfailed') . ': ' . curl_error($ch),
+ ];
+ }
+
+ $json = json_decode($response, true);
+ if (!is_array($json)) {
+ return [
+ 'iserror' => true,
+ 'message' => stack_string('nrwuploadfailed') . ' HTTP ' . $httpcode . ': ' . $response,
+ ];
+ }
+ $id = $json['id'] ?? null;
+
+ if ($httpcode === 201) {
+ if ($json['status'] === 'duplicate') {
+ return [
+ 'iswarning' => true,
+ 'message' => stack_string('nrwuploadduplicate', $id ?? '-'),
+ ];
+ }
+ return [
+ 'issuccess' => true,
+ 'message' => stack_string('nrwuploadcreated', $id ?? '-'),
+ ];
+ }
+
+ if ($httpcode === 400) {
+ return [
+ 'iserror' => true,
+ 'message' => stack_string('nrwuploadvalidationerror') . ' ' . ($json['detail']['error_message'] ?? ''),
+ ];
+ }
+
+ return [
+ 'iserror' => true,
+ 'message' => stack_string('nrwuploadfailed') . ' HTTP ' . $httpcode . ': ' . $response,
+ ];
+ }
}
diff --git a/templates/questionexport.mustache b/templates/questionexport.mustache
new file mode 100644
index 00000000000..d8563f058ac
--- /dev/null
+++ b/templates/questionexport.mustache
@@ -0,0 +1,80 @@
+{{!
+ This file is part of Moodle - https://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see {{#str}} exporttonrw, qtype_stack {{/str}}
+ {{question.name}}
+
+ {{#str}} stack_library_subject, qtype_stack {{/str}}: {{subject}}
+ {{/subject}}
+ {{#source}}
+
+ {{#str}} stack_library_source, qtype_stack {{/str}}: {{source}}
+ {{/source}}
+ {{#license}}
+
+ {{#str}} stack_library_license, qtype_stack {{/str}}: {{license}}
+ {{/license}}
+ {{#description}}
+
+ {{description}}
+ {{/description}}
{{/isdirectory}}
\ No newline at end of file
diff --git a/templates/questionlibrary.mustache b/templates/questionlibrary.mustache
index fddced750c6..b837aa5cb15 100644
--- a/templates/questionlibrary.mustache
+++ b/templates/questionlibrary.mustache
@@ -97,17 +97,35 @@
{{#str}} stack_library_select, qtype_stack {{/str}}
+ {{#libraries.isgithub}}
+
+ {{/libraries.isgithub}}
{{#pix}}y/loading, core, {{#str}}loading, tool_lp{{/str}}{{/pix}}