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}} - {{/isdirectory}} diff --git a/templates/questionlibrary.mustache b/templates/questionlibrary.mustache index fddced750c6..0b7b422b6bc 100644 --- a/templates/questionlibrary.mustache +++ b/templates/questionlibrary.mustache @@ -188,4 +188,6 @@ + diff --git a/version.php b/version.php index 0705b6648a4..fe8a3bcfc93 100644 --- a/version.php +++ b/version.php @@ -24,7 +24,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026012900; +$plugin->version = 2026022301; $plugin->requires = 2022041900; $plugin->cron = 0; $plugin->component = 'qtype_stack'; From ceb82b951deeee36a6a7106d9028739e64cec2dc Mon Sep 17 00:00:00 2001 From: Edmund Farrow Date: Mon, 23 Feb 2026 16:42:23 +0000 Subject: [PATCH 04/31] nrw-external - Tidy up --- amd/build/library.min.js | 2 +- amd/build/library.min.js.map | 2 +- amd/src/library.js | 8 ++++---- classes/library_import.php | 10 +++++----- classes/library_render.php | 14 ++++++------- questionlibrary.php | 32 ++++++++++++++++++++---------- stack/maxima/stackmaxima.mac | 2 +- templates/questionfolder.mustache | 2 +- templates/questionlibrary.mustache | 2 +- version.php | 2 +- 10 files changed, 44 insertions(+), 32 deletions(-) diff --git a/amd/build/library.min.js b/amd/build/library.min.js index d9fc4f4c045..6b8bb8b2ec4 100644 --- a/amd/build/library.min.js +++ b/amd/build/library.min.js @@ -6,6 +6,6 @@ * @copyright 2024 The University of Edinburgh * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -define("qtype_stack/library",["core/ajax","core_filters/events"],(function(Ajax,CustomEvents){let courseId=null,categoryId=null,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)}}})); +define("qtype_stack/library",["core/ajax","core_filters/events"],(function(Ajax,CustomEvents){let courseId=null,categoryId=null,cacheId=null,libraryDiv=null,rawDiv=null,variablesDiv=null,descriptionDiv=null,importListDiv=null,importSuccessDiv=null,importFailureDiv=null,importSuccessFileDiv=null,importFailureFileDiv=null,displayedDiv=null,quizLink=null,dashLink=null,errorDiv=null,errorDetailsDiv=null,currentPath=null;function libraryRender(e){let filepath=e.target.getAttribute("data-filepath");currentPath=filepath,loading(!0),categoryId=Number(document.getElementById("id_category").value.split(",")[0]),Ajax.call([{methodname:"qtype_stack_library_render",args:{category:categoryId,filepath:filepath,cacheid:cacheId},done:function(response){loading(!1),libraryDiv.innerHTML=response.questionrender;for(const iframe of response.iframes)require(["qtype_stack/stackjsvle"],(function(stackjsvle){stackjsvle.create_iframe(iframe.iframeid,iframe.content,iframe.targetdivid,iframe.title,iframe.scrolling,iframe.evil)}));rawDiv.innerText=response.questiontext,descriptionDiv.innerHTML=response.questiondescription,variablesDiv.innerHTML=response.questionvariables.replace(/;/g,";
"),displayedDiv.innerHTML=response.questionname+"
("+filepath.split("/").pop()+")",document.querySelectorAll(".library-secondary-info").forEach((el=>el.removeAttribute("hidden"))),document.querySelector(".library-import-link").removeAttribute("disabled"),filepath.endsWith("_quiz.json")?(document.querySelector(".stack-library-category-holder").setAttribute("hidden",!0),document.querySelector(".stack-library-course").removeAttribute("hidden")):(document.querySelector(".stack-library-course").setAttribute("hidden",!0),document.querySelector(".stack-library-category-holder").removeAttribute("hidden"),document.querySelector(".library-import-link-folder").removeAttribute("disabled")),CustomEvents.notifyFilterContentUpdated(libraryDiv)},fail:function(response){loading(!1),errorDetailsDiv.innerHTML=response.message?response.message:"",errorDiv.hidden=!1}}])}function libraryImport(isFolder){if(!currentPath)return;const filepath=currentPath;loading(!0),categoryId=Number(document.getElementById("id_category").value.split(",")[0]),Ajax.call([{methodname:"qtype_stack_library_import",args:{courseid:courseId,category:categoryId,filepath:filepath,isfolder:isFolder?1:0,cacheid:cacheId},done:function(response){loading(!1);for(const currentQuestion of response)if(currentQuestion.success){let currentDashLink=dashLink+currentQuestion.questionid;currentQuestion.isstack?importListDiv.innerHTML+='
'+currentQuestion.questionname+"":currentQuestion.filename.endsWith("_quiz.json")?importListDiv.innerHTML+='
'+currentQuestion.questionname+"":importListDiv.innerHTML+="
"+currentQuestion.questionname,importSuccessFileDiv.innerHTML+="
"+currentQuestion.filename.split("/").pop()+" --\x3e "+currentQuestion.questionname,importSuccessDiv.removeAttribute("hidden")}else importFailureFileDiv.innerHTML+="
"+currentQuestion.filename.split("/").pop(),importFailureDiv.removeAttribute("hidden")},fail:function(response){loading(!1),errorDetailsDiv.innerHTML=response.message?response.message:"",errorDiv.hidden=!1}}])}function loading(isLoading){errorDiv.hidden=!0,isLoading?(document.querySelector(".loading-display").removeAttribute("hidden"),document.querySelector(".library-import-link").setAttribute("disabled","disabled"),document.querySelector(".library-import-link-folder").setAttribute("disabled","disabled"),document.querySelectorAll(".library-file-link").forEach((el=>el.setAttribute("disabled","disabled"))),importSuccessFileDiv.innerHTML="",importSuccessDiv.setAttribute("hidden",!0),importFailureFileDiv.innerHTML="",importFailureDiv.setAttribute("hidden",!0)):(document.querySelector(".loading-display").setAttribute("hidden",!0),document.querySelectorAll(".library-file-link").forEach((el=>el.removeAttribute("disabled"))))}return{setup:function(){libraryDiv=document.querySelector(".stack_library_display"),rawDiv=document.querySelector(".stack_library_raw_display"),variablesDiv=document.querySelector(".stack_library_variables_display"),importListDiv=document.querySelector(".stack-library-imported-list"),displayedDiv=document.querySelector(".stack_library_selected_question"),descriptionDiv=document.querySelector(".stack_library_description_display"),errorDiv=document.querySelector(".stack-library-error"),errorDetailsDiv=document.querySelector(".stack-library-error-details"),importSuccessDiv=document.querySelector(".stack-library-import-success"),importFailureDiv=document.querySelector(".stack-library-import-failure"),importSuccessFileDiv=document.querySelector(".stack-library-import-success-file"),importFailureFileDiv=document.querySelector(".stack-library-import-failure-file"),dashLink=document.querySelector("#dashboard-link-holder").innerHTML.trim(),quizLink=document.querySelector("#quiz-link-holder").innerHTML.trim(),dashLink=dashLink.includes("?")?dashLink+="&questionid=":dashLink+="?questionid=",loading(!0),document.querySelectorAll(".library-file-link").forEach((function(elem){elem.addEventListener("click",libraryRender)})),courseId=document.querySelector('[data-id="stack_library_course_id"]').getAttribute("data-value"),cacheId=document.querySelector('[data-id="stack_cache_id"]').getAttribute("data-value"),document.querySelector(".library-import-link").addEventListener("click",(()=>libraryImport(!1))),document.querySelector(".library-import-link-folder").addEventListener("click",(()=>libraryImport(!0)));const catOptions=document.querySelectorAll("#id_category option");for(let option of catOptions){const sections=option.text.split("(");sections.length>1&&(sections[0]||sections.length>2)&&(sections.pop(),option.text=sections.join("("))}loading(!1)}}})); //# sourceMappingURL=library.min.js.map \ No newline at end of file diff --git a/amd/build/library.min.js.map b/amd/build/library.min.js.map index 98df37f3319..08b3f38ff08 100644 --- a/amd/build/library.min.js.map +++ b/amd/build/library.min.js.map @@ -1 +1 @@ -{"version":3,"file":"library.min.js","sources":["../src/library.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A javascript module to handle requests for library question info\n * and to import questions.\n *\n * @module qtype_stack/library\n * @copyright 2024 The University of Edinburgh\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([\n 'core/ajax',\n 'core_filters/events'\n], function(\n Ajax,\n CustomEvents\n) {\n\n let courseId = null;\n let categoryId = null;\n let 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 +{"version":3,"file":"library.min.js","sources":["../src/library.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A javascript module to handle requests for library question info\n * and to import questions.\n *\n * @module qtype_stack/library\n * @copyright 2024 The University of Edinburgh\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([\n 'core/ajax',\n 'core_filters/events'\n], function(\n Ajax,\n CustomEvents\n) {\n\n let courseId = null;\n let categoryId = null;\n let cacheId = null;\n let libraryDiv = null;\n let rawDiv = null;\n let variablesDiv = null;\n let descriptionDiv = null;\n let importListDiv = null;\n let importSuccessDiv = null;\n let importFailureDiv = null;\n let importSuccessFileDiv = null;\n let importFailureFileDiv = null;\n let displayedDiv = null;\n let quizLink = null;\n let dashLink = null;\n let errorDiv = null;\n let errorDetailsDiv = null;\n let currentPath = null;\n\n /**\n * Sets up event listeners.\n *\n */\n function setup() {\n libraryDiv = document.querySelector('.stack_library_display');\n rawDiv = document.querySelector('.stack_library_raw_display');\n variablesDiv = document.querySelector('.stack_library_variables_display');\n importListDiv = document.querySelector('.stack-library-imported-list');\n displayedDiv = document.querySelector('.stack_library_selected_question');\n descriptionDiv = document.querySelector('.stack_library_description_display');\n errorDiv = document.querySelector('.stack-library-error');\n errorDetailsDiv = document.querySelector('.stack-library-error-details');\n importSuccessDiv = document.querySelector('.stack-library-import-success');\n importFailureDiv = document.querySelector('.stack-library-import-failure');\n importSuccessFileDiv = document.querySelector('.stack-library-import-success-file');\n importFailureFileDiv = document.querySelector('.stack-library-import-failure-file');\n dashLink = document.querySelector('#dashboard-link-holder').innerHTML.trim();\n quizLink = document.querySelector('#quiz-link-holder').innerHTML.trim();\n dashLink = dashLink.includes('?') ? dashLink = dashLink + '&questionid=' : dashLink = dashLink + '?questionid=';\n loading(true);\n const linksArray = document.querySelectorAll('.library-file-link');\n linksArray.forEach(function(elem) {\n elem.addEventListener('click', libraryRender);\n });\n courseId = document.querySelector('[data-id=\"stack_library_course_id\"]').getAttribute('data-value');\n cacheId = document.querySelector('[data-id=\"stack_cache_id\"]').getAttribute('data-value');\n const importButton = document.querySelector('.library-import-link');\n importButton.addEventListener('click', ()=>libraryImport(false));\n const importFolderButton = document.querySelector('.library-import-link-folder');\n importFolderButton.addEventListener('click', ()=>libraryImport(true));\n // Remove number of questions from category dropdown as we're not\n // updating them and that will confuse users.\n const catOptions = document.querySelectorAll('#id_category option');\n for (let option of catOptions) {\n let optionText = option.text;\n const sections = optionText.split('(');\n if (sections.length > 1) {\n if (sections[0] || sections.length > 2) {\n sections.pop();\n option.text = sections.join('(');\n }\n }\n }\n loading(false);\n }\n\n /**\n * Performs AJAX call to Moodle to get info on a question when\n * a link containing the questions filename is clicked.\n *\n * @param {object} e the click event triggering the function call.\n */\n function libraryRender(e) {\n let filepath = e.target.getAttribute('data-filepath');\n currentPath = filepath;\n loading(true);\n categoryId = Number(document.getElementById('id_category').value.split(',')[0]);\n Ajax.call([{\n methodname: 'qtype_stack_library_render',\n args: {category: categoryId, filepath: filepath, cacheid: cacheId},\n done: function(response) {\n loading(false);\n libraryDiv.innerHTML = response.questionrender;\n for (const iframe of response.iframes) {\n require(['qtype_stack/stackjsvle'],\n function(stackjsvle,) {\n stackjsvle.create_iframe(\n iframe.iframeid,\n iframe.content,\n iframe.targetdivid,\n iframe.title,\n iframe.scrolling,\n iframe.evil\n );\n });\n }\n rawDiv.innerText = response.questiontext;\n descriptionDiv.innerHTML = response.questiondescription;\n variablesDiv.innerHTML = response.questionvariables.replace(/;/g, \";
\");\n displayedDiv.innerHTML = response.questionname + '
(' + filepath.split('/').pop() + ')';\n document.querySelectorAll('.library-secondary-info')\n .forEach(el => el.removeAttribute('hidden'));\n document.querySelector('.library-import-link').removeAttribute('disabled');\n if (filepath.endsWith('_quiz.json')) {\n document.querySelector('.stack-library-category-holder').setAttribute('hidden', true);\n document.querySelector('.stack-library-course').removeAttribute('hidden');\n } else {\n document.querySelector('.stack-library-course').setAttribute('hidden', true);\n document.querySelector('.stack-library-category-holder').removeAttribute('hidden');\n document.querySelector('.library-import-link-folder').removeAttribute('disabled');\n }\n // This fires the Maths filters for content in the validation div.\n CustomEvents.notifyFilterContentUpdated(libraryDiv);\n },\n fail: function(response) {\n loading(false);\n errorDetailsDiv.innerHTML = (response.message) ? response.message : '';\n errorDiv.hidden = false;\n }\n }]);\n }\n\n /**\n * Performs AJAX call to Moodle to import a question.\n *\n * @param {boolean} isFolder is this a request to load the whole folder\n */\n function libraryImport(isFolder) {\n if (!currentPath) {\n return;\n }\n const filepath = currentPath;\n loading(true);\n categoryId = Number(document.getElementById('id_category').value.split(',')[0]);\n Ajax.call([{\n methodname: 'qtype_stack_library_import',\n args: {\n courseid: courseId,\n category: categoryId,\n filepath: filepath,\n isfolder: (isFolder) ? 1 : 0,\n cacheid: cacheId\n },\n done: function(response) {\n loading(false);\n for (const currentQuestion of response) {\n if (currentQuestion.success) {\n let currentDashLink = dashLink + currentQuestion.questionid;\n if (currentQuestion.isstack) {\n importListDiv.innerHTML += '
' + '' + currentQuestion.questionname + '';\n } else if (currentQuestion.filename.endsWith('_quiz.json')) {\n importListDiv.innerHTML += '
' + ''\n + currentQuestion.questionname + '';\n } else {\n importListDiv.innerHTML += '
' + currentQuestion.questionname;\n }\n importSuccessFileDiv.innerHTML += '
' +\n currentQuestion.filename.split('/').pop() + ' --> ' + currentQuestion.questionname;\n importSuccessDiv.removeAttribute('hidden');\n } else {\n importFailureFileDiv.innerHTML += '
' +\n currentQuestion.filename.split('/').pop();\n importFailureDiv.removeAttribute('hidden');\n }\n }\n },\n fail: function(response) {\n loading(false);\n errorDetailsDiv.innerHTML = (response.message) ? response.message : '';\n errorDiv.hidden = false;\n }\n }]);\n }\n\n /**\n * Disable/enable features before/after loading.\n *\n * @param {boolean} isLoading Is an AJAX call taking place?\n */\n function loading(isLoading) {\n errorDiv.hidden = true;\n if (isLoading) {\n document.querySelector('.loading-display').removeAttribute('hidden');\n document.querySelector('.library-import-link').setAttribute('disabled', 'disabled');\n document.querySelector('.library-import-link-folder').setAttribute('disabled', 'disabled');\n document.querySelectorAll('.library-file-link').forEach(el => el.setAttribute('disabled', 'disabled'));\n importSuccessFileDiv.innerHTML = '';\n importSuccessDiv.setAttribute('hidden', true);\n importFailureFileDiv.innerHTML = '';\n importFailureDiv.setAttribute('hidden', true);\n } else {\n document.querySelector('.loading-display').setAttribute('hidden', true);\n document.querySelectorAll('.library-file-link').forEach(el => el.removeAttribute('disabled'));\n }\n }\n\n /** Export our entry point. */\n return {\n setup: setup\n };\n});\n"],"names":["define","Ajax","CustomEvents","courseId","categoryId","cacheId","libraryDiv","rawDiv","variablesDiv","descriptionDiv","importListDiv","importSuccessDiv","importFailureDiv","importSuccessFileDiv","importFailureFileDiv","displayedDiv","quizLink","dashLink","errorDiv","errorDetailsDiv","currentPath","libraryRender","e","filepath","target","getAttribute","loading","Number","document","getElementById","value","split","call","methodname","args","category","cacheid","done","response","innerHTML","questionrender","iframe","iframes","require","stackjsvle","create_iframe","iframeid","content","targetdivid","title","scrolling","evil","innerText","questiontext","questiondescription","questionvariables","replace","questionname","pop","querySelectorAll","forEach","el","removeAttribute","querySelector","endsWith","setAttribute","notifyFilterContentUpdated","fail","message","hidden","libraryImport","isFolder","courseid","isfolder","currentQuestion","success","currentDashLink","questionid","isstack","filename","isLoading","setup","trim","includes","elem","addEventListener","catOptions","option","sections","text","length","join"],"mappings":"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"} \ No newline at end of file diff --git a/amd/src/library.js b/amd/src/library.js index 57650bf1f78..7034ae5c3a5 100644 --- a/amd/src/library.js +++ b/amd/src/library.js @@ -31,7 +31,7 @@ define([ let courseId = null; let categoryId = null; - let libraryName = null; + let cacheId = null; let libraryDiv = null; let rawDiv = null; let variablesDiv = null; @@ -74,7 +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'); + cacheId = document.querySelector('[data-id="stack_cache_id"]').getAttribute('data-value'); const importButton = document.querySelector('.library-import-link'); importButton.addEventListener('click', ()=>libraryImport(false)); const importFolderButton = document.querySelector('.library-import-link-folder'); @@ -108,7 +108,7 @@ define([ categoryId = Number(document.getElementById('id_category').value.split(',')[0]); Ajax.call([{ methodname: 'qtype_stack_library_render', - args: {category: categoryId, filepath: filepath, libraryname: libraryName}, + args: {category: categoryId, filepath: filepath, cacheid: cacheId}, done: function(response) { loading(false); libraryDiv.innerHTML = response.questionrender; @@ -170,7 +170,7 @@ define([ category: categoryId, filepath: filepath, isfolder: (isFolder) ? 1 : 0, - libraryname: libraryName + cacheid: cacheId }, done: function(response) { loading(false); diff --git a/classes/library_import.php b/classes/library_import.php index fe6e4f4d270..8d429672972 100644 --- a/classes/library_import.php +++ b/classes/library_import.php @@ -63,7 +63,7 @@ public static function import_execute_parameters() { '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'), + 'cacheid' => new \external_value(PARAM_RAW, 'Library cache id'), ]); } @@ -92,14 +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, $libraryname) { + public static function import_execute($courseid, $category, $filepath, $isfolder, $cacheid) { global $CFG, $DB; $params = self::validate_parameters(self::import_execute_parameters(), [ 'courseid' => $courseid, 'category' => $category, 'filepath' => $filepath, 'isfolder' => $isfolder, - 'libraryname' => $libraryname, + 'cacheid' => $cacheid, ]); // Check parameters and permissions. $thiscontext = null; @@ -118,11 +118,11 @@ public static function import_execute($courseid, $category, $filepath, $isfolder if (str_starts_with($params['filepath'], 'sitelibrary/')) { $requestedfile = $CFG->dataroot . '/stack/' . $params['filepath']; $basedir = $CFG->dataroot . '/stack/'; - } else if (str_starts_with($params['libraryname'], 'externallibrary')) { + } else if (str_starts_with($params['cacheid'], 'githublibrary')) { $requestedfile = make_request_directory() . "/importq.xml"; $external = true; $cache = \cache::make('qtype_stack', 'librarycache'); - $externalfiles = $cache->get($params['libraryname'] . '_flat_file_list'); + $externalfiles = $cache->get($params['cacheid'] . '_flat_file_list'); } else { $requestedfile = $CFG->dirroot . '/question/type/stack/samplequestions/' . $params['filepath']; $basedir = $CFG->dirroot . '/question/type/stack/samplequestions/'; diff --git a/classes/library_render.php b/classes/library_render.php index d74499eae9b..8a104b784e0 100644 --- a/classes/library_render.php +++ b/classes/library_render.php @@ -62,7 +62,7 @@ public static function render_execute_parameters() { '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'), + 'cacheid' => new \external_value(PARAM_RAW, 'Library cache id'), ]); } @@ -100,31 +100,31 @@ 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, $libraryname) { + public static function render_execute($category, $filepath, $cacheid) { global $CFG, $DB; $params = self::validate_parameters(self::render_execute_parameters(), [ 'category' => $category, 'filepath' => $filepath, - 'libraryname' => $libraryname, + 'cacheid' => $cacheid, ]); StackIframeHolder::$islibrary = true; // Check parameters and that user has question add capability in the supplied category. $context = $DB->get_field('question_categories', 'contextid', ['id' => $params['category']]); - $external = (str_starts_with($params['libraryname'], 'externallibrary')) ? true : false; + $external = (str_starts_with($params['cacheid'], 'githublibrary')) ? 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($external ? "{$params['libraryname']}/{$params['filepath']}" : $params['filepath']); + $result = $cache->get($external ? "{$params['cacheid']}/{$params['filepath']}" : $params['filepath']); $isquiz = (pathinfo($params['filepath'], PATHINFO_EXTENSION) === 'json' && strrpos($params['filepath'], '_quiz.json') !== false) ? true : false; if (str_starts_with($params['filepath'], 'sitelibrary/')) { $requestedfile = $CFG->dataroot . '/stack/' . $params['filepath']; } else if ($external) { - $externalfiles = $cache->get($params['libraryname'] . '_flat_file_list'); + $externalfiles = $cache->get($params['cacheid'] . '_flat_file_list'); $requestedfile = $externalfiles[$params['filepath']]->url; } else { $requestedfile = $CFG->dirroot . '/question/type/stack/samplequestions/' . $params['filepath']; @@ -168,7 +168,7 @@ public static function render_execute($category, $filepath, $libraryname) { 'questiondescription' => $question->questiondescription, 'isstack' => true, ]; - $cache->set($external ? "{$params['libraryname']}/{$params['filepath']}" : $params['filepath'], $result); + $cache->set($external ? "{$params['cacheid']}/{$params['filepath']}" : $params['filepath'], $result); } catch (\stack_exception $e) { // If the question is not a STACK question we can't render it // but we still want users to be able to import it. diff --git a/questionlibrary.php b/questionlibrary.php index ee20ff47623..7787095fb4a 100644 --- a/questionlibrary.php +++ b/questionlibrary.php @@ -93,7 +93,12 @@ $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']]; +$allowedlibraries = [ + 'EIT' => [ + '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]; @@ -106,10 +111,17 @@ } else { $location .= '/*'; } -} else if (in_array($location, array_column($libraryurls, 'url'))) { - $libraryname = optional_param('name', '', PARAM_RAW); - $cacheid = 'externallibrary_' . $libraryname; - $external = true; +} else if (str_starts_with($location, 'githublibrary')) { + $libraryid = explode('/', $location)[1]; + $cacheid = 'githublibrary_' . $libraryid; + $libraryname = $allowedlibraries[$libraryid]['name'] ?? null; + if (!$libraryname) { + $location = __DIR__ . '/samplequestions/stacklibrary/*'; + $libraryname = stack_string('stack_library'); + $cacheid = 'library'; + } else { + $external = true; + } } else { $location = __DIR__ . '/samplequestions/stacklibrary/*'; } @@ -117,7 +129,7 @@ $files = $cache->get($cacheid . '_file_list'); if (!$files) { if ($external) { - [$files, $flatfiles] = stack_question_library::stack_list_github_repo($location); + [$files, $flatfiles] = stack_question_library::stack_list_github_repo($allowedlibraries[$libraryid]['url']); $cache->set($cacheid . '_flat_file_list', $flatfiles); } else { $files = stack_question_library::get_file_list($location); @@ -163,11 +175,11 @@ $outputdata->libraries->items[] = $libentry; } -foreach ($libraryurls as $libraryurl) { +foreach ($allowedlibraries as $id => $lib) { $libentry = new StdClass(); - $libentry->name = $libraryurl['name']; - $urlparams['location'] = $libraryurl['url']; - $urlparams['name'] = $libraryurl['name']; + $libentry->name = $lib['name']; + $urlparams['location'] = 'githublibrary/' . $id; + $urlparams['name'] = $lib['name']; $libentry->url = new moodle_url('/question/type/stack/questionlibrary.php', $urlparams); $libentry->url = $libentry->url->out(); $libentry->active = ($libentry->name === $libraryname) ? true : false; diff --git a/stack/maxima/stackmaxima.mac b/stack/maxima/stackmaxima.mac index 9815d729435..773469147fa 100644 --- a/stack/maxima/stackmaxima.mac +++ b/stack/maxima/stackmaxima.mac @@ -3548,4 +3548,4 @@ is_lang(code):=ev(is(%_STACK_LANG=code),simp=true)$ /* Stack expects some output with the version number the output happens at */ /* maximalocal.mac after additional library loading */ -stackmaximaversion:2026022301$ +stackmaximaversion:2026022302$ diff --git a/templates/questionfolder.mustache b/templates/questionfolder.mustache index c65be5aee28..5bf29293568 100644 --- a/templates/questionfolder.mustache +++ b/templates/questionfolder.mustache @@ -69,7 +69,7 @@ {{/isdirectory}} {{^isdirectory}} - {{/isdirectory}} diff --git a/templates/questionlibrary.mustache b/templates/questionlibrary.mustache index 0b7b422b6bc..028bf50495a 100644 --- a/templates/questionlibrary.mustache +++ b/templates/questionlibrary.mustache @@ -188,6 +188,6 @@ - diff --git a/version.php b/version.php index fe8a3bcfc93..fdd21795091 100644 --- a/version.php +++ b/version.php @@ -24,7 +24,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026022301; +$plugin->version = 2026022302; $plugin->requires = 2022041900; $plugin->cron = 0; $plugin->component = 'qtype_stack'; From ec8d6695233ed296ee00a0c50d6f78d46e6c5537 Mon Sep 17 00:00:00 2001 From: Edmund Farrow Date: Tue, 24 Feb 2026 12:04:58 +0000 Subject: [PATCH 05/31] nrw-external - Add JSON library setting --- classes/library_import.php | 14 ++++----- classes/library_render.php | 12 +++++--- lang/en/qtype_stack.php | 12 ++++++++ questionlibrary.php | 53 ++++++++++++++++++++------------- settings.php | 10 +++++++ stack/questionlibrary.class.php | 22 +++++++++++++- 6 files changed, 90 insertions(+), 33 deletions(-) diff --git a/classes/library_import.php b/classes/library_import.php index 8d429672972..74323680caa 100644 --- a/classes/library_import.php +++ b/classes/library_import.php @@ -112,15 +112,15 @@ public static function import_execute($courseid, $category, $filepath, $isfolder require_capability('moodle/question:add', $thiscontext); $loadingquiz = false; $categories = []; - $external = false; + $external = null; $externalfiles = null; - if (str_starts_with($params['filepath'], 'sitelibrary/')) { + if (str_starts_with($params['filepath'], stack_question_library::SITELIB)) { $requestedfile = $CFG->dataroot . '/stack/' . $params['filepath']; $basedir = $CFG->dataroot . '/stack/'; - } else if (str_starts_with($params['cacheid'], 'githublibrary')) { + } else if (str_starts_with($params['cacheid'], stack_question_library::GITHUB)) { $requestedfile = make_request_directory() . "/importq.xml"; - $external = true; + $external = explode('_', $params['cacheid'])[0]; $cache = \cache::make('qtype_stack', 'librarycache'); $externalfiles = $cache->get($params['cacheid'] . '_flat_file_list'); } else { @@ -142,7 +142,7 @@ public static function import_execute($courseid, $category, $filepath, $isfolder // We've got a quiz file. Load JSON and instantiate. if ($external) { $url = $externalfiles[$params['filepath']]->url; - $quizcontents = stack_question_library::get_external_github_file($url); + $quizcontents = stack_question_library::get_external_file($url, $external); } else { $quizcontents = file_get_contents($requestedfile); } @@ -209,7 +209,7 @@ public static function import_execute($courseid, $category, $filepath, $isfolder $qformat->setCatfromfile(true); if ($external) { $url = $externalfiles[$category]->url; - file_put_contents($requestedfile, stack_question_library::get_external_github_file($url)); + file_put_contents($requestedfile, stack_question_library::get_external_file($url, $external)); $qformat->setFilename($requestedfile); } else { $qformat->setFilename($basedir . $category); @@ -260,7 +260,7 @@ public static function import_execute($courseid, $category, $filepath, $isfolder $qformat->setCatfromfile(false); if ($external) { $url = $externalfiles[$file]->url; - file_put_contents($requestedfile, stack_question_library::get_external_github_file($url)); + file_put_contents($requestedfile, stack_question_library::get_external_file($url, $external)); $qformat->setFilename($requestedfile); } else { $qformat->setFilename($basedir . $file); diff --git a/classes/library_render.php b/classes/library_render.php index 8a104b784e0..d9100b79c06 100644 --- a/classes/library_render.php +++ b/classes/library_render.php @@ -110,7 +110,11 @@ public static function render_execute($category, $filepath, $cacheid) { StackIframeHolder::$islibrary = true; // Check parameters and that user has question add capability in the supplied category. $context = $DB->get_field('question_categories', 'contextid', ['id' => $params['category']]); - $external = (str_starts_with($params['cacheid'], 'githublibrary')) ? true : false; + if (str_starts_with($params['cacheid'], stack_question_library::GITHUB)) { + $external = stack_question_library::GITHUB; + } else { + $external = null; + } $thiscontext = context::instance_by_id($context); self::validate_context($thiscontext); require_capability('moodle/question:add', $thiscontext); @@ -121,7 +125,7 @@ public static function render_execute($category, $filepath, $cacheid) { $isquiz = (pathinfo($params['filepath'], PATHINFO_EXTENSION) === 'json' && strrpos($params['filepath'], '_quiz.json') !== false) ? true : false; - if (str_starts_with($params['filepath'], 'sitelibrary/')) { + if (str_starts_with($params['filepath'], stack_question_library::SITELIB . '/')) { $requestedfile = $CFG->dataroot . '/stack/' . $params['filepath']; } else if ($external) { $externalfiles = $cache->get($params['cacheid'] . '_flat_file_list'); @@ -130,7 +134,7 @@ public static function render_execute($category, $filepath, $cacheid) { $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->dataroot}/stack/sitelibrary/") && !str_starts_with(realpath($requestedfile), "{$CFG->dirroot}/question/type/stack/samplequestions/") && !$external ) { @@ -138,7 +142,7 @@ public static function render_execute($category, $filepath, $cacheid) { } if ($external) { - $qcontents = stack_question_library::get_external_github_file($requestedfile); + $qcontents = stack_question_library::get_external_file($requestedfile, $external); } else { $qcontents = file_get_contents($requestedfile); } diff --git a/lang/en/qtype_stack.php b/lang/en/qtype_stack.php index a796afd61fa..8c4214b5324 100644 --- a/lang/en/qtype_stack.php +++ b/lang/en/qtype_stack.php @@ -506,6 +506,18 @@ $string['settingmaximalibraries_desc'] = 'This is a comma separated list of Maxima library names which will be automatically loaded into Maxima. Only supported library names can be used: "stats, distrib, descriptive, simplex". When you change the listed libraties you must rebuild the Maxima optimised image.'; $string['settingmaximalibraries_error'] = 'Please edit the STACK plugin setting qtype_stack | maximalibraries. The following package is not supported: {$a}'; $string['settingmaximalibraries_failed'] = 'It appears as if some of the Maxima packages you have asked for have failed to load.'; +$string['settingexternallibraries'] = 'External question libraries'; +$string['settingexternallibraries_desc'] = 'JSON object listing display names and URL locations of allowed external GitHub libraries with a short identifier for each. Example: +
{ +
  "EIT": { +
    "url": "https:\/\/github.com\/maths\/moodle-qtype_stack\/tree\/master\/samplequestions\/importtest", +
    "name": "External: import test" +
  }, +
  "ESQ": { +
    "url": "https:\/\/github.com\/maths\/moodle-qtype_stack\/tree\/master\/samplequestions\/stacklibrary", +
    "name": "External: sample library" +
  } +
}'; // Strings used by replace dollars script. $string['replacedollarscount'] = 'This category contains {$a} STACK questions.'; diff --git a/questionlibrary.php b/questionlibrary.php index 7787095fb4a..c566e5a5d86 100644 --- a/questionlibrary.php +++ b/questionlibrary.php @@ -90,19 +90,24 @@ // Make sure we're only listing contents of STACK library or site library. $location = optional_param('location', '', PARAM_RAW); +// Location parameter will be in form: +// samplequestions/stacklibrary +// sitelibrary/foldername +// githublibrary/id. +// Corresponding cache ids are: +// library +// sitelibrary_foldername +// githublibrary_id. $cacheid = 'library'; $libraryname = stack_string('stack_library'); -$external = false; -$allowedlibraries = [ - 'EIT' => [ - 'url' => 'https://github.com/maths/moodle-qtype_stack/tree/master/samplequestions/importtest', - 'name' => 'External: import test', - ] -]; - -if (str_starts_with($location, 'sitelibrary')) { +$external = null; +$allowedlibraries = get_config('qtype_stack', 'libraries'); +$allowedlibraries = json_decode($allowedlibraries); +$allowedlibraries = $allowedlibraries ? $allowedlibraries : new StdClass(); + +if (str_starts_with($location, stack_question_library::SITELIB)) { $libraryname = explode('/', $location)[1]; - $cacheid = 'sitelibrary_' . $libraryname; + $cacheid = stack_question_library::SITELIB . "_{$libraryname}"; $location = "{$CFG->dataroot}/stack/{$location}"; if (!str_starts_with(realpath($location), "{$CFG->dataroot}/stack/sitelibrary")) { $location = __DIR__ . '/samplequestions/stacklibrary/*'; @@ -111,16 +116,18 @@ } else { $location .= '/*'; } -} else if (str_starts_with($location, 'githublibrary')) { - $libraryid = explode('/', $location)[1]; - $cacheid = 'githublibrary_' . $libraryid; - $libraryname = $allowedlibraries[$libraryid]['name'] ?? null; +} else if (str_starts_with($location, stack_question_library::GITHUB)) { + $libparts = explode('/', $location); + $librarytype = $libparts[0]; + $libraryid = $libparts[1]; + $cacheid = $librarytype . "_{$libraryid}"; + $libraryname = $allowedlibraries->{$libraryid}->name ?? null; if (!$libraryname) { $location = __DIR__ . '/samplequestions/stacklibrary/*'; $libraryname = stack_string('stack_library'); $cacheid = 'library'; } else { - $external = true; + $external = $librarytype; } } else { $location = __DIR__ . '/samplequestions/stacklibrary/*'; @@ -129,7 +136,7 @@ $files = $cache->get($cacheid . '_file_list'); if (!$files) { if ($external) { - [$files, $flatfiles] = stack_question_library::stack_list_github_repo($allowedlibraries[$libraryid]['url']); + [$files, $flatfiles] = stack_question_library::get_file_list_from_repo($allowedlibraries->{$libraryid}->url, $external); $cache->set($cacheid . '_flat_file_list', $flatfiles); } else { $files = stack_question_library::get_file_list($location); @@ -157,7 +164,7 @@ if ($libraries) { $libentry = new StdClass(); $libentry->name = stack_string('stack_library'); - $urlparams['location'] = "/samplequestions/stacklibrary"; + $urlparams['location'] = "samplequestions/stacklibrary"; $libentry->url = new moodle_url('/question/type/stack/questionlibrary.php', $urlparams); $libentry->url = $libentry->url->out(); $libentry->active = ($libentry->name === $libraryname) ? true : false; @@ -168,7 +175,7 @@ $libentry = new StdClass(); $parts = explode('/', $library); $libentry->name = end($parts); - $urlparams['location'] = "sitelibrary/{$libentry->name}"; + $urlparams['location'] = stack_question_library::SITELIB . "/{$libentry->name}"; $libentry->url = new moodle_url('/question/type/stack/questionlibrary.php', $urlparams); $libentry->url = $libentry->url->out(); $libentry->active = ($libentry->name === $libraryname) ? true : false; @@ -177,9 +184,13 @@ foreach ($allowedlibraries as $id => $lib) { $libentry = new StdClass(); - $libentry->name = $lib['name']; - $urlparams['location'] = 'githublibrary/' . $id; - $urlparams['name'] = $lib['name']; + $libentry->name = $lib->name; + if (str_starts_with($lib->url, 'https://github.com/')) { + $urlparams['location'] = stack_question_library::GITHUB . "/{$id}"; + } else { + $urlparams['location'] = 'invalid'; + } + $urlparams['name'] = $lib->name; $libentry->url = new moodle_url('/question/type/stack/questionlibrary.php', $urlparams); $libentry->url = $libentry->url->out(); $libentry->active = ($libentry->name === $libraryname) ? true : false; diff --git a/settings.php b/settings.php index 58d2000e9a8..dbaa4626610 100644 --- a/settings.php +++ b/settings.php @@ -237,6 +237,16 @@ 3 )); +$settings->add(new admin_setting_configtextarea( + 'qtype_stack/libraries', + get_string('settingexternallibraries', 'qtype_stack'), + get_string('settingexternallibraries_desc', 'qtype_stack'), + '', + PARAM_RAW, + 60, + 5 +)); + // Options for maths display. $settings->add(new admin_setting_heading( 'mathsdisplayheading', diff --git a/stack/questionlibrary.class.php b/stack/questionlibrary.class.php index 01c810bd635..ff00dc25ef8 100644 --- a/stack/questionlibrary.class.php +++ b/stack/questionlibrary.class.php @@ -37,6 +37,8 @@ class stack_question_library { /** @var int increments unique folder ids */ public static $dircount = 1; + public const GITHUB = 'githublibrary'; + public const SITELIB = 'sitelibrary'; /** * Summary of render_question @@ -210,7 +212,25 @@ public static function get_file_list(string $dir): object { return $results; } - public static function stack_list_github_repo(string $githuburl) { + public static function get_file_list_from_repo($url, $repotype) { + switch ($repotype) { + case self::GITHUB: + return self::list_github_repo($url); + default: + return [[], []]; + } + } + + public static function get_external_file($requestedfile, $repotype) { + switch ($repotype) { + case self::GITHUB: + return self::get_external_github_file($requestedfile); + default: + return null; + } + } + + public static function list_github_repo(string $githuburl) { // Parse github URL like: // https://github.com/{owner}/{repo}/tree/{branch}/{path...} $parts = parse_url($githuburl); From c05ee31be9cd6648c7517cfeda4133489f64e02d Mon Sep 17 00:00:00 2001 From: Edmund Farrow Date: Tue, 24 Feb 2026 15:15:44 +0000 Subject: [PATCH 06/31] nrw-external - Cache refresh for external libraries --- lang/en/qtype_stack.php | 1 + questionlibrary.php | 16 ++++++++++++++++ templates/questionlibrary.mustache | 6 ++++++ 3 files changed, 23 insertions(+) diff --git a/lang/en/qtype_stack.php b/lang/en/qtype_stack.php index 8c4214b5324..39c6f39fc85 100644 --- a/lang/en/qtype_stack.php +++ b/lang/en/qtype_stack.php @@ -1869,6 +1869,7 @@ $string['stack_library_quiz'] = 'This is a quiz:'; $string['stack_library_quiz_course'] = 'The quiz will be imported into course: '; $string['stack_library_quiz_prefix'] = 'Quiz:'; +$string['stack_library_refresh'] = 'Refresh library contents'; $string['stack_library_selected'] = 'Displayed question:'; $string['stack_library_select'] = 'Select library:'; $string['stack_library_success'] = 'Successful import of:'; diff --git a/questionlibrary.php b/questionlibrary.php index c566e5a5d86..41f96e51bb2 100644 --- a/questionlibrary.php +++ b/questionlibrary.php @@ -54,6 +54,7 @@ $urlparams['courseid'] = $courseid; $returntext = get_string('stack_library_qb_return', 'qtype_stack'); } +$isrefresh = optional_param('refresh', 0, PARAM_INT) === 1 ? true : false; // Check user has add capability for the required context. require_capability('moodle/question:add', $thiscontext); @@ -133,6 +134,17 @@ $location = __DIR__ . '/samplequestions/stacklibrary/*'; } +if ($isrefresh) { + $refreshfiles = $cache->get($cacheid . '_flat_file_list'); + if ($refreshfiles) { + $refreshfiles = array_keys($refreshfiles); + $refreshfiles = array_map(function($file) use ($cacheid) { return "{$cacheid}/{$file}";}, $refreshfiles); + } + $refreshfiles[] = $cacheid . '_flat_file_list'; + $refreshfiles[] = $cacheid . '_file_list'; + $cache->delete_many($refreshfiles); +} + $files = $cache->get($cacheid . '_file_list'); if (!$files) { if ($external) { @@ -159,6 +171,9 @@ $outputdata->libraries->items = []; $outputdata->libraries->hasitems = false; $outputdata->libraries->current = $cacheid; +if ($external) { + $outputdata->libraries->external = $cacheid; +} $libraries = glob("{$CFG->dataroot}/stack/sitelibrary/*"); if ($libraries) { @@ -195,6 +210,7 @@ $libentry->url = $libentry->url->out(); $libentry->active = ($libentry->name === $libraryname) ? true : false; $outputdata->libraries->items[] = $libentry; + $outputdata->libraries->hasitems = true; } echo $OUTPUT->render_from_template('qtype_stack/questionlibrary', $outputdata); diff --git a/templates/questionlibrary.mustache b/templates/questionlibrary.mustache index 028bf50495a..c4a80d60be1 100644 --- a/templates/questionlibrary.mustache +++ b/templates/questionlibrary.mustache @@ -103,6 +103,12 @@ {{/libraries.items}} + {{#libraries.external}} + + {{/libraries.external}} From f56b81a4bbfdfde1dd653aa32526ccb47cd2f34a Mon Sep 17 00:00:00 2001 From: Edmund Farrow Date: Wed, 25 Feb 2026 15:25:59 +0000 Subject: [PATCH 07/31] nrw-external - Refactor and tidy --- api/public/sample.php | 2 +- api/public/stack.php | 2 +- classes/library_import.php | 1 - classes/library_render.php | 3 +- questionlibrary.php | 14 +-- stack/questionlibrary.class.php | 215 +++++++++++++++++--------------- 6 files changed, 122 insertions(+), 115 deletions(-) diff --git a/api/public/sample.php b/api/public/sample.php index 03c80ec6478..4896d98f875 100644 --- a/api/public/sample.php +++ b/api/public/sample.php @@ -27,7 +27,7 @@ require_once(__DIR__ . '/../../stack/questionlibrary.class.php'); // Required to pass Moodle code check. Uses emulation stub. require_login(); -$files = stack_question_library::get_file_list(realpath(__DIR__ . '/../../samplequestions/stackdemo') . '/*'); +$files = stack_question_library::get_file_list(realpath(__DIR__ . '/../../samplequestions/stackdemo')); $questions = []; foreach ($files->children as $file) { diff --git a/api/public/stack.php b/api/public/stack.php index eb2acc22264..b3863f90edb 100644 --- a/api/public/stack.php +++ b/api/public/stack.php @@ -134,7 +134,7 @@ function setQuestion(question) { Choose a STACK sample file: '; foreach ($dirdetails as $file) { diff --git a/classes/library_import.php b/classes/library_import.php index 74323680caa..671a7ec4f30 100644 --- a/classes/library_import.php +++ b/classes/library_import.php @@ -194,7 +194,6 @@ public static function import_execute($courseid, $category, $filepath, $isfolder return pathinfo($file, PATHINFO_EXTENSION) === 'xml' && strrpos($file, 'gitsync_category') === false; }); // Convert file names into paths relative to the sample questions folder. - } $response = []; diff --git a/classes/library_render.php b/classes/library_render.php index d9100b79c06..64077d0cd6d 100644 --- a/classes/library_render.php +++ b/classes/library_render.php @@ -61,7 +61,8 @@ public static function render_execute_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, STACK data directory or top of GitHub library'), + 'File path relative to samplequestions, STACK data directory or top of GitHub library' + ), 'cacheid' => new \external_value(PARAM_RAW, 'Library cache id'), ]); } diff --git a/questionlibrary.php b/questionlibrary.php index 41f96e51bb2..7b670859024 100644 --- a/questionlibrary.php +++ b/questionlibrary.php @@ -54,7 +54,7 @@ $urlparams['courseid'] = $courseid; $returntext = get_string('stack_library_qb_return', 'qtype_stack'); } -$isrefresh = optional_param('refresh', 0, PARAM_INT) === 1 ? true : false; +$isrefresh = optional_param('refresh', 0, PARAM_INT) === 1 ? true : false; // Check user has add capability for the required context. require_capability('moodle/question:add', $thiscontext); @@ -111,11 +111,9 @@ $cacheid = stack_question_library::SITELIB . "_{$libraryname}"; $location = "{$CFG->dataroot}/stack/{$location}"; if (!str_starts_with(realpath($location), "{$CFG->dataroot}/stack/sitelibrary")) { - $location = __DIR__ . '/samplequestions/stacklibrary/*'; + $location = __DIR__ . '/samplequestions/stacklibrary'; $libraryname = stack_string('stack_library'); $cacheid = 'library'; - } else { - $location .= '/*'; } } else if (str_starts_with($location, stack_question_library::GITHUB)) { $libparts = explode('/', $location); @@ -124,21 +122,23 @@ $cacheid = $librarytype . "_{$libraryid}"; $libraryname = $allowedlibraries->{$libraryid}->name ?? null; if (!$libraryname) { - $location = __DIR__ . '/samplequestions/stacklibrary/*'; + $location = __DIR__ . '/samplequestions/stacklibrary'; $libraryname = stack_string('stack_library'); $cacheid = 'library'; } else { $external = $librarytype; } } else { - $location = __DIR__ . '/samplequestions/stacklibrary/*'; + $location = __DIR__ . '/samplequestions/stacklibrary'; } if ($isrefresh) { $refreshfiles = $cache->get($cacheid . '_flat_file_list'); if ($refreshfiles) { $refreshfiles = array_keys($refreshfiles); - $refreshfiles = array_map(function($file) use ($cacheid) { return "{$cacheid}/{$file}";}, $refreshfiles); + $refreshfiles = array_map(function ($file) use ($cacheid) { + return "{$cacheid}/{$file}"; + }, $refreshfiles); } $refreshfiles[] = $cacheid . '_flat_file_list'; $refreshfiles[] = $cacheid . '_file_list'; diff --git a/stack/questionlibrary.class.php b/stack/questionlibrary.class.php index ff00dc25ef8..3df7301cc37 100644 --- a/stack/questionlibrary.class.php +++ b/stack/questionlibrary.class.php @@ -28,7 +28,6 @@ use api\util\StackSeedHelper; use api\util\StackPlotReplacer; - use stack_exception; /** * Functions required to display the STACK question library @@ -37,7 +36,15 @@ class stack_question_library { /** @var int increments unique folder ids */ public static $dircount = 1; + /** + * GITHUB library identifier + * @var string + */ public const GITHUB = 'githublibrary'; + /** + * Site library identifier + * @var string + */ public const SITELIB = 'sitelibrary'; /** @@ -138,89 +145,58 @@ public static function render_question(object $question): string { } /** - * Gets the structure of folders and files within a given directory + * Gets the structure of folders and files within a given directory on the server. * See questionfolder.mustache for output and usage. - * We sanitise the structure a bit to remove gitsync files and folders. - * @param string sanitised search string e.g. '/srv/stack/samplequestions/stacklibrary/*' - * with the full real path of the folder and search criteria. + * @param string $dir sanitised full real path of library e.g. '/srv/stack/samplequestions/stacklibrary' * @return object StdClass Representation of the file system */ public static function get_file_list(string $dir): object { global $CFG; - $files = glob($dir); - $results = new stdClass(); - $labels = explode('/', $dir); - $results->label = $labels[count($labels) - 2]; - $results->divid = 'stack-library-folder-' . self::$dircount; - self::$dircount++; - $results->children = []; - $results->isdirectory = 1; - foreach ($files as $path) { - if (!is_dir($path)) { - if ( - (pathinfo($path, PATHINFO_EXTENSION) === 'xml' && strrpos($path, 'gitsync_category') === false) - || (pathinfo($path, PATHINFO_EXTENSION) === 'json' && strrpos($path, '_quiz.json') !== false) - ) { - $childless = new StdClass(); - // Get the path relative to the samplequestions or stack dataroot folder. - $pathfromsq = str_replace(dirname(__DIR__) . '/samplequestions/', '', $path); - $pathfromsq = str_replace("{$CFG->dataroot}/stack/", '', $pathfromsq); - $childless->path = $pathfromsq; - $childless->url = ''; - $labels = explode('/', $path); - $childless->label = end($labels); - $childless->isdirectory = 0; - $results->children[] = $childless; - } - } else { - if (strrpos($path, 'manifest_backups') === false) { - $children = self::get_file_list($path . '/*'); - if ($children->label === 'top') { - $topchildren = $children->children; - $topquizzes = []; - $topfolders = []; - foreach ($topchildren as $topchild) { - if ( - isset($topchild->path) && pathinfo($topchild->path, 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; - } - } - } + $directoryiterator = new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS); + $files = new RecursiveIteratorIterator($directoryiterator, RecursiveIteratorIterator::SELF_FIRST); + $result = []; + foreach ($files as $item) { + $pathfromsq = str_replace(dirname(__DIR__) . '/samplequestions/', '', $item->getPathname()); + $pathfromsq = str_replace("{$CFG->dataroot}/stack/", '', $pathfromsq); + $result[] = (object)[ + 'label' => $item->getFilename(), + 'relpath' => $pathfromsq, + 'isdirectory' => $item->isDir(), + 'url' => '', + ]; } - usort($results->children, function ($a, $b) { - return strnatcmp($a->label, $b->label); - }); - return $results; + + return self::format_file_list($result); } + /** + * Gets the structure of folders and files within a given remote repo. + * Two versions are returned. The first is a structured object for feeding into the mustache template + * for displaying the folder structure. The second is a flat array keyed by file path for easy + * retrieval of file info, paerticularly the file URL. + * This is a wrapper function to make it easier to support different repo types. + * See questionfolder.mustache for output and usage. + * @param string $url URL of the directory required + * @param string $repotype The type of repo being searched. (Currently only GitHub is supported) + * @return array [object StdClass structured representation of the file system, array flat array of file objects] + */ public static function get_file_list_from_repo($url, $repotype) { switch ($repotype) { case self::GITHUB: return self::list_github_repo($url); default: - return [[], []]; + return [new StdClass(), []]; } } + /** + * Gets a file from an external repo. + * This is a wrapper function to make it easier to support different repo types. + * + * @param string $requestedfile URL + * @param string $repotype + * @return void + */ public static function get_external_file($requestedfile, $repotype) { switch ($repotype) { case self::GITHUB: @@ -230,9 +206,14 @@ public static function get_external_file($requestedfile, $repotype) { } } + /** + * Retrieves a list of all the files in a GitHub repo via API + * @param string $githuburl + * @return array [object StdClass structured representation of the file system, array flat array of file objects] + */ public static function list_github_repo(string $githuburl) { // Parse github URL like: - // https://github.com/{owner}/{repo}/tree/{branch}/{path...} + // https://github.com/{owner}/{repo}/tree/{branch}/{path...}. $parts = parse_url($githuburl); if (empty($parts['host']) || strpos($parts['host'], 'github.com') === false) { return []; @@ -246,11 +227,11 @@ public static function list_github_repo(string $githuburl) { $repo = $segments[1]; // Default values. - $branch = 'master'; + $branch = 'main'; $subpath = ''; // If URL uses the tree layout, extract branch and subpath. - // Expected segments: owner, repo, tree, branch, ...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) { @@ -260,7 +241,7 @@ public static function list_github_repo(string $githuburl) { } } - $apiBase = "https://api.github.com/repos/{$owner}/{$repo}"; + $apibase = "https://api.github.com/repos/{$owner}/{$repo}"; $ch = curl_init(); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); @@ -271,7 +252,7 @@ public static function list_github_repo(string $githuburl) { $files = []; // Always use the git/trees API with recursive=1, then filter by subpath. - $apiurl = "{$apiBase}/git/trees/" . rawurlencode($branch) . "?recursive=1"; + $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); @@ -297,35 +278,51 @@ public static function 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), $flatarray]; - } + /** + * Take an array of file objects and format into an object with a directory-style structure. + * The file objects should be in the form: + * {'label' => file/directory name, + * 'relpath' => file path relative to top directory, + * 'isdirectory' => boolean, + * 'url' => url for obtaining the file if remote} + * We sanitise the structure a bit to remove gitsync files and folders. + * @param mixed $filelist + * @return object stdClass + */ public static function format_file_list($filelist) { + usort($filelist, function ($a, $b) { + return strnatcmp($a->relpath, $b->relpath); + }); $results = new stdClass(); $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) : ''; + // First file in sorted list will be in the current base directory. If we're on the first pass, we're in overall top + // directory and dirname will return '.' which we convert to '' for our string compare. + $firstfile = $filelist[array_key_first($filelist)]->relpath; + $basedir = dirname($firstfile !== '.') ? dirname($firstfile) : ''; + + $results->label = end(explode('/', $basedir)); foreach ($filelist as $file) { - if ($results->label === '') { + // We only want children of the current base directory. We ignore more distant descendants. + if ($basedir === '') { if (str_contains($file->relpath, '/')) { continue; } } else { - if (str_contains(ltrim($file->relpath, $results->label . '/'), '/')) { + if (str_contains(str_replace($basedir . '/', '', $file->relpath), '/')) { continue; } } if (!$file->isdirectory) { if ( - (pathinfo($file->relpath, PATHINFO_EXTENSION) === 'xml' && strrpos($file->relpath, 'gitsync_category') === false) + (pathinfo($file->relpath, PATHINFO_EXTENSION) === 'xml' + && strrpos($file->relpath, 'gitsync_category') === false) || (pathinfo($file->relpath, PATHINFO_EXTENSION) === 'json' && strrpos($file->relpath, '_quiz.json') !== false) ) { $childless = new StdClass(); @@ -337,32 +334,36 @@ public static function format_file_list($filelist) { } } 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); + $descendants = array_filter($filelist, fn($x) => str_starts_with($x->relpath, $file->relpath . '/')); + if ($descendants) { + $children = self::format_file_list($descendants); + } else { + continue; + } if ($children->label === 'top') { - $topchildren = $children->children; - $topquizzes = []; - $topfolders = []; - foreach ($topchildren as $topchild) { + $childrenoftop = $children->children; + $quizzesintop = []; + $foldersintop = []; + foreach ($childrenoftop as $childoftop) { if ( - isset($topchild->relpath) && pathinfo($topchild->relpath, PATHINFO_EXTENSION) === 'json' - && strrpos($topchild->relpath, '_quiz.json') !== false + isset($childoftop->path) && pathinfo($childoftop->path, PATHINFO_EXTENSION) === 'json' + && strrpos($childoftop->path, '_quiz.json') !== false ) { - $topquizzes[] = $topchild; - } else if ($topchild->isdirectory) { - $topfolders[] = $topchild; + $quizzesintop[] = $childoftop; + } else if ($childoftop->isdirectory) { + $foldersintop[] = $childoftop; } } - if (count($topfolders) === 1 && count($topquizzes) === 0) { + if (count($foldersintop) === 1 && count($quizzesintop) === 0) { // If we have a 'top' folder containing only a single folder (e.g. 'Default for...) // strip out both from file display. - $results->children = array_merge($results->children, $topchildren[0]->children); - } else if (count($topfolders) === 1 && count($topquizzes) > 0) { + $results->children = array_merge($results->children, $foldersintop[0]->children); + } else if (count($foldersintop) === 1 && count($quizzesintop) > 0) { // Quizzes and a single folder. Display quizzes and contents of folder. - $results->children = array_merge($topquizzes, $topfolders[0]->children); + $results->children = array_merge($results->children, $quizzesintop, $foldersintop[0]->children); } else { // Just strip out 'top'. - $results->children = array_merge($results->children, $topchildren); + $results->children = array_merge($results->children, $childrenoftop); } } else { $results->children[] = $children; @@ -376,6 +377,12 @@ public static function format_file_list($filelist) { return $results; } + /** + * Fetch a file from GitHub using the api blob URL. + * + * @param string $requestedfile API URL + * @return string XML file contents + */ public static function get_external_github_file($requestedfile) { $headers = [ 'User-Agent: PHP', @@ -390,24 +397,24 @@ public static function get_external_github_file($requestedfile) { CURLOPT_SSL_VERIFYPEER => true, ]); $res = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - if ($res === false || $httpCode !== 200) { - throw new \stack_exception(''); + if ($res === false || $httpcode !== 200) { + throw new \stack_exception('File unavailable.'); } $json = json_decode($res, true); if (!is_array($json) || empty($json['content']) || empty($json['encoding'])) { - throw new \stack_exception(''); + throw new \stack_exception('Invalid JSON.'); } if ($json['encoding'] !== 'base64') { - throw new \stack_exception(''); + throw new \stack_exception('Wrongly encoded.'); } $filecontents = base64_decode($json['content'], true); if ($filecontents === false) { - throw new \stack_exception(''); + throw new \stack_exception('Could not decode.'); } return $filecontents; From 86fa9151c4392fa06225e7bd62e277204d272bf7 Mon Sep 17 00:00:00 2001 From: Edmund Farrow Date: Fri, 27 Feb 2026 11:59:39 +0000 Subject: [PATCH 08/31] nrw-external - Start adding unit tests --- classes/fake_render.php | 5 ++ classes/library_render.php | 12 ++- questionlibrary.php | 6 +- stack/questionlibrary.class.php | 8 +- tests/library_import_test.php | 77 ++++++++++++++++--- tests/library_render_test.php | 131 ++++++++++++++++++++++++++++++-- tests/questionlibrary_test.php | 44 ++++++++++- 7 files changed, 262 insertions(+), 21 deletions(-) diff --git a/classes/fake_render.php b/classes/fake_render.php index d340045733a..56d949b74a6 100644 --- a/classes/fake_render.php +++ b/classes/fake_render.php @@ -35,4 +35,9 @@ class fake_render extends library_render { public static function call_question_render($question) { return '

Hello World

'; } + + // phpcs:ignore moodle.Commenting.MissingDocblock.Function + public static function call_external_request($requestedfile, $external) { + return "Fake XML: {$external} {$requestedfile}"; + } } diff --git a/classes/library_render.php b/classes/library_render.php index 64077d0cd6d..7776b456d90 100644 --- a/classes/library_render.php +++ b/classes/library_render.php @@ -143,7 +143,7 @@ public static function render_execute($category, $filepath, $cacheid) { } if ($external) { - $qcontents = stack_question_library::get_external_file($requestedfile, $external); + $qcontents = static::call_external_request($requestedfile, $external); } else { $qcontents = file_get_contents($requestedfile); } @@ -239,4 +239,14 @@ public static function render_execute($category, $filepath, $cacheid) { public static function call_question_render($question) { return stack_question_library::render_question($question); } + + /** + * Separate out to mock in unit testing. + * @param string $requestedfile + * @param string $external + * @return string XML of question + */ + public static function call_external_request($requestedfile, $external) { + return stack_question_library::get_external_file($requestedfile, $external); + } } diff --git a/questionlibrary.php b/questionlibrary.php index 7b670859024..45e6f309804 100644 --- a/questionlibrary.php +++ b/questionlibrary.php @@ -99,7 +99,7 @@ // library // sitelibrary_foldername // githublibrary_id. -$cacheid = 'library'; +$cacheid = stack_question_library::STACKLIB; $libraryname = stack_string('stack_library'); $external = null; $allowedlibraries = get_config('qtype_stack', 'libraries'); @@ -113,7 +113,7 @@ if (!str_starts_with(realpath($location), "{$CFG->dataroot}/stack/sitelibrary")) { $location = __DIR__ . '/samplequestions/stacklibrary'; $libraryname = stack_string('stack_library'); - $cacheid = 'library'; + $cacheid = stack_question_library::STACKLIB; } } else if (str_starts_with($location, stack_question_library::GITHUB)) { $libparts = explode('/', $location); @@ -124,7 +124,7 @@ if (!$libraryname) { $location = __DIR__ . '/samplequestions/stacklibrary'; $libraryname = stack_string('stack_library'); - $cacheid = 'library'; + $cacheid = stack_question_library::STACKLIB; } else { $external = $librarytype; } diff --git a/stack/questionlibrary.class.php b/stack/questionlibrary.class.php index 3df7301cc37..8f0bbc2cf09 100644 --- a/stack/questionlibrary.class.php +++ b/stack/questionlibrary.class.php @@ -46,6 +46,11 @@ class stack_question_library { * @var string */ public const SITELIB = 'sitelibrary'; + /** + * STACK library identifier + * @var string + */ + public const STACKLIB = 'stacklibrary'; /** * Summary of render_question @@ -306,7 +311,8 @@ public static function format_file_list($filelist) { $firstfile = $filelist[array_key_first($filelist)]->relpath; $basedir = dirname($firstfile !== '.') ? dirname($firstfile) : ''; - $results->label = end(explode('/', $basedir)); + $dirparts = explode('/', $basedir); + $results->label = end($dirparts); foreach ($filelist as $file) { // We only want children of the current base directory. We ignore more distant descendants. if ($basedir === '') { diff --git a/tests/library_import_test.php b/tests/library_import_test.php index eb41967d2f3..f3fd92c8c05 100644 --- a/tests/library_import_test.php +++ b/tests/library_import_test.php @@ -31,7 +31,9 @@ require_once($CFG->dirroot . '/webservice/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/stack/tests/fixtures/test_maxima_configuration.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); +require_once($CFG->dirroot . '/question/type/stack/stack/questionlibrary.class.php'); +use cache; use context_course; use externallib_advanced_testcase; use external_api; @@ -90,7 +92,7 @@ public function test_capabilities(): void { $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); role_assign($managerroleid, $this->user->id, $context->id); - $returnvalue = library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false); + $returnvalue = library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false, \stack_question_library::STACKLIB); // We need to execute the return values cleaning process to simulate // the web service server. @@ -113,7 +115,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_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false); + library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false, \stack_question_library::STACKLIB); } /** @@ -127,7 +129,7 @@ public function test_no_access(): void { $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id); $this->expectException(required_capability_exception::class); $this->expectExceptionMessage('you do not currently have permissions to do that (Add new questions).'); - library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false); + library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false, \stack_question_library::STACKLIB); } /** @@ -136,7 +138,7 @@ public function test_no_access(): void { public function test_export_capability(): void { $this->expectException(require_login_exception::class); $this->expectExceptionMessage('Not enrolled'); - library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false); + library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false, \stack_question_library::STACKLIB); } /** @@ -150,7 +152,7 @@ public function test_library_import(): void { role_assign($managerroleid, $this->user->id, $context->id); $sink = $this->redirectEvents(); - $returnvalue = library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false); + $returnvalue = library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false, \stack_question_library::STACKLIB); // We need to execute the return values cleaning process to simulate // the web service server. @@ -185,7 +187,7 @@ public function test_library_import_folder(): void { role_assign($managerroleid, $this->user->id, $context->id); $sink = $this->redirectEvents(); - $returnvalue = library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, true); + $returnvalue = library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, true, \stack_question_library::STACKLIB); // We need to execute the return values cleaning process to simulate // the web service server. @@ -224,7 +226,7 @@ public function test_library_import_quiz(): void { role_assign($managerroleid, $this->user->id, $context->id); $sink = $this->redirectEvents(); - $returnvalue = library_import::import_execute($this->course->id, $this->qcategory->id, $quizfilepath, true); + $returnvalue = library_import::import_execute($this->course->id, $this->qcategory->id, $quizfilepath, true, \stack_question_library::STACKLIB); // We need to execute the return values cleaning process to simulate // the web service server. @@ -330,7 +332,7 @@ public function test_quiz_import_without_sections_and_feedback(): void { $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); role_assign($managerroleid, $this->user->id, $context->id); - library_import::import_execute($this->course->id, $this->qcategory->id, $quizfilepath, true); + library_import::import_execute($this->course->id, $this->qcategory->id, $quizfilepath, true, \stack_question_library::STACKLIB); $sections = $DB->get_records('quiz_sections'); $this->assertEquals(1, count($sections)); @@ -360,7 +362,7 @@ public function test_import_with_require_previous(): void { $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); role_assign($managerroleid, $this->user->id, $context->id); - library_import::import_execute($this->course->id, $this->qcategory->id, $quizfilepath, true); + library_import::import_execute($this->course->id, $this->qcategory->id, $quizfilepath, true, \stack_question_library::STACKLIB); $slots = $DB->get_records('quiz_slots'); $this->assertEquals(4, count($slots)); @@ -371,4 +373,61 @@ public function test_import_with_require_previous(): void { $this->assertEquals(1, $slot2->requireprevious); $this->assertEquals(0, $slot3->requireprevious); } + + /** + * Test output of library_render function when accessing site library. + */ + public function test_dubious_file_check(): void { + global $DB; + $cache = cache::make('qtype_stack', 'librarycache'); + $cache->purge(); + // Set the required capabilities - webservice access and export rights on course. + $context = context_course::instance($this->course->id); + $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); + role_assign($managerroleid, $this->user->id, $context->id); + $iserror = false; + try { + library_import::import_execute( + $this->course->id, + $this->qcategory->id, + 'sitelibrary/libtest/../../testq.xml', + false, + \stack_question_library::SITELIB + ); + } catch (\Exception $e) { + $this->assertEquals('Dubious file request.', $e->getMessage()); + $iserror = true; + } + $this->assertEquals(true, $iserror); + + $iserror = false; + try { + library_import::import_execute( + $this->course->id, + $this->qcategory->id, + 'sitelibrary/libtest/../../testq.xml', + false, + 'fake' + ); + } catch (\Exception $e) { + $this->assertEquals('Dubious file request.', $e->getMessage()); + $iserror = true; + } + $this->assertEquals(true, $iserror); + + $iserror = false; + try { + library_import::import_execute( + $this->course->id, + $this->qcategory->id, + 'otherlib/libtest/../../testq.xml', + false, + 'fake' + ); + } catch (\Exception $e) { + $this->assertEquals('Dubious file request.', $e->getMessage()); + $iserror = true; + } + $this->assertEquals(true, $iserror); + } } diff --git a/tests/library_render_test.php b/tests/library_render_test.php index 29853772305..d59cb6b2748 100644 --- a/tests/library_render_test.php +++ b/tests/library_render_test.php @@ -29,6 +29,7 @@ global $CFG; require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/webservice/tests/helpers.php'); +require_once($CFG->dirroot . '/question/type/stack/stack/questionlibrary.class.php'); use cache; use context_course; @@ -82,7 +83,7 @@ public function test_capabilities(): void { $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); role_assign($managerroleid, $this->user->id, $context->id); - $returnvalue = fake_render::render_execute($this->qcategory->id, $this->filepath); + $returnvalue = fake_render::render_execute($this->qcategory->id, $this->filepath, \stack_question_library::STACKLIB); // We need to execute the return values cleaning process to simulate // the web service server. @@ -105,7 +106,7 @@ public function test_not_logged_in(): void { $this->expectException(require_login_exception::class); // Exception messages don't seem to get translated. $this->expectExceptionMessage('not logged in'); - library_render::render_execute($this->qcategory->id, $this->filepath); + library_render::render_execute($this->qcategory->id, $this->filepath, \stack_question_library::STACKLIB); } /** @@ -119,7 +120,7 @@ public function test_no_webservice_access(): void { $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id); $this->expectException(required_capability_exception::class); $this->expectExceptionMessage('you do not currently have permissions to do that (Add new questions).'); - library_render::render_execute($this->qcategory->id, $this->filepath); + library_render::render_execute($this->qcategory->id, $this->filepath, \stack_question_library::STACKLIB); } /** @@ -128,7 +129,7 @@ public function test_no_webservice_access(): void { public function test_library_render_capability(): void { $this->expectException(require_login_exception::class); $this->expectExceptionMessage('Not enrolled'); - library_render::render_execute($this->qcategory->id, $this->filepath); + library_render::render_execute($this->qcategory->id, $this->filepath, \stack_question_library::STACKLIB); } /** @@ -143,7 +144,7 @@ public function test_library_render(): void { $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); role_assign($managerroleid, $this->user->id, $context->id); - $returnvalue = fake_render::render_execute($this->qcategory->id, $this->filepath); + $returnvalue = fake_render::render_execute($this->qcategory->id, $this->filepath, \stack_question_library::STACKLIB); // We need to execute the return values cleaning process to simulate // the web service server. @@ -162,4 +163,124 @@ public function test_library_render(): void { ); $this->assertStringContainsString('rdm:-(2+rand(8))', $returnvalue['questionvariables']); } + + /** + * Test output of library_render function when accessing external library. + */ + public function test_external_library_render(): void { + global $DB; + $cache = cache::make('qtype_stack', 'librarycache'); + $cache->purge(); + $file = new \StdClass(); + $file->url = 'fakeURL'; + $cache->set(\stack_question_library::GITHUB . '_flat_file_list', ['file' => $file]); + // Set the required capabilities - webservice access and export rights on course. + $context = context_course::instance($this->course->id); + $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); + role_assign($managerroleid, $this->user->id, $context->id); + + $returnvalue = fake_render::render_execute($this->qcategory->id, 'file', \stack_question_library::GITHUB); + + // We need to execute the return values cleaning process to simulate + // the web service server. + $returnvalue = external_api::clean_returnvalue( + fake_render::render_execute_returns(), + $returnvalue + ); + + $this->assertStringContainsString( + '

Hello World

', + $returnvalue['questionrender'] + ); + $this->assertEquals( + 'Fake XML: githublibrary fakeURL', + $returnvalue['questiontext'] + ); + } + + /** + * Test output of library_render function when accessing site library. + */ + public function test_site_library_render(): void { + global $DB, $CFG; + $cache = cache::make('qtype_stack', 'librarycache'); + $cache->purge(); + mkdir($CFG->dataroot . '/stack/sitelibrary/libtest', 0777, true); + file_put_contents( + $CFG->dataroot . '/stack/sitelibrary/libtest/testq.xml', + 'Fake XML: Site'); + // Set the required capabilities - webservice access and export rights on course. + $context = context_course::instance($this->course->id); + $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); + role_assign($managerroleid, $this->user->id, $context->id); + + $returnvalue = fake_render::render_execute($this->qcategory->id, 'sitelibrary/libtest/testq.xml', \stack_question_library::SITELIB); + + // We need to execute the return values cleaning process to simulate + // the web service server. + $returnvalue = external_api::clean_returnvalue( + fake_render::render_execute_returns(), + $returnvalue + ); + + $this->assertStringContainsString( + '

Hello World

', + $returnvalue['questionrender'] + ); + $this->assertEquals( + 'Fake XML: Site', + $returnvalue['questiontext'] + ); + } + + /** + * Test output of library_render function when accessing site library. + */ + public function test_dubious_file_check(): void { + global $DB; + $cache = cache::make('qtype_stack', 'librarycache'); + $cache->purge(); + // Set the required capabilities - webservice access and export rights on course. + $context = context_course::instance($this->course->id); + $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); + role_assign($managerroleid, $this->user->id, $context->id); + $iserror = false; + try { + fake_render::render_execute( + $this->qcategory->id, + 'sitelibrary/libtest/../../testq.xml', + \stack_question_library::SITELIB + ); + } catch (\Exception $e) { + $this->assertEquals('Dubious file request.', $e->getMessage()); + $iserror = true; + } + $this->assertEquals(true, $iserror); + + $iserror = false; + try { + fake_render::render_execute( + $this->qcategory->id, + 'sitelibrary/libtest/../../testq.xml', + 'fake' + ); + } catch (\Exception $e) { + $this->assertEquals('Dubious file request.', $e->getMessage()); + $iserror = true; + } + $this->assertEquals(true, $iserror); + + $iserror = false; + try { + fake_render::render_execute( + $this->qcategory->id, + 'otherlib/libtest/../../testq.xml', + 'fake' + ); + } catch (\Exception $e) { + $this->assertEquals('Dubious file request.', $e->getMessage()); + $iserror = true; + } + $this->assertEquals(true, $iserror); + } } diff --git a/tests/questionlibrary_test.php b/tests/questionlibrary_test.php index 2b7efc770d0..94f98cdb514 100644 --- a/tests/questionlibrary_test.php +++ b/tests/questionlibrary_test.php @@ -64,7 +64,7 @@ public function test_render_question(): void { */ public function test_get_file_list(): void { global $CFG; - $files = stack_question_library::get_file_list($CFG->dirroot . '/question/type/stack/samplequestions/stacklibrary/*'); + $files = stack_question_library::get_file_list($CFG->dirroot . '/question/type/stack/samplequestions/stacklibrary'); $folder = null; foreach ($files->children as $currentfolder) { if ($currentfolder->label === 'Calculus-Refresher') { @@ -102,7 +102,7 @@ public function test_get_file_list(): void { */ public function test_get_file_list_quizzes(): void { global $CFG; - $files = stack_question_library::get_file_list($CFG->dirroot . '/question/type/stack/samplequestions/importtest/*'); + $files = stack_question_library::get_file_list($CFG->dirroot . '/question/type/stack/samplequestions/importtest'); $this->assertEquals(2, count($files->children)); $this->assertEquals('Course1', $files->children[0]->label); $this->assertEquals('Course1_quiz_quiz-1', $files->children[1]->label); @@ -132,4 +132,44 @@ public function test_get_file_list_quizzes(): void { } } } + + /** + * Test getting external files. + */ + public function test_get_external_files(): void { + [$files, $flatfiles ] = stack_question_library::get_file_list_from_repo('https://github.com/maths/moodle-qtype_stack/tree/master/samplequestions/importtest' , stack_question_library::GITHUB); + $this->assertEquals(2, count($files->children)); + $this->assertEquals('Course1', $files->children[0]->label); + $this->assertEquals('Course1_quiz_quiz-1', $files->children[1]->label); + + // Ignore top and lone folder. + $this->assertEquals(18, count($files->children[0]->children)); + + // Seven questions, sub-category and 3 quizzes. + $this->assertEquals(11, count($files->children[1]->children)); + $labels = ['Sub-for-quiz-1', 'Algebraic-input-(with-simplification).xml', 'Checkbox-(no-body-LaTeX).xml', + 'Dropdown-(shuffle).xml', 'Equiv-input-test-(compact).xml', 'Matrix.xml', + 'Radio-(compact).xml', 'Single-char.xml', 'quiz-1_quiz.json', 'quiz-no-sections_quiz.json', + 'quiz-require-prev_quiz.json']; + foreach ($labels as $label) { + $index = null; + foreach ($files->children[1]->children as $childkey => $child) { + if ($child->label === $label) { + $index = $childkey; + break; + } + } + $this->assertEquals(true, isset($index), $label); + if ($label === 'Sub-for-quiz-1') { + $this->assertEquals(1, $files->children[1]->children[$index]->isdirectory, $label); + } else { + $this->assertEquals(0, $files->children[1]->children[$index]->isdirectory, $label); + } + } + + $url = $flatfiles['Course1/top/CR_Diff_01/CR-Diff-01-basic-1-e.xml']->url; + + $file = stack_question_library::get_external_github_file($url); + $this->assertStringContainsString('Differentiate \({@v@}^{@rdm@}\) with respect to {@v@}', $file); + } } From d6b88c240ffef5174ade81e21d941b3a0ed6ebdb Mon Sep 17 00:00:00 2001 From: Edmund Farrow Date: Mon, 2 Mar 2026 11:30:22 +0000 Subject: [PATCH 09/31] nrw-external - Import unit tests --- tests/library_import_test.php | 536 ++++++++++++++++++++++++++++++++- tests/library_render_test.php | 9 +- tests/questionlibrary_test.php | 6 +- 3 files changed, 539 insertions(+), 12 deletions(-) diff --git a/tests/library_import_test.php b/tests/library_import_test.php index f3fd92c8c05..c437073006e 100644 --- a/tests/library_import_test.php +++ b/tests/library_import_test.php @@ -79,6 +79,8 @@ public function setUp(): void { } \stack_utils::clear_config_cache(); \qtype_stack_test_config::setup_test_maxima_connection(); + $cache = cache::make('qtype_stack', 'librarycache'); + $cache->purge(); $this->resetAfterTest(); } @@ -92,7 +94,13 @@ public function test_capabilities(): void { $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); role_assign($managerroleid, $this->user->id, $context->id); - $returnvalue = library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false, \stack_question_library::STACKLIB); + $returnvalue = library_import::import_execute( + $this->course->id, + $this->qcategory->id, + $this->filepath, + false, + \stack_question_library::STACKLIB + ); // We need to execute the return values cleaning process to simulate // the web service server. @@ -115,7 +123,13 @@ public function test_not_logged_in(): void { $this->expectException(require_login_exception::class); // Exception messages don't seem to get translated. $this->expectExceptionMessage('not logged in'); - library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false, \stack_question_library::STACKLIB); + library_import::import_execute( + $this->course->id, + $this->qcategory->id, + $this->filepath, + false, + \stack_question_library::STACKLIB + ); } /** @@ -129,7 +143,13 @@ public function test_no_access(): void { $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id); $this->expectException(required_capability_exception::class); $this->expectExceptionMessage('you do not currently have permissions to do that (Add new questions).'); - library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false, \stack_question_library::STACKLIB); + library_import::import_execute( + $this->course->id, + $this->qcategory->id, + $this->filepath, + false, + \stack_question_library::STACKLIB + ); } /** @@ -138,7 +158,13 @@ public function test_no_access(): void { public function test_export_capability(): void { $this->expectException(require_login_exception::class); $this->expectExceptionMessage('Not enrolled'); - library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false, \stack_question_library::STACKLIB); + library_import::import_execute( + $this->course->id, + $this->qcategory->id, + $this->filepath, + false, + \stack_question_library::STACKLIB + ); } /** @@ -152,7 +178,13 @@ public function test_library_import(): void { role_assign($managerroleid, $this->user->id, $context->id); $sink = $this->redirectEvents(); - $returnvalue = library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, false, \stack_question_library::STACKLIB); + $returnvalue = library_import::import_execute( + $this->course->id, + $this->qcategory->id, + $this->filepath, + false, + \stack_question_library::STACKLIB + ); // We need to execute the return values cleaning process to simulate // the web service server. @@ -187,7 +219,13 @@ public function test_library_import_folder(): void { role_assign($managerroleid, $this->user->id, $context->id); $sink = $this->redirectEvents(); - $returnvalue = library_import::import_execute($this->course->id, $this->qcategory->id, $this->filepath, true, \stack_question_library::STACKLIB); + $returnvalue = library_import::import_execute( + $this->course->id, + $this->qcategory->id, + $this->filepath, + true, + \stack_question_library::STACKLIB + ); // We need to execute the return values cleaning process to simulate // the web service server. @@ -226,7 +264,13 @@ public function test_library_import_quiz(): void { role_assign($managerroleid, $this->user->id, $context->id); $sink = $this->redirectEvents(); - $returnvalue = library_import::import_execute($this->course->id, $this->qcategory->id, $quizfilepath, true, \stack_question_library::STACKLIB); + $returnvalue = library_import::import_execute( + $this->course->id, + $this->qcategory->id, + $quizfilepath, + true, + \stack_question_library::STACKLIB + ); // We need to execute the return values cleaning process to simulate // the web service server. @@ -332,7 +376,13 @@ public function test_quiz_import_without_sections_and_feedback(): void { $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); role_assign($managerroleid, $this->user->id, $context->id); - library_import::import_execute($this->course->id, $this->qcategory->id, $quizfilepath, true, \stack_question_library::STACKLIB); + library_import::import_execute( + $this->course->id, + $this->qcategory->id, + $quizfilepath, + true, + \stack_question_library::STACKLIB + ); $sections = $DB->get_records('quiz_sections'); $this->assertEquals(1, count($sections)); @@ -362,7 +412,13 @@ public function test_import_with_require_previous(): void { $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); role_assign($managerroleid, $this->user->id, $context->id); - library_import::import_execute($this->course->id, $this->qcategory->id, $quizfilepath, true, \stack_question_library::STACKLIB); + library_import::import_execute( + $this->course->id, + $this->qcategory->id, + $quizfilepath, + true, + \stack_question_library::STACKLIB + ); $slots = $DB->get_records('quiz_slots'); $this->assertEquals(4, count($slots)); @@ -430,4 +486,466 @@ public function test_dubious_file_check(): void { } $this->assertEquals(true, $iserror); } + + /** + * Set up files for site library tests. + * @return void + */ + public function copy_directory(): void { + global $CFG; + + $dest = $CFG->dataroot . '/stack/sitelibrary/importtest'; + $source = $CFG->dirroot . '/question/type/stack/samplequestions/importtest'; + $this->filepath = 'sitelibrary/importtest/Course1/top/CR_Diff_01/CR-Diff-01-basic-1-e.xml'; + + mkdir($dest, 0777, true); + foreach ( + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( + $source, + \RecursiveDirectoryIterator::SKIP_DOTS + ), + \RecursiveIteratorIterator::SELF_FIRST + ) as $item + ) { + if ($item->isDir()) { + mkdir($dest . DIRECTORY_SEPARATOR . $iterator->getSubPathname()); + } else { + copy($item, $dest . DIRECTORY_SEPARATOR . $iterator->getSubPathname()); + } + } + } + + /** + * Test output of library_import function for site library. + */ + public function test_site_library_import(): void { + global $DB; + + $this->copy_directory(); + + // Set the required capabilities - webservice access and export rights on course. + $context = context_course::instance($this->course->id); + $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); + role_assign($managerroleid, $this->user->id, $context->id); + $sink = $this->redirectEvents(); + + $returnvalue = library_import::import_execute( + $this->course->id, + $this->qcategory->id, + $this->filepath, + false, + \stack_question_library::SITELIB + ); + + // We need to execute the return values cleaning process to simulate + // the web service server. + $returnvalue = external_api::clean_returnvalue( + library_import::import_execute_returns(), + $returnvalue + ); + + $this->assertEquals(1, count($returnvalue)); + $this->assertEquals(true, $returnvalue[0]['success']); + $this->assertEquals('CR-Diff-01-basic-1.e', $returnvalue[0]['questionname']); + $this->assertEquals(basename($this->filepath), $returnvalue[0]['filename']); + $this->assertEquals(true, $returnvalue[0]['isstack']); + + $events = $sink->get_events(); + $this->assertEquals(count($events), 2); + $this->assertInstanceOf('\core\event\question_created', $events['0']); + $this->assertInstanceOf('\core\event\questions_imported', $events['1']); + + $dbquestion = $DB->get_record('question', ['name' => 'CR-Diff-01-basic-1.e'], '*', MUST_EXIST); + $this->assertEquals($dbquestion->id, $returnvalue[0]['questionid']); + } + + /** + * Test output of library_import function for an entire folder for site library. + */ + public function test_site_library_import_folder(): void { + global $DB, $CFG; + $this->copy_directory(); + // Add in an extra question just to make sure we're getting questions from the correct place. + file_put_contents( + $CFG->dataroot . '/stack/sitelibrary/importtest/Course1/top/CR_Diff_01/testq.xml', + 'Test' + ); + // Set the required capabilities - webservice access and export rights on course. + $context = context_course::instance($this->course->id); + $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); + role_assign($managerroleid, $this->user->id, $context->id); + $sink = $this->redirectEvents(); + + $returnvalue = library_import::import_execute( + $this->course->id, + $this->qcategory->id, + $this->filepath, + true, + \stack_question_library::SITELIB + ); + + // We need to execute the return values cleaning process to simulate + // the web service server. + $returnvalue = external_api::clean_returnvalue( + library_import::import_execute_returns(), + $returnvalue + ); + + $this->assertEquals(19, count($returnvalue)); + $this->assertEquals(true, $returnvalue[0]['success']); + $this->assertEquals('CR-Diff-01-basic-1.b', $returnvalue[0]['questionname']); + $this->assertEquals('CR-Diff-01-basic-1-b.xml', $returnvalue[0]['filename']); + $this->assertEquals(true, $returnvalue[0]['isstack']); + $this->assertEquals('Test', $returnvalue[18]['questionname']); + $this->assertEquals('testq.xml', $returnvalue[18]['filename']); + $this->assertEquals(true, $returnvalue[18]['isstack']); + + $events = $sink->get_events(); + $this->assertEquals(count($events), 20); + $this->assertInstanceOf('\core\event\question_created', $events['0']); + $this->assertInstanceOf('\core\event\question_created', $events['18']); + $this->assertInstanceOf('\core\event\questions_imported', $events['19']); + + $dbquestion = $DB->get_record('question', ['name' => 'CR-Diff-01-basic-1.b'], '*', MUST_EXIST); + $this->assertEquals($dbquestion->id, $returnvalue[0]['questionid']); + $dbquestions = $DB->get_records('question', ['qtype' => 'stack'], '', 'id'); + $this->assertEquals(19, count($dbquestions)); + } + + /** + * Test output of library_import function for an entire folder for site library. + */ + public function test_site_library_import_quiz(): void { + global $DB, $CFG; + $this->copy_directory(); + $quizfolder = $CFG->dataroot . '/stack/sitelibrary/importtest/Course1_quiz_quiz-1'; + // Rename quiz to make sure we're getting it from the correct place. + rename("{$quizfolder}/quiz-1_quiz.json", "{$quizfolder}/quiz-2_quiz.json"); + // Set the required capabilities - webservice access and export rights on course. + $context = context_course::instance($this->course->id); + $quizfilepath = 'sitelibrary/importtest/Course1_quiz_quiz-1/quiz-2_quiz.json'; + $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); + role_assign($managerroleid, $this->user->id, $context->id); + $sink = $this->redirectEvents(); + + $returnvalue = library_import::import_execute( + $this->course->id, + $this->qcategory->id, + $quizfilepath, + true, + \stack_question_library::SITELIB + ); + + // We need to execute the return values cleaning process to simulate + // the web service server. + $returnvalue = external_api::clean_returnvalue( + library_import::import_execute_returns(), + $returnvalue + ); + + // Four questions plus the quiz itself. + $this->assertEquals(5, count($returnvalue)); + $this->assertEquals(true, $returnvalue[0]['success']); + $this->assertEquals('Checkbox', $returnvalue[0]['questionname']); + $this->assertEquals('Checkbox.xml', $returnvalue[0]['filename']); + $this->assertEquals(true, $returnvalue[0]['isstack']); + $this->assertEquals('Algebraic input', $returnvalue[1]['questionname']); + $this->assertEquals('Algebraic-input.xml', $returnvalue[1]['filename']); + $this->assertEquals(true, $returnvalue[1]['isstack']); + $this->assertEquals('Dropdown (shuffle)', $returnvalue[2]['questionname']); + $this->assertEquals('Dropdown-(shuffle).xml', $returnvalue[2]['filename']); + $this->assertEquals(true, $returnvalue[2]['isstack']); + $this->assertEquals('Matrix', $returnvalue[3]['questionname']); + $this->assertEquals('Matrix.xml', $returnvalue[3]['filename']); + $this->assertEquals(true, $returnvalue[3]['isstack']); + $this->assertEquals('Quiz: Quiz 1', $returnvalue[4]['questionname']); + $this->assertEquals('quiz-2_quiz.json', $returnvalue[4]['filename']); + $this->assertEquals(false, $returnvalue[4]['isstack']); + + $events = $sink->get_events(); + $categoriescreated = 0; + $questionscreated = 0; + $questionsimported = 0; + foreach ($events as $currentevent) { + $eventclass = get_class($currentevent); + switch ($eventclass) { + case 'core\event\question_category_created': + $categoriescreated++; + break; + case 'core\event\question_created': + $questionscreated++; + break; + case 'core\event\questions_imported': + // Fired once for each question category. + $questionsimported++; + break; + } + } + $this->assertEquals(1, $categoriescreated); + $this->assertEquals(4, $questionscreated); + $this->assertEquals(2, $questionsimported); + + $dbquestion = $DB->get_record('question', ['name' => 'Checkbox'], '*', MUST_EXIST); + $this->assertEquals($dbquestion->id, $returnvalue[0]['questionid']); + $dbquestions = $DB->get_records('question', ['qtype' => 'stack'], '', 'id'); + $this->assertEquals(4, count($dbquestions)); + + $quizzes = $DB->get_records('quiz'); + $quiz = array_shift($quizzes); + $this->assertEquals('Quiz 1', $quiz->name); + $this->assertEquals('A highly interesting quiz involving maths.', $quiz->intro); + $this->assertEquals(1, $quiz->questionsperpage); + $this->assertEquals('deferredfeedback', $quiz->preferredbehaviour); + $this->assertEquals(2, $quiz->decimalpoints); + $this->assertEquals(4352, $quiz->reviewmarks); + $this->assertEquals(10, $quiz->grade); + + $sections = $DB->get_records('quiz_sections'); + $this->assertEquals(2, count($sections)); + $section1 = array_shift($sections); + $section2 = array_shift($sections); + $this->assertEquals('', $section1->heading); + $this->assertEquals(1, $section1->firstslot); + $this->assertEquals('New heading 1', $section2->heading); + $this->assertEquals(3, $section2->firstslot); + + $slots = $DB->get_records('quiz_slots'); + $this->assertEquals(4, count($slots)); + $slot1 = array_shift($slots); + $slot2 = array_shift($slots); + $this->assertEquals(0, $slot1->requireprevious); + $this->assertEquals(1, $slot1->page); + $this->assertEquals(1, $slot1->maxmark); + $this->assertEquals(0, $slot2->requireprevious); + $this->assertEquals(2, $slot2->page); + $this->assertEquals(1, $slot2->maxmark); + + $feedback = $DB->get_records('quiz_feedback'); + $this->assertEquals(1, count($feedback)); + $feedback1 = array_shift($feedback); + $this->assertEquals('Low score', $feedback1->feedbacktext); + $this->assertEquals(1, $feedback1->feedbacktextformat); + $this->assertEquals(0, $feedback1->mingrade); + $this->assertEquals(6, $feedback1->maxgrade); + } + + /** + * Set cache for external import tests. + * @return void + */ + public function set_external(): void { + $cache = cache::make('qtype_stack', 'librarycache'); + $cache->purge(); + [$files, $flatfiles ] = + \stack_question_library::get_file_list_from_repo( + 'https://github.com/maths/moodle-qtype_stack/tree/master/samplequestions/importtest', + \stack_question_library::GITHUB + ); + + $cache->set(\stack_question_library::GITHUB . '_TEST_flat_file_list', $flatfiles); + $this->filepath = 'Course1/top/CR_Diff_01/CR-Diff-01-basic-1-e.xml'; + } + + /** + * Test output of library_import function for GitHub. + */ + public function test_external_library_import(): void { + global $DB; + $this->set_external(); + + // Set the required capabilities - webservice access and export rights on course. + $context = context_course::instance($this->course->id); + $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); + role_assign($managerroleid, $this->user->id, $context->id); + $sink = $this->redirectEvents(); + + $returnvalue = library_import::import_execute( + $this->course->id, + $this->qcategory->id, + $this->filepath, + false, + \stack_question_library::GITHUB . '_TEST' + ); + + // We need to execute the return values cleaning process to simulate + // the web service server. + $returnvalue = external_api::clean_returnvalue( + library_import::import_execute_returns(), + $returnvalue + ); + + $this->assertEquals(1, count($returnvalue)); + $this->assertEquals(true, $returnvalue[0]['success']); + $this->assertEquals('CR-Diff-01-basic-1.e', $returnvalue[0]['questionname']); + $this->assertEquals(basename($this->filepath), $returnvalue[0]['filename']); + $this->assertEquals(true, $returnvalue[0]['isstack']); + + $events = $sink->get_events(); + $this->assertEquals(count($events), 2); + $this->assertInstanceOf('\core\event\question_created', $events['0']); + $this->assertInstanceOf('\core\event\questions_imported', $events['1']); + + $dbquestion = $DB->get_record('question', ['name' => 'CR-Diff-01-basic-1.e'], '*', MUST_EXIST); + $this->assertEquals($dbquestion->id, $returnvalue[0]['questionid']); + } + + /** + * Test output of library_import function for an entire folder for GitHub. + */ + public function test_external_library_import_folder(): void { + global $DB; + $this->set_external(); + // Set the required capabilities - webservice access and export rights on course. + $context = context_course::instance($this->course->id); + $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); + role_assign($managerroleid, $this->user->id, $context->id); + $sink = $this->redirectEvents(); + + $returnvalue = library_import::import_execute( + $this->course->id, + $this->qcategory->id, + $this->filepath, + true, + \stack_question_library::GITHUB . '_TEST' + ); + + // We need to execute the return values cleaning process to simulate + // the web service server. + $returnvalue = external_api::clean_returnvalue( + library_import::import_execute_returns(), + $returnvalue + ); + + $this->assertEquals(18, count($returnvalue)); + $this->assertEquals(true, $returnvalue[0]['success']); + $this->assertEquals('CR-Diff-01-basic-1.b', $returnvalue[0]['questionname']); + $this->assertEquals('CR-Diff-01-basic-1-b.xml', $returnvalue[0]['filename']); + $this->assertEquals(true, $returnvalue[0]['isstack']); + + $events = $sink->get_events(); + $this->assertEquals(count($events), 19); + $this->assertInstanceOf('\core\event\question_created', $events['0']); + $this->assertInstanceOf('\core\event\question_created', $events['17']); + $this->assertInstanceOf('\core\event\questions_imported', $events['18']); + + $dbquestion = $DB->get_record('question', ['name' => 'CR-Diff-01-basic-1.b'], '*', MUST_EXIST); + $this->assertEquals($dbquestion->id, $returnvalue[0]['questionid']); + $dbquestions = $DB->get_records('question', ['qtype' => 'stack'], '', 'id'); + $this->assertEquals(18, count($dbquestions)); + } + + /** + * Test output of library_import function for an entire folder for GitHub. + */ + public function test_external_library_import_quiz(): void { + global $DB; + $this->set_external(); + // Set the required capabilities - webservice access and export rights on course. + $context = context_course::instance($this->course->id); + $quizfilepath = 'Course1_quiz_quiz-1/quiz-1_quiz.json'; + $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); + role_assign($managerroleid, $this->user->id, $context->id); + $sink = $this->redirectEvents(); + + $returnvalue = library_import::import_execute( + $this->course->id, + $this->qcategory->id, + $quizfilepath, + true, + \stack_question_library::GITHUB . '_TEST' + ); + + // We need to execute the return values cleaning process to simulate + // the web service server. + $returnvalue = external_api::clean_returnvalue( + library_import::import_execute_returns(), + $returnvalue + ); + + // Four questions plus the quiz itself. + $this->assertEquals(5, count($returnvalue)); + $this->assertEquals(true, $returnvalue[0]['success']); + $this->assertEquals('Checkbox', $returnvalue[0]['questionname']); + $this->assertEquals('Checkbox.xml', $returnvalue[0]['filename']); + $this->assertEquals(true, $returnvalue[0]['isstack']); + $this->assertEquals('Algebraic input', $returnvalue[1]['questionname']); + $this->assertEquals('Algebraic-input.xml', $returnvalue[1]['filename']); + $this->assertEquals(true, $returnvalue[1]['isstack']); + $this->assertEquals('Dropdown (shuffle)', $returnvalue[2]['questionname']); + $this->assertEquals('Dropdown-(shuffle).xml', $returnvalue[2]['filename']); + $this->assertEquals(true, $returnvalue[2]['isstack']); + $this->assertEquals('Matrix', $returnvalue[3]['questionname']); + $this->assertEquals('Matrix.xml', $returnvalue[3]['filename']); + $this->assertEquals(true, $returnvalue[3]['isstack']); + $this->assertEquals('Quiz: Quiz 1', $returnvalue[4]['questionname']); + $this->assertEquals('quiz-1_quiz.json', $returnvalue[4]['filename']); + $this->assertEquals(false, $returnvalue[4]['isstack']); + + $events = $sink->get_events(); + $categoriescreated = 0; + $questionscreated = 0; + $questionsimported = 0; + foreach ($events as $currentevent) { + $eventclass = get_class($currentevent); + switch ($eventclass) { + case 'core\event\question_category_created': + $categoriescreated++; + break; + case 'core\event\question_created': + $questionscreated++; + break; + case 'core\event\questions_imported': + // Fired once for each question category. + $questionsimported++; + break; + } + } + $this->assertEquals(1, $categoriescreated); + $this->assertEquals(4, $questionscreated); + $this->assertEquals(2, $questionsimported); + + $dbquestion = $DB->get_record('question', ['name' => 'Checkbox'], '*', MUST_EXIST); + $this->assertEquals($dbquestion->id, $returnvalue[0]['questionid']); + $dbquestions = $DB->get_records('question', ['qtype' => 'stack'], '', 'id'); + $this->assertEquals(4, count($dbquestions)); + + $quizzes = $DB->get_records('quiz'); + $quiz = array_shift($quizzes); + $this->assertEquals('Quiz 1', $quiz->name); + $this->assertEquals('A highly interesting quiz involving maths.', $quiz->intro); + $this->assertEquals(1, $quiz->questionsperpage); + $this->assertEquals('deferredfeedback', $quiz->preferredbehaviour); + $this->assertEquals(2, $quiz->decimalpoints); + $this->assertEquals(4352, $quiz->reviewmarks); + $this->assertEquals(10, $quiz->grade); + + $sections = $DB->get_records('quiz_sections'); + $this->assertEquals(2, count($sections)); + $section1 = array_shift($sections); + $section2 = array_shift($sections); + $this->assertEquals('', $section1->heading); + $this->assertEquals(1, $section1->firstslot); + $this->assertEquals('New heading 1', $section2->heading); + $this->assertEquals(3, $section2->firstslot); + + $slots = $DB->get_records('quiz_slots'); + $this->assertEquals(4, count($slots)); + $slot1 = array_shift($slots); + $slot2 = array_shift($slots); + $this->assertEquals(0, $slot1->requireprevious); + $this->assertEquals(1, $slot1->page); + $this->assertEquals(1, $slot1->maxmark); + $this->assertEquals(0, $slot2->requireprevious); + $this->assertEquals(2, $slot2->page); + $this->assertEquals(1, $slot2->maxmark); + + $feedback = $DB->get_records('quiz_feedback'); + $this->assertEquals(1, count($feedback)); + $feedback1 = array_shift($feedback); + $this->assertEquals('Low score', $feedback1->feedbacktext); + $this->assertEquals(1, $feedback1->feedbacktextformat); + $this->assertEquals(0, $feedback1->mingrade); + $this->assertEquals(6, $feedback1->maxgrade); + } } diff --git a/tests/library_render_test.php b/tests/library_render_test.php index d59cb6b2748..8947f389b9d 100644 --- a/tests/library_render_test.php +++ b/tests/library_render_test.php @@ -208,13 +208,18 @@ public function test_site_library_render(): void { mkdir($CFG->dataroot . '/stack/sitelibrary/libtest', 0777, true); file_put_contents( $CFG->dataroot . '/stack/sitelibrary/libtest/testq.xml', - 'Fake XML: Site'); + 'Fake XML: Site' + ); // Set the required capabilities - webservice access and export rights on course. $context = context_course::instance($this->course->id); $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); role_assign($managerroleid, $this->user->id, $context->id); - $returnvalue = fake_render::render_execute($this->qcategory->id, 'sitelibrary/libtest/testq.xml', \stack_question_library::SITELIB); + $returnvalue = fake_render::render_execute( + $this->qcategory->id, + 'sitelibrary/libtest/testq.xml', + \stack_question_library::SITELIB + ); // We need to execute the return values cleaning process to simulate // the web service server. diff --git a/tests/questionlibrary_test.php b/tests/questionlibrary_test.php index 94f98cdb514..6e8faabdfe4 100644 --- a/tests/questionlibrary_test.php +++ b/tests/questionlibrary_test.php @@ -137,7 +137,11 @@ public function test_get_file_list_quizzes(): void { * Test getting external files. */ public function test_get_external_files(): void { - [$files, $flatfiles ] = stack_question_library::get_file_list_from_repo('https://github.com/maths/moodle-qtype_stack/tree/master/samplequestions/importtest' , stack_question_library::GITHUB); + [$files, $flatfiles ] = + stack_question_library::get_file_list_from_repo( + 'https://github.com/maths/moodle-qtype_stack/tree/master/samplequestions/importtest', + stack_question_library::GITHUB + ); $this->assertEquals(2, count($files->children)); $this->assertEquals('Course1', $files->children[0]->label); $this->assertEquals('Course1_quiz_quiz-1', $files->children[1]->label); From d8ea9bdb2ac68764a5775adcf9cee2ef6fb784d5 Mon Sep 17 00:00:00 2001 From: Edmund Farrow Date: Tue, 21 Apr 2026 16:49:00 +0100 Subject: [PATCH 10/31] nrw-external-api - Import questions from NRW search --- .vscode/launch.json | 88 +++++++++++++++++++++++++ amd/build/library.min.js | 2 +- amd/build/library.min.js.map | 2 +- amd/src/library.js | 11 +++- api/util/StackSeedHelper.php | 4 +- classes/library_import.php | 20 ++++-- classes/library_render.php | 16 +++-- lang/en/qtype_stack.php | 2 + questionlibrary.php | 50 ++++++++++---- stack/maxima/stackmaxima.mac | 2 +- stack/questionlibrary.class.php | 101 +++++++++++++++++++++++++++-- templates/questionlibrary.mustache | 14 +++- version.php | 2 +- 13 files changed, 272 insertions(+), 42 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000000..927b38df72d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,88 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Listen for Xdebug", + "type": "php", + "request": "launch", + "port": 9003 + }, + { + "name": "Launch currently open script", + "type": "php", + "request": "launch", + "program": "${file}", + "cwd": "${fileDirname}", + "port": 0, + "runtimeArgs": [ + "-dxdebug.start_with_request=yes" + ], + "env": { + "XDEBUG_MODE": "debug,develop", + "XDEBUG_CONFIG": "client_port=${port}" + } + }, + { + "name": "Launch Built-in web server", + "type": "php", + "request": "launch", + "runtimeArgs": [ + "-dxdebug.mode=debug", + "-dxdebug.start_with_request=yes", + "-S", + "localhost:0" + ], + "program": "", + "cwd": "${workspaceRoot}", + "port": 9003, + "serverReadyAction": { + "pattern": "Development Server \\(http://localhost:([0-9]+)\\) started", + "uriFormat": "http://localhost:%s", + "action": "openExternally" + } + }, + { + "name": "Launch built-in server and Debug", + "type": "php", + "request": "launch", + "noDebug": false, + "runtimeArgs": [ + "-S", + "localhost:8000", + "-t", + "." + ], + "cwd": "${workspaceRoot}/.", + "serverReadyAction": { + "action": "openExternally" + } + }, + { + "name": "Launch built-in server and Profile", + "type": "php", + "request": "launch", + "noDebug": true, + "runtimeArgs": [ + "-S", + "localhost:8000", + "-t", + "." + ], + "cwd": "${workspaceRoot}/.", + "serverReadyAction": { + "action": "openExternally" + }, + "profile": true, + "openProfile": true + }, + { + "name": "Listen for Xdebug", + "type": "php", + "request": "launch" + } + ] +} \ No newline at end of file diff --git a/amd/build/library.min.js b/amd/build/library.min.js index 6b8bb8b2ec4..5d338cf80a0 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},done:function(response){loading(!1),libraryDiv.innerHTML=response.questionrender;for(const iframe of response.iframes)require(["qtype_stack/stackjsvle"],(function(stackjsvle){stackjsvle.create_iframe(iframe.iframeid,iframe.content,iframe.targetdivid,iframe.title,iframe.scrolling,iframe.evil)}));rawDiv.innerText=response.questiontext,descriptionDiv.innerHTML=response.questiondescription,variablesDiv.innerHTML=response.questionvariables.replace(/;/g,";
"),displayedDiv.innerHTML=response.questionname+"
("+filepath.split("/").pop()+")",document.querySelectorAll(".library-secondary-info").forEach((el=>el.removeAttribute("hidden"))),document.querySelector(".library-import-link").removeAttribute("disabled"),filepath.endsWith("_quiz.json")?(document.querySelector(".stack-library-category-holder").setAttribute("hidden",!0),document.querySelector(".stack-library-course").removeAttribute("hidden")):(document.querySelector(".stack-library-course").setAttribute("hidden",!0),document.querySelector(".stack-library-category-holder").removeAttribute("hidden"),document.querySelector(".library-import-link-folder").removeAttribute("disabled")),CustomEvents.notifyFilterContentUpdated(libraryDiv)},fail:function(response){loading(!1),errorDetailsDiv.innerHTML=response.message?response.message:"",errorDiv.hidden=!1}}])}function libraryImport(isFolder){if(!currentPath)return;const filepath=currentPath;loading(!0),categoryId=Number(document.getElementById("id_category").value.split(",")[0]),Ajax.call([{methodname:"qtype_stack_library_import",args:{courseid:courseId,category:categoryId,filepath:filepath,isfolder:isFolder?1:0,cacheid:cacheId},done:function(response){loading(!1);for(const currentQuestion of response)if(currentQuestion.success){let currentDashLink=dashLink+currentQuestion.questionid;currentQuestion.isstack?importListDiv.innerHTML+='
'+currentQuestion.questionname+"":currentQuestion.filename.endsWith("_quiz.json")?importListDiv.innerHTML+='
'+currentQuestion.questionname+"":importListDiv.innerHTML+="
"+currentQuestion.questionname,importSuccessFileDiv.innerHTML+="
"+currentQuestion.filename.split("/").pop()+" --\x3e "+currentQuestion.questionname,importSuccessDiv.removeAttribute("hidden")}else importFailureFileDiv.innerHTML+="
"+currentQuestion.filename.split("/").pop(),importFailureDiv.removeAttribute("hidden")},fail:function(response){loading(!1),errorDetailsDiv.innerHTML=response.message?response.message:"",errorDiv.hidden=!1}}])}function loading(isLoading){errorDiv.hidden=!0,isLoading?(document.querySelector(".loading-display").removeAttribute("hidden"),document.querySelector(".library-import-link").setAttribute("disabled","disabled"),document.querySelector(".library-import-link-folder").setAttribute("disabled","disabled"),document.querySelectorAll(".library-file-link").forEach((el=>el.setAttribute("disabled","disabled"))),importSuccessFileDiv.innerHTML="",importSuccessDiv.setAttribute("hidden",!0),importFailureFileDiv.innerHTML="",importFailureDiv.setAttribute("hidden",!0)):(document.querySelector(".loading-display").setAttribute("hidden",!0),document.querySelectorAll(".library-file-link").forEach((el=>el.removeAttribute("disabled"))))}return{setup:function(){libraryDiv=document.querySelector(".stack_library_display"),rawDiv=document.querySelector(".stack_library_raw_display"),variablesDiv=document.querySelector(".stack_library_variables_display"),importListDiv=document.querySelector(".stack-library-imported-list"),displayedDiv=document.querySelector(".stack_library_selected_question"),descriptionDiv=document.querySelector(".stack_library_description_display"),errorDiv=document.querySelector(".stack-library-error"),errorDetailsDiv=document.querySelector(".stack-library-error-details"),importSuccessDiv=document.querySelector(".stack-library-import-success"),importFailureDiv=document.querySelector(".stack-library-import-failure"),importSuccessFileDiv=document.querySelector(".stack-library-import-success-file"),importFailureFileDiv=document.querySelector(".stack-library-import-failure-file"),dashLink=document.querySelector("#dashboard-link-holder").innerHTML.trim(),quizLink=document.querySelector("#quiz-link-holder").innerHTML.trim(),dashLink=dashLink.includes("?")?dashLink+="&questionid=":dashLink+="?questionid=",loading(!0),document.querySelectorAll(".library-file-link").forEach((function(elem){elem.addEventListener("click",libraryRender)})),courseId=document.querySelector('[data-id="stack_library_course_id"]').getAttribute("data-value"),cacheId=document.querySelector('[data-id="stack_cache_id"]').getAttribute("data-value"),document.querySelector(".library-import-link").addEventListener("click",(()=>libraryImport(!1))),document.querySelector(".library-import-link-folder").addEventListener("click",(()=>libraryImport(!0)));const catOptions=document.querySelectorAll("#id_category option");for(let option of catOptions){const sections=option.text.split("(");sections.length>1&&(sections[0]||sections.length>2)&&(sections.pop(),option.text=sections.join("("))}loading(!1)}}})); +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)}}})); //# 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 08b3f38ff08..b50c10e511e 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},\n done: function(response) {\n loading(false);\n libraryDiv.innerHTML = response.questionrender;\n for (const iframe of response.iframes) {\n require(['qtype_stack/stackjsvle'],\n function(stackjsvle,) {\n stackjsvle.create_iframe(\n iframe.iframeid,\n iframe.content,\n iframe.targetdivid,\n iframe.title,\n iframe.scrolling,\n iframe.evil\n );\n });\n }\n rawDiv.innerText = response.questiontext;\n descriptionDiv.innerHTML = response.questiondescription;\n variablesDiv.innerHTML = response.questionvariables.replace(/;/g, \";
\");\n displayedDiv.innerHTML = response.questionname + '
(' + filepath.split('/').pop() + ')';\n document.querySelectorAll('.library-secondary-info')\n .forEach(el => el.removeAttribute('hidden'));\n document.querySelector('.library-import-link').removeAttribute('disabled');\n if (filepath.endsWith('_quiz.json')) {\n document.querySelector('.stack-library-category-holder').setAttribute('hidden', true);\n document.querySelector('.stack-library-course').removeAttribute('hidden');\n } else {\n document.querySelector('.stack-library-course').setAttribute('hidden', true);\n document.querySelector('.stack-library-category-holder').removeAttribute('hidden');\n document.querySelector('.library-import-link-folder').removeAttribute('disabled');\n }\n // This fires the Maths filters for content in the validation div.\n CustomEvents.notifyFilterContentUpdated(libraryDiv);\n },\n fail: function(response) {\n loading(false);\n errorDetailsDiv.innerHTML = (response.message) ? response.message : '';\n errorDiv.hidden = false;\n }\n }]);\n }\n\n /**\n * Performs AJAX call to Moodle to import a question.\n *\n * @param {boolean} isFolder is this a request to load the whole folder\n */\n function libraryImport(isFolder) {\n if (!currentPath) {\n return;\n }\n const filepath = currentPath;\n loading(true);\n categoryId = Number(document.getElementById('id_category').value.split(',')[0]);\n Ajax.call([{\n methodname: 'qtype_stack_library_import',\n args: {\n courseid: courseId,\n category: categoryId,\n filepath: filepath,\n isfolder: (isFolder) ? 1 : 0,\n cacheid: cacheId\n },\n done: function(response) {\n loading(false);\n for (const currentQuestion of response) {\n if (currentQuestion.success) {\n let currentDashLink = dashLink + currentQuestion.questionid;\n if (currentQuestion.isstack) {\n importListDiv.innerHTML += '
' + '' + currentQuestion.questionname + '';\n } else if (currentQuestion.filename.endsWith('_quiz.json')) {\n importListDiv.innerHTML += '
' + ''\n + currentQuestion.questionname + '';\n } else {\n importListDiv.innerHTML += '
' + currentQuestion.questionname;\n }\n importSuccessFileDiv.innerHTML += '
' +\n currentQuestion.filename.split('/').pop() + ' --> ' + currentQuestion.questionname;\n importSuccessDiv.removeAttribute('hidden');\n } else {\n importFailureFileDiv.innerHTML += '
' +\n currentQuestion.filename.split('/').pop();\n importFailureDiv.removeAttribute('hidden');\n }\n }\n },\n fail: function(response) {\n loading(false);\n errorDetailsDiv.innerHTML = (response.message) ? response.message : '';\n errorDiv.hidden = false;\n }\n }]);\n }\n\n /**\n * Disable/enable features before/after loading.\n *\n * @param {boolean} isLoading Is an AJAX call taking place?\n */\n function loading(isLoading) {\n errorDiv.hidden = true;\n if (isLoading) {\n document.querySelector('.loading-display').removeAttribute('hidden');\n document.querySelector('.library-import-link').setAttribute('disabled', 'disabled');\n document.querySelector('.library-import-link-folder').setAttribute('disabled', 'disabled');\n document.querySelectorAll('.library-file-link').forEach(el => el.setAttribute('disabled', 'disabled'));\n importSuccessFileDiv.innerHTML = '';\n importSuccessDiv.setAttribute('hidden', true);\n importFailureFileDiv.innerHTML = '';\n importFailureDiv.setAttribute('hidden', true);\n } else {\n document.querySelector('.loading-display').setAttribute('hidden', true);\n document.querySelectorAll('.library-file-link').forEach(el => el.removeAttribute('disabled'));\n }\n }\n\n /** Export our entry point. */\n return {\n setup: setup\n };\n});\n"],"names":["define","Ajax","CustomEvents","courseId","categoryId","cacheId","libraryDiv","rawDiv","variablesDiv","descriptionDiv","importListDiv","importSuccessDiv","importFailureDiv","importSuccessFileDiv","importFailureFileDiv","displayedDiv","quizLink","dashLink","errorDiv","errorDetailsDiv","currentPath","libraryRender","e","filepath","target","getAttribute","loading","Number","document","getElementById","value","split","call","methodname","args","category","cacheid","done","response","innerHTML","questionrender","iframe","iframes","require","stackjsvle","create_iframe","iframeid","content","targetdivid","title","scrolling","evil","innerText","questiontext","questiondescription","questionvariables","replace","questionname","pop","querySelectorAll","forEach","el","removeAttribute","querySelector","endsWith","setAttribute","notifyFilterContentUpdated","fail","message","hidden","libraryImport","isFolder","courseid","isfolder","currentQuestion","success","currentDashLink","questionid","isstack","filename","isLoading","setup","trim","includes","elem","addEventListener","catOptions","option","sections","text","length","join"],"mappings":"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"} \ No newline at end of file +{"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 diff --git a/amd/src/library.js b/amd/src/library.js index 7034ae5c3a5..2376b79e101 100644 --- a/amd/src/library.js +++ b/amd/src/library.js @@ -106,9 +106,10 @@ 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}, + args: {category: categoryId, filepath: filepath, cacheid: cacheId, apikey: apikey}, done: function(response) { loading(false); libraryDiv.innerHTML = response.questionrender; @@ -138,7 +139,9 @@ define([ } else { document.querySelector('.stack-library-course').setAttribute('hidden', true); document.querySelector('.stack-library-category-holder').removeAttribute('hidden'); - document.querySelector('.library-import-link-folder').removeAttribute('disabled'); + if (cacheId !== 'nrwsearch') { + document.querySelector('.library-import-link-folder').removeAttribute('disabled'); + } } // This fires the Maths filters for content in the validation div. CustomEvents.notifyFilterContentUpdated(libraryDiv); @@ -163,6 +166,7 @@ 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: { @@ -170,7 +174,8 @@ define([ category: categoryId, filepath: filepath, isfolder: (isFolder) ? 1 : 0, - cacheid: cacheId + cacheid: cacheId, + apikey: apikey }, done: function(response) { loading(false); diff --git a/api/util/StackSeedHelper.php b/api/util/StackSeedHelper.php index 34dbc149c22..1d0e58d8959 100644 --- a/api/util/StackSeedHelper.php +++ b/api/util/StackSeedHelper.php @@ -31,7 +31,7 @@ public static function initialize_seed($question, $seed) { if ($question->has_random_variants()) { // We require the xml to include deployed variants. if (count($question->deployedseeds) === 0) { - throw new \stack_exception(get_string('api_no_deployed_variants', null)); + throw new \stack_exception(get_string('api_no_deployed_variants', 'qtype_stack')); } // If no seed has been specified, use the first deployed variant. @@ -40,7 +40,7 @@ public static function initialize_seed($question, $seed) { } if (!in_array($seed, $question->deployedseeds)) { - throw new \stack_exception(get_string('api_seed_not_in_variants', null)); + throw new \stack_exception(get_string('api_seed_not_in_variants', 'qtype_stack')); } $question->seed = $seed; diff --git a/classes/library_import.php b/classes/library_import.php index 671a7ec4f30..d6e06a8079c 100644 --- a/classes/library_import.php +++ b/classes/library_import.php @@ -64,6 +64,7 @@ public static function import_execute_parameters() { ), 'isfolder' => new \external_value(PARAM_BOOL, 'Is import of whole question folder requested?'), 'cacheid' => new \external_value(PARAM_RAW, 'Library cache id'), + 'apikey' => new \external_value(PARAM_RAW, 'API key'), ]); } @@ -92,7 +93,7 @@ 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, $cacheid) { + public static function import_execute($courseid, $category, $filepath, $isfolder, $cacheid, $apikey) { global $CFG, $DB; $params = self::validate_parameters(self::import_execute_parameters(), [ 'courseid' => $courseid, @@ -100,6 +101,7 @@ public static function import_execute($courseid, $category, $filepath, $isfolder 'filepath' => $filepath, 'isfolder' => $isfolder, 'cacheid' => $cacheid, + 'apikey' => $apikey, ]); // Check parameters and permissions. $thiscontext = null; @@ -120,9 +122,12 @@ public static function import_execute($courseid, $category, $filepath, $isfolder $basedir = $CFG->dataroot . '/stack/'; } else if (str_starts_with($params['cacheid'], stack_question_library::GITHUB)) { $requestedfile = make_request_directory() . "/importq.xml"; - $external = explode('_', $params['cacheid'])[0]; + $external = stack_question_library::GITHUB; $cache = \cache::make('qtype_stack', 'librarycache'); $externalfiles = $cache->get($params['cacheid'] . '_flat_file_list'); + } else if (str_starts_with($params['cacheid'], stack_question_library::NRWSEARCH)) { + $requestedfile = $params['filepath']; + $external = stack_question_library::NRWSEARCH; } else { $requestedfile = $CFG->dirroot . '/question/type/stack/samplequestions/' . $params['filepath']; $basedir = $CFG->dirroot . '/question/type/stack/samplequestions/'; @@ -142,7 +147,7 @@ public static function import_execute($courseid, $category, $filepath, $isfolder // We've got a quiz file. Load JSON and instantiate. if ($external) { $url = $externalfiles[$params['filepath']]->url; - $quizcontents = stack_question_library::get_external_file($url, $external); + $quizcontents = stack_question_library::get_external_file($url, $external, $apikey); } else { $quizcontents = file_get_contents($requestedfile); } @@ -208,7 +213,7 @@ public static function import_execute($courseid, $category, $filepath, $isfolder $qformat->setCatfromfile(true); if ($external) { $url = $externalfiles[$category]->url; - file_put_contents($requestedfile, stack_question_library::get_external_file($url, $external)); + file_put_contents($requestedfile, stack_question_library::get_external_file($url, $external, $apikey)); $qformat->setFilename($requestedfile); } else { $qformat->setFilename($basedir . $category); @@ -257,9 +262,12 @@ public static function import_execute($courseid, $category, $filepath, $isfolder $qformat->setCategory($thiscategory); } $qformat->setCatfromfile(false); - if ($external) { + if ($external === stack_question_library::GITHUB) { $url = $externalfiles[$file]->url; - file_put_contents($requestedfile, stack_question_library::get_external_file($url, $external)); + file_put_contents($requestedfile, stack_question_library::get_external_file($url, $external, $apikey)); + $qformat->setFilename($requestedfile); + } else if ($external === stack_question_library::NRWSEARCH) { + file_put_contents($requestedfile, stack_question_library::get_external_file($file, $external, $apikey)); $qformat->setFilename($requestedfile); } else { $qformat->setFilename($basedir . $file); diff --git a/classes/library_render.php b/classes/library_render.php index 7776b456d90..9cefc50b22c 100644 --- a/classes/library_render.php +++ b/classes/library_render.php @@ -64,6 +64,7 @@ public static function render_execute_parameters() { 'File path relative to samplequestions, STACK data directory or top of GitHub library' ), 'cacheid' => new \external_value(PARAM_RAW, 'Library cache id'), + 'apikey' => new \external_value(PARAM_RAW, 'API key'), ]); } @@ -101,18 +102,21 @@ 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, $cacheid) { + public static function render_execute($category, $filepath, $cacheid, $apikey) { global $CFG, $DB; $params = self::validate_parameters(self::render_execute_parameters(), [ 'category' => $category, 'filepath' => $filepath, 'cacheid' => $cacheid, + 'apikey' => $apikey, ]); StackIframeHolder::$islibrary = true; // Check parameters and that user has question add capability in the supplied category. $context = $DB->get_field('question_categories', 'contextid', ['id' => $params['category']]); if (str_starts_with($params['cacheid'], stack_question_library::GITHUB)) { $external = stack_question_library::GITHUB; + } else if (str_starts_with($params['cacheid'], stack_question_library::NRWSEARCH)) { + $external = stack_question_library::NRWSEARCH; } else { $external = null; } @@ -128,9 +132,11 @@ public static function render_execute($category, $filepath, $cacheid) { if (str_starts_with($params['filepath'], stack_question_library::SITELIB . '/')) { $requestedfile = $CFG->dataroot . '/stack/' . $params['filepath']; - } else if ($external) { + } else if ($external && $external === stack_question_library::GITHUB) { $externalfiles = $cache->get($params['cacheid'] . '_flat_file_list'); $requestedfile = $externalfiles[$params['filepath']]->url; + } else if ($external && $external === stack_question_library::NRWSEARCH) { + $requestedfile = $params['filepath']; } else { $requestedfile = $CFG->dirroot . '/question/type/stack/samplequestions/' . $params['filepath']; } @@ -143,7 +149,7 @@ public static function render_execute($category, $filepath, $cacheid) { } if ($external) { - $qcontents = static::call_external_request($requestedfile, $external); + $qcontents = static::call_external_request($requestedfile, $external, $apikey); } else { $qcontents = file_get_contents($requestedfile); } @@ -246,7 +252,7 @@ public static function call_question_render($question) { * @param string $external * @return string XML of question */ - public static function call_external_request($requestedfile, $external) { - return stack_question_library::get_external_file($requestedfile, $external); + public static function call_external_request($requestedfile, $external, $apikey) { + return stack_question_library::get_external_file($requestedfile, $external, $apikey); } } diff --git a/lang/en/qtype_stack.php b/lang/en/qtype_stack.php index 39c6f39fc85..8f603802abd 100644 --- a/lang/en/qtype_stack.php +++ b/lang/en/qtype_stack.php @@ -1872,6 +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_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/questionlibrary.php b/questionlibrary.php index 45e6f309804..842459318f7 100644 --- a/questionlibrary.php +++ b/questionlibrary.php @@ -94,17 +94,21 @@ // Location parameter will be in form: // samplequestions/stacklibrary // sitelibrary/foldername -// githublibrary/id. +// githublibrary/id +// nrwsearch. // Corresponding cache ids are: // library // sitelibrary_foldername -// githublibrary_id. +// githublibrary_id +// null. $cacheid = stack_question_library::STACKLIB; $libraryname = stack_string('stack_library'); $external = null; $allowedlibraries = get_config('qtype_stack', 'libraries'); $allowedlibraries = json_decode($allowedlibraries); $allowedlibraries = $allowedlibraries ? $allowedlibraries : new StdClass(); +$search = optional_param('search', '', PARAM_RAW); +$apikey = optional_param('apikey', '', PARAM_RAW); if (str_starts_with($location, stack_question_library::SITELIB)) { $libraryname = explode('/', $location)[1]; @@ -127,7 +131,13 @@ $cacheid = stack_question_library::STACKLIB; } else { $external = $librarytype; + $externaldetail = $allowedlibraries->{$libraryid}->url; } +} else if (str_starts_with($location, stack_question_library::NRWSEARCH)) { + $cacheid = stack_question_library::NRWSEARCH; + $libraryname = null; + $external = stack_question_library::NRWSEARCH; + $externaldetail = ['search' => $search, 'apikey' => $apikey]; } else { $location = __DIR__ . '/samplequestions/stacklibrary'; } @@ -145,15 +155,22 @@ $cache->delete_many($refreshfiles); } -$files = $cache->get($cacheid . '_file_list'); -if (!$files) { - if ($external) { - [$files, $flatfiles] = stack_question_library::get_file_list_from_repo($allowedlibraries->{$libraryid}->url, $external); - $cache->set($cacheid . '_flat_file_list', $flatfiles); - } else { - $files = stack_question_library::get_file_list($location); +$files = new StdClass(); + +if ($cacheid && $cacheid != stack_question_library::NRWSEARCH) { + $files = $cache->get($cacheid . '_file_list'); + if (!$files) { + if ($external) { + [$files, $flatfiles] = stack_question_library::get_file_list_from_repo($externaldetail, $external); + $cache->set($cacheid . '_flat_file_list', $flatfiles); + } else { + $files = stack_question_library::get_file_list($location); + } + $cache->set($cacheid . '_file_list', $files); } - $cache->set($cacheid . '_file_list', $files); +} +if ($cacheid && $cacheid === stack_question_library::NRWSEARCH) { + [$files, $flatfiles] = stack_question_library::get_file_list_from_repo($externaldetail, $external); } $mform = new category_form(null, ['qcontext' => $contexts]); @@ -163,16 +180,18 @@ $outputdata->dashboardlink = $dashboardlink->out(); $outputdata->quizlink = $quizlink->out(); $outputdata->returntext = $returntext; -$outputdata->files = $files->children; +$outputdata->files = (isset($files->children)) ? $files->children : []; $outputdata->category = $mform->render(); $outputdata->coursename = $coursename; $outputdata->courseid = $courseid; +$outputdata->apikey = $apikey; +$outputdata->search = $search; $outputdata->libraries = new StdClass(); $outputdata->libraries->items = []; $outputdata->libraries->hasitems = false; $outputdata->libraries->current = $cacheid; -if ($external) { - $outputdata->libraries->external = $cacheid; +if ($external === stack_question_library::GITHUB) { + $outputdata->libraries->isgithub = true; } $libraries = glob("{$CFG->dataroot}/stack/sitelibrary/*"); @@ -213,6 +232,11 @@ $outputdata->libraries->hasitems = true; } +unset($urlparams['name']); +$urlparams['location'] = stack_question_library::NRWSEARCH; +$outputdata->searchurl = new moodle_url('/question/type/stack/questionlibrary.php', $urlparams); +$outputdata->searchurl = $outputdata->searchurl->out(); + echo $OUTPUT->render_from_template('qtype_stack/questionlibrary', $outputdata); // Finish output. diff --git a/stack/maxima/stackmaxima.mac b/stack/maxima/stackmaxima.mac index 773469147fa..28727fe864e 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:2026022302$ +stackmaximaversion:2026042100$ diff --git a/stack/questionlibrary.class.php b/stack/questionlibrary.class.php index 8f0bbc2cf09..5c1b9256322 100644 --- a/stack/questionlibrary.class.php +++ b/stack/questionlibrary.class.php @@ -51,6 +51,11 @@ class stack_question_library { * @var string */ public const STACKLIB = 'stacklibrary'; + /** + * NRW API identifier + * @var string + */ + public const NRWSEARCH = 'nrwsearch'; /** * Summary of render_question @@ -60,7 +65,12 @@ class stack_question_library { */ public static function render_question(object $question): string { global $CFG; - StackSeedHelper::initialize_seed($question, null); + try { + StackSeedHelper::initialize_seed($question, null); + } catch (stack_exception $e) { + // XML has no deployed seeds but we don't care in the library. + $question->seed = 0; + } // Handle Pluginfiles. $storeprefix = uniqid(); @@ -181,14 +191,16 @@ public static function get_file_list(string $dir): object { * retrieval of file info, paerticularly the file URL. * This is a wrapper function to make it easier to support different repo types. * See questionfolder.mustache for output and usage. - * @param string $url URL of the directory required - * @param string $repotype The type of repo being searched. (Currently only GitHub is supported) + * @param string|array $detail URL of the directory required or search term and apikey + * @param string $repotype The type of repo being searched. * @return array [object StdClass structured representation of the file system, array flat array of file objects] */ - public static function get_file_list_from_repo($url, $repotype) { + public static function get_file_list_from_repo($detail, $repotype) { switch ($repotype) { case self::GITHUB: - return self::list_github_repo($url); + return self::list_github_repo($detail); + case self::NRWSEARCH: + return self::list_nrw_search($detail); default: return [new StdClass(), []]; } @@ -202,10 +214,12 @@ public static function get_file_list_from_repo($url, $repotype) { * @param string $repotype * @return void */ - public static function get_external_file($requestedfile, $repotype) { + public static function get_external_file($requestedfile, $repotype, $apikey) { switch ($repotype) { case self::GITHUB: return self::get_external_github_file($requestedfile); + case self::NRWSEARCH: + return self::get_external_nrw_file($requestedfile, $apikey); default: return null; } @@ -252,6 +266,7 @@ public static function list_github_repo(string $githuburl) { curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_USERAGENT, 'Moodle-STACK'); // GitHub requires a user agent. curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/vnd.github.v3+json']); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/vnd.github.v3+json']); curl_setopt($ch, CURLOPT_TIMEOUT, 10); $files = []; @@ -286,6 +301,48 @@ public static function list_github_repo(string $githuburl) { return [self::format_file_list($files), $flatarray]; } + /** + * Retrieves a list of all the files in a GitHub repo via API + * @param array $details - search term and apikey + * @return array [object StdClass structured representation of the file system, array flat array of file objects] + */ + public static function list_nrw_search(array $details) { + $apibase = "https://vmits1614.vm.ruhr-uni-bochum.de:8742/questions/search?fields=id,data"; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_USERAGENT, 'Moodle-STACK'); // GitHub requires a user agent. + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/vnd.github.v3+json']); + curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer {$details['apikey']}"]); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + + $files = new StdClass(); + $files->divid = 'stack-library-folder-1'; + $files->children = []; + $files->isdirectory = 1; + $files->label = '.'; + + // Always use the git/trees API with recursive=1, then filter by subpath. + $apiurl = "{$apibase}&q={$details['search']}"; + curl_setopt($ch, CURLOPT_URL, $apiurl); + $response = curl_exec($ch); + $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($response === false || $httpcode >= 400) { + return []; + } + $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' => '', + ]; + } + + return [$files, []]; + } + /** * Take an array of file objects and format into an object with a directory-style structure. * The file objects should be in the form: @@ -391,7 +448,7 @@ public static function format_file_list($filelist) { */ public static function get_external_github_file($requestedfile) { $headers = [ - 'User-Agent: PHP', + 'User-Agent: Moodle-STACK', 'Accept: application/vnd.github.v3+json', ]; @@ -425,4 +482,34 @@ 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 + * @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/"; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_USERAGENT, 'Moodle-STACK'); // GitHub requires a user agent. + curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer {$apikey}"]); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + $apiurl = "{$apibase}{$requestedid}?fields=xml"; + curl_setopt($ch, CURLOPT_URL, $apiurl); + $response = curl_exec($ch); + $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($response === false || $httpcode >= 400) { + throw new \stack_exception('File unavailable.'); + } + + $json = json_decode($response, true); + if (!$json) { + throw new \stack_exception('Invalid JSON.'); + } + + return $json['xml']; + } } diff --git a/templates/questionlibrary.mustache b/templates/questionlibrary.mustache index c4a80d60be1..377bf77310a 100644 --- a/templates/questionlibrary.mustache +++ b/templates/questionlibrary.mustache @@ -97,23 +97,33 @@ {{#str}} stack_library_select, qtype_stack {{/str}} - {{#libraries.external}} + {{#libraries.isgithub}} - {{/libraries.external}} + {{/libraries.isgithub}}

{{/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":";;;;;;;;AAuBAA,6BAAO,CACH,YACA,wBACD,SACCC,KACAC,kBAGIC,SAAW,KACXC,WAAa,KACbC,QAAU,KACVC,WAAa,KACbC,OAAS,KACTC,aAAe,KACfC,eAAiB,KACjBC,cAAgB,KAChBC,iBAAmB,KACnBC,iBAAmB,KACnBC,qBAAuB,KACvBC,qBAAuB,KACvBC,aAAe,KACfC,SAAW,KACXC,SAAW,KACXC,SAAW,KACXC,gBAAkB,KAClBC,YAAc,cAuDTC,cAAcC,OACfC,SAAWD,EAAEE,OAAOC,aAAa,iBACrCL,YAAcG,SACdG,SAAQ,GACRtB,WAAauB,OAAOC,SAASC,eAAe,eAAeC,MAAMC,MAAM,KAAK,IAC5E9B,KAAK+B,KAAK,CAAC,CACPC,WAAY,6BACZC,KAAM,CAACC,SAAU/B,WAAYmB,SAAUA,SAAUa,QAAS/B,QAASgC,OAAQ,IAC3EC,KAAM,SAASC,UACXb,SAAQ,GACRpB,WAAWkC,UAAYD,SAASE,mBAC3B,MAAMC,UAAUH,SAASI,QAC1BC,QAAQ,CAAC,2BACL,SAASC,YACLA,WAAWC,cACPJ,OAAOK,SACPL,OAAOM,QACPN,OAAOO,YACPP,OAAOQ,MACPR,OAAOS,UACPT,OAAOU,SAIvB7C,OAAO8C,UAAYd,SAASe,aAC5B7C,eAAe+B,UAAYD,SAASgB,oBACpC/C,aAAagC,UAAYD,SAASiB,kBAAkBC,QAAQ,KAAM,SAClE1C,aAAayB,UAAYD,SAASmB,aAAe,QAAUnC,SAASQ,MAAM,KAAK4B,MAAQ,IACvF/B,SAASgC,iBAAiB,2BACrBC,SAAQC,IAAMA,GAAGC,gBAAgB,YACtCnC,SAASoC,cAAc,wBAAwBD,gBAAgB,YAC3DxC,SAAS0C,SAAS,eAClBrC,SAASoC,cAAc,kCAAkCE,aAAa,UAAU,GAChFtC,SAASoC,cAAc,yBAAyBD,gBAAgB,YAEhEnC,SAASoC,cAAc,yBAAyBE,aAAa,UAAU,GACvEtC,SAASoC,cAAc,kCAAkCD,gBAAgB,UACzD,cAAZ1D,SACAuB,SAASoC,cAAc,+BAA+BD,gBAAgB,aAI9E7D,aAAaiE,2BAA2B7D,aAE5C8D,KAAM,SAAS7B,UACXb,SAAQ,GACRP,gBAAgBqB,UAAaD,SAAS8B,QAAW9B,SAAS8B,QAAU,GACpEnD,SAASoD,QAAS,eAUrBC,cAAcC,cACdpD,yBAGCG,SAAWH,YACjBM,SAAQ,GACRtB,WAAauB,OAAOC,SAASC,eAAe,eAAeC,MAAMC,MAAM,KAAK,IAC5E9B,KAAK+B,KAAK,CAAC,CACPC,WAAY,6BACZC,KAAM,CACFuC,SAAUtE,SACVgC,SAAU/B,WACVmB,SAAUA,SACVmD,SAAWF,SAAY,EAAI,EAC3BpC,QAAS/B,QACTgC,OAAQ,IAEZC,KAAM,SAASC,UACXb,SAAQ,OACH,MAAMiD,mBAAmBpC,YACtBoC,gBAAgBC,QAAS,KACrBC,gBAAkB5D,SAAW0D,gBAAgBG,WAC7CH,gBAAgBI,QAChBrE,cAAc8B,WAAa,gCACrBqC,gBAAkB,KAAOF,gBAAgBjB,aAAe,OACvDiB,gBAAgBK,SAASf,SAAS,cACzCvD,cAAc8B,WAAa,gCACrBxB,SAAW,OAAS2D,gBAAgBG,WAAa,KACjDH,gBAAgBjB,aAAe,OAErChD,cAAc8B,WAAa,OAASmC,gBAAgBjB,aAExD7C,qBAAqB2B,WAAa,OAC9BmC,gBAAgBK,SAASjD,MAAM,KAAK4B,MAAQ,WAAUgB,gBAAgBjB,aAC1E/C,iBAAiBoD,gBAAgB,eAEjCjD,qBAAqB0B,WAAa,OAC9BmC,gBAAgBK,SAASjD,MAAM,KAAK4B,MACxC/C,iBAAiBmD,gBAAgB,WAI7CK,KAAM,SAAS7B,UACXb,SAAQ,GACRP,gBAAgBqB,UAAaD,SAAS8B,QAAW9B,SAAS8B,QAAU,GACpEnD,SAASoD,QAAS,eAUrB5C,QAAQuD,WACb/D,SAASoD,QAAS,EACdW,WACArD,SAASoC,cAAc,oBAAoBD,gBAAgB,UAC3DnC,SAASoC,cAAc,wBAAwBE,aAAa,WAAY,YACxEtC,SAASoC,cAAc,+BAA+BE,aAAa,WAAY,YAC/EtC,SAASgC,iBAAiB,sBAAsBC,SAAQC,IAAMA,GAAGI,aAAa,WAAY,cAC1FrD,qBAAqB2B,UAAY,GACjC7B,iBAAiBuD,aAAa,UAAU,GACxCpD,qBAAqB0B,UAAY,GACjC5B,iBAAiBsD,aAAa,UAAU,KAExCtC,SAASoC,cAAc,oBAAoBE,aAAa,UAAU,GAClEtC,SAASgC,iBAAiB,sBAAsBC,SAAQC,IAAMA,GAAGC,gBAAgB,qBAKlF,CACHmB,iBAnLA5E,WAAasB,SAASoC,cAAc,0BACpCzD,OAASqB,SAASoC,cAAc,8BAChCxD,aAAeoB,SAASoC,cAAc,oCACtCtD,cAAgBkB,SAASoC,cAAc,gCACvCjD,aAAea,SAASoC,cAAc,oCACtCvD,eAAiBmB,SAASoC,cAAc,sCACxC9C,SAAWU,SAASoC,cAAc,wBAClC7C,gBAAkBS,SAASoC,cAAc,gCACzCrD,iBAAmBiB,SAASoC,cAAc,iCAC1CpD,iBAAmBgB,SAASoC,cAAc,iCAC1CnD,qBAAuBe,SAASoC,cAAc,sCAC9ClD,qBAAuBc,SAASoC,cAAc,sCAC9C/C,SAAWW,SAASoC,cAAc,0BAA0BxB,UAAU2C,OACtEnE,SAAWY,SAASoC,cAAc,qBAAqBxB,UAAU2C,OACjElE,SAAWA,SAASmE,SAAS,KAAOnE,UAAsB,eAAiBA,UAAsB,eACjGS,SAAQ,GACWE,SAASgC,iBAAiB,sBAClCC,SAAQ,SAASwB,MACxBA,KAAKC,iBAAiB,QAASjE,kBAEnClB,SAAWyB,SAASoC,cAAc,uCAAuCvC,aAAa,cACtFpB,QAAUuB,SAASoC,cAAc,8BAA8BvC,aAAa,cACvDG,SAASoC,cAAc,wBAC/BsB,iBAAiB,SAAS,IAAIf,eAAc,KAC9B3C,SAASoC,cAAc,+BAC/BsB,iBAAiB,SAAS,IAAIf,eAAc,WAGzDgB,WAAa3D,SAASgC,iBAAiB,2BACxC,IAAI4B,UAAUD,WAAY,OAErBE,SADWD,OAAOE,KACI3D,MAAM,KAC9B0D,SAASE,OAAS,IACdF,SAAS,IAAMA,SAASE,OAAS,KACjCF,SAAS9B,MACT6B,OAAOE,KAAOD,SAASG,KAAK,MAIxClE,SAAQ"} \ 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 @@
+ {{#fileserror}} +
+ {{fileserror}} +
+ {{/fileserror}} {{#files}} {{>qtype_stack/questionfolder}} {{/files}} From d447b8a8a50835967d8ff588b2f764f53a44cf3b Mon Sep 17 00:00:00 2001 From: Edmund Farrow Date: Fri, 24 Apr 2026 15:14:54 +0100 Subject: [PATCH 13/31] Nrw-external-api - Remove rogue file --- .vscode/launch.json | 88 --------------------------------------------- 1 file changed, 88 deletions(-) delete mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 927b38df72d..00000000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - - { - "name": "Listen for Xdebug", - "type": "php", - "request": "launch", - "port": 9003 - }, - { - "name": "Launch currently open script", - "type": "php", - "request": "launch", - "program": "${file}", - "cwd": "${fileDirname}", - "port": 0, - "runtimeArgs": [ - "-dxdebug.start_with_request=yes" - ], - "env": { - "XDEBUG_MODE": "debug,develop", - "XDEBUG_CONFIG": "client_port=${port}" - } - }, - { - "name": "Launch Built-in web server", - "type": "php", - "request": "launch", - "runtimeArgs": [ - "-dxdebug.mode=debug", - "-dxdebug.start_with_request=yes", - "-S", - "localhost:0" - ], - "program": "", - "cwd": "${workspaceRoot}", - "port": 9003, - "serverReadyAction": { - "pattern": "Development Server \\(http://localhost:([0-9]+)\\) started", - "uriFormat": "http://localhost:%s", - "action": "openExternally" - } - }, - { - "name": "Launch built-in server and Debug", - "type": "php", - "request": "launch", - "noDebug": false, - "runtimeArgs": [ - "-S", - "localhost:8000", - "-t", - "." - ], - "cwd": "${workspaceRoot}/.", - "serverReadyAction": { - "action": "openExternally" - } - }, - { - "name": "Launch built-in server and Profile", - "type": "php", - "request": "launch", - "noDebug": true, - "runtimeArgs": [ - "-S", - "localhost:8000", - "-t", - "." - ], - "cwd": "${workspaceRoot}/.", - "serverReadyAction": { - "action": "openExternally" - }, - "profile": true, - "openProfile": true - }, - { - "name": "Listen for Xdebug", - "type": "php", - "request": "launch" - } - ] -} \ No newline at end of file From 43faa5c3d9ebc277381a9350d525b6b741b20505 Mon Sep 17 00:00:00 2001 From: Edmund Farrow Date: Fri, 24 Apr 2026 16:16:03 +0100 Subject: [PATCH 14/31] nrw-external-api - Hide search if no API key config. Add more data to search result display. --- lang/en/qtype_stack.php | 4 +++- questionlibrary.php | 2 +- stack/questionlibrary.class.php | 5 +++++ templates/questionfolder.mustache | 16 ++++++++++++++++ templates/questionlibrary.mustache | 2 ++ 5 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lang/en/qtype_stack.php b/lang/en/qtype_stack.php index fc87e352eb7..efbcf6bc1b1 100644 --- a/lang/en/qtype_stack.php +++ b/lang/en/qtype_stack.php @@ -1869,14 +1869,16 @@ $string['stack_library_import'] = 'Import'; $string['stack_library_importlist'] = 'Imported questions:'; $string['stack_library_import_folder'] = 'Import folder'; +$string['stack_library_license'] = 'License'; $string['stack_library_quiz'] = 'This is a quiz:'; $string['stack_library_quiz_course'] = 'The quiz will be imported into course: '; $string['stack_library_quiz_prefix'] = 'Quiz:'; $string['stack_library_refresh'] = 'Refresh library contents'; $string['stack_library_selected'] = 'Displayed question:'; $string['stack_library_select'] = 'Select library:'; +$string['stack_library_source'] = 'Source'; $string['stack_library_nrw'] = 'Search NRW database'; -$string['stack_library_apikey'] = 'using API key'; +$string['stack_library_subject'] = 'Subject'; $string['stack_library_success'] = 'Successful import of:'; $string['stack_library_not_stack'] = 'This is not a STACK question and so cannot be fully rendered here but you can still import it.'; $string['stack_library_quiz_return'] = 'Return to quiz'; diff --git a/questionlibrary.php b/questionlibrary.php index c41c71d3c5e..cf5f22a7317 100644 --- a/questionlibrary.php +++ b/questionlibrary.php @@ -187,7 +187,7 @@ $outputdata->category = $mform->render(); $outputdata->coursename = $coursename; $outputdata->courseid = $courseid; -$outputdata->apikey = $apikey; +$outputdata->isnrwapikey = (get_config('qtype_stack', 'nrwapikey')) ? true : false; $outputdata->search = $search; $outputdata->libraries = new StdClass(); $outputdata->libraries->items = []; diff --git a/stack/questionlibrary.class.php b/stack/questionlibrary.class.php index 4473610f791..5f6d097c11c 100644 --- a/stack/questionlibrary.class.php +++ b/stack/questionlibrary.class.php @@ -345,6 +345,11 @@ public static function list_nrw_search(array $details) { foreach ($data['results'] as $item) { $files->children[] = (object)[ 'label' => $item['question']['data']['title'], + 'description' => $item['question']['data']['description'], + 'license' => $item['question']['data']['license'], + 'source' => $item['question']['data']['source'], + 'subject' => (is_array($item['question']['data']['subject'])) ? + implode(', ', $item['question']['data']['subject']) : $item['question']['data']['subject'], 'path' => $item['question']['id'], 'isdirectory' => 0, 'url' => '', diff --git a/templates/questionfolder.mustache b/templates/questionfolder.mustache index 5bf29293568..e2d1a934034 100644 --- a/templates/questionfolder.mustache +++ b/templates/questionfolder.mustache @@ -72,5 +72,21 @@ + {{#subject}} +
+ {{#str}} stack_library_subject, qtype_stack {{/str}}: {{subject}} + {{/subject}} + {{#source}} +
+ {{#str}} stack_library_source, qtype_stack {{/str}}: {{source}} + {{/source}} + {{#license}} +
+ {{#str}} stack_library_license, qtype_stack {{/str}}: {{license}} + {{/license}} + {{#description}} +
+ {{description}} + {{/description}} {{/isdirectory}}
\ No newline at end of file diff --git a/templates/questionlibrary.mustache b/templates/questionlibrary.mustache index 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}} + + {{/general.canexporttonrw}} 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}} + + {{/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
- +