diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..08a4490 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Development-only paths are stripped from the released package produced by +# `git archive` (the zip the Moodle Plugins Directory builds). They are kept in +# the repository for CI and contributors, but are not part of the shipped plugin. +# +# tests/ is deliberately NOT excluded: Moodle plugins ship their tests so a site +# can run them under its own PHPUnit/Behat, matching core convention. +/.github/ export-ignore +/.claude/ export-ignore +/docs/ export-ignore +/tools/ export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore diff --git a/.github/workflows/moodle-plugin-ci.yml b/.github/workflows/moodle-plugin-ci.yml index 5937614..42b1a1a 100644 --- a/.github/workflows/moodle-plugin-ci.yml +++ b/.github/workflows/moodle-plugin-ci.yml @@ -25,12 +25,27 @@ jobs: --health-timeout=5s --health-retries=3 + postgres: + image: postgres:14 + env: + POSTGRES_USER: 'postgres' + POSTGRES_HOST_AUTH_METHOD: 'trust' + ports: + - 5432:5432 + options: >- + --health-cmd=pg_isready + --health-interval=10s + --health-timeout=5s + --health-retries=3 + strategy: fail-fast: false matrix: php: ['8.1', '8.2', '8.3'] moodle-branch: ['MOODLE_405_STABLE'] - database: ['mariadb'] + # Both engines are exercised: a PostgreSQL failure is a Plugins-directory + # approval blocker, so it must run in CI, not just MariaDB. + database: ['mariadb', 'pgsql'] steps: - name: Check out plugin @@ -46,6 +61,13 @@ jobs: ini-values: max_input_vars=5000 coverage: pcov + # Moodle 4.5's Grunt toolchain requires Node 22; needed for the grunt step + # (rebuilds amd/src -> amd/build and fails if the committed artifacts differ). + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Initialise moodle-plugin-ci run: | composer create-project -n --no-dev --prefer-dist \ @@ -130,6 +152,8 @@ jobs: run: moodle-plugin-ci behat --profile chrome - name: Coverage gate (overall 85% / get_my_videos 90%) - if: ${{ !cancelled() && matrix.php == '8.3' }} + # One representative job only — coverage is DB-independent, so it need + # not run on every (php, database) combination. + if: ${{ !cancelled() && matrix.php == '8.3' && matrix.database == 'mariadb' }} working-directory: plugin run: bash tools/coverage.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 67e0a2d..f4730b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to **tiny_fastpix** (FastPix video for TinyMCE) are documented here. This project follows [Semantic Versioning](https://semver.org/). +## Unreleased + +### Added +- **Search box in the picker.** The modal now has a search field that filters the + listed videos by title as you type. It is a client-side filter over the + already-loaded, course-scoped list — no extra web service call. +- **Usage events.** The picker now records two Moodle events for the site log: + `\tiny_fastpix\event\picker_opened` (fired when the picker loads its list) and + `\tiny_fastpix\event\shortcode_inserted` (fired when an author inserts a + shortcode, via the new `tiny_fastpix_log_shortcode_inserted` web service). +- **`.gitattributes`** to keep CI/dev scaffolding out of the released package. + ## 1.1.0 — 2026-06-10 ### Changed diff --git a/README.md b/README.md index 0f42479..a8948af 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,15 @@ Also: Moodle 4.5 LTS or newer with the TinyMCE editor, and PHP 8.1+ (tested to 8.3). The picker itself adds no tables, holds no FastPix credentials, and pulls in no Composer packages. +**FastPix account & credentials.** Using FastPix requires a FastPix account and a +FastPix **API Key** (created in the FastPix Dashboard under *Settings → API Keys*; +see [Activate your account](https://fastpix.com/docs/getting-started/activate-your-account)). +This picker never asks for or stores the key — it is configured **once** in +`local_fastpix` under *Site administration → Plugins → Local plugins → FastPix*, +and every plugin in the suite (including this one) reads through `local_fastpix` +from there. If listings aren't working, check the credentials in `local_fastpix` +first. + **Install it** the usual Moodle way — upload the ZIP under *Site administration → Plugins → Install plugins*, or drop the folder at `lib/editor/tiny/plugins/fastpix/` and finish the upgrade at *Notifications*. diff --git a/amd/build/ui.min.js b/amd/build/ui.min.js index 0d48edd..5fa652d 100644 --- a/amd/build/ui.min.js +++ b/amd/build/ui.min.js @@ -6,6 +6,6 @@ define("tiny_fastpix/ui",["exports","core/ajax","core/templates","core/str","./c * @module tiny_fastpix/ui * @copyright 2026 FastPix Inc. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.handleAction=void 0,_modal=(obj=_modal)&&obj.__esModule?obj:{default:obj};_exports.handleAction=editor=>{displayDialogue(editor)};const displayDialogue=async editor=>{const bookmark=editor.selection.getBookmark(),contextid=(0,_options.getContextId)(editor),modal=await _modal.default.create({templateContext:{elementid:editor.id}}),region=modal.getRoot()[0].querySelector('[data-region="fastpix-video-list"]');let videos;region.addEventListener("click",(e=>{const choice=e.target.closest("[data-playbackid]");choice&&(e.preventDefault(),((editor,modal,bookmark,playbackid)=>{editor.selection.moveToBookmark(bookmark),editor.execCommand("mceInsertContent",!1,"{fastpix:pb_".concat(playbackid,"}")),editor.selection.moveToBookmark(bookmark),modal.destroy()})(editor,modal,bookmark,choice.dataset.playbackid))}));try{videos=await(contextid=>(0,_ajax.call)([{methodname:"tiny_fastpix_get_my_videos",args:{contextid:contextid}}])[0].then((result=>result.videos)))(contextid)}catch(error){const message=await(0,_str.getString)("loaderror",_common.component);return void(region.textContent=message)}const{html:html,js:js}=await(0,_templates.renderForPromise)("".concat(_common.component,"/videos"),{videos:videos,hasvideos:videos.length>0});(0,_templates.replaceNodeContents)(region,html,js)}})); + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.handleAction=void 0,_modal=(obj=_modal)&&obj.__esModule?obj:{default:obj};_exports.handleAction=editor=>{displayDialogue(editor)};const insertShortcode=(editor,modal,bookmark,contextid,playbackid)=>{editor.selection.moveToBookmark(bookmark),editor.execCommand("mceInsertContent",!1,"{fastpix:pb_".concat(playbackid,"}")),editor.selection.moveToBookmark(bookmark),((contextid,playbackid)=>{(0,_ajax.call)([{methodname:"tiny_fastpix_log_shortcode_inserted",args:{contextid:contextid,playbackid:playbackid}}])[0].catch((()=>null))})(contextid,playbackid),modal.destroy()},displayDialogue=async editor=>{const bookmark=editor.selection.getBookmark(),contextid=(0,_options.getContextId)(editor),modal=await _modal.default.create({templateContext:{elementid:editor.id}}),root=modal.getRoot()[0],region=root.querySelector('[data-region="fastpix-video-list"]');let videos;region.addEventListener("click",(e=>{const choice=e.target.closest("[data-playbackid]");choice&&(e.preventDefault(),insertShortcode(editor,modal,bookmark,contextid,choice.dataset.playbackid))}));try{videos=await(contextid=>(0,_ajax.call)([{methodname:"tiny_fastpix_get_my_videos",args:{contextid:contextid}}])[0].then((result=>result.videos)))(contextid)}catch(error){const message=await(0,_str.getString)("loaderror",_common.component);return void(region.textContent=message)}const{html:html,js:js}=await(0,_templates.renderForPromise)("".concat(_common.component,"/videos"),{videos:videos,hasvideos:videos.length>0});(0,_templates.replaceNodeContents)(region,html,js),((root,region)=>{const search=root.querySelector('[data-region="fastpix-video-search"]'),wrap=root.querySelector('[data-region="fastpix-video-search-wrap"]'),nomatches=root.querySelector('[data-region="fastpix-video-nomatches"]'),cards=region.querySelectorAll("[data-playbackid]");search&&cards.length&&(wrap&&wrap.classList.remove("d-none"),search.addEventListener("input",(()=>{const needle=search.value.trim().toLowerCase();let visible=0;cards.forEach((card=>{const match=""===needle||card.textContent.trim().toLowerCase().includes(needle);card.closest("li").classList.toggle("d-none",!match),match&&(visible+=1)})),nomatches&&nomatches.classList.toggle("d-none",!(""!==needle&&0===visible))})))})(root,region)}})); //# sourceMappingURL=ui.min.js.map \ No newline at end of file diff --git a/amd/build/ui.min.js.map b/amd/build/ui.min.js.map index d8f7e76..15a365a 100644 --- a/amd/build/ui.min.js.map +++ b/amd/build/ui.min.js.map @@ -1 +1 @@ -{"version":3,"file":"ui.min.js","sources":["../src/ui.js"],"sourcesContent":["// This file is part of Moodle - https://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 * Tiny FastPix UI — opens the picker, lists this course's videos the author\n * uploaded and inserts the {fastpix:pb_} shortcode for the chosen one.\n *\n * @module tiny_fastpix/ui\n * @copyright 2026 FastPix Inc. \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {call as fetchMany} from 'core/ajax';\nimport {renderForPromise, replaceNodeContents} from 'core/templates';\nimport {getString} from 'core/str';\nimport {component} from './common';\nimport {getContextId} from './options';\nimport FastpixModal from './modal';\n\nconst listRegionSelector = '[data-region=\"fastpix-video-list\"]';\n\n/**\n * Open the picker for the current editor.\n *\n * @param {TinyMCE} editor\n */\nexport const handleAction = (editor) => {\n displayDialogue(editor);\n};\n\n/**\n * Fetch the videos this course holds for the current user via the web service.\n * The server derives the course from the context, so only the context is sent.\n *\n * @param {number} contextid\n * @returns {Promise>}\n */\nconst fetchVideos = (contextid) => fetchMany([{\n methodname: 'tiny_fastpix_get_my_videos',\n args: {contextid},\n}])[0].then((result) => result.videos);\n\n/**\n * Insert the shortcode for the chosen video and close the modal.\n *\n * @param {TinyMCE} editor\n * @param {object} modal\n * @param {string} bookmark\n * @param {string} playbackid\n */\nconst insertShortcode = (editor, modal, bookmark, playbackid) => {\n editor.selection.moveToBookmark(bookmark);\n editor.execCommand('mceInsertContent', false, `{fastpix:pb_${playbackid}}`);\n editor.selection.moveToBookmark(bookmark);\n modal.destroy();\n};\n\n/**\n * Build and show the picker dialogue.\n *\n * @param {TinyMCE} editor\n */\nconst displayDialogue = async(editor) => {\n // Remember where the cursor was so the shortcode lands there.\n const bookmark = editor.selection.getBookmark();\n const contextid = getContextId(editor);\n\n const modal = await FastpixModal.create({\n templateContext: {\n elementid: editor.id,\n },\n });\n\n const root = modal.getRoot()[0];\n const region = root.querySelector(listRegionSelector);\n\n // The list region persists, so one delegated click listener covers every\n // rendered card.\n region.addEventListener('click', (e) => {\n const choice = e.target.closest('[data-playbackid]');\n if (!choice) {\n return;\n }\n e.preventDefault();\n insertShortcode(editor, modal, bookmark, choice.dataset.playbackid);\n });\n\n let videos;\n try {\n videos = await fetchVideos(contextid);\n } catch (error) {\n const message = await getString('loaderror', component);\n region.textContent = message;\n return;\n }\n\n const {html, js} = await renderForPromise(`${component}/videos`, {\n videos,\n hasvideos: videos.length > 0,\n });\n replaceNodeContents(region, html, js);\n};\n"],"names":["editor","displayDialogue","async","bookmark","selection","getBookmark","contextid","modal","FastpixModal","create","templateContext","elementid","id","region","getRoot","querySelector","videos","addEventListener","e","choice","target","closest","preventDefault","playbackid","moveToBookmark","execCommand","destroy","insertShortcode","dataset","methodname","args","then","result","fetchVideos","error","message","component","textContent","html","js","hasvideos","length"],"mappings":";;;;;;;;sKAsC6BA,SACzBC,gBAAgBD,eAmCdC,gBAAkBC,MAAAA,eAEdC,SAAWH,OAAOI,UAAUC,cAC5BC,WAAY,yBAAaN,QAEzBO,YAAcC,eAAaC,OAAO,CACpCC,gBAAiB,CACbC,UAAWX,OAAOY,MAKpBC,OADON,MAAMO,UAAU,GACTC,cAvDG,0CAoEnBC,OATJH,OAAOI,iBAAiB,SAAUC,UACxBC,OAASD,EAAEE,OAAOC,QAAQ,qBAC3BF,SAGLD,EAAEI,iBAjCc,EAACtB,OAAQO,MAAOJ,SAAUoB,cAC9CvB,OAAOI,UAAUoB,eAAerB,UAChCH,OAAOyB,YAAY,oBAAoB,wBAAsBF,iBAC7DvB,OAAOI,UAAUoB,eAAerB,UAChCI,MAAMmB,WA8BFC,CAAgB3B,OAAQO,MAAOJ,SAAUgB,OAAOS,QAAQL,oBAKxDP,YApDaV,CAAAA,YAAc,cAAU,CAAC,CAC1CuB,WAAY,6BACZC,KAAM,CAACxB,UAAAA,cACP,GAAGyB,MAAMC,QAAWA,OAAOhB,SAiDRiB,CAAY3B,WAC7B,MAAO4B,aACCC,cAAgB,kBAAU,YAAaC,+BAC7CvB,OAAOwB,YAAcF,eAInBG,KAACA,KAADC,GAAOA,UAAY,yCAAoBH,6BAAoB,CAC7DpB,OAAAA,OACAwB,UAAWxB,OAAOyB,OAAS,uCAEX5B,OAAQyB,KAAMC"} \ No newline at end of file +{"version":3,"file":"ui.min.js","sources":["../src/ui.js"],"sourcesContent":["// This file is part of Moodle - https://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 * Tiny FastPix UI — opens the picker, lists this course's videos the author\n * uploaded and inserts the {fastpix:pb_} shortcode for the chosen one.\n *\n * @module tiny_fastpix/ui\n * @copyright 2026 FastPix Inc. \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {call as fetchMany} from 'core/ajax';\nimport {renderForPromise, replaceNodeContents} from 'core/templates';\nimport {getString} from 'core/str';\nimport {component} from './common';\nimport {getContextId} from './options';\nimport FastpixModal from './modal';\n\nconst listRegionSelector = '[data-region=\"fastpix-video-list\"]';\nconst searchSelector = '[data-region=\"fastpix-video-search\"]';\nconst searchWrapSelector = '[data-region=\"fastpix-video-search-wrap\"]';\nconst noMatchesSelector = '[data-region=\"fastpix-video-nomatches\"]';\n\n/**\n * Open the picker for the current editor.\n *\n * @param {TinyMCE} editor\n */\nexport const handleAction = (editor) => {\n displayDialogue(editor);\n};\n\n/**\n * Fetch the videos this course holds for the current user via the web service.\n * The server derives the course from the context, so only the context is sent.\n *\n * @param {number} contextid\n * @returns {Promise>}\n */\nconst fetchVideos = (contextid) => fetchMany([{\n methodname: 'tiny_fastpix_get_my_videos',\n args: {contextid},\n}])[0].then((result) => result.videos);\n\n/**\n * Record that the author inserted a shortcode. Best-effort telemetry: a failure\n * here must never block the insertion, so the rejection is swallowed.\n *\n * @param {number} contextid\n * @param {string} playbackid\n */\nconst logInsertion = (contextid, playbackid) => {\n fetchMany([{\n methodname: 'tiny_fastpix_log_shortcode_inserted',\n args: {contextid, playbackid},\n }])[0].catch(() => {\n // Logging is non-critical; ignore failures so the editor is unaffected.\n return null;\n });\n};\n\n/**\n * Insert the shortcode for the chosen video and close the modal.\n *\n * @param {TinyMCE} editor\n * @param {object} modal\n * @param {string} bookmark\n * @param {number} contextid\n * @param {string} playbackid\n */\nconst insertShortcode = (editor, modal, bookmark, contextid, playbackid) => {\n editor.selection.moveToBookmark(bookmark);\n editor.execCommand('mceInsertContent', false, `{fastpix:pb_${playbackid}}`);\n editor.selection.moveToBookmark(bookmark);\n logInsertion(contextid, playbackid);\n modal.destroy();\n};\n\n/**\n * Wire the client-side title filter. The list is course-scoped and small, so the\n * already-fetched cards are filtered in the browser by their title text — no\n * extra web service call. The search box and the \"no matches\" notice live\n * outside the list region so they survive the list render.\n *\n * @param {HTMLElement} root The modal root element.\n * @param {HTMLElement} region The list region holding the rendered cards.\n */\nconst wireSearch = (root, region) => {\n const search = root.querySelector(searchSelector);\n const wrap = root.querySelector(searchWrapSelector);\n const nomatches = root.querySelector(noMatchesSelector);\n const cards = region.querySelectorAll('[data-playbackid]');\n\n // With nothing to filter, leave the search box hidden — the empty-state\n // message already explains why the list is empty.\n if (!search || !cards.length) {\n return;\n }\n if (wrap) {\n wrap.classList.remove('d-none');\n }\n\n search.addEventListener('input', () => {\n const needle = search.value.trim().toLowerCase();\n let visible = 0;\n cards.forEach((card) => {\n const match = needle === '' || card.textContent.trim().toLowerCase().includes(needle);\n card.closest('li').classList.toggle('d-none', !match);\n if (match) {\n visible += 1;\n }\n });\n if (nomatches) {\n nomatches.classList.toggle('d-none', !(needle !== '' && visible === 0));\n }\n });\n};\n\n/**\n * Build and show the picker dialogue.\n *\n * @param {TinyMCE} editor\n */\nconst displayDialogue = async(editor) => {\n // Remember where the cursor was so the shortcode lands there.\n const bookmark = editor.selection.getBookmark();\n const contextid = getContextId(editor);\n\n const modal = await FastpixModal.create({\n templateContext: {\n elementid: editor.id,\n },\n });\n\n const root = modal.getRoot()[0];\n const region = root.querySelector(listRegionSelector);\n\n // The list region persists, so one delegated click listener covers every\n // rendered card.\n region.addEventListener('click', (e) => {\n const choice = e.target.closest('[data-playbackid]');\n if (!choice) {\n return;\n }\n e.preventDefault();\n insertShortcode(editor, modal, bookmark, contextid, choice.dataset.playbackid);\n });\n\n let videos;\n try {\n videos = await fetchVideos(contextid);\n } catch (error) {\n const message = await getString('loaderror', component);\n region.textContent = message;\n return;\n }\n\n const {html, js} = await renderForPromise(`${component}/videos`, {\n videos,\n hasvideos: videos.length > 0,\n });\n replaceNodeContents(region, html, js);\n\n wireSearch(root, region);\n};\n"],"names":["editor","displayDialogue","insertShortcode","modal","bookmark","contextid","playbackid","selection","moveToBookmark","execCommand","methodname","args","catch","logInsertion","destroy","async","getBookmark","FastpixModal","create","templateContext","elementid","id","root","getRoot","region","querySelector","videos","addEventListener","e","choice","target","closest","preventDefault","dataset","then","result","fetchVideos","error","message","component","textContent","html","js","hasvideos","length","search","wrap","nomatches","cards","querySelectorAll","classList","remove","needle","value","trim","toLowerCase","visible","forEach","card","match","includes","toggle","wireSearch"],"mappings":";;;;;;;;sKAyC6BA,SACzBC,gBAAgBD,eAyCdE,gBAAkB,CAACF,OAAQG,MAAOC,SAAUC,UAAWC,cACzDN,OAAOO,UAAUC,eAAeJ,UAChCJ,OAAOS,YAAY,oBAAoB,wBAAsBH,iBAC7DN,OAAOO,UAAUC,eAAeJ,UAtBf,EAACC,UAAWC,6BACnB,CAAC,CACPI,WAAY,sCACZC,KAAM,CAACN,UAAAA,UAAWC,WAAAA,eAClB,GAAGM,OAAM,IAEF,QAiBXC,CAAaR,UAAWC,YACxBH,MAAMW,WAgDJb,gBAAkBc,MAAAA,eAEdX,SAAWJ,OAAOO,UAAUS,cAC5BX,WAAY,yBAAaL,QAEzBG,YAAcc,eAAaC,OAAO,CACpCC,gBAAiB,CACbC,UAAWpB,OAAOqB,MAIpBC,KAAOnB,MAAMoB,UAAU,GACvBC,OAASF,KAAKG,cArHG,0CAkInBC,OATJF,OAAOG,iBAAiB,SAAUC,UACxBC,OAASD,EAAEE,OAAOC,QAAQ,qBAC3BF,SAGLD,EAAEI,iBACF9B,gBAAgBF,OAAQG,MAAOC,SAAUC,UAAWwB,OAAOI,QAAQ3B,oBAKnEoB,YA/GarB,CAAAA,YAAc,cAAU,CAAC,CAC1CK,WAAY,6BACZC,KAAM,CAACN,UAAAA,cACP,GAAG6B,MAAMC,QAAWA,OAAOT,SA4GRU,CAAY/B,WAC7B,MAAOgC,aACCC,cAAgB,kBAAU,YAAaC,+BAC7Cf,OAAOgB,YAAcF,eAInBG,KAACA,KAADC,GAAOA,UAAY,yCAAoBH,6BAAoB,CAC7Db,OAAAA,OACAiB,UAAWjB,OAAOkB,OAAS,uCAEXpB,OAAQiB,KAAMC,IA1EnB,EAACpB,KAAME,gBAChBqB,OAASvB,KAAKG,cArED,wCAsEbqB,KAAOxB,KAAKG,cArEK,6CAsEjBsB,UAAYzB,KAAKG,cArED,2CAsEhBuB,MAAQxB,OAAOyB,iBAAiB,qBAIjCJ,QAAWG,MAAMJ,SAGlBE,MACAA,KAAKI,UAAUC,OAAO,UAG1BN,OAAOlB,iBAAiB,SAAS,WACvByB,OAASP,OAAOQ,MAAMC,OAAOC,kBAC/BC,QAAU,EACdR,MAAMS,SAASC,aACLC,MAAmB,KAAXP,QAAiBM,KAAKlB,YAAYc,OAAOC,cAAcK,SAASR,QAC9EM,KAAK3B,QAAQ,MAAMmB,UAAUW,OAAO,UAAWF,OAC3CA,QACAH,SAAW,MAGfT,WACAA,UAAUG,UAAUW,OAAO,WAAuB,KAAXT,QAA6B,IAAZI,eAkDhEM,CAAWxC,KAAME"} \ No newline at end of file diff --git a/amd/src/ui.js b/amd/src/ui.js index 6ca0b01..db42c24 100644 --- a/amd/src/ui.js +++ b/amd/src/ui.js @@ -30,6 +30,9 @@ import {getContextId} from './options'; import FastpixModal from './modal'; const listRegionSelector = '[data-region="fastpix-video-list"]'; +const searchSelector = '[data-region="fastpix-video-search"]'; +const searchWrapSelector = '[data-region="fastpix-video-search-wrap"]'; +const noMatchesSelector = '[data-region="fastpix-video-nomatches"]'; /** * Open the picker for the current editor. @@ -52,21 +55,80 @@ const fetchVideos = (contextid) => fetchMany([{ args: {contextid}, }])[0].then((result) => result.videos); +/** + * Record that the author inserted a shortcode. Best-effort telemetry: a failure + * here must never block the insertion, so the rejection is swallowed. + * + * @param {number} contextid + * @param {string} playbackid + */ +const logInsertion = (contextid, playbackid) => { + fetchMany([{ + methodname: 'tiny_fastpix_log_shortcode_inserted', + args: {contextid, playbackid}, + }])[0].catch(() => { + // Logging is non-critical; ignore failures so the editor is unaffected. + return null; + }); +}; + /** * Insert the shortcode for the chosen video and close the modal. * * @param {TinyMCE} editor * @param {object} modal * @param {string} bookmark + * @param {number} contextid * @param {string} playbackid */ -const insertShortcode = (editor, modal, bookmark, playbackid) => { +const insertShortcode = (editor, modal, bookmark, contextid, playbackid) => { editor.selection.moveToBookmark(bookmark); editor.execCommand('mceInsertContent', false, `{fastpix:pb_${playbackid}}`); editor.selection.moveToBookmark(bookmark); + logInsertion(contextid, playbackid); modal.destroy(); }; +/** + * Wire the client-side title filter. The list is course-scoped and small, so the + * already-fetched cards are filtered in the browser by their title text — no + * extra web service call. The search box and the "no matches" notice live + * outside the list region so they survive the list render. + * + * @param {HTMLElement} root The modal root element. + * @param {HTMLElement} region The list region holding the rendered cards. + */ +const wireSearch = (root, region) => { + const search = root.querySelector(searchSelector); + const wrap = root.querySelector(searchWrapSelector); + const nomatches = root.querySelector(noMatchesSelector); + const cards = region.querySelectorAll('[data-playbackid]'); + + // With nothing to filter, leave the search box hidden — the empty-state + // message already explains why the list is empty. + if (!search || !cards.length) { + return; + } + if (wrap) { + wrap.classList.remove('d-none'); + } + + search.addEventListener('input', () => { + const needle = search.value.trim().toLowerCase(); + let visible = 0; + cards.forEach((card) => { + const match = needle === '' || card.textContent.trim().toLowerCase().includes(needle); + card.closest('li').classList.toggle('d-none', !match); + if (match) { + visible += 1; + } + }); + if (nomatches) { + nomatches.classList.toggle('d-none', !(needle !== '' && visible === 0)); + } + }); +}; + /** * Build and show the picker dialogue. * @@ -94,7 +156,7 @@ const displayDialogue = async(editor) => { return; } e.preventDefault(); - insertShortcode(editor, modal, bookmark, choice.dataset.playbackid); + insertShortcode(editor, modal, bookmark, contextid, choice.dataset.playbackid); }); let videos; @@ -111,4 +173,6 @@ const displayDialogue = async(editor) => { hasvideos: videos.length > 0, }); replaceNodeContents(region, html, js); + + wireSearch(root, region); }; diff --git a/classes/event/picker_opened.php b/classes/event/picker_opened.php new file mode 100644 index 0000000..25336c0 --- /dev/null +++ b/classes/event/picker_opened.php @@ -0,0 +1,76 @@ +. + +namespace tiny_fastpix\event; + +/** + * Picker-opened event for the FastPix TinyMCE plugin. + * + * @package tiny_fastpix + * @copyright 2026 FastPix Inc. + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Fired when a capable author opens the picker in a course (the get_my_videos + * web service is the server-side moment the picker loads its list). Conceptually + * the suite's "tiny.picker.opened" signal. + */ +class picker_opened extends \core\event\base { + /** + * Build the event for an editor context, carrying the resolved course id. + * + * @param \context $context The editor context the picker opened in. + * @param int $courseid The course the picker is scoped to. + * @return self The created event. + */ + public static function create_from_context(\context $context, int $courseid): self { + return self::create([ + 'context' => $context, + 'other' => ['courseid' => $courseid], + ]); + } + + /** + * Initialise the event data. + * + * @return void + */ + protected function init() { + $this->data['crud'] = 'r'; + $this->data['edulevel'] = self::LEVEL_OTHER; + } + + /** + * Return the localised event name. + * + * @return string The event name. + */ + public static function get_name() { + return get_string('eventpickeropened', 'tiny_fastpix'); + } + + /** + * Return a human-readable description of the event. + * + * @return string The event description. + */ + public function get_description() { + $courseid = (int)($this->other['courseid'] ?? 0); + return "The user with id '$this->userid' opened the FastPix video picker " . + "in the course with id '$courseid'."; + } +} diff --git a/classes/event/shortcode_inserted.php b/classes/event/shortcode_inserted.php new file mode 100644 index 0000000..d2a84b0 --- /dev/null +++ b/classes/event/shortcode_inserted.php @@ -0,0 +1,77 @@ +. + +namespace tiny_fastpix\event; + +/** + * Shortcode-inserted event for the FastPix TinyMCE plugin. + * + * @package tiny_fastpix + * @copyright 2026 FastPix Inc. + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Fired when an author picks a video and the {fastpix:pb_} shortcode is + * inserted at the cursor. Conceptually the suite's "tiny.shortcode.inserted" + * signal. The insertion itself is client-side, so the editor reports it through + * the tiny_fastpix_log_shortcode_inserted web service. + */ +class shortcode_inserted extends \core\event\base { + /** + * Build the event for an editor context and the chosen playback id. + * + * @param \context $context The editor context the shortcode was inserted in. + * @param string $playbackid The bare playback id (no pb_ prefix). + * @return self The created event. + */ + public static function create_from_context(\context $context, string $playbackid): self { + return self::create([ + 'context' => $context, + 'other' => ['playbackid' => $playbackid], + ]); + } + + /** + * Initialise the event data. + * + * @return void + */ + protected function init() { + $this->data['crud'] = 'c'; + $this->data['edulevel'] = self::LEVEL_OTHER; + } + + /** + * Return the localised event name. + * + * @return string The event name. + */ + public static function get_name() { + return get_string('eventshortcodeinserted', 'tiny_fastpix'); + } + + /** + * Return a human-readable description of the event. + * + * @return string The event description. + */ + public function get_description() { + $playbackid = (string)($this->other['playbackid'] ?? ''); + return "The user with id '$this->userid' inserted a FastPix video shortcode " . + "for the playback id '$playbackid'."; + } +} diff --git a/classes/external/get_my_videos.php b/classes/external/get_my_videos.php index 7d5c1bb..a61e152 100644 --- a/classes/external/get_my_videos.php +++ b/classes/external/get_my_videos.php @@ -98,6 +98,10 @@ public static function execute(int $contextid): array { $context = \context::instance_by_id($params['contextid']); self::validate_context($context); + // The validate_context() call already enforces login, but the auth chain + // is kept explicit to match the suite-wide external-function contract: + // validate_parameters -> validate_context -> require_login -> capability. + require_login(null, false); // Course scope: the picker only lists videos belonging to the course the // editor lives in. Outside a course (user/system context) nothing is in @@ -109,6 +113,10 @@ public static function execute(int $contextid): array { require_capability('mod/fastpix:uploadmedia', $coursecontext); $courseid = (int)$coursecontext->instanceid; + // The picker opens client-side, but this read is the server-side moment + // its list loads, so it is where we record the "picker opened" signal. + \tiny_fastpix\event\picker_opened::create_from_context($context, $courseid)->trigger(); + // Owner + ready scope from local_fastpix's own list method (no direct // local_fastpix table queries). $ownedbyid = []; diff --git a/classes/external/log_shortcode_inserted.php b/classes/external/log_shortcode_inserted.php new file mode 100644 index 0000000..1ce49d5 --- /dev/null +++ b/classes/external/log_shortcode_inserted.php @@ -0,0 +1,102 @@ +. + +/** + * External function: record that an author inserted a FastPix shortcode. + * + * @package tiny_fastpix + * @copyright 2026 FastPix Inc. + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tiny_fastpix\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_single_structure; +use core_external\external_value; + +/** + * Records the "shortcode inserted" signal for the picker. + * + * The insertion happens entirely in the editor (client-side), so the JS calls + * this after it writes {fastpix:pb_} at the cursor. It stores nothing of its + * own — it only triggers a Moodle event for the site log. It is gated by the + * same mod/fastpix:uploadmedia capability (resolved at the course context) as + * the picker itself, and require_sesskey() guards the state-changing call. + * + * @package tiny_fastpix + * @copyright 2026 FastPix Inc. + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class log_shortcode_inserted extends external_api { + /** + * Parameter definition. + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'contextid' => new external_value(PARAM_INT, 'The context the editor is used in.'), + 'playbackid' => new external_value(PARAM_ALPHANUMEXT, 'The bare playback id that was inserted (no pb_ prefix).'), + ]); + } + + /** + * Return-value definition. + * + * @return external_single_structure + */ + public static function execute_returns(): external_single_structure { + return new external_single_structure([ + 'logged' => new external_value(PARAM_BOOL, 'Whether the event was recorded.'), + ]); + } + + /** + * Record the shortcode insertion as a Moodle event. + * + * @param int $contextid The context the editor is used in. + * @param string $playbackid The bare playback id that was inserted. + * @return array Whether the event was recorded. + */ + public static function execute(int $contextid, string $playbackid): array { + $params = self::validate_parameters( + self::execute_parameters(), + ['contextid' => $contextid, 'playbackid' => $playbackid] + ); + + $context = \context::instance_by_id($params['contextid']); + self::validate_context($context); + // Explicit auth chain (validate_context already logs in, but the contract + // is kept visible): validate_context -> require_login -> require_sesskey + // (write) -> require_capability. + require_login(null, false); + require_sesskey(); + + // Insertion only makes sense inside a course (the picker is course-scoped + // and the capability lives there); outside one there is nothing to log. + $coursecontext = $context->get_course_context(false); + if (!$coursecontext) { + return ['logged' => false]; + } + require_capability('mod/fastpix:uploadmedia', $coursecontext); + + \tiny_fastpix\event\shortcode_inserted::create_from_context($context, $params['playbackid'])->trigger(); + + return ['logged' => true]; + } +} diff --git a/db/services.php b/db/services.php index 01a5e18..2a6d6e9 100644 --- a/db/services.php +++ b/db/services.php @@ -33,4 +33,12 @@ 'ajax' => true, 'capabilities' => 'mod/fastpix:uploadmedia', ], + 'tiny_fastpix_log_shortcode_inserted' => [ + 'classname' => 'tiny_fastpix\external\log_shortcode_inserted', + 'methodname' => 'execute', + 'description' => 'Record that an author inserted a FastPix shortcode from the picker.', + 'type' => 'write', + 'ajax' => true, + 'capabilities' => 'mod/fastpix:uploadmedia', + ], ]; diff --git a/lang/en/tiny_fastpix.php b/lang/en/tiny_fastpix.php index d044bf1..b73274c 100644 --- a/lang/en/tiny_fastpix.php +++ b/lang/en/tiny_fastpix.php @@ -25,10 +25,15 @@ defined('MOODLE_INTERNAL') || die(); $string['buttontitle'] = 'Insert FastPix video'; +$string['eventpickeropened'] = 'FastPix video picker opened'; +$string['eventshortcodeinserted'] = 'FastPix video shortcode inserted'; $string['loaderror'] = 'Could not load your videos. Please try again.'; $string['loading'] = 'Loading your videos…'; $string['modaltitle'] = 'Insert FastPix video'; +$string['nomatches'] = 'No videos match your search.'; $string['novideos'] = 'This course has no ready videos you can embed yet. Upload a video in a FastPix activity in this course first.'; $string['pluginname'] = 'FastPix video'; $string['privacy:metadata'] = 'The FastPix video plugin for TinyMCE does not store any personal data. It lists the videos you already own and inserts a shortcode.'; +$string['searchlabel'] = 'Search videos'; +$string['searchplaceholder'] = 'Search your videos…'; $string['untitledvideo'] = 'Untitled video'; diff --git a/templates/modal.mustache b/templates/modal.mustache index bcb6017..2468c2b 100644 --- a/templates/modal.mustache +++ b/templates/modal.mustache @@ -35,9 +35,23 @@ {{/title}} {{$body}} +
+ + +
{{#str}} loading, tiny_fastpix {{/str}}
+
+ {{#str}} nomatches, tiny_fastpix {{/str}} +
{{/body}} {{$footer}} diff --git a/tests/behat/insert_shortcode.feature b/tests/behat/insert_shortcode.feature index 0754a29..2f02cc7 100644 --- a/tests/behat/insert_shortcode.feature +++ b/tests/behat/insert_shortcode.feature @@ -105,3 +105,20 @@ Feature: Insert a FastPix shortcode via the TinyMCE picker And I press "Save and display" And I navigate to "Settings" in current page administration Then the field "Description" matches expression "/\{fastpix:pb_keepme01\}/" + + # --------------------------------------------------------------------------- + # Search box (client-side filter). Typing in the search field narrows the + # listed videos by title; a non-matching video is hidden from the dialogue. + # --------------------------------------------------------------------------- + Scenario: The picker search box filters the listed videos by title + Given the following "tiny_fastpix > assets" exist: + | user | course | playback_id | title | + | teacher1 | C1 | second02 | Algebra basics | + And I am on the "PageName1" "page activity" page logged in as teacher1 + And I navigate to "Settings" in current page administration + When I click on the "Insert FastPix video" button for the "Description" TinyMCE editor + Then I should see "My lecture" in the "Insert FastPix video" "dialogue" + And I should see "Algebra basics" in the "Insert FastPix video" "dialogue" + When I set the field "Search videos" to "algebra" + Then I should see "Algebra basics" in the "Insert FastPix video" "dialogue" + And I should not see "My lecture" in the "Insert FastPix video" "dialogue" diff --git a/tests/event/picker_opened_test.php b/tests/event/picker_opened_test.php new file mode 100644 index 0000000..17d9cdc --- /dev/null +++ b/tests/event/picker_opened_test.php @@ -0,0 +1,52 @@ +. + +namespace tiny_fastpix\event; + +/** + * Tests for the picker_opened event. + * + * @package tiny_fastpix + * @copyright 2026 FastPix Inc. + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \tiny_fastpix\event\picker_opened + */ +final class picker_opened_test extends \advanced_testcase { + /** + * The event triggers with the editor context and the course id, and exposes + * a localised name and a description naming the course. + */ + public function test_event_payload_and_strings(): void { + $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(); + $context = \context_course::instance($course->id); + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + $sink = $this->redirectEvents(); + picker_opened::create_from_context($context, (int)$course->id)->trigger(); + $events = $sink->get_events(); + $sink->close(); + + $this->assertCount(1, $events); + $event = $events[0]; + $this->assertInstanceOf(picker_opened::class, $event); + $this->assertSame($context->id, $event->contextid); + $this->assertSame((int)$course->id, (int)$event->other['courseid']); + $this->assertNotEmpty(picker_opened::get_name()); + $this->assertStringContainsString((string)$course->id, $event->get_description()); + } +} diff --git a/tests/event/shortcode_inserted_test.php b/tests/event/shortcode_inserted_test.php new file mode 100644 index 0000000..fefb1b0 --- /dev/null +++ b/tests/event/shortcode_inserted_test.php @@ -0,0 +1,52 @@ +. + +namespace tiny_fastpix\event; + +/** + * Tests for the shortcode_inserted event. + * + * @package tiny_fastpix + * @copyright 2026 FastPix Inc. + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \tiny_fastpix\event\shortcode_inserted + */ +final class shortcode_inserted_test extends \advanced_testcase { + /** + * The event triggers with the editor context and the playback id, and + * exposes a localised name and a description naming the playback id. + */ + public function test_event_payload_and_strings(): void { + $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(); + $context = \context_course::instance($course->id); + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + $sink = $this->redirectEvents(); + shortcode_inserted::create_from_context($context, 'keepme01')->trigger(); + $events = $sink->get_events(); + $sink->close(); + + $this->assertCount(1, $events); + $event = $events[0]; + $this->assertInstanceOf(shortcode_inserted::class, $event); + $this->assertSame($context->id, $event->contextid); + $this->assertSame('keepme01', $event->other['playbackid']); + $this->assertNotEmpty(shortcode_inserted::get_name()); + $this->assertStringContainsString('keepme01', $event->get_description()); + } +} diff --git a/tests/external/get_my_videos_test.php b/tests/external/get_my_videos_test.php index 7030e55..5f7772e 100644 --- a/tests/external/get_my_videos_test.php +++ b/tests/external/get_my_videos_test.php @@ -254,6 +254,67 @@ public function test_excludes_unembeddable_states(): void { $this->assertCount(0, $result['videos']); } + /** + * Opening the picker (calling the service) in a course fires the + * picker_opened event, scoped to the editor context and carrying the course. + */ + public function test_picker_opened_event_fired(): void { + $this->resetAfterTest(); + [$context, $teacher, $course] = $this->teacher_in_course(); + $this->setUser($teacher); + + $sink = $this->redirectEvents(); + $this->list_videos($context); + $events = $sink->get_events(); + $sink->close(); + + $opened = array_values(array_filter($events, static function ($e) { + return $e instanceof \tiny_fastpix\event\picker_opened; + })); + $this->assertCount(1, $opened); + $this->assertSame($context->id, $opened[0]->contextid); + $this->assertSame((int)$course->id, (int)$opened[0]->other['courseid']); + // Exercise the event's display strings (name + description). + $this->assertNotEmpty(\tiny_fastpix\event\picker_opened::get_name()); + $this->assertStringContainsString((string)$course->id, $opened[0]->get_description()); + } + + /** + * Outside a course there is no picker_opened event (nothing is in scope). + */ + public function test_picker_opened_event_not_fired_outside_course(): void { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + $sink = $this->redirectEvents(); + $this->list_videos(\context_user::instance($user->id)); + $events = $sink->get_events(); + $sink->close(); + + $opened = array_filter($events, static function ($e) { + return $e instanceof \tiny_fastpix\event\picker_opened; + }); + $this->assertCount(0, $opened); + } + + /** + * An unauthenticated caller is rejected before any data is returned (the + * explicit require_login in the auth chain; also enforced by + * validate_context). Guards the contract against accidental removal. + */ + public function test_rejects_unauthenticated_caller(): void { + $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(); + $context = \context_course::instance($course->id); + // The not-logged-in user. + $this->setUser(0); + + // Both require_login and validate_context raise a moodle_exception subclass. + $this->expectException(\moodle_exception::class); + get_my_videos::execute($context->id); + } + /** * A user without mod/fastpix:uploadmedia in the course is refused. */ diff --git a/tests/external/log_shortcode_inserted_test.php b/tests/external/log_shortcode_inserted_test.php new file mode 100644 index 0000000..24f740d --- /dev/null +++ b/tests/external/log_shortcode_inserted_test.php @@ -0,0 +1,144 @@ +. + +namespace tiny_fastpix\external; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/webservice/tests/helpers.php'); + +/** + * Tests for the tiny_fastpix_log_shortcode_inserted web service. + * + * The service records the "shortcode inserted" signal as a Moodle event. It is + * gated by mod/fastpix:uploadmedia at the course context and protected by + * require_sesskey(); these tests lock that gate plus the event payload. + * + * @package tiny_fastpix + * @copyright 2026 FastPix Inc. + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \tiny_fastpix\external\log_shortcode_inserted + */ +final class log_shortcode_inserted_test extends \externallib_advanced_testcase { + /** + * Create a course with an editing teacher enrolled, set as the current user, + * with a valid sesskey ready for the state-changing call. + * + * @return array [\context_course $context, \stdClass $teacher, \stdClass $course] + */ + private function teacher_in_course(): array { + $course = $this->getDataGenerator()->create_course(); + $teacher = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($teacher->id, $course->id, 'editingteacher'); + $this->setUser($teacher); + $_GET['sesskey'] = sesskey(); + return [\context_course::instance($course->id), $teacher, $course]; + } + + /** + * Run the service and clean the return value. + * + * @param \context $context The editor context. + * @param string $playbackid The playback id inserted. + * @return array The cleaned result. + */ + private function log(\context $context, string $playbackid): array { + $result = log_shortcode_inserted::execute($context->id, $playbackid); + return \core_external\external_api::clean_returnvalue( + log_shortcode_inserted::execute_returns(), + $result + ); + } + + /** + * The happy path: a capable teacher's insertion is logged and fires the + * shortcode_inserted event carrying the context and playback id. + */ + public function test_logs_and_fires_event(): void { + $this->resetAfterTest(); + [$context, $teacher] = $this->teacher_in_course(); + + $sink = $this->redirectEvents(); + $result = $this->log($context, 'keepme01'); + $events = $sink->get_events(); + $sink->close(); + + $this->assertTrue($result['logged']); + + $inserted = array_values(array_filter($events, static function ($e) { + return $e instanceof \tiny_fastpix\event\shortcode_inserted; + })); + $this->assertCount(1, $inserted); + $this->assertSame($context->id, $inserted[0]->contextid); + $this->assertSame((int)$teacher->id, (int)$inserted[0]->userid); + $this->assertSame('keepme01', $inserted[0]->other['playbackid']); + // Exercise the event's display strings (name + description). + $this->assertNotEmpty(\tiny_fastpix\event\shortcode_inserted::get_name()); + $this->assertStringContainsString('keepme01', $inserted[0]->get_description()); + } + + /** + * Outside a course there is nothing to log: the call returns logged=false + * and fires no event. + */ + public function test_non_course_context_does_not_log(): void { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + $_GET['sesskey'] = sesskey(); + + $sink = $this->redirectEvents(); + $result = $this->log(\context_user::instance($user->id), 'keepme01'); + $events = $sink->get_events(); + $sink->close(); + + $this->assertFalse($result['logged']); + $inserted = array_filter($events, static function ($e) { + return $e instanceof \tiny_fastpix\event\shortcode_inserted; + }); + $this->assertCount(0, $inserted); + } + + /** + * A user without mod/fastpix:uploadmedia in the course is refused. + */ + public function test_requires_uploadmedia_capability(): void { + $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(); + $context = \context_course::instance($course->id); + $student = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($student->id, $course->id, 'student'); + $this->setUser($student); + $_GET['sesskey'] = sesskey(); + + $this->expectException(\required_capability_exception::class); + log_shortcode_inserted::execute($context->id, 'keepme01'); + } + + /** + * A missing/incorrect sesskey is rejected (CSRF guard on the write call). + */ + public function test_requires_sesskey(): void { + $this->resetAfterTest(); + [$context] = $this->teacher_in_course(); + unset($_GET['sesskey']); + $_POST['sesskey'] = 'definitely-wrong'; + + $this->expectException(\moodle_exception::class); + log_shortcode_inserted::execute($context->id, 'keepme01'); + } +} diff --git a/version.php b/version.php index 4da2423..7ab0098 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'tiny_fastpix'; -$plugin->version = 2026061010; +$plugin->version = 2026061501; $plugin->requires = 2024100100; // Moodle 4.5 LTS. $plugin->maturity = MATURITY_STABLE; $plugin->release = '1.1.0';