diff --git a/amd/build/package_search/components/area.min.js b/amd/build/package_search/components/area.min.js
index 99cd28f7..d12626a1 100644
--- a/amd/build/package_search/components/area.min.js
+++ b/amd/build/package_search/components/area.min.js
@@ -1,3 +1,3 @@
-define("qtype_questionpy/package_search/components/area",["exports","qtype_questionpy/package_search/component","qtype_questionpy/package_search/components/container","qtype_questionpy/package_search/components/search_bar"],(function(_exports,_component,_container,_search_bar){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_component=_interopRequireDefault(_component),_container=_interopRequireDefault(_container),_search_bar=_interopRequireDefault(_search_bar);class _default extends _component.default{getWatchers(){return[{watch:"general.loading:updated",handler:this.updateStatus}]}create(descriptor){new _search_bar.default({element:this.getElement('[data-for="search-bar-container"'),name:"search_bar",reactive:descriptor.reactive}),new _container.default({element:this.getElement('[data-for="package-container"'),name:"container",reactive:descriptor.reactive})}stateReady(){this.reactive.dispatch("searchPackages")}updateStatus(){const loading=this.getState().general.loading;this.element.classList.toggle("qpy-loading",loading)}}return _exports.default=_default,_exports.default}));
+define("qtype_questionpy/package_search/components/area",["exports","qtype_questionpy/package_search/component","qtype_questionpy/package_search/components/container","qtype_questionpy/package_search/components/upload","qtype_questionpy/package_search/components/search_bar"],(function(_exports,_component,_container,_upload,_search_bar){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_component=_interopRequireDefault(_component),_container=_interopRequireDefault(_container),_upload=_interopRequireDefault(_upload),_search_bar=_interopRequireDefault(_search_bar);class _default extends _component.default{getWatchers(){return[{watch:"general.loading:updated",handler:this.updateStatus}]}create(descriptor){new _search_bar.default({element:this.getElement('[data-for="search-bar-container"'),name:"search_bar",reactive:descriptor.reactive}),new _upload.default({element:this.getElement('[data-for="upload-button"]'),name:"upload_button",reactive:descriptor.reactive}),new _container.default({element:this.getElement('[data-for="package-container"'),name:"container",reactive:descriptor.reactive})}stateReady(){this.reactive.dispatch("searchPackages")}updateStatus(){const loading=this.getState().general.loading;this.element.classList.toggle("qpy-loading",loading)}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=area.min.js.map
\ No newline at end of file
diff --git a/amd/build/package_search/components/area.min.js.map b/amd/build/package_search/components/area.min.js.map
index 4936008f..e48e31db 100644
--- a/amd/build/package_search/components/area.min.js.map
+++ b/amd/build/package_search/components/area.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"area.min.js","sources":["../../../src/package_search/components/area.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.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/**\n * @module qtype_questionpy/package_search/components/area\n */\n\nimport Component from 'qtype_questionpy/package_search/component';\nimport Container from 'qtype_questionpy/package_search/components/container';\nimport SearchBar from 'qtype_questionpy/package_search/components/search_bar';\n\nexport default class extends Component {\n getWatchers() {\n return [\n {watch: `general.loading:updated`, handler: this.updateStatus},\n ];\n }\n\n create(descriptor) {\n // Register search bar.\n new SearchBar({\n element: this.getElement('[data-for=\"search-bar-container\"'),\n name: \"search_bar\",\n reactive: descriptor.reactive,\n });\n // Register package container.\n new Container({\n element: this.getElement('[data-for=\"package-container\"'),\n name: \"container\",\n reactive: descriptor.reactive,\n });\n }\n\n stateReady() {\n // Initial loading of the packages.\n this.reactive.dispatch(\"searchPackages\");\n }\n\n /**\n * Adds or removes the `qpy-loading` class from the search area.\n */\n updateStatus() {\n const loading = this.getState().general.loading;\n this.element.classList.toggle(\"qpy-loading\", loading);\n }\n}\n"],"names":["Component","getWatchers","watch","handler","this","updateStatus","create","descriptor","SearchBar","element","getElement","name","reactive","Container","stateReady","dispatch","loading","getState","general","classList","toggle"],"mappings":"2lBAyB6BA,mBACzBC,oBACW,CACH,CAACC,gCAAkCC,QAASC,KAAKC,eAIzDC,OAAOC,gBAECC,oBAAU,CACVC,QAASL,KAAKM,WAAW,oCACzBC,KAAM,aACNC,SAAUL,WAAWK,eAGrBC,mBAAU,CACVJ,QAASL,KAAKM,WAAW,iCACzBC,KAAM,YACNC,SAAUL,WAAWK,WAI7BE,kBAESF,SAASG,SAAS,kBAM3BV,qBACUW,QAAUZ,KAAKa,WAAWC,QAAQF,aACnCP,QAAQU,UAAUC,OAAO,cAAeJ"}
\ No newline at end of file
+{"version":3,"file":"area.min.js","sources":["../../../src/package_search/components/area.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.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/**\n * @module qtype_questionpy/package_search/components/area\n */\n\nimport Component from 'qtype_questionpy/package_search/component';\nimport Container from 'qtype_questionpy/package_search/components/container';\nimport UploadButton from 'qtype_questionpy/package_search/components/upload';\nimport SearchBar from 'qtype_questionpy/package_search/components/search_bar';\n\nexport default class extends Component {\n getWatchers() {\n return [\n {watch: `general.loading:updated`, handler: this.updateStatus},\n ];\n }\n\n create(descriptor) {\n // Register search bar.\n // TODO: register component inside mustache template.\n new SearchBar({\n element: this.getElement('[data-for=\"search-bar-container\"'),\n name: \"search_bar\",\n reactive: descriptor.reactive,\n });\n // Register upload button.\n new UploadButton({\n element: this.getElement('[data-for=\"upload-button\"]'),\n name: \"upload_button\",\n reactive: descriptor.reactive,\n });\n // Register package container.\n // TODO: register component inside mustache template.\n new Container({\n element: this.getElement('[data-for=\"package-container\"'),\n name: \"container\",\n reactive: descriptor.reactive,\n });\n }\n\n stateReady() {\n // Initial loading of the packages.\n this.reactive.dispatch(\"searchPackages\");\n }\n\n /**\n * Adds or removes the `qpy-loading` class from the search area.\n */\n updateStatus() {\n const loading = this.getState().general.loading;\n this.element.classList.toggle(\"qpy-loading\", loading);\n }\n}\n"],"names":["Component","getWatchers","watch","handler","this","updateStatus","create","descriptor","SearchBar","element","getElement","name","reactive","UploadButton","Container","stateReady","dispatch","loading","getState","general","classList","toggle"],"mappings":"+rBA0B6BA,mBACzBC,oBACW,CACH,CAACC,gCAAkCC,QAASC,KAAKC,eAIzDC,OAAOC,gBAGCC,oBAAU,CACVC,QAASL,KAAKM,WAAW,oCACzBC,KAAM,aACNC,SAAUL,WAAWK,eAGrBC,gBAAa,CACbJ,QAASL,KAAKM,WAAW,8BACzBC,KAAM,gBACNC,SAAUL,WAAWK,eAIrBE,mBAAU,CACVL,QAASL,KAAKM,WAAW,iCACzBC,KAAM,YACNC,SAAUL,WAAWK,WAI7BG,kBAESH,SAASI,SAAS,kBAM3BX,qBACUY,QAAUb,KAAKc,WAAWC,QAAQF,aACnCR,QAAQW,UAAUC,OAAO,cAAeJ"}
\ No newline at end of file
diff --git a/amd/build/package_search/components/container.min.js.map b/amd/build/package_search/components/container.min.js.map
index 0a2e73cb..22fb7806 100644
--- a/amd/build/package_search/components/container.min.js.map
+++ b/amd/build/package_search/components/container.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"container.min.js","sources":["../../../src/package_search/components/container.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.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/**\n * @module qtype_questionpy/package_search/components/container\n */\n\nimport * as templates from 'core/templates';\nimport Component from 'qtype_questionpy/package_search/component';\nimport TabHeader from 'qtype_questionpy/package_search/components/tab_header';\nimport TabContent from 'qtype_questionpy/package_search/components/tab_content';\n\nexport default class extends Component {\n async create(descriptor) {\n // Register header and content of tabs.\n for (const category of [\"all\", \"recentlyused\", \"favourites\", \"mine\"]) {\n new TabHeader({\n element: this.getElement(`[data-for=\"${category}-header\"]`),\n name: `category_${category}_header`,\n reactive: descriptor.reactive,\n category: category,\n });\n new TabContent({\n element: this.getElement(`[data-for=\"${category}-content\"]`),\n name: `category_${category}_header`,\n reactive: descriptor.reactive,\n category: category,\n });\n }\n\n // Prefetch the package template for faster rendering.\n templates.prefetchTemplates([\"qtype_questionpy/package/package_selection\"]);\n }\n}\n"],"names":["Component","descriptor","category","TabHeader","element","this","getElement","name","reactive","TabContent","templates","prefetchTemplates"],"mappings":"wjDA0B6BA,gCACZC,gBAEJ,MAAMC,WAAY,CAAC,MAAO,eAAgB,aAAc,YACrDC,oBAAU,CACVC,QAASC,KAAKC,gCAAyBJ,uBACvCK,wBAAkBL,oBAClBM,SAAUP,WAAWO,SACrBN,SAAUA,eAEVO,qBAAW,CACXL,QAASC,KAAKC,gCAAyBJ,wBACvCK,wBAAkBL,oBAClBM,SAAUP,WAAWO,SACrBN,SAAUA,WAKlBQ,UAAUC,kBAAkB,CAAC"}
\ No newline at end of file
+{"version":3,"file":"container.min.js","sources":["../../../src/package_search/components/container.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.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/**\n * @module qtype_questionpy/package_search/components/container\n */\n\nimport * as templates from 'core/templates';\nimport Component from 'qtype_questionpy/package_search/component';\nimport TabHeader from 'qtype_questionpy/package_search/components/tab_header';\nimport TabContent from 'qtype_questionpy/package_search/components/tab_content';\n\nexport default class extends Component {\n async create(descriptor) {\n // Register header and content of tabs.\n // TODO: register components inside mustache template.\n for (const category of [\"all\", \"recentlyused\", \"favourites\", \"mine\"]) {\n new TabHeader({\n element: this.getElement(`[data-for=\"${category}-header\"]`),\n name: `category_${category}_header`,\n reactive: descriptor.reactive,\n category: category,\n });\n new TabContent({\n element: this.getElement(`[data-for=\"${category}-content\"]`),\n name: `category_${category}_header`,\n reactive: descriptor.reactive,\n category: category,\n });\n }\n\n // Prefetch the package template for faster rendering.\n templates.prefetchTemplates([\"qtype_questionpy/package/package_selection\"]);\n }\n}\n"],"names":["Component","descriptor","category","TabHeader","element","this","getElement","name","reactive","TabContent","templates","prefetchTemplates"],"mappings":"wjDA0B6BA,gCACZC,gBAGJ,MAAMC,WAAY,CAAC,MAAO,eAAgB,aAAc,YACrDC,oBAAU,CACVC,QAASC,KAAKC,gCAAyBJ,uBACvCK,wBAAkBL,oBAClBM,SAAUP,WAAWO,SACrBN,SAAUA,eAEVO,qBAAW,CACXL,QAASC,KAAKC,gCAAyBJ,wBACvCK,wBAAkBL,oBAClBM,SAAUP,WAAWO,SACrBN,SAAUA,WAKlBQ,UAAUC,kBAAkB,CAAC"}
\ No newline at end of file
diff --git a/amd/build/package_search/components/package.min.js b/amd/build/package_search/components/package.min.js
index 4e3bfc0d..9b36679f 100644
--- a/amd/build/package_search/components/package.min.js
+++ b/amd/build/package_search/components/package.min.js
@@ -1,3 +1,3 @@
-define("qtype_questionpy/package_search/components/package",["exports","qtype_questionpy/package_search/component"],(function(_exports,_component){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_component=(obj=_component)&&obj.__esModule?obj:{default:obj};class _default extends _component.default{getWatchers(){return[{watch:"".concat(this.category,"Packages[").concat(this.packageid,"].isfavourite:updated"),handler:this.favouriteChanged}]}create(descriptor){this.packageid=descriptor.packageid,this.category=descriptor.category,this.selectors={FAVOURITE_BUTTON:'[data-for="favourite-button"]'}}isFavourite(){return this.getState()["".concat(this.category,"Packages")].get(this.packageid).isfavourite}stateReady(){this.addEventListener(this.getElement(this.selectors.FAVOURITE_BUTTON),"click",(()=>{this.reactive.dispatch("favourite",this.packageid,!this.isFavourite())}))}async favouriteChanged(){const isFavourite=this.isFavourite();this.getElement(this.selectors.FAVOURITE_BUTTON).toggleAttribute("data-is-favourite",isFavourite)}}return _exports.default=_default,_exports.default}));
+define("qtype_questionpy/package_search/components/package",["exports","qtype_questionpy/package_search/component"],(function(_exports,_component){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_component=(obj=_component)&&obj.__esModule?obj:{default:obj};class _default extends _component.default{getWatchers(){return[{watch:"".concat(this.category,"Packages[").concat(this.packageid,"].isfavourite:updated"),handler:this.favouriteChanged}]}create(descriptor){this.packageid=descriptor.packageid,this.category=descriptor.category,this.selectors={FAVOURITE_BUTTON:'[data-for="favourite-button"]',DOWNLOAD_BUTTON:'[data-for="download-button"]',VERSION_SELECTION:".qpy-version-selection"}}isFavourite(){return this.getState()["".concat(this.category,"Packages")].get(this.packageid).isfavourite}stateReady(){this.addEventListener(this.getElement(this.selectors.FAVOURITE_BUTTON),"click",(()=>{this.reactive.dispatch("favourite",this.packageid,!this.isFavourite())})),this.addEventListener(this.getElement(this.selectors.VERSION_SELECTION),"change",(()=>{this.setUpDownloadButton()})),this.setUpDownloadButton()}setUpDownloadButton(){const selection=this.getElement(this.selectors.VERSION_SELECTION),option=selection.options[selection.selectedIndex],button=this.getElement(this.selectors.DOWNLOAD_BUTTON);button.classList.toggle("d-none",!option.hasAttribute("data-is-mine")),button.href=option.dataset.fileurl}async favouriteChanged(){const isFavourite=this.isFavourite();this.getElement(this.selectors.FAVOURITE_BUTTON).toggleAttribute("data-is-favourite",isFavourite)}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=package.min.js.map
\ No newline at end of file
diff --git a/amd/build/package_search/components/package.min.js.map b/amd/build/package_search/components/package.min.js.map
index f52e8d13..76d1820d 100644
--- a/amd/build/package_search/components/package.min.js.map
+++ b/amd/build/package_search/components/package.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"package.min.js","sources":["../../../src/package_search/components/package.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.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/**\n * @module qtype_questionpy/package_search/components/package\n */\n\nimport Component from 'qtype_questionpy/package_search/component';\n\nexport default class extends Component {\n getWatchers() {\n return [\n {watch: `${this.category}Packages[${this.packageid}].isfavourite:updated`, handler: this.favouriteChanged},\n ];\n }\n\n create(descriptor) {\n this.packageid = descriptor.packageid;\n this.category = descriptor.category;\n this.selectors = {\n FAVOURITE_BUTTON: '[data-for=\"favourite-button\"]',\n };\n }\n\n isFavourite() {\n return this.getState()[`${this.category}Packages`].get(this.packageid).isfavourite;\n }\n\n stateReady() {\n this.addEventListener(this.getElement(this.selectors.FAVOURITE_BUTTON), \"click\", () => {\n this.reactive.dispatch(\"favourite\", this.packageid, !this.isFavourite());\n });\n }\n\n async favouriteChanged() {\n const isFavourite = this.isFavourite();\n this.getElement(this.selectors.FAVOURITE_BUTTON).toggleAttribute(\"data-is-favourite\", isFavourite);\n }\n}\n"],"names":["Component","getWatchers","watch","this","category","packageid","handler","favouriteChanged","create","descriptor","selectors","FAVOURITE_BUTTON","isFavourite","getState","get","isfavourite","stateReady","addEventListener","getElement","reactive","dispatch","toggleAttribute"],"mappings":"gUAuB6BA,mBACzBC,oBACW,CACH,CAACC,gBAAUC,KAAKC,6BAAoBD,KAAKE,mCAAkCC,QAASH,KAAKI,mBAIjGC,OAAOC,iBACEJ,UAAYI,WAAWJ,eACvBD,SAAWK,WAAWL,cACtBM,UAAY,CACbC,iBAAkB,iCAI1BC,qBACWT,KAAKU,qBAAcV,KAAKC,sBAAoBU,IAAIX,KAAKE,WAAWU,YAG3EC,kBACSC,iBAAiBd,KAAKe,WAAWf,KAAKO,UAAUC,kBAAmB,SAAS,UACxEQ,SAASC,SAAS,YAAajB,KAAKE,WAAYF,KAAKS,iDAKxDA,YAAcT,KAAKS,mBACpBM,WAAWf,KAAKO,UAAUC,kBAAkBU,gBAAgB,oBAAqBT"}
\ No newline at end of file
+{"version":3,"file":"package.min.js","sources":["../../../src/package_search/components/package.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.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/**\n * @module qtype_questionpy/package_search/components/package\n */\n\nimport Component from 'qtype_questionpy/package_search/component';\n\nexport default class extends Component {\n getWatchers() {\n return [\n {watch: `${this.category}Packages[${this.packageid}].isfavourite:updated`, handler: this.favouriteChanged},\n ];\n }\n\n create(descriptor) {\n this.packageid = descriptor.packageid;\n this.category = descriptor.category;\n this.selectors = {\n FAVOURITE_BUTTON: '[data-for=\"favourite-button\"]',\n DOWNLOAD_BUTTON: '[data-for=\"download-button\"]',\n VERSION_SELECTION: '.qpy-version-selection',\n };\n }\n\n isFavourite() {\n return this.getState()[`${this.category}Packages`].get(this.packageid).isfavourite;\n }\n\n stateReady() {\n this.addEventListener(this.getElement(this.selectors.FAVOURITE_BUTTON), \"click\", () => {\n this.reactive.dispatch(\"favourite\", this.packageid, !this.isFavourite());\n });\n\n this.addEventListener(this.getElement(this.selectors.VERSION_SELECTION), \"change\", () => {\n this.setUpDownloadButton();\n });\n\n this.setUpDownloadButton();\n }\n\n setUpDownloadButton() {\n const selection = this.getElement(this.selectors.VERSION_SELECTION);\n const option = selection.options[selection.selectedIndex];\n const button = this.getElement(this.selectors.DOWNLOAD_BUTTON);\n button.classList.toggle(\"d-none\", !option.hasAttribute(\"data-is-mine\"));\n button.href = option.dataset.fileurl;\n }\n\n async favouriteChanged() {\n const isFavourite = this.isFavourite();\n this.getElement(this.selectors.FAVOURITE_BUTTON).toggleAttribute(\"data-is-favourite\", isFavourite);\n }\n}\n"],"names":["Component","getWatchers","watch","this","category","packageid","handler","favouriteChanged","create","descriptor","selectors","FAVOURITE_BUTTON","DOWNLOAD_BUTTON","VERSION_SELECTION","isFavourite","getState","get","isfavourite","stateReady","addEventListener","getElement","reactive","dispatch","setUpDownloadButton","selection","option","options","selectedIndex","button","classList","toggle","hasAttribute","href","dataset","fileurl","toggleAttribute"],"mappings":"gUAuB6BA,mBACzBC,oBACW,CACH,CAACC,gBAAUC,KAAKC,6BAAoBD,KAAKE,mCAAkCC,QAASH,KAAKI,mBAIjGC,OAAOC,iBACEJ,UAAYI,WAAWJ,eACvBD,SAAWK,WAAWL,cACtBM,UAAY,CACbC,iBAAkB,gCAClBC,gBAAiB,+BACjBC,kBAAmB,0BAI3BC,qBACWX,KAAKY,qBAAcZ,KAAKC,sBAAoBY,IAAIb,KAAKE,WAAWY,YAG3EC,kBACSC,iBAAiBhB,KAAKiB,WAAWjB,KAAKO,UAAUC,kBAAmB,SAAS,UACxEU,SAASC,SAAS,YAAanB,KAAKE,WAAYF,KAAKW,uBAGzDK,iBAAiBhB,KAAKiB,WAAWjB,KAAKO,UAAUG,mBAAoB,UAAU,UAC1EU,8BAGJA,sBAGTA,4BACUC,UAAYrB,KAAKiB,WAAWjB,KAAKO,UAAUG,mBAC3CY,OAASD,UAAUE,QAAQF,UAAUG,eACrCC,OAASzB,KAAKiB,WAAWjB,KAAKO,UAAUE,iBAC9CgB,OAAOC,UAAUC,OAAO,UAAWL,OAAOM,aAAa,iBACvDH,OAAOI,KAAOP,OAAOQ,QAAQC,uCAIvBpB,YAAcX,KAAKW,mBACpBM,WAAWjB,KAAKO,UAAUC,kBAAkBwB,gBAAgB,oBAAqBrB"}
\ No newline at end of file
diff --git a/amd/build/package_search/components/tab_content.min.js.map b/amd/build/package_search/components/tab_content.min.js.map
index e0157e10..94edd05e 100644
--- a/amd/build/package_search/components/tab_content.min.js.map
+++ b/amd/build/package_search/components/tab_content.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"tab_content.min.js","sources":["../../../src/package_search/components/tab_content.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.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/**\n * @module qtype_questionpy/package_search/components/tab_content\n */\n\nimport * as templates from 'core/templates';\nimport Notification from 'core/notification';\nimport Component from 'qtype_questionpy/package_search/component';\nimport Pagination from 'qtype_questionpy/package_search/components/pagination';\nimport Sort from 'qtype_questionpy/package_search/components/sort';\nimport Package from 'qtype_questionpy/package_search/components/package';\n\nexport default class extends Component {\n getWatchers() {\n return [\n {watch: `state.${this.category}Packages:updated`, handler: this.render},\n ];\n }\n\n async create(descriptor) {\n this.category = descriptor.category;\n this.selectors = {\n CONTENT: \".qpy-tab-content\",\n SORT: '[data-for=\"sort\"]',\n PAGINATION: '[data-for=\"pagination\"]',\n };\n\n // Register sort if available.\n const sortElement = this.getElement(this.selectors.SORT);\n if (sortElement) {\n new Sort({\n element: sortElement,\n name: `sort_${this.category}`,\n reactive: descriptor.reactive,\n });\n }\n\n // Register pagination.\n new Pagination({\n element: this.getElement(this.selectors.PAGINATION),\n name: `pagiation_${this.category}`,\n reactive: descriptor.reactive,\n category: this.category,\n });\n }\n\n /**\n * Groups render promises for package templates.\n *\n * @param {Object[]} contexts\n * @returns {Promise}\n * @private\n */\n _getPackageTemplatesPromise(contexts) {\n let promises = [];\n for (const context of contexts) {\n // Context is a proxy, we need to get the target.\n const contextObj = Object.assign({}, context);\n const promise = templates.renderForPromise(\"qtype_questionpy/package/package_selection\", contextObj);\n promises.push(promise);\n }\n return Promise.all(promises);\n }\n\n /**\n * Renders every package inside the current state.\n */\n async render() {\n try {\n const packages = Array.from(this.getState()[`${this.category}Packages`].values());\n const packageTemplates = await this._getPackageTemplatesPromise(packages);\n const contentElement = this.getElement(this.selectors.CONTENT);\n contentElement.innerHTML = \"\";\n let index = 0;\n for (const {html, js} of packageTemplates) {\n const packageElement = templates.appendNodeContents(contentElement, html, js)[0];\n new Package({\n element: packageElement,\n category: this.category,\n packageid: packages[index++].id,\n });\n }\n } catch (exception) {\n await Notification.exception(exception);\n }\n }\n}\n"],"names":["Component","getWatchers","watch","this","category","handler","render","descriptor","selectors","CONTENT","SORT","PAGINATION","sortElement","getElement","Sort","element","name","reactive","Pagination","_getPackageTemplatesPromise","contexts","promises","context","contextObj","Object","assign","promise","templates","renderForPromise","push","Promise","all","packages","Array","from","getState","values","packageTemplates","contentElement","innerHTML","index","html","js","packageElement","appendNodeContents","Package","packageid","id","exception","Notification"],"mappings":"4tDA4B6BA,mBACzBC,oBACW,CACH,CAACC,sBAAgBC,KAAKC,6BAA4BC,QAASF,KAAKG,sBAI3DC,iBACJH,SAAWG,WAAWH,cACtBI,UAAY,CACbC,QAAS,mBACTC,KAAM,oBACNC,WAAY,iCAIVC,YAAcT,KAAKU,WAAWV,KAAKK,UAAUE,MAC/CE,iBACIE,cAAK,CACLC,QAASH,YACTI,oBAAcb,KAAKC,UACnBa,SAAUV,WAAWU,eAKzBC,oBAAW,CACXH,QAASZ,KAAKU,WAAWV,KAAKK,UAAUG,YACxCK,yBAAmBb,KAAKC,UACxBa,SAAUV,WAAWU,SACrBb,SAAUD,KAAKC,WAWvBe,4BAA4BC,cACpBC,SAAW,OACV,MAAMC,WAAWF,SAAU,OAEtBG,WAAaC,OAAOC,OAAO,GAAIH,SAC/BI,QAAUC,UAAUC,iBAAiB,6CAA8CL,YACzFF,SAASQ,KAAKH,gBAEXI,QAAQC,IAAIV,mCAQTW,SAAWC,MAAMC,KAAK/B,KAAKgC,qBAAchC,KAAKC,sBAAoBgC,UAClEC,uBAAyBlC,KAAKgB,4BAA4Ba,UAC1DM,eAAiBnC,KAAKU,WAAWV,KAAKK,UAAUC,SACtD6B,eAAeC,UAAY,OACvBC,MAAQ,MACP,MAAMC,KAACA,KAADC,GAAOA,MAAOL,iBAAkB,OACjCM,eAAiBhB,UAAUiB,mBAAmBN,eAAgBG,KAAMC,IAAI,OAC1EG,iBAAQ,CACR9B,QAAS4B,eACTvC,SAAUD,KAAKC,SACf0C,UAAWd,SAASQ,SAASO,MAGvC,MAAOC,iBACCC,sBAAaD,UAAUA"}
\ No newline at end of file
+{"version":3,"file":"tab_content.min.js","sources":["../../../src/package_search/components/tab_content.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.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/**\n * @module qtype_questionpy/package_search/components/tab_content\n */\n\nimport * as templates from 'core/templates';\nimport Notification from 'core/notification';\nimport Component from 'qtype_questionpy/package_search/component';\nimport Pagination from 'qtype_questionpy/package_search/components/pagination';\nimport Sort from 'qtype_questionpy/package_search/components/sort';\nimport Package from 'qtype_questionpy/package_search/components/package';\n\nexport default class extends Component {\n getWatchers() {\n return [\n {watch: `state.${this.category}Packages:updated`, handler: this.render},\n ];\n }\n\n async create(descriptor) {\n this.category = descriptor.category;\n this.selectors = {\n CONTENT: \".qpy-tab-content\",\n SORT: '[data-for=\"sort\"]',\n PAGINATION: '[data-for=\"pagination\"]',\n };\n\n // Register sort if available.\n // TODO: register component inside mustache template.\n const sortElement = this.getElement(this.selectors.SORT);\n if (sortElement) {\n new Sort({\n element: sortElement,\n name: `sort_${this.category}`,\n reactive: descriptor.reactive,\n });\n }\n\n // Register pagination.\n // TODO: register component inside mustache template.\n new Pagination({\n element: this.getElement(this.selectors.PAGINATION),\n name: `pagiation_${this.category}`,\n reactive: descriptor.reactive,\n category: this.category,\n });\n }\n\n /**\n * Groups render promises for package templates.\n *\n * @param {Object[]} contexts\n * @returns {Promise}\n * @private\n */\n _getPackageTemplatesPromise(contexts) {\n let promises = [];\n for (const context of contexts) {\n // Context is a proxy, we need to get the target.\n const contextObj = Object.assign({}, context);\n const promise = templates.renderForPromise(\"qtype_questionpy/package/package_selection\", contextObj);\n promises.push(promise);\n }\n return Promise.all(promises);\n }\n\n /**\n * Renders every package inside the current state.\n */\n async render() {\n try {\n const packages = Array.from(this.getState()[`${this.category}Packages`].values());\n const packageTemplates = await this._getPackageTemplatesPromise(packages);\n const contentElement = this.getElement(this.selectors.CONTENT);\n contentElement.innerHTML = \"\";\n let index = 0;\n for (const {html, js} of packageTemplates) {\n const packageElement = templates.appendNodeContents(contentElement, html, js)[0];\n // TODO: register component inside mustache template.\n new Package({\n element: packageElement,\n category: this.category,\n packageid: packages[index++].id,\n });\n }\n } catch (exception) {\n await Notification.exception(exception);\n }\n }\n}\n"],"names":["Component","getWatchers","watch","this","category","handler","render","descriptor","selectors","CONTENT","SORT","PAGINATION","sortElement","getElement","Sort","element","name","reactive","Pagination","_getPackageTemplatesPromise","contexts","promises","context","contextObj","Object","assign","promise","templates","renderForPromise","push","Promise","all","packages","Array","from","getState","values","packageTemplates","contentElement","innerHTML","index","html","js","packageElement","appendNodeContents","Package","packageid","id","exception","Notification"],"mappings":"4tDA4B6BA,mBACzBC,oBACW,CACH,CAACC,sBAAgBC,KAAKC,6BAA4BC,QAASF,KAAKG,sBAI3DC,iBACJH,SAAWG,WAAWH,cACtBI,UAAY,CACbC,QAAS,mBACTC,KAAM,oBACNC,WAAY,iCAKVC,YAAcT,KAAKU,WAAWV,KAAKK,UAAUE,MAC/CE,iBACIE,cAAK,CACLC,QAASH,YACTI,oBAAcb,KAAKC,UACnBa,SAAUV,WAAWU,eAMzBC,oBAAW,CACXH,QAASZ,KAAKU,WAAWV,KAAKK,UAAUG,YACxCK,yBAAmBb,KAAKC,UACxBa,SAAUV,WAAWU,SACrBb,SAAUD,KAAKC,WAWvBe,4BAA4BC,cACpBC,SAAW,OACV,MAAMC,WAAWF,SAAU,OAEtBG,WAAaC,OAAOC,OAAO,GAAIH,SAC/BI,QAAUC,UAAUC,iBAAiB,6CAA8CL,YACzFF,SAASQ,KAAKH,gBAEXI,QAAQC,IAAIV,mCAQTW,SAAWC,MAAMC,KAAK/B,KAAKgC,qBAAchC,KAAKC,sBAAoBgC,UAClEC,uBAAyBlC,KAAKgB,4BAA4Ba,UAC1DM,eAAiBnC,KAAKU,WAAWV,KAAKK,UAAUC,SACtD6B,eAAeC,UAAY,OACvBC,MAAQ,MACP,MAAMC,KAACA,KAADC,GAAOA,MAAOL,iBAAkB,OACjCM,eAAiBhB,UAAUiB,mBAAmBN,eAAgBG,KAAMC,IAAI,OAE1EG,iBAAQ,CACR9B,QAAS4B,eACTvC,SAAUD,KAAKC,SACf0C,UAAWd,SAASQ,SAASO,MAGvC,MAAOC,iBACCC,sBAAaD,UAAUA"}
\ No newline at end of file
diff --git a/amd/build/package_search/components/upload.min.js b/amd/build/package_search/components/upload.min.js
new file mode 100644
index 00000000..e15d8b98
--- /dev/null
+++ b/amd/build/package_search/components/upload.min.js
@@ -0,0 +1,3 @@
+define("qtype_questionpy/package_search/components/upload",["exports","qtype_questionpy/package_search/component","core_form/modalform","core/str"],(function(_exports,_component,_modalform,strings){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_component=_interopRequireDefault(_component),_modalform=_interopRequireDefault(_modalform),strings=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(strings);class _default extends _component.default{stateReady(){this.addEventListener(this.element,"click",this.openUploadForm)}openUploadForm(event){const element=event.target,modalForm=new _modalform.default({formClass:"qtype_questionpy\\form\\package_upload",args:{contextid:this.reactive.options.contextid},modalConfig:{title:strings.get_string("upload_package","qtype_questionpy")},saveButtonText:strings.get_string("upload"),returnFocus:element});modalForm.addEventListener(modalForm.events.SUBMIT_BUTTON_PRESSED,(()=>this._packageIsUploading())),modalForm.addEventListener(modalForm.events.FORM_SUBMITTED,(()=>this._packageWasUploaded())),modalForm.show()}_packageIsUploading(){}_packageWasUploaded(){this.reactive.dispatch("packageUploaded"),$(this.reactive.target).find('[data-for="mine-header"]').tab("show")}}return _exports.default=_default,_exports.default}));
+
+//# sourceMappingURL=upload.min.js.map
\ No newline at end of file
diff --git a/amd/build/package_search/components/upload.min.js.map b/amd/build/package_search/components/upload.min.js.map
new file mode 100644
index 00000000..c7bab2a9
--- /dev/null
+++ b/amd/build/package_search/components/upload.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"upload.min.js","sources":["../../../src/package_search/components/upload.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.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/**\n * @module qtype_questionpy/package_search/components/upload\n */\n\nimport Component from 'qtype_questionpy/package_search/component';\n\nimport ModalForm from 'core_form/modalform';\nimport * as strings from 'core/str';\n\nexport default class extends Component {\n stateReady() {\n this.addEventListener(this.element, \"click\", this.openUploadForm);\n }\n\n /**\n *\n * @param {MouseEvent} event\n */\n openUploadForm(event) {\n const element = event.target;\n const modalForm = new ModalForm({\n formClass: \"qtype_questionpy\\\\form\\\\package_upload\",\n args: {contextid: this.reactive.options.contextid},\n modalConfig: {\n title: strings.get_string(\"upload_package\", \"qtype_questionpy\"),\n },\n saveButtonText: strings.get_string(\"upload\"),\n returnFocus: element,\n });\n modalForm.addEventListener(modalForm.events.SUBMIT_BUTTON_PRESSED, () => this._packageIsUploading());\n modalForm.addEventListener(modalForm.events.FORM_SUBMITTED, () => this._packageWasUploaded());\n modalForm.show();\n }\n\n _packageIsUploading() {\n // TODO: show loading icon.\n }\n\n _packageWasUploaded() {\n this.reactive.dispatch(\"packageUploaded\");\n $(this.reactive.target).find('[data-for=\"mine-header\"]').tab('show');\n }\n\n}\n"],"names":["Component","stateReady","addEventListener","this","element","openUploadForm","event","target","modalForm","ModalForm","formClass","args","contextid","reactive","options","modalConfig","title","strings","get_string","saveButtonText","returnFocus","events","SUBMIT_BUTTON_PRESSED","_packageIsUploading","FORM_SUBMITTED","_packageWasUploaded","show","dispatch","$","find","tab"],"mappings":"44CA0B6BA,mBACzBC,kBACSC,iBAAiBC,KAAKC,QAAS,QAASD,KAAKE,gBAOtDA,eAAeC,aACLF,QAAUE,MAAMC,OAChBC,UAAY,IAAIC,mBAAU,CAC5BC,UAAW,yCACXC,KAAM,CAACC,UAAWT,KAAKU,SAASC,QAAQF,WACxCG,YAAa,CACTC,MAAOC,QAAQC,WAAW,iBAAkB,qBAEhDC,eAAgBF,QAAQC,WAAW,UACnCE,YAAahB,UAEjBI,UAAUN,iBAAiBM,UAAUa,OAAOC,uBAAuB,IAAMnB,KAAKoB,wBAC9Ef,UAAUN,iBAAiBM,UAAUa,OAAOG,gBAAgB,IAAMrB,KAAKsB,wBACvEjB,UAAUkB,OAGdH,uBAIAE,2BACSZ,SAASc,SAAS,mBACvBC,EAAEzB,KAAKU,SAASN,QAAQsB,KAAK,4BAA4BC,IAAI"}
\ No newline at end of file
diff --git a/amd/build/package_search/mutations.min.js b/amd/build/package_search/mutations.min.js
index d8a0bdc4..2522f2fa 100644
--- a/amd/build/package_search/mutations.min.js
+++ b/amd/build/package_search/mutations.min.js
@@ -1,3 +1,3 @@
-define("qtype_questionpy/package_search/mutations",["exports","core/ajax","core/notification","qtype_questionpy/utils"],(function(_exports,_ajax,_notification,_utils){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification);return _exports.default=class{constructor(options){this.options=options}_getSearchPackagesInCategoriesPromise(state,page,categories){const methods=[];for(const category of categories){const method={methodname:"qtype_questionpy_search_packages",args:{query:state.general.query,tags:state.general.tags,category:category,sort:state.general.sorting.sort,order:state.general.sorting.order,limit:this.options.limit,page:"number"==typeof page?page:state[category].page,contextid:this.options.contextid}};methods.push(method)}return _ajax.default.call(methods)}_setLoading(stateManager,loading){const state=stateManager.state;if(state.loading===loading)return;const isReadonly=stateManager.readonly;isReadonly&&stateManager.setReadOnly(!1),state.general.loading=loading,isReadonly&&stateManager.setReadOnly(!0)}async searchPackages(stateManager){let args=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,categories=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;const state=stateManager.state;args=args||{},categories=categories||["all","recentlyused","favourites","mine"],stateManager.setReadOnly(!1),this._setLoading(stateManager,!0),state.general.query="string"==typeof args.query?args.query:state.general.query,state.general.tags=[],state.general.sorting={sort:args.sort||state.general.sorting.sort,order:args.order||state.general.sorting.order},stateManager.setReadOnly(!0);try{let results=await this._getSearchPackagesInCategoriesPromise(state,args.page,categories);stateManager.setReadOnly(!1);for(const[index,category]of categories.entries()){const result=await results[index];state["".concat(category,"Packages")]=result.packages,state[category].count=result.count,state[category].total=result.total,"number"==typeof args.page&&(state[category].page=args.page)}stateManager.setReadOnly(!0)}catch(exception){await _notification.default.exception(exception)}finally{this._setLoading(stateManager,!1)}}async searchPackagesByQuery(stateManager,query){await this.searchPackages(stateManager,{page:0,query:query})}async changePage(stateManager,category,page){await this.searchPackages(stateManager,{page:page},[category])}async changeSort(stateManager,sort,order){await this.searchPackages(stateManager,{sort:sort,order:order},["all","favourites","mine"])}async load(stateManager,categories){await this.searchPackages(stateManager,{},categories)}async favourite(stateManager,packageid,favourite){const state=stateManager.state;try{this._setLoading(stateManager,!0);if(!await(0,_utils.favouritePackage)(packageid,favourite,this.options.contextid))return;stateManager.setReadOnly(!1);for(const category of["all","mine","recentlyused"]){const pkg=state["".concat(category,"Packages")].get(packageid);pkg&&(pkg.isfavourite=favourite)}stateManager.setReadOnly(!0);let page=state.favourites.page;if(!favourite){const isFirstPage=0===page,isLastPage=page===Math.floor((state.favourites.total-1)/this.options.limit),existsOnePackage=1===state.favourites.count;!isFirstPage&&isLastPage&&existsOnePackage&&(page-=1)}await this.changePage(stateManager,"favourites",page)}catch(exception){await _notification.default.exception(exception)}finally{this._setLoading(stateManager,!1)}}},_exports.default}));
+define("qtype_questionpy/package_search/mutations",["exports","core/ajax","core/notification","qtype_questionpy/utils"],(function(_exports,_ajax,_notification,_utils){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification);return _exports.default=class{constructor(options){this.options=options}_getSearchPackagesInCategoriesPromise(state,page,categories){const methods=[];for(const category of categories){const method={methodname:"qtype_questionpy_search_packages",args:{query:state.general.query,tags:state.general.tags,category:category,sort:state.general.sorting.sort,order:state.general.sorting.order,limit:this.options.limit,page:"number"==typeof page?page:state[category].page,contextid:this.options.contextid}};methods.push(method)}return _ajax.default.call(methods)}_setLoading(stateManager,loading){const state=stateManager.state;if(state.loading===loading)return;const isReadonly=stateManager.readonly;isReadonly&&stateManager.setReadOnly(!1),state.general.loading=loading,isReadonly&&stateManager.setReadOnly(!0)}async searchPackages(stateManager){let args=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,categories=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;const state=stateManager.state;args=args||{},categories=categories||["all","recentlyused","favourites","mine"],stateManager.setReadOnly(!1),this._setLoading(stateManager,!0),state.general.query="string"==typeof args.query?args.query:state.general.query,state.general.tags=[],state.general.sorting={sort:args.sort||state.general.sorting.sort,order:args.order||state.general.sorting.order},stateManager.setReadOnly(!0);try{let results=await this._getSearchPackagesInCategoriesPromise(state,args.page,categories);stateManager.setReadOnly(!1);for(const[index,category]of categories.entries()){const result=await results[index];state["".concat(category,"Packages")]=result.packages,state[category].count=result.count,state[category].total=result.total,"number"==typeof args.page&&(state[category].page=args.page)}stateManager.setReadOnly(!0)}catch(exception){await _notification.default.exception(exception)}finally{this._setLoading(stateManager,!1)}}async searchPackagesByQuery(stateManager,query){await this.searchPackages(stateManager,{page:0,query:query})}async changePage(stateManager,category,page){await this.searchPackages(stateManager,{page:page},[category])}async changeSort(stateManager,sort,order){await this.searchPackages(stateManager,{sort:sort,order:order},["all","favourites","mine"])}async load(stateManager,categories){await this.searchPackages(stateManager,{},categories)}async favourite(stateManager,packageid,favourite){const state=stateManager.state;try{this._setLoading(stateManager,!0);if(!await(0,_utils.favouritePackage)(packageid,favourite,this.options.contextid))return;stateManager.setReadOnly(!1);for(const category of["all","mine","recentlyused"]){const pkg=state["".concat(category,"Packages")].get(packageid);pkg&&(pkg.isfavourite=favourite)}stateManager.setReadOnly(!0);let page=state.favourites.page;if(!favourite){const isFirstPage=0===page,isLastPage=page===Math.floor((state.favourites.total-1)/this.options.limit),existsOnePackage=1===state.favourites.count;!isFirstPage&&isLastPage&&existsOnePackage&&(page-=1)}await this.changePage(stateManager,"favourites",page)}catch(exception){await _notification.default.exception(exception)}finally{this._setLoading(stateManager,!1)}}async packageUploaded(stateManager){await this.changeSort(stateManager,"date","desc")}},_exports.default}));
//# sourceMappingURL=mutations.min.js.map
\ No newline at end of file
diff --git a/amd/build/package_search/mutations.min.js.map b/amd/build/package_search/mutations.min.js.map
index 896653fb..20864bad 100644
--- a/amd/build/package_search/mutations.min.js.map
+++ b/amd/build/package_search/mutations.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"mutations.min.js","sources":["../../src/package_search/mutations.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.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/**\n * @module qtype_questionpy/package_search/mutations\n */\n\nimport Ajax from 'core/ajax';\nimport Notification from 'core/notification';\nimport {favouritePackage} from 'qtype_questionpy/utils';\n\nexport default class {\n /**\n * @param {{contextid: number, limit: number}} options\n */\n constructor(options) {\n this.options = options;\n }\n\n /**\n * Search through given categories.\n *\n * If no page is provided the current page will be used.\n *\n * @param {any} state\n * @param {number|null} page\n * @param {string[]} categories\n * @returns {any}\n * @private\n */\n _getSearchPackagesInCategoriesPromise(state, page, categories) {\n const methods = [];\n for (const category of categories) {\n const method = {\n methodname: \"qtype_questionpy_search_packages\",\n args: {\n query: state.general.query,\n tags: state.general.tags,\n category: category,\n sort: state.general.sorting.sort,\n order: state.general.sorting.order,\n limit: this.options.limit,\n page: (typeof page === \"number\") ? page : state[category].page,\n contextid: this.options.contextid,\n },\n };\n methods.push(method);\n }\n return Ajax.call(methods);\n }\n\n /**\n * Sets the `loading` property.\n *\n * It only communicates changes to the watchers if the `StateManager` is currently readonly.\n *\n * @param {StateManager} stateManager\n * @param {boolean} loading\n * @private\n */\n _setLoading(stateManager, loading) {\n const state = stateManager.state;\n if (state.loading === loading) {\n return;\n }\n const isReadonly = stateManager.readonly;\n if (isReadonly) {\n stateManager.setReadOnly(false);\n }\n state.general.loading = loading;\n if (isReadonly) {\n stateManager.setReadOnly(true);\n }\n }\n\n /**\n * Used to search packages.\n *\n * Missing arguments are taken from the current state.\n *\n * @param {StateManager} stateManager\n * @param {Object|null} args\n * @param {string[]|null} categories\n */\n async searchPackages(stateManager, args = null, categories = null) {\n const state = stateManager.state;\n\n // Missing arguments are taken from the current state.\n args = args || {};\n\n // Search through every category if no categories are provided.\n categories = categories || [\"all\", \"recentlyused\", \"favourites\", \"mine\"];\n\n // Update general data.\n stateManager.setReadOnly(false);\n this._setLoading(stateManager, true);\n state.general.query = (typeof args.query === \"string\") ? args.query : state.general.query;\n state.general.tags = [];\n state.general.sorting = {\n sort: args.sort || state.general.sorting.sort,\n order: args.order || state.general.sorting.order,\n };\n stateManager.setReadOnly(true);\n\n try {\n // Get search results for each category.\n let results = await this._getSearchPackagesInCategoriesPromise(state, args.page, categories);\n\n stateManager.setReadOnly(false);\n // Update category specific data.\n for (const [index, category] of categories.entries()) {\n const result = await results[index];\n state[`${category}Packages`] = result.packages;\n state[category].count = result.count;\n state[category].total = result.total;\n if (typeof args.page === \"number\") {\n state[category].page = args.page;\n }\n }\n stateManager.setReadOnly(true);\n } catch (exception) {\n await Notification.exception(exception);\n } finally {\n this._setLoading(stateManager, false);\n }\n }\n\n /**\n * Used to search for packages only by providing a query.\n *\n * @param {StateManager} stateManager\n * @param {string} query\n */\n async searchPackagesByQuery(stateManager, query) {\n await this.searchPackages(stateManager, {page: 0, query: query});\n }\n\n /**\n * Used to change the current page of a tab.\n *\n * @param {StateManager} stateManager\n * @param {string} category\n * @param {number} page\n */\n async changePage(stateManager, category, page) {\n await this.searchPackages(stateManager, {page: page}, [category]);\n }\n\n /**\n * Used to change the current sorting.\n *\n * @param {StateManager} stateManager\n * @param {string} sort\n * @param {string} order\n */\n async changeSort(stateManager, sort, order) {\n await this.searchPackages(stateManager, {sort: sort, order: order}, [\"all\", \"favourites\", \"mine\"]);\n }\n\n /**\n * Used to re-/load data of given categories.\n *\n * @param {StateManager} stateManager\n * @param {string[]} categories\n */\n async load(stateManager, categories) {\n await this.searchPackages(stateManager, {}, categories);\n }\n\n /**\n * Used to un-/favourite a package.\n *\n * @param {StateManager} stateManager\n * @param {int} packageid\n * @param {boolean} favourite\n */\n async favourite(stateManager, packageid, favourite) {\n const state = stateManager.state;\n try {\n this._setLoading(stateManager, true);\n const successful = await favouritePackage(packageid, favourite, this.options.contextid);\n if (!successful) {\n return;\n }\n stateManager.setReadOnly(false);\n for (const category of [\"all\", \"mine\", \"recentlyused\"]) {\n const pkg = state[`${category}Packages`].get(packageid);\n if (pkg) {\n pkg.isfavourite = favourite;\n }\n }\n stateManager.setReadOnly(true);\n let page = state.favourites.page;\n if (!favourite) {\n // Turn back a page in 'favourites' if the unmarked package was the last one on the page.\n const isFirstPage = page === 0;\n const isLastPage = page === Math.floor((state.favourites.total - 1) / this.options.limit);\n const existsOnePackage = state.favourites.count === 1;\n if (!isFirstPage && isLastPage && existsOnePackage) {\n page -= 1;\n }\n }\n await this.changePage(stateManager, 'favourites', page);\n } catch (exception) {\n await Notification.exception(exception);\n } finally {\n this._setLoading(stateManager, false);\n }\n }\n}\n"],"names":["constructor","options","_getSearchPackagesInCategoriesPromise","state","page","categories","methods","category","method","methodname","args","query","general","tags","sort","sorting","order","limit","this","contextid","push","Ajax","call","_setLoading","stateManager","loading","isReadonly","readonly","setReadOnly","results","index","entries","result","packages","count","total","exception","Notification","searchPackages","packageid","favourite","pkg","get","isfavourite","favourites","isFirstPage","isLastPage","Math","floor","existsOnePackage","changePage"],"mappings":"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"}
\ No newline at end of file
+{"version":3,"file":"mutations.min.js","sources":["../../src/package_search/mutations.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.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/**\n * @module qtype_questionpy/package_search/mutations\n */\n\nimport Ajax from 'core/ajax';\nimport Notification from 'core/notification';\nimport {favouritePackage} from 'qtype_questionpy/utils';\n\nexport default class {\n /**\n * @param {{contextid: number, limit: number}} options\n */\n constructor(options) {\n this.options = options;\n }\n\n /**\n * Search through given categories.\n *\n * If no page is provided the current page will be used.\n *\n * @param {any} state\n * @param {number|null} page\n * @param {string[]} categories\n * @returns {any}\n * @private\n */\n _getSearchPackagesInCategoriesPromise(state, page, categories) {\n const methods = [];\n for (const category of categories) {\n const method = {\n methodname: \"qtype_questionpy_search_packages\",\n args: {\n query: state.general.query,\n tags: state.general.tags,\n category: category,\n sort: state.general.sorting.sort,\n order: state.general.sorting.order,\n limit: this.options.limit,\n page: (typeof page === \"number\") ? page : state[category].page,\n contextid: this.options.contextid,\n },\n };\n methods.push(method);\n }\n return Ajax.call(methods);\n }\n\n /**\n * Sets the `loading` property.\n *\n * It only communicates changes to the watchers if the `StateManager` is currently readonly.\n *\n * @param {StateManager} stateManager\n * @param {boolean} loading\n * @private\n */\n _setLoading(stateManager, loading) {\n const state = stateManager.state;\n if (state.loading === loading) {\n return;\n }\n const isReadonly = stateManager.readonly;\n if (isReadonly) {\n stateManager.setReadOnly(false);\n }\n state.general.loading = loading;\n if (isReadonly) {\n stateManager.setReadOnly(true);\n }\n }\n\n /**\n * Used to search packages.\n *\n * Missing arguments are taken from the current state.\n *\n * @param {StateManager} stateManager\n * @param {Object|null} args\n * @param {string[]|null} categories\n */\n async searchPackages(stateManager, args = null, categories = null) {\n const state = stateManager.state;\n\n // Missing arguments are taken from the current state.\n args = args || {};\n\n // Search through every category if no categories are provided.\n categories = categories || [\"all\", \"recentlyused\", \"favourites\", \"mine\"];\n\n // Update general data.\n stateManager.setReadOnly(false);\n this._setLoading(stateManager, true);\n state.general.query = (typeof args.query === \"string\") ? args.query : state.general.query;\n state.general.tags = [];\n state.general.sorting = {\n sort: args.sort || state.general.sorting.sort,\n order: args.order || state.general.sorting.order,\n };\n stateManager.setReadOnly(true);\n\n try {\n // Get search results for each category.\n let results = await this._getSearchPackagesInCategoriesPromise(state, args.page, categories);\n\n stateManager.setReadOnly(false);\n // Update category specific data.\n for (const [index, category] of categories.entries()) {\n const result = await results[index];\n state[`${category}Packages`] = result.packages;\n state[category].count = result.count;\n state[category].total = result.total;\n if (typeof args.page === \"number\") {\n state[category].page = args.page;\n }\n }\n stateManager.setReadOnly(true);\n } catch (exception) {\n await Notification.exception(exception);\n } finally {\n this._setLoading(stateManager, false);\n }\n }\n\n /**\n * Used to search for packages only by providing a query.\n *\n * @param {StateManager} stateManager\n * @param {string} query\n */\n async searchPackagesByQuery(stateManager, query) {\n await this.searchPackages(stateManager, {page: 0, query: query});\n }\n\n /**\n * Used to change the current page of a tab.\n *\n * @param {StateManager} stateManager\n * @param {string} category\n * @param {number} page\n */\n async changePage(stateManager, category, page) {\n await this.searchPackages(stateManager, {page: page}, [category]);\n }\n\n /**\n * Used to change the current sorting.\n *\n * @param {StateManager} stateManager\n * @param {string} sort\n * @param {string} order\n */\n async changeSort(stateManager, sort, order) {\n await this.searchPackages(stateManager, {sort: sort, order: order}, [\"all\", \"favourites\", \"mine\"]);\n }\n\n /**\n * Used to re-/load data of given categories.\n *\n * @param {StateManager} stateManager\n * @param {string[]} categories\n */\n async load(stateManager, categories) {\n await this.searchPackages(stateManager, {}, categories);\n }\n\n /**\n * Used to un-/favourite a package.\n *\n * @param {StateManager} stateManager\n * @param {int} packageid\n * @param {boolean} favourite\n */\n async favourite(stateManager, packageid, favourite) {\n const state = stateManager.state;\n try {\n this._setLoading(stateManager, true);\n const successful = await favouritePackage(packageid, favourite, this.options.contextid);\n if (!successful) {\n return;\n }\n stateManager.setReadOnly(false);\n for (const category of [\"all\", \"mine\", \"recentlyused\"]) {\n const pkg = state[`${category}Packages`].get(packageid);\n if (pkg) {\n pkg.isfavourite = favourite;\n }\n }\n stateManager.setReadOnly(true);\n let page = state.favourites.page;\n if (!favourite) {\n // Turn back a page in 'favourites' if the unmarked package was the last one on the page.\n const isFirstPage = page === 0;\n const isLastPage = page === Math.floor((state.favourites.total - 1) / this.options.limit);\n const existsOnePackage = state.favourites.count === 1;\n if (!isFirstPage && isLastPage && existsOnePackage) {\n page -= 1;\n }\n }\n await this.changePage(stateManager, 'favourites', page);\n } catch (exception) {\n await Notification.exception(exception);\n } finally {\n this._setLoading(stateManager, false);\n }\n }\n\n async packageUploaded(stateManager) {\n await this.changeSort(stateManager, 'date', 'desc');\n }\n}\n"],"names":["constructor","options","_getSearchPackagesInCategoriesPromise","state","page","categories","methods","category","method","methodname","args","query","general","tags","sort","sorting","order","limit","this","contextid","push","Ajax","call","_setLoading","stateManager","loading","isReadonly","readonly","setReadOnly","results","index","entries","result","packages","count","total","exception","Notification","searchPackages","packageid","favourite","pkg","get","isfavourite","favourites","isFirstPage","isLastPage","Math","floor","existsOnePackage","changePage","changeSort"],"mappings":"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"}
\ No newline at end of file
diff --git a/amd/src/package_search/components/area.js b/amd/src/package_search/components/area.js
index 6e604993..7e5d3465 100644
--- a/amd/src/package_search/components/area.js
+++ b/amd/src/package_search/components/area.js
@@ -21,6 +21,7 @@
import Component from 'qtype_questionpy/package_search/component';
import Container from 'qtype_questionpy/package_search/components/container';
+import UploadButton from 'qtype_questionpy/package_search/components/upload';
import SearchBar from 'qtype_questionpy/package_search/components/search_bar';
export default class extends Component {
@@ -38,6 +39,12 @@ export default class extends Component {
name: "search_bar",
reactive: descriptor.reactive,
});
+ // Register upload button.
+ new UploadButton({
+ element: this.getElement('[data-for="upload-button"]'),
+ name: "upload_button",
+ reactive: descriptor.reactive,
+ });
// Register package container.
// TODO: register component inside mustache template.
new Container({
diff --git a/amd/src/package_search/components/package.js b/amd/src/package_search/components/package.js
index d48329eb..8d56136c 100644
--- a/amd/src/package_search/components/package.js
+++ b/amd/src/package_search/components/package.js
@@ -33,6 +33,8 @@ export default class extends Component {
this.category = descriptor.category;
this.selectors = {
FAVOURITE_BUTTON: '[data-for="favourite-button"]',
+ DOWNLOAD_BUTTON: '[data-for="download-button"]',
+ VERSION_SELECTION: '.qpy-version-selection',
};
}
@@ -44,6 +46,20 @@ export default class extends Component {
this.addEventListener(this.getElement(this.selectors.FAVOURITE_BUTTON), "click", () => {
this.reactive.dispatch("favourite", this.packageid, !this.isFavourite());
});
+
+ this.addEventListener(this.getElement(this.selectors.VERSION_SELECTION), "change", () => {
+ this.setUpDownloadButton();
+ });
+
+ this.setUpDownloadButton();
+ }
+
+ setUpDownloadButton() {
+ const selection = this.getElement(this.selectors.VERSION_SELECTION);
+ const option = selection.options[selection.selectedIndex];
+ const button = this.getElement(this.selectors.DOWNLOAD_BUTTON);
+ button.classList.toggle("d-none", !option.hasAttribute("data-is-mine"));
+ button.href = option.dataset.fileurl;
}
async favouriteChanged() {
diff --git a/amd/src/package_search/components/upload.js b/amd/src/package_search/components/upload.js
new file mode 100644
index 00000000..d146c353
--- /dev/null
+++ b/amd/src/package_search/components/upload.js
@@ -0,0 +1,61 @@
+/*
+ * This file is part of the QuestionPy Moodle plugin - https://questionpy.org
+ *
+ * Moodle is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Moodle is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Moodle. If not, see .
+ */
+
+/**
+ * @module qtype_questionpy/package_search/components/upload
+ */
+
+import Component from 'qtype_questionpy/package_search/component';
+
+import ModalForm from 'core_form/modalform';
+import * as strings from 'core/str';
+
+export default class extends Component {
+ stateReady() {
+ this.addEventListener(this.element, "click", this.openUploadForm);
+ }
+
+ /**
+ *
+ * @param {MouseEvent} event
+ */
+ openUploadForm(event) {
+ const element = event.target;
+ const modalForm = new ModalForm({
+ formClass: "qtype_questionpy\\form\\package_upload",
+ args: {contextid: this.reactive.options.contextid},
+ modalConfig: {
+ title: strings.get_string("upload_package", "qtype_questionpy"),
+ },
+ saveButtonText: strings.get_string("upload"),
+ returnFocus: element,
+ });
+ modalForm.addEventListener(modalForm.events.SUBMIT_BUTTON_PRESSED, () => this._packageIsUploading());
+ modalForm.addEventListener(modalForm.events.FORM_SUBMITTED, () => this._packageWasUploaded());
+ modalForm.show();
+ }
+
+ _packageIsUploading() {
+ // TODO: show loading icon.
+ }
+
+ _packageWasUploaded() {
+ this.reactive.dispatch("packageUploaded");
+ $(this.reactive.target).find('[data-for="mine-header"]').tab('show');
+ }
+
+}
diff --git a/amd/src/package_search/mutations.js b/amd/src/package_search/mutations.js
index 51277444..64b4c46f 100644
--- a/amd/src/package_search/mutations.js
+++ b/amd/src/package_search/mutations.js
@@ -221,4 +221,8 @@ export default class {
this._setLoading(stateManager, false);
}
}
+
+ async packageUploaded(stateManager) {
+ await this.changeSort(stateManager, 'date', 'desc');
+ }
}
diff --git a/classes/api/api.php b/classes/api/api.php
index e74695d7..a98c8e03 100644
--- a/classes/api/api.php
+++ b/classes/api/api.php
@@ -224,20 +224,22 @@ public function score_attempt(string $packagehash, string $questionstate, string
}
/**
- * Get the Package information from the server.
+ * Get a {@see package_raw} from a file.
*
- * @param string $filename
* @param string $filepath
- * @return http_response_container
+ * @return package_raw
* @throws moodle_exception
*/
- public static function package_extract_info(string $filename, string $filepath): http_response_container {
- $curlfile = curl_file_create($filepath, $filename);
+ public static function extract_package_info(string $filepath): package_raw {
+ $connector = connector::default();
+
$data = [
- 'package' => $curlfile,
+ 'package' => curl_file_create($filepath),
];
- $connector = connector::default();
- return $connector->post("/package-extract-info", $data);
+
+ $response = $connector->post("/package-extract-info", $data);
+ $response->assert_2xx();
+ return array_converter::from_array(package_raw::class, $response->get_data());
}
/**
diff --git a/classes/external/remove_packages.php b/classes/external/remove_packages.php
index fcf46617..cb5a275a 100644
--- a/classes/external/remove_packages.php
+++ b/classes/external/remove_packages.php
@@ -49,16 +49,25 @@ public static function execute_parameters(): external_function_parameters {
* @throws moodle_exception
*/
public static function execute(): array {
- global $DB;
+ global $DB, $USER;
$transaction = $DB->start_delegated_transaction();
// Only delete package versions that were not uploaded by a user.
+
$versions = package_version::get_records(['userid' => null]);
+ $DB->delete_records('qtype_questionpy_package');
+ $DB->delete_records('qtype_questionpy_pkgversion');
+ $DB->delete_records('qtype_questionpy_tags');
+ $DB->delete_records('qtype_questionpy_language');
+ /*
+ $DB->delete_records('qtype_questionpy_lastused');
+ //$versions = package_version::get_records();
foreach ($versions as $version) {
$version->delete();
+ $version->delete($USER->id);
}
-
+ */
$transaction->allow_commit();
return [
diff --git a/classes/external/search_packages.php b/classes/external/search_packages.php
index 7cba6837..c5ebe3b0 100644
--- a/classes/external/search_packages.php
+++ b/classes/external/search_packages.php
@@ -30,6 +30,7 @@
use external_value;
use invalid_parameter_exception;
use moodle_exception;
+use moodle_url;
use qtype_questionpy\localizer;
/**
@@ -176,20 +177,28 @@ private static function get_tags_and_versions(array $packageids, array $contexti
];
}
- // Get relevant package versions.
+ /*
+ Get relevant package versions.
+ TODO: if a package was uploaded by a user and also was uploaded by another person in the same context
+ and/or by the server, prefer the user uploaded package.
+ */
$versionsraw = $DB->get_records_sql("
- SELECT id, packageid, hash, version, userid
+ SELECT id, packageid, contextid, hash, version, userid, filename
FROM {qtype_questionpy_pkgversion}
WHERE packageid {$inpackagesql} AND (userid IS NULL OR userid {$inusersql} OR contextid {$incontextsql})
", array_merge($inpackageparams, $incontextparams, $inuserparams));
$versions = [];
foreach ($versionsraw as $version) {
+ $ismine = $version->userid === $USER->id;
+ $fileurl = $ismine ? moodle_url::make_pluginfile_url($version->contextid, 'qtype_questionpy', 'package', 0, '/',
+ $version->filename)->out() : null;
$versions[$version->packageid][] = [
'id' => $version->id,
'hash' => $version->hash,
'version' => $version->version,
- 'ismine' => $version->userid === $USER->id,
+ 'ismine' => $ismine,
+ 'fileurl' => $fileurl,
];
}
@@ -513,6 +522,7 @@ public static function execute_returns(): external_single_structure {
'hash' => new external_value(PARAM_ALPHANUM),
'version' => new external_value(PARAM_TEXT),
'ismine' => new external_value(PARAM_BOOL),
+ 'fileurl' => new external_value(PARAM_URL),
])),
'author' => new external_value(PARAM_RAW),
'name' => new external_value(PARAM_TEXT),
diff --git a/classes/form/package_upload.php b/classes/form/package_upload.php
index b6653b0f..71e1030e 100644
--- a/classes/form/package_upload.php
+++ b/classes/form/package_upload.php
@@ -26,90 +26,109 @@
defined('MOODLE_INTERNAL') || die;
+use context;
+use core_form\dynamic_form;
use moodle_exception;
-use qtype_questionpy\localizer;
-use qtype_questionpy\package\package;
-use qtype_questionpy\package\package_version;
+use moodle_url;
+use qtype_questionpy\api\api;
require_once($CFG->libdir . "/formslib.php");
/**
- * QuestionPy package upload form definition.
+ * Dynamic QuestionPy package upload form.
*
* @copyright 2022 Alexander Schmitz, TU Berlin, innoCampus - www.questionpy.org
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class package_upload extends \moodleform {
+class package_upload extends dynamic_form {
/**
- * Build the form definition.
+ * Builds the form definition.
*
* @throws moodle_exception
*/
protected function definition() {
- global $OUTPUT;
-
$mform = $this->_form;
- $contextid = $this->_customdata['contextid'];
-
- // Create group which contains selectable QuestionPy packages.
- $group = [];
-
- $languages = localizer::get_preferred_languages();
- $versions = package_version::get_records(['contextid' => $contextid]);
-
- foreach ($versions as $version) {
- // Get localized package texts.
- $packagearray = package::get_by_version($version->id)->as_localized_array($languages);
- $group[] = $mform->createElement('text', 'questionpy_package_hash',
- $OUTPUT->render_from_template('qtype_questionpy/package', $packagearray),
- '', ''
- );
- }
- $mform->addGroup($group, 'questionpy_package_container', '', '');
- $mform->setType('questionpy_package_container', PARAM_TEXT);
+ $mform->addElement('hidden', 'contextid');
+ $mform->setType('contextid', PARAM_INT);
$maxkb = get_config('qtype_questionpy', 'max_package_size_kb');
$mform->addElement('filepicker', 'qpy_package', get_string('file'), null,
['maxbytes' => $maxkb * 1024, 'accepted_types' => ['.qpy']]);
+ $mform->addRule('qpy_package', null, 'required');
+ }
+
+ /**
+ * @throws moodle_exception
+ */
+ protected function get_context_for_dynamic_submission(): context {
+ $contextid = $this->optional_param('contextid', null, PARAM_INT);
+ return context::instance_by_id($contextid);
+ }
- $this->add_action_buttons();
+ /**
+ * @throws moodle_exception
+ */
+ protected function check_access_for_dynamic_submission(): void {
+ $context = $this->get_context_for_dynamic_submission();
+ require_capability('qtype/questionpy:uploadpackages', $context);
}
/**
- * Load in existing data as form defaults. Usually new entry defaults are stored directly in
- * form definition (new entry form); this function is used to load in data where values
- * already exist and data is being edited (edit entry form).
- *
- * note: $slashed param removed
- *
- * @param array $data
- * @param array $files
- * @return array $errors
+ * @throws moodle_exception
*/
- public function validation($data, $files) {
- $errors = parent::validation($data, $files);
- if (!self::file_uploaded($data['qpy_package'])) {
- $errors["qpy_package"] = get_string('formerror_noqpy_package', 'qtype_questionpy');
+ public function process_dynamic_submission(): void {
+ $contextid = $this->optional_param('contextid', null, PARAM_INT);
+
+ // Get file storage.
+ $filestorage = get_file_storage();
+
+ // Get filename.
+ $filename = $this->get_new_filename('qpy_package');
+ $filename = $filestorage->get_unused_filename($contextid, 'qtype_questionpy', 'package', 0, '/', $filename);
+ if (strlen($filename) > 255) {
+ throw new moodle_exception('file_name_too_long_error', 'qtype_questionpy');
+ }
+
+ // Save file inside current file area.
+ $file = $this->save_stored_file('qpy_package', $contextid, 'qtype_questionpy', 'package', 0, '/', $filename);
+ if (!$file) {
+ throw new moodle_exception('cannotuploadfile');
+ }
+
+ try {
+ // Store the package in the database.
+ $path = $filestorage->get_file_system()->get_local_path_from_storedfile($file);
+ $rawpackage = api::extract_package_info($path);
+ $rawpackage->store($contextid, true, $filename);
+ } catch (moodle_exception $exception) {
+ $file->delete();
+ throw $exception;
}
- return $errors;
}
/**
- * Checks to see if a file has been uploaded.
- *
- * @param string $draftitemid The draft id
- * @return bool True if files exist, false if not.
+ * @throws moodle_exception
*/
- public static function file_uploaded($draftitemid) {
- $draftareafiles = file_get_drafarea_files($draftitemid);
- do {
- $draftareafile = array_shift($draftareafiles->list);
- } while ($draftareafile !== null && $draftareafile->filename == '.');
- if ($draftareafile === null) {
- return false;
+ public function set_data_for_dynamic_submission(): void {
+ $contextid = $this->optional_param('contextid', null, PARAM_INT);
+
+ // Set the context id to the course context id if the context is part of a course.
+ $context = context::instance_by_id($contextid);
+ if ($coursecontext = $context->get_course_context(false)) {
+ $contextid = $coursecontext->id;
}
- return true;
+
+ $this->set_data([
+ 'contextid' => $contextid,
+ ]);
+ }
+
+ /**
+ * @throws moodle_exception
+ */
+ protected function get_page_url_for_dynamic_submission(): moodle_url {
+ return $this->get_context_for_dynamic_submission()->get_url();
}
}
diff --git a/classes/package/package_raw.php b/classes/package/package_raw.php
index bef6793f..d9eebcab 100644
--- a/classes/package/package_raw.php
+++ b/classes/package/package_raw.php
@@ -19,6 +19,7 @@
use moodle_exception;
use qtype_questionpy\array_converter\array_converter;
use qtype_questionpy\array_converter\converter_config;
+use stored_file;
defined('MOODLE_INTERNAL') || die;
@@ -79,10 +80,11 @@ public function __construct(string $hash, string $shortname, string $namespace,
*
* @param int $contextid
* @param bool $withuserid
+ * @param string|null $filename
* @return int the ID of the inserted record in the DB
* @throws moodle_exception
*/
- public function store(int $contextid = 0, bool $withuserid = true): int {
+ public function store(int $contextid = 0, bool $withuserid = true, string $filename = null): int {
global $DB, $USER;
$transaction = $DB->start_delegated_transaction();
@@ -134,13 +136,13 @@ public function store(int $contextid = 0, bool $withuserid = true): int {
}
} else {
// Package does already exist - check if the version also exists.
- $pkgversionid = $DB->get_field('qtype_questionpy_pkgversion', 'id', [
+ $pkgversion = $DB->get_record('qtype_questionpy_pkgversion', [
'packageid' => $packageid,
'version' => $this->version,
]);
- if ($pkgversionid) {
- return $pkgversionid;
+ if ($pkgversion && $pkgversion->hash !== $this->hash) {
+ throw new moodle_exception('same_version_different_hash_error', 'qtype_questionpy');
}
}
// Add the package version.
@@ -152,6 +154,7 @@ public function store(int $contextid = 0, bool $withuserid = true): int {
'version' => $this->version,
'timecreated' => $timestamp,
'userid' => $withuserid ? $USER->id : null,
+ 'filename' => $filename,
]);
$transaction->allow_commit();
diff --git a/classes/package/package_version.php b/classes/package/package_version.php
index 5141bdcc..76ca30c2 100644
--- a/classes/package/package_version.php
+++ b/classes/package/package_version.php
@@ -17,6 +17,7 @@
namespace qtype_questionpy\package;
use moodle_exception;
+use moodle_url;
use qtype_questionpy\array_converter\array_converter;
/**
@@ -50,6 +51,11 @@ class package_version {
*/
public string $version;
+ /**
+ * @var string|null path name hash
+ */
+ public ?string $pathnamehash;
+
/**
* Retrieves a package version by its id.
*
@@ -94,18 +100,38 @@ public static function get_records(?array $conditions = null): array {
return $packages;
}
+ public function download_package_file() {
+ if (is_null($this->pathnamehash) || ($file = get_file_storage()->get_file_by_hash($this->pathnamehash))) {
+ throw new moodle_exception('storedfilecannotread');
+ }
+ return moodle_url::make_pluginfile_url(
+ $file->get_contextid(),
+ $file->get_component(),
+ $file->get_filearea(),
+ $file->get_itemid(),
+ $file->get_filepath(),
+ $file->get_filename(),
+ true
+ );
+ }
+
/**
* Deletes the package version from the database.
* If the package has only one version, the package related data is also deleted.
*
+ * @param int|null $userid
* @throws moodle_exception
*/
- public function delete(): void {
+ public function delete(int $userid = null): void {
global $DB;
$transaction = $DB->start_delegated_transaction();
$versioncount = $DB->count_records('qtype_questionpy_pkgversion', ['packageid' => $this->packageid]);
- $DB->delete_records('qtype_questionpy_pkgversion', ['hash' => $this->hash, 'packageid' => $this->packageid]);
+ $DB->delete_records('qtype_questionpy_pkgversion', [
+ 'hash' => $this->hash,
+ 'packageid' => $this->packageid,
+ 'userid' => $userid,
+ ]);
if ($versioncount === 1) {
// Only one package version exists, therefore we also delete package related data.
diff --git a/db/install.xml b/db/install.xml
index 8b5ddc0b..8c1d7a19 100644
--- a/db/install.xml
+++ b/db/install.xml
@@ -63,6 +63,7 @@
+
diff --git a/edit_questionpy_form.php b/edit_questionpy_form.php
index a53560e8..3b07c3b1 100644
--- a/edit_questionpy_form.php
+++ b/edit_questionpy_form.php
@@ -61,8 +61,6 @@ protected function definition_package_selection(MoodleQuickForm $mform): void {
'questionpy_package_container',
get_string('selection_required', 'qtype_questionpy'), 'required'
);
-
- $mform->addElement('button', 'uploadlink', 'QPy Package upload form', $uploadlink);
}
/**
diff --git a/lang/en/qtype_questionpy.php b/lang/en/qtype_questionpy.php
index 05651cef..8b710522 100644
--- a/lang/en/qtype_questionpy.php
+++ b/lang/en/qtype_questionpy.php
@@ -53,7 +53,9 @@
$string['server_info_requests_in_queue'] = 'Requests in queue';
// Package upload.
-$string['formerror_noqpy_package'] = 'Selected file must be of type .qpy';
+$string['upload_package'] = 'Upload a package';
+$string['file_name_too_long_error'] = 'The filename "{a}" is too long.';
+$string['same_version_different_hash_error'] = 'A package with the same version but different hash already exists.';
// Package selection.
$string['selection_title'] = 'Select QuestionPy Package';
diff --git a/lib.php b/lib.php
index a3d9da2e..79043e29 100644
--- a/lib.php
+++ b/lib.php
@@ -23,20 +23,49 @@
*/
/**
- * Checks file access for QuestionPy questions.
- * @package qtype_questionpy
- * @category files
+ * Serve files from the QuestionPy file areas.
+ *
* @param stdClass $course course object
* @param stdClass $cm course module object
* @param stdClass $context context object
* @param string $filearea file area
* @param array $args extra arguments
- * @param bool $forcedownload whether or not force download
+ * @param bool $forcedownload whether to force download
* @param array $options additional options affecting the file serving
* @return bool
+ * @throws moodle_exception
*/
-function qtype_questionpy_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options=[]) {
- global $CFG;
- require_once($CFG->libdir . '/questionlib.php');
- question_pluginfile($course, $context, 'qtype_questionpy', $filearea, $args, $forcedownload, $options);
+function qtype_questionpy_pluginfile($course, $cm, $context, string $filearea, array $args,
+ bool $forcedownload, array $options=[]): bool {
+ global $USER;
+
+ // We currently only store files inside the package file area.
+ if ($filearea !== 'package') {
+ return false;
+ }
+
+ require_login($course, true, $cm);
+
+ // Extract the item id.
+ $itemid = array_shift($args);
+
+ // Extract the filename and filepath.
+ $filename = array_pop($args);
+ $filepath = '/';
+ if (!empty($args)) {
+ $filepath .= implode('/', $args) . '/';
+ }
+
+ // Get the file.
+ $filestorage = get_file_storage();
+ $file = $filestorage->get_file($context->id, 'qtype_questionpy', $filearea, $itemid, $filepath, $filename);
+
+ // Check if package was found and uploaded by the current user.
+ if (!$file || $file->get_userid() !== $USER->id) {
+ return false;
+ }
+
+ // Package was found and is accessible by the current user - send it.
+ send_stored_file($file, DAYSECS, 0, $forcedownload, $options);
+ return true;
}
diff --git a/renderer.php b/renderer.php
index 4e2afc6a..ba389957 100644
--- a/renderer.php
+++ b/renderer.php
@@ -118,4 +118,20 @@ public function package_upload_link(context $context) {
return $this->action_link($link, 'qpy_package_upload', $action, null);
}
+
+ /**
+ *
+ */
+ public function get_file_download_link(string $pathnamehash): moodle_url {
+ $filestorage = get_file_storage();
+ $file = $filestorage->get_file_by_hash($pathnamehash);
+ return moodle_url::make_pluginfile_url(
+ $file->get_contextid(),
+ $file->get_component(),
+ $file->get_filearea(),
+ $file->get_itemid(),
+ $file->get_filepath(),
+ $file->get_filename(),
+ );
+ }
}
diff --git a/templates/package/package_selection.mustache b/templates/package/package_selection.mustache
index 25027dfc..d8005ac3 100644
--- a/templates/package/package_selection.mustache
+++ b/templates/package/package_selection.mustache
@@ -25,7 +25,8 @@
Data attributes required for JS:
* data-for="favouite-button",
- * data-is-favourite
+ * data-is-favourite,
+ * data-for="download-button"
Context variables required for this template:
* id - QuestionPy package id,
@@ -46,9 +47,9 @@
"name": "ExamplePackage",
"description": "This describes the package ExamplePackage.",
"icon": "https://picsum.photos/48/48?grayscale",
- "versions": [{"hash": "example_hash", "version": "0.1.0"}],
- "url": "https://example.com",
"selected": true,
+ "versions": [{"hash": "example_hash", "version": "0.1.0", "ismine": true, "fileurl": "www.example.com/package.qpy"}],
+ "url": "https://example.com"
"isfavourite": false,
"contextid": 0
}
@@ -59,7 +60,7 @@