From fc9e45b1b0c60a6d3c5676f81608e5254870a817 Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Fri, 20 Feb 2026 15:37:11 +0000
Subject: [PATCH 01/31] nrw-external - Render a question from GitHub
---
classes/library_import.php | 12 ++-
classes/library_render.php | 20 +++-
questionlibrary.php | 13 +++
stack/questionlibrary.class.php | 181 ++++++++++++++++++++++++++++++++
4 files changed, 219 insertions(+), 7 deletions(-)
diff --git a/classes/library_import.php b/classes/library_import.php
index 5a9e4a63ea5..a9401ba3554 100644
--- a/classes/library_import.php
+++ b/classes/library_import.php
@@ -106,21 +106,27 @@ public static function import_execute($courseid, $category, $filepath, $isfolder
require_capability('moodle/question:add', $thiscontext);
$loadingquiz = false;
$categories = [];
+ $external = false;
if (str_starts_with($params['filepath'], 'sitelibrary/')) {
$requestedfile = $CFG->dataroot . '/stack/' . $params['filepath'];
$basedir = $CFG->dataroot . '/stack/';
+ } else if (str_starts_with($params['filepath'], 'https://api.github.com/')) {
+ $requestedfile = $params['filepath'];
+ $external = true;
} 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/") &&
+ !str_starts_with($requestedfile, "https://api.github.com/")
) {
throw new \Exception('Dubious file request.');
}
+
if (
pathinfo($params['filepath'], PATHINFO_EXTENSION) === 'json'
&& strrpos($params['filepath'], '_quiz.json') !== false
@@ -151,8 +157,8 @@ 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.
- $files = [$params['filepath']];
+ // We're only importing one question. Stick the supplied fieldpath in an array.
+ $files = [$requestedfile];
} else {
// We're importing a folder.
// Full path of supplied question.
diff --git a/classes/library_render.php b/classes/library_render.php
index 53ab52caa0f..e0570dcef13 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;
@@ -113,22 +115,32 @@ public static function render_execute($category, $filepath) {
$result = $cache->get($params['filepath']);
$isquiz = (pathinfo($params['filepath'], PATHINFO_EXTENSION) === 'json'
&& strrpos($params['filepath'], '_quiz.json') !== false) ? true : false;
+ $external = false;
if (str_starts_with($params['filepath'], 'sitelibrary/')) {
$requestedfile = $CFG->dataroot . '/stack/' . $params['filepath'];
+ } else if (str_starts_with($params['filepath'], 'https://api.github.com/')) {
+ $requestedfile = $params['filepath'];
+ $external = true;
} 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->dirroot}/question/type/stack/samplequestions/") &&
+ !str_starts_with($requestedfile, "https://api.github.com/")
) {
throw new \Exception('Dubious file request.');
}
+ if ($external) {
+ stack_question_library::get_external_file($requestedfile);
+ } 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);
@@ -176,9 +188,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;
diff --git a/questionlibrary.php b/questionlibrary.php
index 44d27070227..6b9f993e15c 100644
--- a/questionlibrary.php
+++ b/questionlibrary.php
@@ -113,6 +113,9 @@
$cache->set($cacheid, $files);
}
+$libraryurls = [['url' => 'https://github.com/maths/moodle-qtype_stack/tree/master/samplequestions/importtest', 'name' => 'Extrnal: import test']];
+//$files = stack_question_library::stack_list_github_repo('https://github.com/maths/moodle-qtype_stack/tree/master/samplequestions/importtest');
+
$mform = new category_form(null, ['qcontext' => $contexts]);
// Prepare data for template.
$outputdata = new StdClass();
@@ -150,6 +153,16 @@
$outputdata->libraries->items[] = $libentry;
}
+foreach ($libraryurls as $libraryurl) {
+ $libentry = new StdClass();
+ $libentry->name = $libraryurl['name'];
+ $urlparams['location'] = $libraryurl['url'];
+ $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;
+}
+
echo $OUTPUT->render_from_template('qtype_stack/questionlibrary', $outputdata);
// Finish output.
diff --git a/stack/questionlibrary.class.php b/stack/questionlibrary.class.php
index 4af79db8f5d..d733f84c2da 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
@@ -206,4 +207,184 @@ public static function get_file_list(string $dir): object {
});
return $results;
}
+
+ public static function stack_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 = 'master';
+ $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'],
+ ];
+ }
+ }
+
+ usort($files, function ($a, $b) {
+ return strnatcmp($a->relpath, $b->relpath);
+ });
+
+ return self::format_file_list($files);
+
+ }
+
+ public static function format_file_list($filelist) {
+ $results = new stdClass();
+ $results->divid = 'stack-library-folder-' . self::$dircount;
+ self::$dircount++;
+ $results->children = [];
+ $results->isdirectory = 1;
+ $results->label = dirname($filelist[array_key_first($filelist)]->relpath) !== '.' ? dirname($filelist[array_key_first($filelist)]->relpath) : '';
+ foreach ($filelist as $file) {
+ if ($results->label === '') {
+ if (str_contains($file->relpath, '/')) {
+ continue;
+ }
+ } else {
+ if (str_contains(ltrim($file->relpath, $results->label . '/'), '/')) {
+ continue;
+ }
+ }
+
+ if (!$file->isdirectory) {
+ if (
+ (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();
+ $childless->path = $file->url;
+ $childless->label = $file->label;
+ $childless->isdirectory = 0;
+ $results->children[] = $childless;
+ }
+ } else {
+ if (strrpos($file->relpath, 'manifest_backups') === false) {
+ $children = array_filter($filelist, fn($x) => str_starts_with($x->relpath, $file->relpath . '/'));
+ $children = self::format_file_list($children);
+ if ($children->label === 'top') {
+ $topchildren = $children->children;
+ $topquizzes = [];
+ $topfolders = [];
+ foreach ($topchildren as $topchild) {
+ if (
+ isset($topchild->relpath) && pathinfo($topchild->relpath, PATHINFO_EXTENSION) === 'json'
+ && strrpos($topchild->path, '_quiz.json') !== false
+ ) {
+ $topquizzes[] = $topchild;
+ } else if ($topchild->isdirectory) {
+ $topfolders[] = $topchild;
+ }
+ }
+ if (count($topfolders) === 1 && count($topquizzes) === 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) {
+ // Quizzes and a single folder. Display quizzes and contents of folder.
+ $results->children = array_merge($topquizzes, $topfolders[0]->children);
+ } else {
+ // Just strip out 'top'.
+ $results->children = array_merge($results->children, $topchildren);
+ }
+ } else {
+ $results->children[] = $children;
+ }
+ }
+ }
+ }
+ usort($results->children, function ($a, $b) {
+ return strnatcmp($a->label, $b->label);
+ });
+ return $results;
+ }
+
+ public static function get_external_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('');
+ }
+
+ $json = json_decode($res, true);
+ if (!is_array($json) || empty($json['content']) || empty($json['encoding'])) {
+ throw new stack_exception('');
+ }
+
+ if ($json['encoding'] !== 'base64') {
+ throw new stack_exception('');
+ }
+
+ $filecontents = base64_decode($json['content'], true);
+ if ($filecontents === false) {
+ throw new stack_exception('');
+ }
+
+ return $filecontents;
+ }
}
From 65dfdd74a5084f698dc5ef3922f13c045dd1774b Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Fri, 20 Feb 2026 16:14:59 +0000
Subject: [PATCH 02/31] nrw-external - Render GitHub question.
---
classes/library_import.php | 1 -
classes/library_render.php | 2 +-
questionlibrary.php | 17 ++++++++++++++---
3 files changed, 15 insertions(+), 5 deletions(-)
diff --git a/classes/library_import.php b/classes/library_import.php
index a9401ba3554..53aae293497 100644
--- a/classes/library_import.php
+++ b/classes/library_import.php
@@ -126,7 +126,6 @@ public static function import_execute($courseid, $category, $filepath, $isfolder
throw new \Exception('Dubious file request.');
}
-
if (
pathinfo($params['filepath'], PATHINFO_EXTENSION) === 'json'
&& strrpos($params['filepath'], '_quiz.json') !== false
diff --git a/classes/library_render.php b/classes/library_render.php
index e0570dcef13..b6b79e9d388 100644
--- a/classes/library_render.php
+++ b/classes/library_render.php
@@ -134,7 +134,7 @@ public static function render_execute($category, $filepath) {
}
if ($external) {
- stack_question_library::get_external_file($requestedfile);
+ $qcontents = stack_question_library::get_external_file($requestedfile);
} else {
$qcontents = file_get_contents($requestedfile);
}
diff --git a/questionlibrary.php b/questionlibrary.php
index 6b9f993e15c..3753859bd92 100644
--- a/questionlibrary.php
+++ b/questionlibrary.php
@@ -92,6 +92,9 @@
$location = optional_param('location', '', PARAM_RAW);
$cacheid = 'library_file_list';
$libraryname = stack_string('stack_library');
+$external = false;
+$libraryurls = [['url' => 'https://github.com/maths/moodle-qtype_stack/tree/master/samplequestions/importtest', 'name' => 'External: import test']];
+
if (str_starts_with($location, 'sitelibrary')) {
$libraryname = explode('/', $location)[1];
$cacheid = 'sitelibrary_' . $libraryname . '_file_list';
@@ -103,18 +106,25 @@
} else {
$location .= '/*';
}
+} else if (in_array($location, array_column($libraryurls, 'url'))) {
+ $libraryname = optional_param('name', '', PARAM_RAW);
+ $cacheid = 'sitelibrary_' . $libraryname . '_file_list';
+ $external = true;
} else {
$location = __DIR__ . '/samplequestions/stacklibrary/*';
}
$files = $cache->get($cacheid);
if (!$files) {
- $files = stack_question_library::get_file_list($location);
+ if ($external) {
+ $files = stack_question_library::stack_list_github_repo($location);
+ } else {
+ $files = stack_question_library::get_file_list($location);
+ }
$cache->set($cacheid, $files);
}
-$libraryurls = [['url' => 'https://github.com/maths/moodle-qtype_stack/tree/master/samplequestions/importtest', 'name' => 'Extrnal: import test']];
-//$files = stack_question_library::stack_list_github_repo('https://github.com/maths/moodle-qtype_stack/tree/master/samplequestions/importtest');
+
$mform = new category_form(null, ['qcontext' => $contexts]);
// Prepare data for template.
@@ -157,6 +167,7 @@
$libentry = new StdClass();
$libentry->name = $libraryurl['name'];
$urlparams['location'] = $libraryurl['url'];
+ $urlparams['name'] = $libraryurl['name'];
$libentry->url = new moodle_url('/question/type/stack/questionlibrary.php', $urlparams);
$libentry->url = $libentry->url->out();
$libentry->active = ($libentry->name === $libraryname) ? true : false;
From eee507ef0f96db75b7f61cf1eaf269b23d2ce758 Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Mon, 23 Feb 2026 15:17:22 +0000
Subject: [PATCH 03/31] nrw-external - Import from external GitHub library
---
amd/build/library.min.js | 2 +-
amd/build/library.min.js.map | 2 +-
amd/src/library.js | 14 +++++--
classes/library_import.php | 60 +++++++++++++++++++++++-------
classes/library_render.php | 26 +++++++------
questionlibrary.php | 18 ++++-----
stack/maxima/stackmaxima.mac | 2 +-
stack/questionlibrary.class.php | 21 +++++++----
templates/questionfolder.mustache | 2 +-
templates/questionlibrary.mustache | 2 +
version.php | 2 +-
11 files changed, 102 insertions(+), 49 deletions(-)
diff --git a/amd/build/library.min.js b/amd/build/library.min.js
index 1e419161dc2..d9fc4f4c045 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,libraryName=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,libraryname:libraryName},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,libraryname:libraryName},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"),libraryName=document.querySelector('[data-id="stack_library_name"]').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..98df37f3319 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 libraryName = 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 libraryName = document.querySelector('[data-id=\"stack_library_name\"]').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, libraryname: libraryName},\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 libraryname: libraryName\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","libraryName","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","libraryname","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..57650bf1f78 100644
--- a/amd/src/library.js
+++ b/amd/src/library.js
@@ -31,6 +31,7 @@ define([
let courseId = null;
let categoryId = null;
+ let libraryName = 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');
+ libraryName = document.querySelector('[data-id="stack_library_name"]').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, libraryname: libraryName},
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,
+ libraryname: libraryName
+ },
done: function(response) {
loading(false);
for (const currentQuestion of response) {
diff --git a/classes/library_import.php b/classes/library_import.php
index 53aae293497..fe6e4f4d270 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?'),
+ 'libraryname' => 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, $libraryname) {
global $CFG, $DB;
$params = self::validate_parameters(self::import_execute_parameters(), [
'courseid' => $courseid,
'category' => $category,
'filepath' => $filepath,
'isfolder' => $isfolder,
+ 'libraryname' => $libraryname,
]);
// Check parameters and permissions.
$thiscontext = null;
@@ -107,13 +113,16 @@ public static function import_execute($courseid, $category, $filepath, $isfolder
$loadingquiz = false;
$categories = [];
$external = false;
+ $externalfiles = null;
if (str_starts_with($params['filepath'], 'sitelibrary/')) {
$requestedfile = $CFG->dataroot . '/stack/' . $params['filepath'];
$basedir = $CFG->dataroot . '/stack/';
- } else if (str_starts_with($params['filepath'], 'https://api.github.com/')) {
- $requestedfile = $params['filepath'];
+ } else if (str_starts_with($params['libraryname'], 'externallibrary')) {
+ $requestedfile = make_request_directory() . "/importq.xml";
$external = true;
+ $cache = \cache::make('qtype_stack', 'librarycache');
+ $externalfiles = $cache->get($params['libraryname'] . '_flat_file_list');
} else {
$requestedfile = $CFG->dirroot . '/question/type/stack/samplequestions/' . $params['filepath'];
$basedir = $CFG->dirroot . '/question/type/stack/samplequestions/';
@@ -121,7 +130,7 @@ public static function import_execute($courseid, $category, $filepath, $isfolder
if (
!str_starts_with(realpath($requestedfile), "{$CFG->dataroot}/stack/sitelibrary") &&
!str_starts_with(realpath($requestedfile), "{$CFG->dirroot}/question/type/stack/samplequestions/") &&
- !str_starts_with($requestedfile, "https://api.github.com/")
+ !$external
) {
throw new \Exception('Dubious file request.');
}
@@ -131,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_github_file($url);
+ } 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.
@@ -157,22 +171,30 @@ public static function import_execute($courseid, $category, $filepath, $isfolder
$loadingquiz = true;
} else if (!$params['isfolder']) {
// We're only importing one question. Stick the supplied fieldpath in an array.
- $files = [$requestedfile];
+ $files = [$params['filepath']];
} else {
// We're importing a folder.
// Full path of supplied question.
$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 = [];
@@ -185,7 +207,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_github_file($url));
+ $qformat->setFilename($requestedfile);
+ } else {
+ $qformat->setFilename($basedir . $category);
+ }
$qformat->setContextfromfile(false);
$qformat->setStoponerror(true);
$contexts = new question_edit_contexts($thiscontext);
@@ -230,8 +258,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_github_file($url));
+ $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 b6b79e9d388..d74499eae9b 100644
--- a/classes/library_render.php
+++ b/classes/library_render.php
@@ -59,7 +59,10 @@ 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'),
+ 'libraryname' => new \external_value(PARAM_RAW, 'Library cache id'),
]);
}
@@ -97,44 +100,45 @@ 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, $libraryname) {
global $CFG, $DB;
$params = self::validate_parameters(self::render_execute_parameters(), [
'category' => $category,
'filepath' => $filepath,
+ 'libraryname' => $libraryname,
]);
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']]);
+ $external = (str_starts_with($params['libraryname'], 'externallibrary')) ? true : false;
$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['libraryname']}/{$params['filepath']}" : $params['filepath']);
$isquiz = (pathinfo($params['filepath'], PATHINFO_EXTENSION) === 'json'
&& strrpos($params['filepath'], '_quiz.json') !== false) ? true : false;
- $external = false;
if (str_starts_with($params['filepath'], 'sitelibrary/')) {
$requestedfile = $CFG->dataroot . '/stack/' . $params['filepath'];
- } else if (str_starts_with($params['filepath'], 'https://api.github.com/')) {
- $requestedfile = $params['filepath'];
- $external = true;
+ } else if ($external) {
+ $externalfiles = $cache->get($params['libraryname'] . '_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($requestedfile, "https://api.github.com/")
+ !$external
) {
throw new \Exception('Dubious file request.');
}
if ($external) {
- $qcontents = stack_question_library::get_external_file($requestedfile);
+ $qcontents = stack_question_library::get_external_github_file($requestedfile);
} else {
$qcontents = file_get_contents($requestedfile);
}
@@ -164,7 +168,7 @@ public static function render_execute($category, $filepath) {
'questiondescription' => $question->questiondescription,
'isstack' => true,
];
- $cache->set($params['filepath'], $result);
+ $cache->set($external ? "{$params['libraryname']}/{$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.
@@ -200,7 +204,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++;
}
diff --git a/questionlibrary.php b/questionlibrary.php
index 3753859bd92..ee20ff47623 100644
--- a/questionlibrary.php
+++ b/questionlibrary.php
@@ -90,42 +90,41 @@
// Make sure we're only listing contents of STACK library or site library.
$location = optional_param('location', '', PARAM_RAW);
-$cacheid = 'library_file_list';
+$cacheid = 'library';
$libraryname = stack_string('stack_library');
$external = false;
$libraryurls = [['url' => 'https://github.com/maths/moodle-qtype_stack/tree/master/samplequestions/importtest', 'name' => 'External: import test']];
if (str_starts_with($location, 'sitelibrary')) {
$libraryname = explode('/', $location)[1];
- $cacheid = 'sitelibrary_' . $libraryname . '_file_list';
+ $cacheid = 'sitelibrary_' . $libraryname;
$location = "{$CFG->dataroot}/stack/{$location}";
if (!str_starts_with(realpath($location), "{$CFG->dataroot}/stack/sitelibrary")) {
$location = __DIR__ . '/samplequestions/stacklibrary/*';
$libraryname = stack_string('stack_library');
- $cacheid = 'library_file_list';
+ $cacheid = 'library';
} else {
$location .= '/*';
}
} else if (in_array($location, array_column($libraryurls, 'url'))) {
$libraryname = optional_param('name', '', PARAM_RAW);
- $cacheid = 'sitelibrary_' . $libraryname . '_file_list';
+ $cacheid = 'externallibrary_' . $libraryname;
$external = true;
} else {
$location = __DIR__ . '/samplequestions/stacklibrary/*';
}
-$files = $cache->get($cacheid);
+$files = $cache->get($cacheid . '_file_list');
if (!$files) {
if ($external) {
- $files = stack_question_library::stack_list_github_repo($location);
+ [$files, $flatfiles] = stack_question_library::stack_list_github_repo($location);
+ $cache->set($cacheid . '_flat_file_list', $flatfiles);
} else {
$files = stack_question_library::get_file_list($location);
}
- $cache->set($cacheid, $files);
+ $cache->set($cacheid . '_file_list', $files);
}
-
-
$mform = new category_form(null, ['qcontext' => $contexts]);
// Prepare data for template.
$outputdata = new StdClass();
@@ -140,6 +139,7 @@
$outputdata->libraries = new StdClass();
$outputdata->libraries->items = [];
$outputdata->libraries->hasitems = false;
+$outputdata->libraries->current = $cacheid;
$libraries = glob("{$CFG->dataroot}/stack/sitelibrary/*");
if ($libraries) {
diff --git a/stack/maxima/stackmaxima.mac b/stack/maxima/stackmaxima.mac
index 18b68474406..9815d729435 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:2026022301$
diff --git a/stack/questionlibrary.class.php b/stack/questionlibrary.class.php
index d733f84c2da..01c810bd635 100644
--- a/stack/questionlibrary.class.php
+++ b/stack/questionlibrary.class.php
@@ -28,6 +28,7 @@
use api\util\StackSeedHelper;
use api\util\StackPlotReplacer;
+ use stack_exception;
/**
* Functions required to display the STACK question library
@@ -163,6 +164,7 @@ public static function get_file_list(string $dir): object {
$pathfromsq = str_replace(dirname(__DIR__) . '/samplequestions/', '', $path);
$pathfromsq = str_replace("{$CFG->dataroot}/stack/", '', $pathfromsq);
$childless->path = $pathfromsq;
+ $childless->url = '';
$labels = explode('/', $path);
$childless->label = end($labels);
$childless->isdirectory = 0;
@@ -273,11 +275,13 @@ public static function stack_list_github_repo(string $githuburl) {
}
}
+ $flatarray = array_column($files, null, 'relpath');
+
usort($files, function ($a, $b) {
return strnatcmp($a->relpath, $b->relpath);
});
- return self::format_file_list($files);
+ return [self::format_file_list($files), $flatarray];
}
@@ -305,7 +309,8 @@ public static function format_file_list($filelist) {
|| (pathinfo($file->relpath, PATHINFO_EXTENSION) === 'json' && strrpos($file->relpath, '_quiz.json') !== false)
) {
$childless = new StdClass();
- $childless->path = $file->url;
+ $childless->path = $file->relpath;
+ $childless->url = $file->url;
$childless->label = $file->label;
$childless->isdirectory = 0;
$results->children[] = $childless;
@@ -321,7 +326,7 @@ public static function format_file_list($filelist) {
foreach ($topchildren as $topchild) {
if (
isset($topchild->relpath) && pathinfo($topchild->relpath, PATHINFO_EXTENSION) === 'json'
- && strrpos($topchild->path, '_quiz.json') !== false
+ && strrpos($topchild->relpath, '_quiz.json') !== false
) {
$topquizzes[] = $topchild;
} else if ($topchild->isdirectory) {
@@ -351,7 +356,7 @@ public static function format_file_list($filelist) {
return $results;
}
- public static function get_external_file($requestedfile) {
+ public static function get_external_github_file($requestedfile) {
$headers = [
'User-Agent: PHP',
'Accept: application/vnd.github.v3+json',
@@ -368,21 +373,21 @@ public static function get_external_file($requestedfile) {
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($res === false || $httpCode !== 200) {
- throw new stack_exception('');
+ throw new \stack_exception('');
}
$json = json_decode($res, true);
if (!is_array($json) || empty($json['content']) || empty($json['encoding'])) {
- throw new stack_exception('');
+ throw new \stack_exception('');
}
if ($json['encoding'] !== 'base64') {
- throw new stack_exception('');
+ throw new \stack_exception('');
}
$filecontents = base64_decode($json['content'], true);
if ($filecontents === false) {
- throw new stack_exception('');
+ throw new \stack_exception('');
}
return $filecontents;
diff --git a/templates/questionfolder.mustache b/templates/questionfolder.mustache
index 5bf29293568..c65be5aee28 100644
--- a/templates/questionfolder.mustache
+++ b/templates/questionfolder.mustache
@@ -69,7 +69,7 @@
{{/isdirectory}}
{{^isdirectory}}
-
{{/libraries.hasitems}}
+
+
{{#str}} stack_library_destination, qtype_stack {{/str}}
diff --git a/version.php b/version.php
index fdd21795091..a2bb9f932f1 100644
--- a/version.php
+++ b/version.php
@@ -24,7 +24,7 @@
defined('MOODLE_INTERNAL') || die();
-$plugin->version = 2026022302;
+$plugin->version = 2026042100;
$plugin->requires = 2022041900;
$plugin->cron = 0;
$plugin->component = 'qtype_stack';
From c02b15955cd01d30fff06575893bd28feb11766b Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Wed, 22 Apr 2026 11:50:14 +0100
Subject: [PATCH 11/31] nrw-external-api - Update layout
---
lang/en/qtype_stack.php | 4 ++--
templates/questionlibrary.mustache | 14 +++++++++-----
2 files changed, 11 insertions(+), 7 deletions(-)
diff --git a/lang/en/qtype_stack.php b/lang/en/qtype_stack.php
index 8f603802abd..b3aae9107c8 100644
--- a/lang/en/qtype_stack.php
+++ b/lang/en/qtype_stack.php
@@ -1872,8 +1872,8 @@
$string['stack_library_refresh'] = 'Refresh library contents';
$string['stack_library_selected'] = 'Displayed question:';
$string['stack_library_select'] = 'Select library:';
-$string['stack_library_nrw'] = 'Search NRW database';
-$string['stack_library_apikey'] = 'API key';
+$string['stack_library_nrw'] = 'Search NRW database for';
+$string['stack_library_apikey'] = 'using API key';
$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';
diff --git a/templates/questionlibrary.mustache b/templates/questionlibrary.mustache
index 377bf77310a..3e42bd65cf9 100644
--- a/templates/questionlibrary.mustache
+++ b/templates/questionlibrary.mustache
@@ -117,11 +117,15 @@
{{/libraries.hasitems}}
From 660128cfddef5e3e2936bdf3085dfa6ab613a2ca Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Thu, 23 Apr 2026 16:51:01 +0100
Subject: [PATCH 12/31] nrw-external-api - Change API key to STACK config
---
amd/build/library.min.js | 2 +-
amd/build/library.min.js.map | 2 +-
amd/src/library.js | 6 ++---
classes/library_import.php | 1 +
classes/library_render.php | 1 +
lang/en/qtype_stack.php | 6 ++++-
questionlibrary.php | 5 +++-
settings.php | 9 +++++++
stack/questionlibrary.class.php | 39 +++++++++++++++++++++---------
templates/questionlibrary.mustache | 11 +++++----
10 files changed, 58 insertions(+), 24 deletions(-)
diff --git a/amd/build/library.min.js b/amd/build/library.min.js
index 5d338cf80a0..55f34c35164 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,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]);const apikey=document.querySelector("#stack_library_apikey").value;Ajax.call([{methodname:"qtype_stack_library_render",args:{category:categoryId,filepath:filepath,cacheid:cacheId,apikey: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.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"),"nrwsearch"!==cacheId&&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]);const apikey=document.querySelector("#stack_library_apikey").value;Ajax.call([{methodname:"qtype_stack_library_import",args:{courseid:courseId,category:categoryId,filepath:filepath,isfolder:isFolder?1:0,cacheid:cacheId,apikey:apikey},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)}}}));
+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,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.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"),"nrwsearch"!==cacheId&&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,apikey:""},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 b50c10e511e..191b55423ba 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 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 const apikey = document.querySelector('#stack_library_apikey').value;\n Ajax.call([{\n methodname: 'qtype_stack_library_render',\n args: {category: categoryId, filepath: filepath, cacheid: cacheId, apikey: apikey},\n done: function(response) {\n loading(false);\n libraryDiv.innerHTML = response.questionrender;\n for (const iframe of response.iframes) {\n require(['qtype_stack/stackjsvle'],\n function(stackjsvle,) {\n stackjsvle.create_iframe(\n iframe.iframeid,\n iframe.content,\n iframe.targetdivid,\n iframe.title,\n iframe.scrolling,\n iframe.evil\n );\n });\n }\n rawDiv.innerText = response.questiontext;\n descriptionDiv.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 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.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 const apikey = document.querySelector('#stack_library_apikey').value;\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: 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 += '
' + '' + 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","apikey","querySelector","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","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, apikey: ''},\n done: function(response) {\n loading(false);\n libraryDiv.innerHTML = response.questionrender;\n for (const iframe of response.iframes) {\n require(['qtype_stack/stackjsvle'],\n function(stackjsvle,) {\n stackjsvle.create_iframe(\n iframe.iframeid,\n iframe.content,\n iframe.targetdivid,\n iframe.title,\n iframe.scrolling,\n iframe.evil\n );\n });\n }\n rawDiv.innerText = response.questiontext;\n descriptionDiv.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 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.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 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 += '
' + '' + 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","apikey","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 2376b79e101..621770b7e38 100644
--- a/amd/src/library.js
+++ b/amd/src/library.js
@@ -106,10 +106,9 @@ define([
currentPath = filepath;
loading(true);
categoryId = Number(document.getElementById('id_category').value.split(',')[0]);
- const apikey = document.querySelector('#stack_library_apikey').value;
Ajax.call([{
methodname: 'qtype_stack_library_render',
- args: {category: categoryId, filepath: filepath, cacheid: cacheId, apikey: apikey},
+ args: {category: categoryId, filepath: filepath, cacheid: cacheId, apikey: ''},
done: function(response) {
loading(false);
libraryDiv.innerHTML = response.questionrender;
@@ -166,7 +165,6 @@ define([
const filepath = currentPath;
loading(true);
categoryId = Number(document.getElementById('id_category').value.split(',')[0]);
- const apikey = document.querySelector('#stack_library_apikey').value;
Ajax.call([{
methodname: 'qtype_stack_library_import',
args: {
@@ -175,7 +173,7 @@ define([
filepath: filepath,
isfolder: (isFolder) ? 1 : 0,
cacheid: cacheId,
- apikey: apikey
+ apikey: ''
},
done: function(response) {
loading(false);
diff --git a/classes/library_import.php b/classes/library_import.php
index d6e06a8079c..35e29274e72 100644
--- a/classes/library_import.php
+++ b/classes/library_import.php
@@ -128,6 +128,7 @@ public static function import_execute($courseid, $category, $filepath, $isfolder
} else if (str_starts_with($params['cacheid'], stack_question_library::NRWSEARCH)) {
$requestedfile = $params['filepath'];
$external = stack_question_library::NRWSEARCH;
+ $apikey = get_config('qtype_stack', 'nrwapikey');
} else {
$requestedfile = $CFG->dirroot . '/question/type/stack/samplequestions/' . $params['filepath'];
$basedir = $CFG->dirroot . '/question/type/stack/samplequestions/';
diff --git a/classes/library_render.php b/classes/library_render.php
index 9cefc50b22c..5b2e51ddc56 100644
--- a/classes/library_render.php
+++ b/classes/library_render.php
@@ -137,6 +137,7 @@ public static function render_execute($category, $filepath, $cacheid, $apikey) {
$requestedfile = $externalfiles[$params['filepath']]->url;
} else if ($external && $external === stack_question_library::NRWSEARCH) {
$requestedfile = $params['filepath'];
+ $apikey = get_config('qtype_stack', 'nrwapikey');
} else {
$requestedfile = $CFG->dirroot . '/question/type/stack/samplequestions/' . $params['filepath'];
}
diff --git a/lang/en/qtype_stack.php b/lang/en/qtype_stack.php
index b3aae9107c8..fc87e352eb7 100644
--- a/lang/en/qtype_stack.php
+++ b/lang/en/qtype_stack.php
@@ -518,6 +518,8 @@
"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 XXXX.';
// Strings used by replace dollars script.
$string['replacedollarscount'] = 'This category contains {$a} STACK questions.';
@@ -1855,6 +1857,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.';
@@ -1872,12 +1875,13 @@
$string['stack_library_refresh'] = 'Refresh library contents';
$string['stack_library_selected'] = 'Displayed question:';
$string['stack_library_select'] = 'Select library:';
-$string['stack_library_nrw'] = 'Search NRW database for';
+$string['stack_library_nrw'] = 'Search NRW database';
$string['stack_library_apikey'] = 'using API key';
$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/questionlibrary.php b/questionlibrary.php
index 842459318f7..c41c71d3c5e 100644
--- a/questionlibrary.php
+++ b/questionlibrary.php
@@ -137,7 +137,7 @@
$cacheid = stack_question_library::NRWSEARCH;
$libraryname = null;
$external = stack_question_library::NRWSEARCH;
- $externaldetail = ['search' => $search, 'apikey' => $apikey];
+ $externaldetail = ['search' => $search, 'apikey' => get_config('qtype_stack', 'nrwapikey')];
} else {
$location = __DIR__ . '/samplequestions/stacklibrary';
}
@@ -181,6 +181,9 @@
$outputdata->quizlink = $quizlink->out();
$outputdata->returntext = $returntext;
$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;
diff --git a/settings.php b/settings.php
index dbaa4626610..cce9d99a74e 100644
--- a/settings.php
+++ b/settings.php
@@ -247,6 +247,15 @@
5
));
+$settings->add(new admin_setting_configtext(
+ 'qtype_stack/nrwapikey',
+ get_string('settingnrwapikey', 'qtype_stack'),
+ get_string('settingnrwapikey_desc', 'qtype_stack'),
+ '',
+ PARAM_TEXT,
+ 60
+));
+
// Options for maths display.
$settings->add(new admin_setting_heading(
'mathsdisplayheading',
diff --git a/stack/questionlibrary.class.php b/stack/questionlibrary.class.php
index 5c1b9256322..4473610f791 100644
--- a/stack/questionlibrary.class.php
+++ b/stack/questionlibrary.class.php
@@ -67,7 +67,7 @@ public static function render_question(object $question): string {
global $CFG;
try {
StackSeedHelper::initialize_seed($question, null);
- } catch (stack_exception $e) {
+ } catch (\stack_exception $e) {
// XML has no deployed seeds but we don't care in the library.
$question->seed = 0;
}
@@ -270,6 +270,8 @@ public static function list_github_repo(string $githuburl) {
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";
@@ -277,11 +279,14 @@ public static function list_github_repo(string $githuburl) {
$response = curl_exec($ch);
$httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($response === false || $httpcode >= 400) {
- return [];
+ if ($response) {
+ $errorresponse->error .= ': ' . $response;
+ }
+ return [$errorresponse, []];
}
$data = json_decode($response, true);
if (empty($data['tree']) || !is_array($data['tree'])) {
- return [];
+ return [$errorresponse, []];
}
$prefix = $subpath === '' ? '' : rtrim($subpath, '/') . '/';
foreach ($data['tree'] as $item) {
@@ -328,16 +333,28 @@ public static function list_nrw_search(array $details) {
$response = curl_exec($ch);
$httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($response === false || $httpcode >= 400) {
- return [];
+ $files->error = stack_string('stack_library_connection_error');
+ if ($response) {
+ $files->error .= ': ' . $response;
+ }
+
+ return [$files, []];
}
$data = json_decode($response, true);
- foreach ($data['results'] as $item) {
- $files->children[] = (object)[
- 'label' => $item['question']['data']['title'],
- 'path' => $item['question']['id'],
- 'isdirectory' => 0,
- 'url' => '',
- ];
+ if (isset($data['results'])) {
+ foreach ($data['results'] as $item) {
+ $files->children[] = (object)[
+ 'label' => $item['question']['data']['title'],
+ '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, []];
diff --git a/templates/questionlibrary.mustache b/templates/questionlibrary.mustache
index 3e42bd65cf9..cb4e4b41315 100644
--- a/templates/questionlibrary.mustache
+++ b/templates/questionlibrary.mustache
@@ -119,11 +119,7 @@
@@ -173,6 +169,11 @@
\ No newline at end of file
diff --git a/templates/questionlibrary.mustache b/templates/questionlibrary.mustache
index cb4e4b41315..b837aa5cb15 100644
--- a/templates/questionlibrary.mustache
+++ b/templates/questionlibrary.mustache
@@ -116,6 +116,7 @@
{{/libraries.hasitems}}
+ {{#isnrwapikey}}
+ {{/isnrwapikey}}
{{#str}} stack_library_destination, qtype_stack {{/str}}
From 508532e3e298029dfd0ca1fc1c447b9f31c4e221 Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Mon, 27 Apr 2026 15:09:51 +0100
Subject: [PATCH 15/31] nrw-external-api - Add capability to access external
libraries
---
db/access.php | 10 ++++++++++
lang/en/qtype_stack.php | 1 +
questionlibrary.php | 2 ++
stack/maxima/stackmaxima.mac | 2 +-
version.php | 2 +-
5 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/db/access.php b/db/access.php
index 4db1be58f08..8aa502c1329 100644
--- a/db/access.php
+++ b/db/access.php
@@ -35,4 +35,14 @@
'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',
+ ],
];
diff --git a/lang/en/qtype_stack.php b/lang/en/qtype_stack.php
index efbcf6bc1b1..d43468425a0 100644
--- a/lang/en/qtype_stack.php
+++ b/lang/en/qtype_stack.php
@@ -51,6 +51,7 @@
// Capability names.
$string['stack:usediagnostictools'] = 'Use the STACK tools';
+$string['stack:useexternallibraries'] = 'Use external libraries';
// Versions of STACK.
$string['stackversionedited'] = 'This question was authored with STACK version {$a}.';
diff --git a/questionlibrary.php b/questionlibrary.php
index cf5f22a7317..ac12afd8fd7 100644
--- a/questionlibrary.php
+++ b/questionlibrary.php
@@ -120,6 +120,7 @@
$cacheid = stack_question_library::STACKLIB;
}
} else if (str_starts_with($location, stack_question_library::GITHUB)) {
+ require_capability('qtype/stack:useexternallibraries', $thiscontext);
$libparts = explode('/', $location);
$librarytype = $libparts[0];
$libraryid = $libparts[1];
@@ -134,6 +135,7 @@
$externaldetail = $allowedlibraries->{$libraryid}->url;
}
} else if (str_starts_with($location, stack_question_library::NRWSEARCH)) {
+ require_capability('qtype/stack:useexternallibraries', $thiscontext);
$cacheid = stack_question_library::NRWSEARCH;
$libraryname = null;
$external = stack_question_library::NRWSEARCH;
diff --git a/stack/maxima/stackmaxima.mac b/stack/maxima/stackmaxima.mac
index 28727fe864e..55be893f185 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:2026042100$
+stackmaximaversion:2026042700$
diff --git a/version.php b/version.php
index a2bb9f932f1..f28d89ebdda 100644
--- a/version.php
+++ b/version.php
@@ -24,7 +24,7 @@
defined('MOODLE_INTERNAL') || die();
-$plugin->version = 2026042100;
+$plugin->version = 2026042700;
$plugin->requires = 2022041900;
$plugin->cron = 0;
$plugin->component = 'qtype_stack';
From f3ab202d364ddc64323970e29ede3f2b63413a8b Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Tue, 16 Jun 2026 14:07:57 +0100
Subject: [PATCH 16/31] nrw-external-api - Export page stub
---
db/access.php | 10 ++++
lang/en/qtype_stack.php | 7 ++-
questionexport.php | 79 ++++++++++++++++++++++++++++++
questiontestrun.php | 5 ++
settings.php | 7 +++
templates/questionexport.mustache | 46 +++++++++++++++++
templates/questiontestrun.mustache | 5 ++
7 files changed, 158 insertions(+), 1 deletion(-)
create mode 100644 questionexport.php
create mode 100644 templates/questionexport.mustache
diff --git a/db/access.php b/db/access.php
index 8aa502c1329..be582242907 100644
--- a/db/access.php
+++ b/db/access.php
@@ -45,4 +45,14 @@
],
'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 8a92225de02..3a62bcf42ef 100644
--- a/lang/en/qtype_stack.php
+++ b/lang/en/qtype_stack.php
@@ -52,6 +52,7 @@
// 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}.';
@@ -521,7 +522,9 @@
}
}';
$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 XXXX.';
+$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.';
@@ -677,6 +680,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 NRW';
$string['tidyquestion'] = ' Tidy inputs and PRTs';
$string['tidyquestion_txt'] = 'Tidy inputs and PRTs';
$string['sendgeneralfeedback'] = ' Send general feedback to the CAS';
@@ -688,6 +692,7 @@
$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['bulktestquizselect'] = 'Select a quiz';
$string['basicquestionreport'] = ' Analyze responses';
diff --git a/questionexport.php b/questionexport.php
new file mode 100644
index 00000000000..ff83590be72
--- /dev/null
+++ b/questionexport.php
@@ -0,0 +1,79 @@
+.
+
+/**
+ * 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');
+
+$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, $seed, $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);
+
+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();
+
+echo $OUTPUT->render_from_template('qtype_stack/questionexport', $outputdata);
+
+echo $OUTPUT->footer();
diff --git a/questiontestrun.php b/questiontestrun.php
index 9429498f1d9..1bb6676051a 100644
--- a/questiontestrun.php
+++ b/questiontestrun.php
@@ -71,6 +71,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;
@@ -166,8 +169,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 68b81161714..4781b4832cf 100644
--- a/settings.php
+++ b/settings.php
@@ -257,6 +257,13 @@
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/templates/questionexport.mustache b/templates/questionexport.mustache
new file mode 100644
index 00000000000..90df0936d41
--- /dev/null
+++ b/templates/questionexport.mustache
@@ -0,0 +1,46 @@
+{{!
+ This file is part of Moodle - https://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template qtype_stack/questionexport
+
+ Generic export page scaffold for question exports.
+
+ Context variables required for this template:
+ * question.name
+ * question.version
+ * general.testquestionlink
+ * general.previewquestionlink
+ * general.editquestionlink
+}}
+
+
+
+
{{#str}} exporttonrw, qtype_stack {{/str}}
+
{{question.name}}
+
{{#str}} version, qtype_stack {{/str}} {{question.version}}
+
{{#str}} questionexportplaceholder, qtype_stack {{/str}}
+
diff --git a/templates/questiontestrun.mustache b/templates/questiontestrun.mustache
index 89193eb8a90..d2a1e5c29e9 100644
--- a/templates/questiontestrun.mustache
+++ b/templates/questiontestrun.mustache
@@ -121,6 +121,11 @@
{{#str}} exportthisquestion, qtype_stack {{/str}}
{{/general.caneditpermission}}
+ {{#general.canexporttonrw}}
+
+ {{#str}} exporttonrw, qtype_stack {{/str}}
+
+ {{/general.canexporttonrw}}
{{#str}} basicquestionreport, qtype_stack {{/str}}
From 36caf05f4aea23e753107743e07c7043267cb51f Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Tue, 16 Jun 2026 16:11:58 +0100
Subject: [PATCH 17/31] nrw-external-api - Export file to NRW
---
lang/en/qtype_stack.php | 14 +++-
questionexport.php | 13 +++-
stack/questionlibrary.class.php | 107 +++++++++++++++++++++++++++++-
templates/questionexport.mustache | 13 ++++
4 files changed, 143 insertions(+), 4 deletions(-)
diff --git a/lang/en/qtype_stack.php b/lang/en/qtype_stack.php
index 3a62bcf42ef..e9d45df1787 100644
--- a/lang/en/qtype_stack.php
+++ b/lang/en/qtype_stack.php
@@ -680,7 +680,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 NRW';
+$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';
@@ -693,6 +693,18 @@
$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';
diff --git a/questionexport.php b/questionexport.php
index ff83590be72..2de2b3c4987 100644
--- a/questionexport.php
+++ b/questionexport.php
@@ -27,6 +27,7 @@
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);
@@ -37,7 +38,7 @@
}
$question = question_bank::load_question($questionid);
-[$context, $seed, $urlparams] = qtype_stack_setup_question_test_page($question);
+[$context, , $urlparams] = qtype_stack_setup_question_test_page($question);
question_require_capability_on($questiondata, 'view');
require_capability('qtype/stack:exporttoexternallibraries', $context);
@@ -63,6 +64,13 @@
$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();
@@ -73,6 +81,9 @@
$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);
diff --git a/stack/questionlibrary.class.php b/stack/questionlibrary.class.php
index 5f6d097c11c..d41d056149b 100644
--- a/stack/questionlibrary.class.php
+++ b/stack/questionlibrary.class.php
@@ -56,6 +56,11 @@ class stack_question_library {
* @var string
*/
public const NRWSEARCH = 'nrwsearch';
+ /**
+ * NRW API base URL
+ * @var string
+ */
+ public const NRWAPIBASE = 'https://vmits1614.vm.ruhr-uni-bochum.de:8742';
/**
* Summary of render_question
@@ -312,7 +317,7 @@ public static function list_github_repo(string $githuburl) {
* @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 = "https://vmits1614.vm.ruhr-uni-bochum.de:8742/questions/search?fields=id,data";
+ $apibase = self::NRWAPIBASE . "/questions/search?fields=id,data";
$ch = curl_init();
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
@@ -512,7 +517,7 @@ public static function get_external_github_file($requestedfile) {
* @return string XML file contents
*/
public static function get_external_nrw_file($requestedid, $apikey) {
- $apibase = "https://vmits1614.vm.ruhr-uni-bochum.de:8742/questions/get/";
+ $apibase = self::NRWAPIBASE . "/questions/get/";
$ch = curl_init();
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
@@ -534,4 +539,102 @@ public static function get_external_nrw_file($requestedid, $apikey) {
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;
+ require_once($CFG->dirroot . '/question/format/xml/format.php');
+
+ if (empty($apikey)) {
+ return [
+ 'iserror' => true,
+ 'message' => stack_string('nrwuploadapikeymissing'),
+ ];
+ }
+
+ $qformat = new \qformat_xml();
+ $qformat->setQuestions([$questiondata]);
+ 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 = curl_exec($ch);
+ $httpcode = curl_getinfo($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
index 90df0936d41..383e84678f6 100644
--- a/templates/questionexport.mustache
+++ b/templates/questionexport.mustache
@@ -25,6 +25,9 @@
* general.testquestionlink
* general.previewquestionlink
* general.editquestionlink
+ * general.uploadurl
+ * general.sesskey
+ * uploadresult
}}
@@ -42,5 +45,15 @@
{{#str}} exporttonrw, qtype_stack {{/str}}
{{question.name}}
{{#str}} version, qtype_stack {{/str}} {{question.version}}
+
+ {{#uploadresult}}
+
+ {{message}}
+
+ {{/uploadresult}}
{{#str}} questionexportplaceholder, qtype_stack {{/str}}
From 1d40799beae65e63af1290c801edba28b6f49953 Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Wed, 17 Jun 2026 09:09:04 +0100
Subject: [PATCH 18/31] nrw-external-api - Add tests
---
stack/questionlibrary.class.php | 46 +++-
templates/questionexport.mustache | 21 ++
tests/questionlibrary_nrw_test.php | 353 +++++++++++++++++++++++++++++
3 files changed, 408 insertions(+), 12 deletions(-)
create mode 100644 tests/questionlibrary_nrw_test.php
diff --git a/stack/questionlibrary.class.php b/stack/questionlibrary.class.php
index d41d056149b..bdb56acc70f 100644
--- a/stack/questionlibrary.class.php
+++ b/stack/questionlibrary.class.php
@@ -62,6 +62,27 @@ class stack_question_library {
*/
public const NRWAPIBASE = 'https://vmits1614.vm.ruhr-uni-bochum.de:8742';
+ /**
+ * 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
* @param object Moodle XML of question
@@ -281,8 +302,8 @@ public static function list_github_repo(string $githuburl) {
// 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);
+ $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;
@@ -312,7 +333,7 @@ public static function list_github_repo(string $githuburl) {
}
/**
- * Retrieves a list of all the files in a GitHub repo via API
+ * 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]
*/
@@ -335,8 +356,8 @@ public static function list_nrw_search(array $details) {
// 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 = curl_exec($ch);
- $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $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) {
@@ -350,6 +371,7 @@ public static function list_nrw_search(array $details) {
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'],
'license' => $item['question']['data']['license'],
'source' => $item['question']['data']['source'],
@@ -486,8 +508,8 @@ public static function get_external_github_file($requestedfile) {
CURLOPT_FAILONERROR => false,
CURLOPT_SSL_VERIFYPEER => true,
]);
- $res = curl_exec($ch);
- $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $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.');
@@ -510,7 +532,7 @@ public static function get_external_github_file($requestedfile) {
return $filecontents;
}
- /**
+ /**
* Fetch a file from GitHub using the api blob URL.
*
* @param string $requestedfile API URL
@@ -526,8 +548,8 @@ public static function get_external_nrw_file($requestedid, $apikey) {
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$apiurl = "{$apibase}{$requestedid}?fields=xml";
curl_setopt($ch, CURLOPT_URL, $apiurl);
- $response = curl_exec($ch);
- $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $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.');
}
@@ -594,8 +616,8 @@ public static function upload_nrw_question(object $questiondata, string $apikey)
'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
- $response = curl_exec($ch);
- $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $response = static::execute_curl_request($ch);
+ $httpcode = static::get_curl_info($ch, CURLINFO_HTTP_CODE);
if ($response === false) {
return [
'iserror' => true,
diff --git a/templates/questionexport.mustache b/templates/questionexport.mustache
index 383e84678f6..d8563f058ac 100644
--- a/templates/questionexport.mustache
+++ b/templates/questionexport.mustache
@@ -28,6 +28,27 @@
* general.uploadurl
* general.sesskey
* uploadresult
+
+ Example context (json):
+ {
+ "question": {
+ "name": "Question name",
+ "version": "2"
+ },
+ "general": {
+ "testquestionlink": "DashboardURL",
+ "previewquestionlink": "PreviewURL",
+ "editquestionlink": "EditURL",
+ "uploadurl": "UploadURL",
+ "sesskey": "abc123"
+ },
+ "uploadresult": {
+ "issuccess": 1,
+ "iswarning": 0,
+ "iserror": 0,
+ "message": "Question uploaded to NRW."
+ }
+ }
}}
diff --git a/tests/questionlibrary_nrw_test.php b/tests/questionlibrary_nrw_test.php
new file mode 100644
index 00000000000..2197627adb3
--- /dev/null
+++ b/tests/questionlibrary_nrw_test.php
@@ -0,0 +1,353 @@
+.
+
+/**
+ * Unit tests for the STACK NRW library integration.
+ *
+ * @package qtype_stack
+ * @copyright 2026 The University of Edinburgh
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * phpcs:disable PSR1.Classes.ClassDeclaration.MultipleClasses
+ */
+
+namespace qtype_stack;
+
+use advanced_testcase;
+use stack_question_library;
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+require_once($CFG->dirroot . '/question/type/stack/stack/utils.class.php');
+require_once($CFG->dirroot . '/question/type/stack/locallib.php');
+require_once($CFG->dirroot . '/question/type/stack/stack/questionlibrary.class.php');
+
+/**
+ * Test double for stack_question_library NRW API calls.
+ */
+class stack_question_library_nrw_testable extends \stack_question_library {
+ /** @var array mocked curl_exec responses */
+ private static array $mockresponses = [];
+ /** @var array mocked CURLINFO_HTTP_CODE values */
+ private static array $mockhttpcodes = [];
+
+ /**
+ * Prime mock values consumed by wrapped curl methods.
+ *
+ * @param array $responses Values returned by execute_curl_request.
+ * @param array $httpcodes Values returned by get_curl_info.
+ * @return void
+ */
+ public static function set_mock_curl_responses(array $responses, array $httpcodes): void {
+ self::$mockresponses = $responses;
+ self::$mockhttpcodes = $httpcodes;
+ }
+
+ /**
+ * Wrapper for curl_exec used by class under test.
+ *
+ * @param resource $ch cURL handle
+ * @return string|bool
+ */
+ protected static function execute_curl_request($ch) {
+ if (!self::$mockresponses) {
+ throw new \RuntimeException('Missing mocked curl_exec response.');
+ }
+ return array_shift(self::$mockresponses);
+ }
+
+ /**
+ * Wrapper for curl_getinfo used by class under test.
+ *
+ * @param resource $ch cURL handle
+ * @param int $opt cURL info option
+ * @return mixed
+ */
+ protected static function get_curl_info($ch, int $opt) {
+ if ($opt !== CURLINFO_HTTP_CODE) {
+ return parent::get_curl_info($ch, $opt);
+ }
+ if (!self::$mockhttpcodes) {
+ throw new \RuntimeException('Missing mocked curl_getinfo HTTP code.');
+ }
+ return array_shift(self::$mockhttpcodes);
+ }
+}
+
+/**
+ * Tests of NRW search/download/upload behavior.
+ *
+ * @group qtype_stack
+ * @covers \stack_question_library::list_nrw_search
+ * @covers \stack_question_library::get_external_nrw_file
+ * @covers \stack_question_library::upload_nrw_question
+ */
+final class questionlibrary_nrw_test extends advanced_testcase {
+ public function setUp(): void {
+ parent::setUp();
+ $this->resetAfterTest();
+ stack_question_library_nrw_testable::set_mock_curl_responses([], []);
+ }
+
+ public function test_list_nrw_search_returns_results(): void {
+ $payload = [
+ 'results' => [
+ [
+ 'question' => [
+ 'id' => 'atlas-1',
+ 'data' => [
+ 'title' => 'Question 1',
+ 'description' => 'Description 1',
+ 'license' => 'CC-BY',
+ 'source' => 'Source 1',
+ 'subject' => ['Algebra', 'Calculus'],
+ ],
+ ],
+ ],
+ [
+ 'question' => [
+ 'id' => 'atlas-2',
+ 'data' => [
+ 'title' => 'Question 2',
+ 'description' => 'Description 2',
+ 'license' => 'CC0',
+ 'source' => 'Source 2',
+ 'subject' => 'Geometry',
+ ],
+ ],
+ ],
+ ],
+ ];
+ stack_question_library_nrw_testable::set_mock_curl_responses([json_encode($payload)], [200]);
+
+ [$files, $flat] = stack_question_library_nrw_testable::list_nrw_search([
+ 'search' => 'integration',
+ 'apikey' => 'secret',
+ ]);
+
+ $this->assertEquals('.', $files->label);
+ $this->assertEquals(1, $files->isdirectory);
+ $this->assertCount(2, $files->children);
+ $this->assertEquals('Question 1', $files->children[0]->label);
+ $this->assertEquals('Description 1', $files->children[0]->description);
+ $this->assertEquals('CC-BY', $files->children[0]->license);
+ $this->assertEquals('Source 1', $files->children[0]->source);
+ $this->assertEquals('Algebra, Calculus', $files->children[0]->subject);
+ $this->assertEquals('atlas-1', $files->children[0]->path);
+ $this->assertEquals('Geometry', $files->children[1]->subject);
+ $this->assertFalse(property_exists($files, 'error'));
+ $this->assertEquals([], $flat);
+ }
+
+ public function test_get_file_list_from_repo_routes_to_nrw_search(): void {
+ stack_question_library_nrw_testable::set_mock_curl_responses([
+ json_encode([
+ 'results' => [[
+ 'question' => [
+ 'id' => 'atlas-1',
+ 'data' => [
+ 'title' => 'Question 1',
+ 'description' => 'Description 1',
+ 'license' => 'CC-BY',
+ 'source' => 'Source 1',
+ 'subject' => 'Algebra',
+ ],
+ ],
+ ]],
+ ]),
+ ], [200]);
+
+ [$files, $flat] = stack_question_library_nrw_testable::get_file_list_from_repo(
+ ['search' => 'algebra', 'apikey' => 'secret'],
+ stack_question_library::NRWSEARCH
+ );
+
+ $this->assertCount(1, $files->children);
+ $this->assertEquals('atlas-1', $files->children[0]->path);
+ $this->assertEquals([], $flat);
+ }
+
+ public function test_list_nrw_search_sets_error_for_empty_results(): void {
+ stack_question_library_nrw_testable::set_mock_curl_responses([json_encode(['results' => []])], [200]);
+
+ [$files, $flat] = stack_question_library_nrw_testable::list_nrw_search([
+ 'search' => 'none',
+ 'apikey' => 'secret',
+ ]);
+
+ $this->assertEquals(stack_string('stack_library_nothing'), $files->error);
+ $this->assertEquals([], $files->children);
+ $this->assertEquals([], $flat);
+ }
+
+ public function test_list_nrw_search_sets_error_for_invalid_payload(): void {
+ stack_question_library_nrw_testable::set_mock_curl_responses([json_encode(['foo' => 'bar'])], [200]);
+
+ [$files, $flat] = stack_question_library_nrw_testable::list_nrw_search([
+ 'search' => 'invalid',
+ 'apikey' => 'secret',
+ ]);
+
+ $this->assertEquals(stack_string('stack_library_connection_error'), $files->error);
+ $this->assertEquals([], $files->children);
+ $this->assertEquals([], $flat);
+ }
+
+ public function test_list_nrw_search_sets_error_for_http_failure(): void {
+ stack_question_library_nrw_testable::set_mock_curl_responses(['upstream error'], [500]);
+
+ [$files, $flat] = stack_question_library_nrw_testable::list_nrw_search([
+ 'search' => 'fail',
+ 'apikey' => 'secret',
+ ]);
+
+ $expected = stack_string('stack_library_connection_error') . ': upstream error';
+ $this->assertEquals($expected, $files->error);
+ $this->assertEquals([], $files->children);
+ $this->assertEquals([], $flat);
+ }
+
+ public function test_list_nrw_search_sets_error_for_curl_false_response(): void {
+ stack_question_library_nrw_testable::set_mock_curl_responses([false], [0]);
+
+ [$files] = stack_question_library_nrw_testable::list_nrw_search([
+ 'search' => 'timeout',
+ 'apikey' => 'secret',
+ ]);
+
+ $this->assertEquals(stack_string('stack_library_connection_error'), $files->error);
+ }
+
+ public function test_get_external_nrw_file_returns_xml(): void {
+ stack_question_library_nrw_testable::set_mock_curl_responses([
+ json_encode(['xml' => '
']),
+ ], [200]);
+
+ $result = stack_question_library_nrw_testable::get_external_nrw_file('atlas-1', 'secret');
+ $this->assertEquals('
', $result);
+ }
+
+ public function test_get_external_nrw_file_throws_for_http_error(): void {
+ stack_question_library_nrw_testable::set_mock_curl_responses(['server error'], [500]);
+
+ $this->expectException(\stack_exception::class);
+ $this->expectExceptionMessage('File unavailable.');
+ stack_question_library_nrw_testable::get_external_nrw_file('atlas-1', 'secret');
+ }
+
+ public function test_get_external_nrw_file_throws_for_invalid_json(): void {
+ stack_question_library_nrw_testable::set_mock_curl_responses(['not json'], [200]);
+
+ $this->expectException(\stack_exception::class);
+ $this->expectExceptionMessage('Invalid JSON.');
+ stack_question_library_nrw_testable::get_external_nrw_file('atlas-1', 'secret');
+ }
+
+ public function test_upload_nrw_question_requires_api_key(): void {
+ $result = stack_question_library_nrw_testable::upload_nrw_question(new \stdClass(), '');
+
+ $this->assertTrue($result['iserror']);
+ $this->assertEquals(stack_string('nrwuploadapikeymissing'), $result['message']);
+ }
+
+ public function test_upload_nrw_question_handles_curl_failure(): void {
+ $questiondata = $this->create_stack_question_data();
+ stack_question_library_nrw_testable::set_mock_curl_responses([false], [0]);
+
+ $result = stack_question_library_nrw_testable::upload_nrw_question($questiondata, 'secret');
+
+ $this->assertTrue($result['iserror']);
+ $this->assertStringStartsWith(stack_string('nrwuploadfailed') . ':', $result['message']);
+ }
+
+ public function test_upload_nrw_question_handles_non_json_response(): void {
+ $questiondata = $this->create_stack_question_data();
+ stack_question_library_nrw_testable::set_mock_curl_responses(['not json'], [502]);
+
+ $result = stack_question_library_nrw_testable::upload_nrw_question($questiondata, 'secret');
+
+ $this->assertTrue($result['iserror']);
+ $this->assertEquals(
+ stack_string('nrwuploadfailed') . ' HTTP 502: not json',
+ $result['message']
+ );
+ }
+
+ public function test_upload_nrw_question_returns_success_for_created_question(): void {
+ $questiondata = $this->create_stack_question_data();
+ stack_question_library_nrw_testable::set_mock_curl_responses([
+ json_encode(['status' => 'created', 'id' => 'ATLAS-123']),
+ ], [201]);
+
+ $result = stack_question_library_nrw_testable::upload_nrw_question($questiondata, 'secret');
+
+ $this->assertTrue($result['issuccess']);
+ $this->assertEquals(stack_string('nrwuploadcreated', 'ATLAS-123'), $result['message']);
+ }
+
+ public function test_upload_nrw_question_returns_warning_for_duplicate(): void {
+ $questiondata = $this->create_stack_question_data();
+ stack_question_library_nrw_testable::set_mock_curl_responses([
+ json_encode(['status' => 'duplicate', 'id' => 'ATLAS-999']),
+ ], [201]);
+
+ $result = stack_question_library_nrw_testable::upload_nrw_question($questiondata, 'secret');
+
+ $this->assertTrue($result['iswarning']);
+ $this->assertEquals(stack_string('nrwuploadduplicate', 'ATLAS-999'), $result['message']);
+ }
+
+ public function test_upload_nrw_question_returns_validation_error_for_http_400(): void {
+ $questiondata = $this->create_stack_question_data();
+ stack_question_library_nrw_testable::set_mock_curl_responses([
+ json_encode(['detail' => ['error_message' => 'Unexpected tag
.']]),
+ ], [400]);
+
+ $result = stack_question_library_nrw_testable::upload_nrw_question($questiondata, 'secret');
+
+ $this->assertTrue($result['iserror']);
+ $this->assertEquals(
+ stack_string('nrwuploadvalidationerror') . ' Unexpected tag .',
+ $result['message']
+ );
+ }
+
+ public function test_upload_nrw_question_returns_generic_error_for_non_201_non_400_json_response(): void {
+ $questiondata = $this->create_stack_question_data();
+ $response = json_encode(['status' => 'forbidden']);
+ stack_question_library_nrw_testable::set_mock_curl_responses([$response], [403]);
+
+ $result = stack_question_library_nrw_testable::upload_nrw_question($questiondata, 'secret');
+
+ $this->assertTrue($result['iserror']);
+ $this->assertEquals(
+ stack_string('nrwuploadfailed') . ' HTTP 403: ' . $response,
+ $result['message']
+ );
+ }
+
+ /**
+ * Create realistic question data suitable for qformat_xml export.
+ *
+ * @return object
+ */
+ private function create_stack_question_data(): object {
+ $this->setAdminUser();
+ $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+ $category = $generator->create_question_category();
+ $question = $generator->create_question('stack', 'test3', ['category' => $category->id]);
+ return \question_bank::load_question_data($question->id);
+ }
+}
From 273d06aa7ba9d1c141700c477d9faa9857805f75 Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Wed, 17 Jun 2026 11:13:24 +0100
Subject: [PATCH 19/31] nrw-external-api - Fix some tests
---
stack/questionlibrary.class.php | 22 +++++++++++++++
tests/library_import_test.php | 47 +++++++++++++++++++++------------
2 files changed, 52 insertions(+), 17 deletions(-)
diff --git a/stack/questionlibrary.class.php b/stack/questionlibrary.class.php
index bdb56acc70f..460e3a186fc 100644
--- a/stack/questionlibrary.class.php
+++ b/stack/questionlibrary.class.php
@@ -582,6 +582,28 @@ public static function upload_nrw_question(object $questiondata, string $apikey)
$qformat = new \qformat_xml();
$qformat->setQuestions([$questiondata]);
+
+ // Moodle 4.5- export paths may expect category/context metadata to be initialised
+ // even when exporting an explicit question list.
+ if (!empty($questiondata->category) && property_exists($qformat, 'category')) {
+ $category = new \stdClass();
+ $category->id = (int)$questiondata->category;
+ if (!empty($questiondata->contextid)) {
+ $category->contextid = (int)$questiondata->contextid;
+ }
+ $qformat->category = $category;
+ }
+ if (!empty($questiondata->contextid) && method_exists($qformat, 'setContexts')) {
+ try {
+ $context = \context::instance_by_id((int)$questiondata->contextid, IGNORE_MISSING);
+ if ($context) {
+ $qformat->setContexts([$context]);
+ }
+ } catch (\Throwable $e) {
+ // Context metadata is optional for our XML payload generation.
+ }
+ }
+
if (!$qformat->exportpreprocess()) {
return [
'iserror' => true,
diff --git a/tests/library_import_test.php b/tests/library_import_test.php
index c437073006e..e1dac09fc29 100644
--- a/tests/library_import_test.php
+++ b/tests/library_import_test.php
@@ -62,7 +62,6 @@ final class library_import_test extends externallib_advanced_testcase {
public function setUp(): void {
parent::setUp();
- global $DB;
$this->resetAfterTest();
$this->generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$this->course = $this->getDataGenerator()->create_course();
@@ -81,7 +80,6 @@ public function setUp(): void {
\qtype_stack_test_config::setup_test_maxima_connection();
$cache = cache::make('qtype_stack', 'librarycache');
$cache->purge();
- $this->resetAfterTest();
}
/**
@@ -99,7 +97,8 @@ public function test_capabilities(): void {
$this->qcategory->id,
$this->filepath,
false,
- \stack_question_library::STACKLIB
+ \stack_question_library::STACKLIB,
+ ''
);
// We need to execute the return values cleaning process to simulate
@@ -128,7 +127,8 @@ public function test_not_logged_in(): void {
$this->qcategory->id,
$this->filepath,
false,
- \stack_question_library::STACKLIB
+ \stack_question_library::STACKLIB,
+ ''
);
}
@@ -148,7 +148,8 @@ public function test_no_access(): void {
$this->qcategory->id,
$this->filepath,
false,
- \stack_question_library::STACKLIB
+ \stack_question_library::STACKLIB,
+ ''
);
}
@@ -163,7 +164,8 @@ public function test_export_capability(): void {
$this->qcategory->id,
$this->filepath,
false,
- \stack_question_library::STACKLIB
+ \stack_question_library::STACKLIB,
+ ''
);
}
@@ -183,7 +185,8 @@ public function test_library_import(): void {
$this->qcategory->id,
$this->filepath,
false,
- \stack_question_library::STACKLIB
+ \stack_question_library::STACKLIB,
+ ''
);
// We need to execute the return values cleaning process to simulate
@@ -269,7 +272,8 @@ public function test_library_import_quiz(): void {
$this->qcategory->id,
$quizfilepath,
true,
- \stack_question_library::STACKLIB
+ \stack_question_library::STACKLIB,
+ ''
);
// We need to execute the return values cleaning process to simulate
@@ -381,7 +385,8 @@ public function test_quiz_import_without_sections_and_feedback(): void {
$this->qcategory->id,
$quizfilepath,
true,
- \stack_question_library::STACKLIB
+ \stack_question_library::STACKLIB,
+ ''
);
$sections = $DB->get_records('quiz_sections');
@@ -417,7 +422,8 @@ public function test_import_with_require_previous(): void {
$this->qcategory->id,
$quizfilepath,
true,
- \stack_question_library::STACKLIB
+ \stack_question_library::STACKLIB,
+ ''
);
$slots = $DB->get_records('quiz_slots');
@@ -448,7 +454,8 @@ public function test_dubious_file_check(): void {
$this->qcategory->id,
'sitelibrary/libtest/../../testq.xml',
false,
- \stack_question_library::SITELIB
+ \stack_question_library::SITELIB,
+ ''
);
} catch (\Exception $e) {
$this->assertEquals('Dubious file request.', $e->getMessage());
@@ -535,7 +542,8 @@ public function test_site_library_import(): void {
$this->qcategory->id,
$this->filepath,
false,
- \stack_question_library::SITELIB
+ \stack_question_library::SITELIB,
+ ''
);
// We need to execute the return values cleaning process to simulate
@@ -582,7 +590,8 @@ public function test_site_library_import_folder(): void {
$this->qcategory->id,
$this->filepath,
true,
- \stack_question_library::SITELIB
+ \stack_question_library::SITELIB,
+ ''
);
// We need to execute the return values cleaning process to simulate
@@ -634,7 +643,8 @@ public function test_site_library_import_quiz(): void {
$this->qcategory->id,
$quizfilepath,
true,
- \stack_question_library::SITELIB
+ \stack_question_library::SITELIB,
+ ''
);
// We need to execute the return values cleaning process to simulate
@@ -765,7 +775,8 @@ public function test_external_library_import(): void {
$this->qcategory->id,
$this->filepath,
false,
- \stack_question_library::GITHUB . '_TEST'
+ \stack_question_library::GITHUB . '_TEST',
+ ''
);
// We need to execute the return values cleaning process to simulate
@@ -807,7 +818,8 @@ public function test_external_library_import_folder(): void {
$this->qcategory->id,
$this->filepath,
true,
- \stack_question_library::GITHUB . '_TEST'
+ \stack_question_library::GITHUB . '_TEST',
+ ''
);
// We need to execute the return values cleaning process to simulate
@@ -853,7 +865,8 @@ public function test_external_library_import_quiz(): void {
$this->qcategory->id,
$quizfilepath,
true,
- \stack_question_library::GITHUB . '_TEST'
+ \stack_question_library::GITHUB . '_TEST',
+ ''
);
// We need to execute the return values cleaning process to simulate
From 89960677ea625488150dd5f8b65b003205d76d1e Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Wed, 17 Jun 2026 16:25:56 +0100
Subject: [PATCH 20/31] nrw-external-api - Update export
---
classes/fake_render.php | 2 +-
stack/questionlibrary.class.php | 41 +++++++++++++++------------------
tests/library_import_test.php | 6 +++--
tests/library_render_test.php | 1 -
4 files changed, 24 insertions(+), 26 deletions(-)
diff --git a/classes/fake_render.php b/classes/fake_render.php
index 56d949b74a6..b1cc560080d 100644
--- a/classes/fake_render.php
+++ b/classes/fake_render.php
@@ -37,7 +37,7 @@ public static function call_question_render($question) {
}
// phpcs:ignore moodle.Commenting.MissingDocblock.Function
- public static function call_external_request($requestedfile, $external) {
+ public static function call_external_request($requestedfile, $external, $apikey) {
return "Fake XML: {$external} {$requestedfile}";
}
}
diff --git a/stack/questionlibrary.class.php b/stack/questionlibrary.class.php
index 460e3a186fc..decb08110c8 100644
--- a/stack/questionlibrary.class.php
+++ b/stack/questionlibrary.class.php
@@ -26,6 +26,7 @@
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;
@@ -570,7 +571,8 @@ public static function get_external_nrw_file($requestedid, $apikey) {
* @return array Upload result for template output.
*/
public static function upload_nrw_question(object $questiondata, string $apikey): array {
- global $CFG;
+ global $CFG, $COURSE;
+ require_once($CFG->libdir . '/questionlib.php');
require_once($CFG->dirroot . '/question/format/xml/format.php');
if (empty($apikey)) {
@@ -580,29 +582,24 @@ public static function upload_nrw_question(object $questiondata, string $apikey)
];
}
+ // 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]);
-
- // Moodle 4.5- export paths may expect category/context metadata to be initialised
- // even when exporting an explicit question list.
- if (!empty($questiondata->category) && property_exists($qformat, 'category')) {
- $category = new \stdClass();
- $category->id = (int)$questiondata->category;
- if (!empty($questiondata->contextid)) {
- $category->contextid = (int)$questiondata->contextid;
- }
- $qformat->category = $category;
- }
- if (!empty($questiondata->contextid) && method_exists($qformat, 'setContexts')) {
- try {
- $context = \context::instance_by_id((int)$questiondata->contextid, IGNORE_MISSING);
- if ($context) {
- $qformat->setContexts([$context]);
- }
- } catch (\Throwable $e) {
- // Context metadata is optional for our XML payload generation.
- }
- }
+ $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 [
diff --git a/tests/library_import_test.php b/tests/library_import_test.php
index e1dac09fc29..3e9837cdfac 100644
--- a/tests/library_import_test.php
+++ b/tests/library_import_test.php
@@ -227,7 +227,8 @@ public function test_library_import_folder(): void {
$this->qcategory->id,
$this->filepath,
true,
- \stack_question_library::STACKLIB
+ \stack_question_library::STACKLIB,
+ ''
);
// We need to execute the return values cleaning process to simulate
@@ -470,7 +471,8 @@ public function test_dubious_file_check(): void {
$this->qcategory->id,
'sitelibrary/libtest/../../testq.xml',
false,
- 'fake'
+ 'fake',
+ ''
);
} catch (\Exception $e) {
$this->assertEquals('Dubious file request.', $e->getMessage());
diff --git a/tests/library_render_test.php b/tests/library_render_test.php
index 8947f389b9d..f5dd9e07d79 100644
--- a/tests/library_render_test.php
+++ b/tests/library_render_test.php
@@ -59,7 +59,6 @@ final class library_render_test extends externallib_advanced_testcase {
public function setUp(): void {
parent::setUp();
- global $DB;
$this->resetAfterTest();
$this->generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$this->course = $this->getDataGenerator()->create_course();
From 909ecd74d9937390f8f3328bb42c2503910462b0 Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Thu, 18 Jun 2026 09:23:52 +0100
Subject: [PATCH 21/31] nrw-external-api - Fix more tests
---
tests/library_render_test.php | 24 ++++++++++++++----------
1 file changed, 14 insertions(+), 10 deletions(-)
diff --git a/tests/library_render_test.php b/tests/library_render_test.php
index f5dd9e07d79..3fbe97d0907 100644
--- a/tests/library_render_test.php
+++ b/tests/library_render_test.php
@@ -82,7 +82,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, \stack_question_library::STACKLIB);
+ $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 +105,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, \stack_question_library::STACKLIB);
+ library_render::render_execute($this->qcategory->id, $this->filepath, \stack_question_library::STACKLIB, '');
}
/**
@@ -119,7 +119,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, \stack_question_library::STACKLIB);
+ library_render::render_execute($this->qcategory->id, $this->filepath, \stack_question_library::STACKLIB, '');
}
/**
@@ -128,7 +128,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, \stack_question_library::STACKLIB);
+ library_render::render_execute($this->qcategory->id, $this->filepath, \stack_question_library::STACKLIB, '');
}
/**
@@ -143,7 +143,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, \stack_question_library::STACKLIB);
+ $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.
@@ -178,7 +178,7 @@ public function test_external_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, 'file', \stack_question_library::GITHUB);
+ $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.
@@ -217,7 +217,8 @@ public function test_site_library_render(): void {
$returnvalue = fake_render::render_execute(
$this->qcategory->id,
'sitelibrary/libtest/testq.xml',
- \stack_question_library::SITELIB
+ \stack_question_library::SITELIB,
+ ''
);
// We need to execute the return values cleaning process to simulate
@@ -253,7 +254,8 @@ public function test_dubious_file_check(): void {
fake_render::render_execute(
$this->qcategory->id,
'sitelibrary/libtest/../../testq.xml',
- \stack_question_library::SITELIB
+ \stack_question_library::SITELIB,
+ ''
);
} catch (\Exception $e) {
$this->assertEquals('Dubious file request.', $e->getMessage());
@@ -266,7 +268,8 @@ public function test_dubious_file_check(): void {
fake_render::render_execute(
$this->qcategory->id,
'sitelibrary/libtest/../../testq.xml',
- 'fake'
+ 'fake',
+ ''
);
} catch (\Exception $e) {
$this->assertEquals('Dubious file request.', $e->getMessage());
@@ -279,7 +282,8 @@ public function test_dubious_file_check(): void {
fake_render::render_execute(
$this->qcategory->id,
'otherlib/libtest/../../testq.xml',
- 'fake'
+ 'fake',
+ ''
);
} catch (\Exception $e) {
$this->assertEquals('Dubious file request.', $e->getMessage());
From 147cbca15498572910982fccf58543b7f74fc8c8 Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Thu, 18 Jun 2026 09:35:35 +0100
Subject: [PATCH 22/31] nrw-external-api - Code tidy
---
classes/fake_render.php | 3 ++-
questiontestedit.php | 2 +-
tests/library_import_test.php | 2 +-
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/classes/fake_render.php b/classes/fake_render.php
index b1cc560080d..1f34f8afd90 100644
--- a/classes/fake_render.php
+++ b/classes/fake_render.php
@@ -38,6 +38,7 @@ public static function call_question_render($question) {
// phpcs:ignore moodle.Commenting.MissingDocblock.Function
public static function call_external_request($requestedfile, $external, $apikey) {
- return "Fake XML: {$external} {$requestedfile}";
+ return "Fake XML: {$external} {$requestedfile}" .
+ '';
}
}
diff --git a/questiontestedit.php b/questiontestedit.php
index a6d10b3de91..e7b56effaca 100644
--- a/questiontestedit.php
+++ b/questiontestedit.php
@@ -191,7 +191,7 @@
$errors[] = stack_string(
'questiontestslong',
['prt' => $prtname, 'len' => strlen($data->{$prtname . 'answernote'})]
- );
+ );
}
question_bank::get_qtype('stack')->save_question_test($questionid, $qtest, $testcase);
if (empty($errors)) {
diff --git a/tests/library_import_test.php b/tests/library_import_test.php
index 3e9837cdfac..870f8363cdd 100644
--- a/tests/library_import_test.php
+++ b/tests/library_import_test.php
@@ -456,7 +456,7 @@ public function test_dubious_file_check(): void {
'sitelibrary/libtest/../../testq.xml',
false,
\stack_question_library::SITELIB,
- ''
+ ''
);
} catch (\Exception $e) {
$this->assertEquals('Dubious file request.', $e->getMessage());
From d6c4ef8c39aa320111d9506e935cfab4521fa63a Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Thu, 18 Jun 2026 10:47:10 +0100
Subject: [PATCH 23/31] Update library_import_test.php
---
tests/library_import_test.php | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/tests/library_import_test.php b/tests/library_import_test.php
index 870f8363cdd..909ab69174b 100644
--- a/tests/library_import_test.php
+++ b/tests/library_import_test.php
@@ -487,7 +487,8 @@ public function test_dubious_file_check(): void {
$this->qcategory->id,
'otherlib/libtest/../../testq.xml',
false,
- 'fake'
+ 'fake',
+ ''
);
} catch (\Exception $e) {
$this->assertEquals('Dubious file request.', $e->getMessage());
From 87035a3d27f2616f9a6e2a6b1e924920057e2bd2 Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Tue, 30 Jun 2026 15:47:23 +0100
Subject: [PATCH 24/31] nrw-external-api - Temporary search fix
---
stack/questionlibrary.class.php | 2 +-
tests/questionlibrary_nrw_test.php | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/stack/questionlibrary.class.php b/stack/questionlibrary.class.php
index decb08110c8..9ad4ea6494f 100644
--- a/stack/questionlibrary.class.php
+++ b/stack/questionlibrary.class.php
@@ -373,7 +373,7 @@ public static function list_nrw_search(array $details) {
$files->children[] = (object)[
'label' => $item['question']['data']['title'],
// Once we have format data use format_text($text, FORMAT_MARKDOWN).
- 'description' => $item['question']['data']['description'],
+ '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'])) ?
diff --git a/tests/questionlibrary_nrw_test.php b/tests/questionlibrary_nrw_test.php
index 2197627adb3..a880792aa36 100644
--- a/tests/questionlibrary_nrw_test.php
+++ b/tests/questionlibrary_nrw_test.php
@@ -109,7 +109,7 @@ public function test_list_nrw_search_returns_results(): void {
'id' => 'atlas-1',
'data' => [
'title' => 'Question 1',
- 'description' => 'Description 1',
+ 'description' => [['', 'Description 1']],
'license' => 'CC-BY',
'source' => 'Source 1',
'subject' => ['Algebra', 'Calculus'],
@@ -121,7 +121,7 @@ public function test_list_nrw_search_returns_results(): void {
'id' => 'atlas-2',
'data' => [
'title' => 'Question 2',
- 'description' => 'Description 2',
+ 'description' => [['', 'Description 2']],
'license' => 'CC0',
'source' => 'Source 2',
'subject' => 'Geometry',
From 04554c892d985e522a6823ec4dc575cb9c15913a Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Mon, 20 Jul 2026 16:12:00 +0100
Subject: [PATCH 25/31] nrw-external-api - Bump version
---
stack/maxima/stackmaxima.mac | 2 +-
version.php | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/stack/maxima/stackmaxima.mac b/stack/maxima/stackmaxima.mac
index 15b86b4db9b..e612ebc4a8a 100644
--- a/stack/maxima/stackmaxima.mac
+++ b/stack/maxima/stackmaxima.mac
@@ -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/version.php b/version.php
index 1acf6df67d6..6aa03782ac9 100644
--- a/version.php
+++ b/version.php
@@ -24,7 +24,7 @@
defined('MOODLE_INTERNAL') || die();
-$plugin->version = 2026063000;
+$plugin->version = 2026071800;
$plugin->requires = 2022041900;
$plugin->cron = 0;
$plugin->component = 'qtype_stack';
From 5559db6587dc4c05eb232ec84e9c0faa96448ce4 Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Tue, 21 Jul 2026 09:49:12 +0100
Subject: [PATCH 26/31] nrw-external-api - Check capability on AJAX call.
---
classes/library_import.php | 3 +++
classes/library_render.php | 3 +++
tests/library_import_test.php | 24 ++++++++++++++++++++++++
tests/library_render_test.php | 19 +++++++++++++++++++
4 files changed, 49 insertions(+)
diff --git a/classes/library_import.php b/classes/library_import.php
index 35e29274e72..fc3d0251687 100644
--- a/classes/library_import.php
+++ b/classes/library_import.php
@@ -133,6 +133,9 @@ public static function import_execute($courseid, $category, $filepath, $isfolder
$requestedfile = $CFG->dirroot . '/question/type/stack/samplequestions/' . $params['filepath'];
$basedir = $CFG->dirroot . '/question/type/stack/samplequestions/';
}
+ if ($external) {
+ require_capability('qtype/stack:useexternallibraries', $thiscontext);
+ }
if (
!str_starts_with(realpath($requestedfile), "{$CFG->dataroot}/stack/sitelibrary") &&
!str_starts_with(realpath($requestedfile), "{$CFG->dirroot}/question/type/stack/samplequestions/") &&
diff --git a/classes/library_render.php b/classes/library_render.php
index 5b2e51ddc56..3e05a2a509c 100644
--- a/classes/library_render.php
+++ b/classes/library_render.php
@@ -123,6 +123,9 @@ public static function render_execute($category, $filepath, $cacheid, $apikey) {
$thiscontext = context::instance_by_id($context);
self::validate_context($thiscontext);
require_capability('moodle/question:add', $thiscontext);
+ if ($external) {
+ require_capability('qtype/stack:useexternallibraries', $thiscontext);
+ }
// Check if we've already cached the answer.
$cache = cache::make('qtype_stack', 'librarycache');
diff --git a/tests/library_import_test.php b/tests/library_import_test.php
index 909ab69174b..bf19c0b537a 100644
--- a/tests/library_import_test.php
+++ b/tests/library_import_test.php
@@ -771,6 +771,7 @@ public function test_external_library_import(): void {
$context = context_course::instance($this->course->id);
$managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
role_assign($managerroleid, $this->user->id, $context->id);
+ role_assign($managerroleid, $this->user->id, \context_system::instance()->id);
$sink = $this->redirectEvents();
$returnvalue = library_import::import_execute(
@@ -804,6 +805,27 @@ public function test_external_library_import(): void {
$this->assertEquals($dbquestion->id, $returnvalue[0]['questionid']);
}
+ /**
+ * Test external library import requires the external library capability.
+ */
+ public function test_external_library_import_requires_external_capability(): void {
+ global $DB;
+ $this->set_external();
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+
+ $this->expectException(required_capability_exception::class);
+ library_import::import_execute(
+ $this->course->id,
+ $this->qcategory->id,
+ $this->filepath,
+ false,
+ \stack_question_library::GITHUB . '_TEST',
+ ''
+ );
+ }
+
/**
* Test output of library_import function for an entire folder for GitHub.
*/
@@ -814,6 +836,7 @@ public function test_external_library_import_folder(): void {
$context = context_course::instance($this->course->id);
$managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
role_assign($managerroleid, $this->user->id, $context->id);
+ role_assign($managerroleid, $this->user->id, \context_system::instance()->id);
$sink = $this->redirectEvents();
$returnvalue = library_import::import_execute(
@@ -861,6 +884,7 @@ public function test_external_library_import_quiz(): void {
$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);
+ role_assign($managerroleid, $this->user->id, \context_system::instance()->id);
$sink = $this->redirectEvents();
$returnvalue = library_import::import_execute(
diff --git a/tests/library_render_test.php b/tests/library_render_test.php
index 3fbe97d0907..2463c1e68a8 100644
--- a/tests/library_render_test.php
+++ b/tests/library_render_test.php
@@ -177,6 +177,7 @@ public function test_external_library_render(): void {
$context = context_course::instance($this->course->id);
$managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
role_assign($managerroleid, $this->user->id, $context->id);
+ role_assign($managerroleid, $this->user->id, \context_system::instance()->id);
$returnvalue = fake_render::render_execute($this->qcategory->id, 'file', \stack_question_library::GITHUB, '');
@@ -197,6 +198,24 @@ public function test_external_library_render(): void {
);
}
+ /**
+ * Test external library render requires the external library capability.
+ */
+ public function test_external_library_render_requires_external_capability(): 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]);
+ $context = context_course::instance($this->course->id);
+ $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+ role_assign($managerroleid, $this->user->id, $context->id);
+
+ $this->expectException(required_capability_exception::class);
+ fake_render::render_execute($this->qcategory->id, 'file', \stack_question_library::GITHUB, '');
+ }
+
/**
* Test output of library_render function when accessing site library.
*/
From 702ff3608eb7566aa6262b7bfbdd5222cbae1835 Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Tue, 21 Jul 2026 10:28:38 +0100
Subject: [PATCH 27/31] nrw-external-api - Use textcontent where possible
---
amd/build/library.min.js | 2 +-
amd/build/library.min.js.map | 2 +-
amd/src/library.js | 37 ++++++++++++++++++++++++++----------
tests/jest/library.test.js | 22 +++++++++++++--------
4 files changed, 43 insertions(+), 20 deletions(-)
diff --git a/amd/build/library.min.js b/amd/build/library.min.js
index 55f34c35164..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,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,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.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"),"nrwsearch"!==cacheId&&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,apikey:""},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)}}}));
+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 191b55423ba..1e3293d3068 100644
--- a/amd/build/library.min.js.map
+++ b/amd/build/library.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"library.min.js","sources":["../src/library.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A javascript module to handle requests for library question info\n * and to import questions.\n *\n * @module qtype_stack/library\n * @copyright 2024 The University of Edinburgh\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([\n 'core/ajax',\n 'core_filters/events'\n], function(\n Ajax,\n CustomEvents\n) {\n\n let courseId = null;\n let categoryId = null;\n let 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, apikey: ''},\n done: function(response) {\n loading(false);\n libraryDiv.innerHTML = response.questionrender;\n for (const iframe of response.iframes) {\n require(['qtype_stack/stackjsvle'],\n function(stackjsvle,) {\n stackjsvle.create_iframe(\n iframe.iframeid,\n iframe.content,\n iframe.targetdivid,\n iframe.title,\n iframe.scrolling,\n iframe.evil\n );\n });\n }\n rawDiv.innerText = response.questiontext;\n descriptionDiv.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 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.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 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 += '
' + '' + 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","apikey","done","response","innerHTML","questionrender","iframe","iframes","require","stackjsvle","create_iframe","iframeid","content","targetdivid","title","scrolling","evil","innerText","questiontext","questiondescription","questionvariables","replace","questionname","pop","querySelectorAll","forEach","el","removeAttribute","querySelector","endsWith","setAttribute","notifyFilterContentUpdated","fail","message","hidden","libraryImport","isFolder","courseid","isfolder","currentQuestion","success","currentDashLink","questionid","isstack","filename","isLoading","setup","trim","includes","elem","addEventListener","catOptions","option","sections","text","length","join"],"mappings":"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"}
\ No newline at end of file
+{"version":3,"file":"library.min.js","sources":["../src/library.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A javascript module to handle requests for library question info\n * and to import questions.\n *\n * @module qtype_stack/library\n * @copyright 2024 The University of Edinburgh\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([\n 'core/ajax',\n 'core_filters/events'\n], function(\n Ajax,\n CustomEvents\n) {\n\n let courseId = null;\n let categoryId = null;\n let cacheId = null;\n let libraryDiv = null;\n let rawDiv = null;\n let variablesDiv = null;\n let descriptionDiv = null;\n let importListDiv = null;\n let importSuccessDiv = null;\n let importFailureDiv = null;\n let importSuccessFileDiv = null;\n let importFailureFileDiv = null;\n let displayedDiv = null;\n let quizLink = null;\n let dashLink = null;\n let errorDiv = null;\n let errorDetailsDiv = null;\n let currentPath = null;\n\n /**\n * Escape text before inserting it into innerHTML.\n *\n * @param {string} text text to escape\n * @returns {string} escaped text\n */\n function escapeHtml(text) {\n return String(text || '')\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n }\n\n /**\n * Sets up event listeners.\n *\n */\n function setup() {\n libraryDiv = document.querySelector('.stack_library_display');\n rawDiv = document.querySelector('.stack_library_raw_display');\n variablesDiv = document.querySelector('.stack_library_variables_display');\n importListDiv = document.querySelector('.stack-library-imported-list');\n displayedDiv = document.querySelector('.stack_library_selected_question');\n descriptionDiv = document.querySelector('.stack_library_description_display');\n errorDiv = document.querySelector('.stack-library-error');\n errorDetailsDiv = document.querySelector('.stack-library-error-details');\n importSuccessDiv = document.querySelector('.stack-library-import-success');\n importFailureDiv = document.querySelector('.stack-library-import-failure');\n importSuccessFileDiv = document.querySelector('.stack-library-import-success-file');\n importFailureFileDiv = document.querySelector('.stack-library-import-failure-file');\n dashLink = document.querySelector('#dashboard-link-holder').innerHTML.trim();\n quizLink = document.querySelector('#quiz-link-holder').innerHTML.trim();\n dashLink = dashLink.includes('?') ? dashLink = dashLink + '&questionid=' : dashLink = dashLink + '?questionid=';\n loading(true);\n const linksArray = document.querySelectorAll('.library-file-link');\n linksArray.forEach(function(elem) {\n elem.addEventListener('click', libraryRender);\n });\n courseId = document.querySelector('[data-id=\"stack_library_course_id\"]').getAttribute('data-value');\n cacheId = document.querySelector('[data-id=\"stack_cache_id\"]').getAttribute('data-value');\n const importButton = document.querySelector('.library-import-link');\n importButton.addEventListener('click', ()=>libraryImport(false));\n const importFolderButton = document.querySelector('.library-import-link-folder');\n importFolderButton.addEventListener('click', ()=>libraryImport(true));\n // Remove number of questions from category dropdown as we're not\n // updating them and that will confuse users.\n const catOptions = document.querySelectorAll('#id_category option');\n for (let option of catOptions) {\n let optionText = option.text;\n const sections = optionText.split('(');\n if (sections.length > 1) {\n if (sections[0] || sections.length > 2) {\n sections.pop();\n option.text = sections.join('(');\n }\n }\n }\n loading(false);\n }\n\n /**\n * Performs AJAX call to Moodle to get info on a question when\n * a link containing the questions filename is clicked.\n *\n * @param {object} e the click event triggering the function call.\n */\n function libraryRender(e) {\n let filepath = e.target.getAttribute('data-filepath');\n currentPath = filepath;\n loading(true);\n categoryId = Number(document.getElementById('id_category').value.split(',')[0]);\n Ajax.call([{\n methodname: 'qtype_stack_library_render',\n args: {category: categoryId, filepath: filepath, cacheid: cacheId, apikey: ''},\n done: function(response) {\n loading(false);\n libraryDiv.innerHTML = response.questionrender;\n for (const iframe of response.iframes) {\n require(['qtype_stack/stackjsvle'],\n function(stackjsvle,) {\n stackjsvle.create_iframe(\n iframe.iframeid,\n iframe.content,\n iframe.targetdivid,\n iframe.title,\n iframe.scrolling,\n iframe.evil\n );\n });\n }\n rawDiv.innerText = response.questiontext;\n descriptionDiv.textContent = response.questiondescription;\n variablesDiv.innerHTML = response.questionvariables.split(';').map(escapeHtml).join(\";
\");\n displayedDiv.innerHTML = escapeHtml(response.questionname) +\n '
(' + escapeHtml(filepath.split('/').pop()) + ')';\n document.querySelectorAll('.library-secondary-info')\n .forEach(el => el.removeAttribute('hidden'));\n document.querySelector('.library-import-link').removeAttribute('disabled');\n if (filepath.endsWith('_quiz.json')) {\n document.querySelector('.stack-library-category-holder').setAttribute('hidden', true);\n document.querySelector('.stack-library-course').removeAttribute('hidden');\n } else {\n document.querySelector('.stack-library-course').setAttribute('hidden', true);\n document.querySelector('.stack-library-category-holder').removeAttribute('hidden');\n if (cacheId !== 'nrwsearch') {\n document.querySelector('.library-import-link-folder').removeAttribute('disabled');\n }\n }\n // This fires the Maths filters for content in the validation div.\n CustomEvents.notifyFilterContentUpdated(libraryDiv);\n },\n fail: function(response) {\n loading(false);\n errorDetailsDiv.textContent = response.message || '';\n errorDiv.hidden = false;\n }\n }]);\n }\n\n /**\n * Performs AJAX call to Moodle to import a question.\n *\n * @param {boolean} isFolder is this a request to load the whole folder\n */\n function libraryImport(isFolder) {\n if (!currentPath) {\n return;\n }\n const filepath = currentPath;\n loading(true);\n categoryId = Number(document.getElementById('id_category').value.split(',')[0]);\n Ajax.call([{\n methodname: 'qtype_stack_library_import',\n args: {\n courseid: courseId,\n category: categoryId,\n filepath: filepath,\n isfolder: (isFolder) ? 1 : 0,\n cacheid: cacheId,\n apikey: ''\n },\n done: function(response) {\n loading(false);\n for (const currentQuestion of response) {\n if (currentQuestion.success) {\n let currentDashLink = dashLink + currentQuestion.questionid;\n if (currentQuestion.isstack) {\n importListDiv.innerHTML += '
' + '' + escapeHtml(currentQuestion.questionname) + '';\n } else if (currentQuestion.filename.endsWith('_quiz.json')) {\n importListDiv.innerHTML += '
' + ''\n + escapeHtml(currentQuestion.questionname) + '';\n } else {\n importListDiv.innerHTML += '
' + escapeHtml(currentQuestion.questionname);\n }\n importSuccessFileDiv.innerHTML += '
' +\n escapeHtml(currentQuestion.filename.split('/').pop()) + ' --> ' +\n escapeHtml(currentQuestion.questionname);\n importSuccessDiv.removeAttribute('hidden');\n } else {\n importFailureFileDiv.innerHTML += '
' +\n escapeHtml(currentQuestion.filename.split('/').pop());\n importFailureDiv.removeAttribute('hidden');\n }\n }\n },\n fail: function(response) {\n loading(false);\n errorDetailsDiv.textContent = response.message || '';\n errorDiv.hidden = false;\n }\n }]);\n }\n\n /**\n * Disable/enable features before/after loading.\n *\n * @param {boolean} isLoading Is an AJAX call taking place?\n */\n function loading(isLoading) {\n errorDiv.hidden = true;\n if (isLoading) {\n document.querySelector('.loading-display').removeAttribute('hidden');\n document.querySelector('.library-import-link').setAttribute('disabled', 'disabled');\n document.querySelector('.library-import-link-folder').setAttribute('disabled', 'disabled');\n document.querySelectorAll('.library-file-link').forEach(el => el.setAttribute('disabled', 'disabled'));\n importSuccessFileDiv.innerHTML = '';\n importSuccessDiv.setAttribute('hidden', true);\n importFailureFileDiv.innerHTML = '';\n importFailureDiv.setAttribute('hidden', true);\n } else {\n document.querySelector('.loading-display').setAttribute('hidden', true);\n document.querySelectorAll('.library-file-link').forEach(el => el.removeAttribute('disabled'));\n }\n }\n\n /** Export our entry point. */\n return {\n setup: setup\n };\n});\n"],"names":["define","Ajax","CustomEvents","courseId","categoryId","cacheId","libraryDiv","rawDiv","variablesDiv","descriptionDiv","importListDiv","importSuccessDiv","importFailureDiv","importSuccessFileDiv","importFailureFileDiv","displayedDiv","quizLink","dashLink","errorDiv","errorDetailsDiv","currentPath","escapeHtml","text","String","replace","libraryRender","e","filepath","target","getAttribute","loading","Number","document","getElementById","value","split","call","methodname","args","category","cacheid","apikey","done","response","innerHTML","questionrender","iframe","iframes","require","stackjsvle","create_iframe","iframeid","content","targetdivid","title","scrolling","evil","innerText","questiontext","textContent","questiondescription","questionvariables","map","join","questionname","pop","querySelectorAll","forEach","el","removeAttribute","querySelector","endsWith","setAttribute","notifyFilterContentUpdated","fail","message","hidden","libraryImport","isFolder","courseid","isfolder","currentQuestion","success","currentDashLink","questionid","isstack","filename","isLoading","setup","trim","includes","elem","addEventListener","catOptions","option","sections","length"],"mappings":"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"}
\ No newline at end of file
diff --git a/amd/src/library.js b/amd/src/library.js
index 621770b7e38..51ce927981e 100644
--- a/amd/src/library.js
+++ b/amd/src/library.js
@@ -48,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.
*
@@ -126,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');
@@ -147,7 +163,7 @@ define([
},
fail: function(response) {
loading(false);
- errorDetailsDiv.innerHTML = (response.message) ? response.message : '';
+ errorDetailsDiv.textContent = response.message || '';
errorDiv.hidden = false;
}
}]);
@@ -182,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/tests/jest/library.test.js b/tests/jest/library.test.js
index 31e444931e1..8c10102effd 100644
--- a/tests/jest/library.test.js
+++ b/tests/jest/library.test.js
@@ -43,7 +43,7 @@ function setupLibraryDom() {
Quiz
-
+