From fc9e45b1b0c60a6d3c5676f81608e5254870a817 Mon Sep 17 00:00:00 2001 From: Edmund Farrow Date: Fri, 20 Feb 2026 15:37:11 +0000 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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);