diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a73be5..67e0a2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to **tiny_fastpix** (FastPix video for TinyMCE) are documented here. This project follows [Semantic Versioning](https://semver.org/). +## 1.1.0 — 2026-06-10 + +### Changed +- **Course-scoped picker.** The picker now lists only the signed-in user's own + videos that belong to the **current course** (referenced by a FastPix activity + in it) — previously it listed the user's videos across all courses. The listing + stays owner-scoped, ready-only and embeddable-only (public, non-DRM). +- **Course-context capability gating.** `mod/fastpix:uploadmedia` is now resolved + at the editor's **course context**, both for showing the toolbar button + (`plugininfo`) and in the `get_my_videos` web service. Students (no capability) + get no button and a rejected service call; they can still **watch** embeds via + `filter_fastpix` (`mod/fastpix:view`). + +### Removed +- **Whole-library search.** With the list scoped to a single course it is no + longer needed; the `query` parameter and the modal search box are gone. + ## 1.0.0 — 2026-06-05 First stable release. diff --git a/README.md b/README.md index 2bac9b0..0f42479 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,7 @@ you already have. 1. A teacher opens any TinyMCE editor — a forum post, a Page, a Book chapter, a label. 2. They click **Insert FastPix video** (or **Insert → FastPix video**). -3. A dialog shows *their* videos. They can scroll, or type to search the whole - library by name. +3. A dialog shows *their* videos for **this course**. 4. They click one. `{fastpix:pb_}` appears at the cursor. When someone later reads that content, `filter_fastpix` swaps the short code for a @@ -42,13 +41,11 @@ actually play: | Rule | Why | | --- | --- | | **Your videos only** | The dialog is scoped to the signed-in user — you never see another teacher's library. | +| **This course only** | Only videos uploaded through a FastPix activity *in the current course* are listed, so a course's editors see just that course's media. | | **Ready only** | Videos still uploading or processing are hidden until FastPix finishes them. | | **Playable only** | DRM-protected videos are left out — `filter_fastpix` can't embed them, so offering them would only insert a dead short code. | | **Named, not numbered** | Each result shows the video's activity name (falling back to *Untitled video*), not a raw UUID. | -Searching looks across your whole library by activity name and title, so a big -collection is still one keystroke away — not just the most recent handful. - ## Setting it up **You need the rest of the FastPix suite first.** This is a picker, not a @@ -82,7 +79,7 @@ capability to manage: | Capability | Effect | Owned by | | --- | --- | --- | -| `mod/fastpix:uploadmedia` | Holders see the toolbar button and can list their own videos; everyone else gets no button at all. | `mod_fastpix` | +| `mod/fastpix:uploadmedia` | Checked at the **course context**: holders see the toolbar button and can list their own videos in that course; everyone else gets no button at all. | `mod_fastpix` | ## Good to know @@ -91,7 +88,8 @@ capability to manage: them, so content written elsewhere (or by hand) still plays. - **No uploading here.** New videos come in through a `mod_fastpix` activity and surface in the picker once they're ready. An upload tab is on the roadmap. -- A video only appears for the teacher recorded as its owner. +- A video only appears for the teacher recorded as its owner, and only in the + course it was uploaded to. ## Privacy diff --git a/amd/build/commands.min.js b/amd/build/commands.min.js index 4a1f8a2..2478322 100644 --- a/amd/build/commands.min.js +++ b/amd/build/commands.min.js @@ -1,3 +1,3 @@ -define("tiny_fastpix/commands",["exports","editor_tiny/utils","core/str","./common","./options","./ui"],(function(_exports,_utils,_str,_common,_options,_ui){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.getSetup=void 0;_exports.getSetup=async()=>{const[buttonText,buttonImage]=await Promise.all([(0,_str.getString)("buttontitle",_common.component),(0,_utils.getButtonImage)("icon",_common.component)]);return editor=>{(0,_options.canUpload)(editor)&&(editor.ui.registry.addIcon(_common.icon,buttonImage.html),editor.ui.registry.addButton(_common.buttonName,{icon:_common.icon,tooltip:buttonText,onAction:()=>(0,_ui.handleAction)(editor)}),editor.ui.registry.addMenuItem(_common.buttonName,{icon:_common.icon,text:buttonText,onAction:()=>(0,_ui.handleAction)(editor)}))}}})); +define("tiny_fastpix/commands",["exports","editor_tiny/utils","core/str","./common","./options","./ui"],(function(_exports,_utils,_str,_common,_options,_ui){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.getSetup=void 0;_exports.getSetup=async()=>{const[buttonText,buttonImage]=await Promise.all([(0,_str.getString)("buttontitle",_common.component),(0,_utils.getButtonImage)("icon",_common.component)]);return editor=>{(0,_options.canEmbed)(editor)&&(editor.ui.registry.addIcon(_common.icon,buttonImage.html),editor.ui.registry.addButton(_common.buttonName,{icon:_common.icon,tooltip:buttonText,onAction:()=>(0,_ui.handleAction)(editor)}),editor.ui.registry.addMenuItem(_common.buttonName,{icon:_common.icon,text:buttonText,onAction:()=>(0,_ui.handleAction)(editor)}))}}})); //# sourceMappingURL=commands.min.js.map \ No newline at end of file diff --git a/amd/build/commands.min.js.map b/amd/build/commands.min.js.map index e3f0574..fdcc600 100644 --- a/amd/build/commands.min.js.map +++ b/amd/build/commands.min.js.map @@ -1 +1 @@ -{"version":3,"file":"commands.min.js","sources":["../src/commands.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 commands — registers the toolbar button and menu item.\n *\n * @module tiny_fastpix/commands\n * @copyright 2026 FastPix Inc. \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {getButtonImage} from 'editor_tiny/utils';\nimport {getString} from 'core/str';\nimport {component, buttonName, icon} from './common';\nimport {canUpload} from './options';\nimport {handleAction} from './ui';\n\nexport const getSetup = async() => {\n const [\n buttonText,\n buttonImage,\n ] = await Promise.all([\n getString('buttontitle', component),\n getButtonImage('icon', component),\n ]);\n\n return (editor) => {\n if (!canUpload(editor)) {\n return;\n }\n\n // Register the FastPix icon.\n editor.ui.registry.addIcon(icon, buttonImage.html);\n\n // Toolbar button.\n editor.ui.registry.addButton(buttonName, {\n icon,\n tooltip: buttonText,\n onAction: () => handleAction(editor),\n });\n\n // Menu item (Insert menu / context menu).\n editor.ui.registry.addMenuItem(buttonName, {\n icon,\n text: buttonText,\n onAction: () => handleAction(editor),\n });\n };\n};\n"],"names":["async","buttonText","buttonImage","Promise","all","component","editor","ui","registry","addIcon","icon","html","addButton","buttonName","tooltip","onAction","addMenuItem","text"],"mappings":"gQA6BwBA,gBAEhBC,WACAC,mBACMC,QAAQC,IAAI,EAClB,kBAAU,cAAeC,oBACzB,yBAAe,OAAQA,4BAGnBC,UACC,sBAAUA,UAKfA,OAAOC,GAAGC,SAASC,QAAQC,aAAMR,YAAYS,MAG7CL,OAAOC,GAAGC,SAASI,UAAUC,mBAAY,CACrCH,KAAAA,aACAI,QAASb,WACTc,SAAU,KAAM,oBAAaT,UAIjCA,OAAOC,GAAGC,SAASQ,YAAYH,mBAAY,CACvCH,KAAAA,aACAO,KAAMhB,WACNc,SAAU,KAAM,oBAAaT"} \ No newline at end of file +{"version":3,"file":"commands.min.js","sources":["../src/commands.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 commands — registers the toolbar button and menu item.\n *\n * @module tiny_fastpix/commands\n * @copyright 2026 FastPix Inc. \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {getButtonImage} from 'editor_tiny/utils';\nimport {getString} from 'core/str';\nimport {component, buttonName, icon} from './common';\nimport {canEmbed} from './options';\nimport {handleAction} from './ui';\n\nexport const getSetup = async() => {\n const [\n buttonText,\n buttonImage,\n ] = await Promise.all([\n getString('buttontitle', component),\n getButtonImage('icon', component),\n ]);\n\n return (editor) => {\n if (!canEmbed(editor)) {\n return;\n }\n\n // Register the FastPix icon.\n editor.ui.registry.addIcon(icon, buttonImage.html);\n\n // Toolbar button.\n editor.ui.registry.addButton(buttonName, {\n icon,\n tooltip: buttonText,\n onAction: () => handleAction(editor),\n });\n\n // Menu item (Insert menu / context menu).\n editor.ui.registry.addMenuItem(buttonName, {\n icon,\n text: buttonText,\n onAction: () => handleAction(editor),\n });\n };\n};\n"],"names":["async","buttonText","buttonImage","Promise","all","component","editor","ui","registry","addIcon","icon","html","addButton","buttonName","tooltip","onAction","addMenuItem","text"],"mappings":"gQA6BwBA,gBAEhBC,WACAC,mBACMC,QAAQC,IAAI,EAClB,kBAAU,cAAeC,oBACzB,yBAAe,OAAQA,4BAGnBC,UACC,qBAASA,UAKdA,OAAOC,GAAGC,SAASC,QAAQC,aAAMR,YAAYS,MAG7CL,OAAOC,GAAGC,SAASI,UAAUC,mBAAY,CACrCH,KAAAA,aACAI,QAASb,WACTc,SAAU,KAAM,oBAAaT,UAIjCA,OAAOC,GAAGC,SAASQ,YAAYH,mBAAY,CACvCH,KAAAA,aACAO,KAAMhB,WACNc,SAAU,KAAM,oBAAaT"} \ No newline at end of file diff --git a/amd/build/options.min.js b/amd/build/options.min.js index 80ce60d..87e5e8d 100644 --- a/amd/build/options.min.js +++ b/amd/build/options.min.js @@ -1,4 +1,4 @@ -define("tiny_fastpix/options",["exports","editor_tiny/options","./common"],(function(_exports,_options,_common){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.register=_exports.getContextId=_exports.canUpload=void 0; +define("tiny_fastpix/options",["exports","editor_tiny/options","./common"],(function(_exports,_options,_common){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.register=_exports.getCourseId=_exports.getContextId=_exports.canEmbed=void 0; /** * Options helper for the Tiny FastPix plugin. * @@ -6,6 +6,6 @@ define("tiny_fastpix/options",["exports","editor_tiny/options","./common"],(func * @copyright 2026 FastPix Inc. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -const contextIdName=(0,_options.getPluginOptionName)(_common.pluginName,"contextid"),canUploadName=(0,_options.getPluginOptionName)(_common.pluginName,"canupload");_exports.register=editor=>{const registerOption=editor.options.register;registerOption(contextIdName,{processor:"number",default:0}),registerOption(canUploadName,{processor:"boolean",default:!1})};_exports.getContextId=editor=>editor.options.get(contextIdName);_exports.canUpload=editor=>editor.options.get(canUploadName)})); +const contextIdName=(0,_options.getPluginOptionName)(_common.pluginName,"contextid"),courseIdName=(0,_options.getPluginOptionName)(_common.pluginName,"courseid"),canEmbedName=(0,_options.getPluginOptionName)(_common.pluginName,"canembed");_exports.register=editor=>{const registerOption=editor.options.register;registerOption(contextIdName,{processor:"number",default:0}),registerOption(courseIdName,{processor:"number",default:0}),registerOption(canEmbedName,{processor:"boolean",default:!1})};_exports.getContextId=editor=>editor.options.get(contextIdName);_exports.getCourseId=editor=>editor.options.get(courseIdName);_exports.canEmbed=editor=>editor.options.get(canEmbedName)})); //# sourceMappingURL=options.min.js.map \ No newline at end of file diff --git a/amd/build/options.min.js.map b/amd/build/options.min.js.map index 695ec13..8ae47ec 100644 --- a/amd/build/options.min.js.map +++ b/amd/build/options.min.js.map @@ -1 +1 @@ -{"version":3,"file":"options.min.js","sources":["../src/options.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 * Options helper for the Tiny FastPix plugin.\n *\n * @module tiny_fastpix/options\n * @copyright 2026 FastPix Inc. \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {getPluginOptionName} from 'editor_tiny/options';\nimport {pluginName} from './common';\n\nconst contextIdName = getPluginOptionName(pluginName, 'contextid');\nconst canUploadName = getPluginOptionName(pluginName, 'canupload');\n\n/**\n * Register the plugin options (populated from plugininfo).\n *\n * @param {TinyMCE} editor\n */\nexport const register = (editor) => {\n const registerOption = editor.options.register;\n\n registerOption(contextIdName, {\n processor: 'number',\n \"default\": 0,\n });\n\n registerOption(canUploadName, {\n processor: 'boolean',\n \"default\": false,\n });\n};\n\n/**\n * The context id the editor is used in.\n *\n * @param {TinyMCE} editor\n * @returns {number}\n */\nexport const getContextId = (editor) => editor.options.get(contextIdName);\n\n/**\n * Whether the current user may use the picker.\n *\n * @param {TinyMCE} editor\n * @returns {boolean}\n */\nexport const canUpload = (editor) => editor.options.get(canUploadName);\n"],"names":["contextIdName","pluginName","canUploadName","editor","registerOption","options","register","processor","get"],"mappings":";;;;;;;;MA0BMA,eAAgB,gCAAoBC,mBAAY,aAChDC,eAAgB,gCAAoBD,mBAAY,+BAO7BE,eACfC,eAAiBD,OAAOE,QAAQC,SAEtCF,eAAeJ,cAAe,CAC1BO,UAAW,iBACA,IAGfH,eAAeF,cAAe,CAC1BK,UAAW,mBACA,2BAUUJ,QAAWA,OAAOE,QAAQG,IAAIR,kCAQjCG,QAAWA,OAAOE,QAAQG,IAAIN"} \ No newline at end of file +{"version":3,"file":"options.min.js","sources":["../src/options.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 * Options helper for the Tiny FastPix plugin.\n *\n * @module tiny_fastpix/options\n * @copyright 2026 FastPix Inc. \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {getPluginOptionName} from 'editor_tiny/options';\nimport {pluginName} from './common';\n\nconst contextIdName = getPluginOptionName(pluginName, 'contextid');\nconst courseIdName = getPluginOptionName(pluginName, 'courseid');\nconst canEmbedName = getPluginOptionName(pluginName, 'canembed');\n\n/**\n * Register the plugin options (populated from plugininfo).\n *\n * @param {TinyMCE} editor\n */\nexport const register = (editor) => {\n const registerOption = editor.options.register;\n\n registerOption(contextIdName, {\n processor: 'number',\n \"default\": 0,\n });\n\n registerOption(courseIdName, {\n processor: 'number',\n \"default\": 0,\n });\n\n registerOption(canEmbedName, {\n processor: 'boolean',\n \"default\": false,\n });\n};\n\n/**\n * The context id the editor is used in.\n *\n * @param {TinyMCE} editor\n * @returns {number}\n */\nexport const getContextId = (editor) => editor.options.get(contextIdName);\n\n/**\n * The course id the editor is used in (0 outside a course).\n *\n * @param {TinyMCE} editor\n * @returns {number}\n */\nexport const getCourseId = (editor) => editor.options.get(courseIdName);\n\n/**\n * Whether the current user may embed FastPix videos (gates the toolbar button).\n *\n * @param {TinyMCE} editor\n * @returns {boolean}\n */\nexport const canEmbed = (editor) => editor.options.get(canEmbedName);\n"],"names":["contextIdName","pluginName","courseIdName","canEmbedName","editor","registerOption","options","register","processor","get"],"mappings":";;;;;;;;MA0BMA,eAAgB,gCAAoBC,mBAAY,aAChDC,cAAe,gCAAoBD,mBAAY,YAC/CE,cAAe,gCAAoBF,mBAAY,8BAO5BG,eACfC,eAAiBD,OAAOE,QAAQC,SAEtCF,eAAeL,cAAe,CAC1BQ,UAAW,iBACA,IAGfH,eAAeH,aAAc,CACzBM,UAAW,iBACA,IAGfH,eAAeF,aAAc,CACzBK,UAAW,mBACA,2BAUUJ,QAAWA,OAAOE,QAAQG,IAAIT,oCAQ/BI,QAAWA,OAAOE,QAAQG,IAAIP,gCAQjCE,QAAWA,OAAOE,QAAQG,IAAIN"} \ No newline at end of file diff --git a/amd/build/ui.min.js b/amd/build/ui.min.js index cefb4b0..0d48edd 100644 --- a/amd/build/ui.min.js +++ b/amd/build/ui.min.js @@ -1,11 +1,11 @@ define("tiny_fastpix/ui",["exports","core/ajax","core/templates","core/str","./common","./options","./modal"],(function(_exports,_ajax,_templates,_str,_common,_options,_modal){var obj; /** - * Tiny FastPix UI — opens the picker, lists the author's videos and inserts the - * {fastpix:pb_} shortcode for the chosen one. + * Tiny FastPix UI — opens the picker, lists this course's videos the author + * uploaded and inserts the {fastpix:pb_} shortcode for the chosen one. * * @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}}),root=modal.getRoot()[0],region=root.querySelector('[data-region="fastpix-video-list"]'),search=root.querySelector('[data-region="fastpix-search"]');let currentQuery="";const renderList=async query=>{let videos;currentQuery=query;try{videos=await function(contextid){let query=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";return(0,_ajax.call)([{methodname:"tiny_fastpix_get_my_videos",args:{contextid:contextid,query:query}}])[0].then((result=>result.videos))}(contextid,query)}catch(error){if(currentQuery!==query)return;const message=await(0,_str.getString)("loaderror",_common.component);return void(region.textContent=message)}if(currentQuery!==query)return;const{html:html,js:js}=await(0,_templates.renderForPromise)("".concat(_common.component,"/videos"),{videos:videos,hasvideos:videos.length>0,issearch:""!==query});currentQuery===query&&(0,_templates.replaceNodeContents)(region,html,js)};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))})),search&&search.addEventListener("input",((fn,delay)=>{let timer;return function(){for(var _len=arguments.length,args=new Array(_len),_key=0;_key<_len;_key++)args[_key]=arguments[_key];clearTimeout(timer),timer=setTimeout((()=>fn(...args)),delay)}})((()=>{const term=search.value.trim();renderList(term.length>=2?term:"")}),300)),await renderList("")}})); + */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)}})); //# 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 bb4beaa..d8f7e76 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 the author's videos and inserts the\n * {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-search\"]';\n\n// Wait for a pause in typing before hitting the web service, and ignore very\n// short terms (a single letter would match almost everything).\nconst searchDebounceMs = 300;\nconst searchMinChars = 2;\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 current user's embeddable videos via the web service.\n *\n * @param {number} contextid\n * @param {string} query Optional search term; empty returns the recent list.\n * @returns {Promise>}\n */\nconst fetchVideos = (contextid, query = '') => fetchMany([{\n methodname: 'tiny_fastpix_get_my_videos',\n args: {contextid, query},\n}])[0].then((result) => result.videos);\n\n/**\n * Debounce a function: only run it after `delay` ms pass without a new call.\n *\n * @param {Function} fn\n * @param {number} delay\n * @returns {Function}\n */\nconst debounce = (fn, delay) => {\n let timer;\n return (...args) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), delay);\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 {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 const search = root.querySelector(searchSelector);\n\n // Tracks the most recent query so an out-of-order web service response (a\n // slow early request landing after a later one) can't overwrite the list.\n let currentQuery = '';\n\n const renderList = async(query) => {\n currentQuery = query;\n\n let videos;\n try {\n videos = await fetchVideos(contextid, query);\n } catch (error) {\n if (currentQuery !== query) {\n return;\n }\n const message = await getString('loaderror', component);\n region.textContent = message;\n return;\n }\n if (currentQuery !== query) {\n return;\n }\n\n const {html, js} = await renderForPromise(`${component}/videos`, {\n videos,\n hasvideos: videos.length > 0,\n issearch: query !== '',\n });\n if (currentQuery !== query) {\n return;\n }\n replaceNodeContents(region, html, js);\n };\n\n // The list region persists across re-renders, so one delegated click\n // listener covers every (re)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 if (search) {\n search.addEventListener('input', debounce(() => {\n const term = search.value.trim();\n // Below the minimum length, fall back to the recent list.\n renderList(term.length >= searchMinChars ? term : '');\n }, searchDebounceMs));\n }\n\n // Initial load: the most recent videos.\n await renderList('');\n};\n"],"names":["editor","displayDialogue","async","bookmark","selection","getBookmark","contextid","modal","FastpixModal","create","templateContext","elementid","id","root","getRoot","region","querySelector","search","currentQuery","renderList","videos","query","methodname","args","then","result","fetchVideos","error","message","component","textContent","html","js","hasvideos","length","issearch","addEventListener","e","choice","target","closest","preventDefault","playbackid","moveToBookmark","execCommand","destroy","insertShortcode","dataset","fn","delay","timer","clearTimeout","setTimeout","debounce","term","value","trim"],"mappings":";;;;;;;;sKA4C6BA,SACzBC,gBAAgBD,eAkDdC,gBAAkBC,MAAAA,eAEdC,SAAWH,OAAOI,UAAUC,cAC5BC,WAAY,yBAAaN,QAEzBO,YAAcC,eAAaC,OAAO,CACpCC,gBAAiB,CACbC,UAAWX,OAAOY,MAIpBC,KAAON,MAAMO,UAAU,GACvBC,OAASF,KAAKG,cA5EG,sCA6EjBC,OAASJ,KAAKG,cA5ED,sCAgFfE,aAAe,SAEbC,WAAajB,MAAAA,YAGXkB,OAFJF,aAAeG,UAIXD,aAhEQ,SAACd,eAAWe,6DAAQ,UAAO,cAAU,CAAC,CACtDC,WAAY,6BACZC,KAAM,CAACjB,UAAAA,UAAWe,MAAAA,UAClB,GAAGG,MAAMC,QAAWA,OAAOL,SA6DJM,CAAYpB,UAAWe,OACxC,MAAOM,UACDT,eAAiBG,mBAGfO,cAAgB,kBAAU,YAAaC,+BAC7Cd,OAAOe,YAAcF,YAGrBV,eAAiBG,mBAIfU,KAACA,KAADC,GAAOA,UAAY,yCAAoBH,6BAAoB,CAC7DT,OAAAA,OACAa,UAAWb,OAAOc,OAAS,EAC3BC,SAAoB,KAAVd,QAEVH,eAAiBG,0CAGDN,OAAQgB,KAAMC,KAKtCjB,OAAOqB,iBAAiB,SAAUC,UACxBC,OAASD,EAAEE,OAAOC,QAAQ,qBAC3BF,SAGLD,EAAEI,iBAnEc,EAACzC,OAAQO,MAAOJ,SAAUuC,cAC9C1C,OAAOI,UAAUuC,eAAexC,UAChCH,OAAO4C,YAAY,oBAAoB,wBAAsBF,iBAC7D1C,OAAOI,UAAUuC,eAAexC,UAChCI,MAAMsC,WAgEFC,CAAgB9C,OAAQO,MAAOJ,SAAUmC,OAAOS,QAAQL,gBAGxDzB,QACAA,OAAOmB,iBAAiB,QAxFf,EAACY,GAAIC,aACdC,aACG,yCAAI3B,6CAAAA,2BACP4B,aAAaD,OACbA,MAAQE,YAAW,IAAMJ,MAAMzB,OAAO0B,SAoFLI,EAAS,WAChCC,KAAOrC,OAAOsC,MAAMC,OAE1BrC,WAAWmC,KAAKpB,QAzHL,EAyHgCoB,KAAO,MA1HrC,YA+HfnC,WAAW"} \ 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\"]';\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 diff --git a/amd/src/commands.js b/amd/src/commands.js index ca3fb7b..cc8842e 100644 --- a/amd/src/commands.js +++ b/amd/src/commands.js @@ -24,7 +24,7 @@ import {getButtonImage} from 'editor_tiny/utils'; import {getString} from 'core/str'; import {component, buttonName, icon} from './common'; -import {canUpload} from './options'; +import {canEmbed} from './options'; import {handleAction} from './ui'; export const getSetup = async() => { @@ -37,7 +37,7 @@ export const getSetup = async() => { ]); return (editor) => { - if (!canUpload(editor)) { + if (!canEmbed(editor)) { return; } diff --git a/amd/src/options.js b/amd/src/options.js index 35a4239..23ae68f 100644 --- a/amd/src/options.js +++ b/amd/src/options.js @@ -25,7 +25,8 @@ import {getPluginOptionName} from 'editor_tiny/options'; import {pluginName} from './common'; const contextIdName = getPluginOptionName(pluginName, 'contextid'); -const canUploadName = getPluginOptionName(pluginName, 'canupload'); +const courseIdName = getPluginOptionName(pluginName, 'courseid'); +const canEmbedName = getPluginOptionName(pluginName, 'canembed'); /** * Register the plugin options (populated from plugininfo). @@ -40,7 +41,12 @@ export const register = (editor) => { "default": 0, }); - registerOption(canUploadName, { + registerOption(courseIdName, { + processor: 'number', + "default": 0, + }); + + registerOption(canEmbedName, { processor: 'boolean', "default": false, }); @@ -55,9 +61,17 @@ export const register = (editor) => { export const getContextId = (editor) => editor.options.get(contextIdName); /** - * Whether the current user may use the picker. + * The course id the editor is used in (0 outside a course). + * + * @param {TinyMCE} editor + * @returns {number} + */ +export const getCourseId = (editor) => editor.options.get(courseIdName); + +/** + * Whether the current user may embed FastPix videos (gates the toolbar button). * * @param {TinyMCE} editor * @returns {boolean} */ -export const canUpload = (editor) => editor.options.get(canUploadName); +export const canEmbed = (editor) => editor.options.get(canEmbedName); diff --git a/amd/src/ui.js b/amd/src/ui.js index f9a462c..6ca0b01 100644 --- a/amd/src/ui.js +++ b/amd/src/ui.js @@ -14,8 +14,8 @@ // along with Moodle. If not, see . /** - * Tiny FastPix UI — opens the picker, lists the author's videos and inserts the - * {fastpix:pb_} shortcode for the chosen one. + * Tiny FastPix UI — opens the picker, lists this course's videos the author + * uploaded and inserts the {fastpix:pb_} shortcode for the chosen one. * * @module tiny_fastpix/ui * @copyright 2026 FastPix Inc. @@ -30,12 +30,6 @@ import {getContextId} from './options'; import FastpixModal from './modal'; const listRegionSelector = '[data-region="fastpix-video-list"]'; -const searchSelector = '[data-region="fastpix-search"]'; - -// Wait for a pause in typing before hitting the web service, and ignore very -// short terms (a single letter would match almost everything). -const searchDebounceMs = 300; -const searchMinChars = 2; /** * Open the picker for the current editor. @@ -47,32 +41,17 @@ export const handleAction = (editor) => { }; /** - * Fetch the current user's embeddable videos via the web service. + * Fetch the videos this course holds for the current user via the web service. + * The server derives the course from the context, so only the context is sent. * * @param {number} contextid - * @param {string} query Optional search term; empty returns the recent list. * @returns {Promise>} */ -const fetchVideos = (contextid, query = '') => fetchMany([{ +const fetchVideos = (contextid) => fetchMany([{ methodname: 'tiny_fastpix_get_my_videos', - args: {contextid, query}, + args: {contextid}, }])[0].then((result) => result.videos); -/** - * Debounce a function: only run it after `delay` ms pass without a new call. - * - * @param {Function} fn - * @param {number} delay - * @returns {Function} - */ -const debounce = (fn, delay) => { - let timer; - return (...args) => { - clearTimeout(timer); - timer = setTimeout(() => fn(...args), delay); - }; -}; - /** * Insert the shortcode for the chosen video and close the modal. * @@ -106,43 +85,9 @@ const displayDialogue = async(editor) => { const root = modal.getRoot()[0]; const region = root.querySelector(listRegionSelector); - const search = root.querySelector(searchSelector); - - // Tracks the most recent query so an out-of-order web service response (a - // slow early request landing after a later one) can't overwrite the list. - let currentQuery = ''; - const renderList = async(query) => { - currentQuery = query; - - let videos; - try { - videos = await fetchVideos(contextid, query); - } catch (error) { - if (currentQuery !== query) { - return; - } - const message = await getString('loaderror', component); - region.textContent = message; - return; - } - if (currentQuery !== query) { - return; - } - - const {html, js} = await renderForPromise(`${component}/videos`, { - videos, - hasvideos: videos.length > 0, - issearch: query !== '', - }); - if (currentQuery !== query) { - return; - } - replaceNodeContents(region, html, js); - }; - - // The list region persists across re-renders, so one delegated click - // listener covers every (re)rendered card. + // The list region persists, so one delegated click listener covers every + // rendered card. region.addEventListener('click', (e) => { const choice = e.target.closest('[data-playbackid]'); if (!choice) { @@ -152,14 +97,18 @@ const displayDialogue = async(editor) => { insertShortcode(editor, modal, bookmark, choice.dataset.playbackid); }); - if (search) { - search.addEventListener('input', debounce(() => { - const term = search.value.trim(); - // Below the minimum length, fall back to the recent list. - renderList(term.length >= searchMinChars ? term : ''); - }, searchDebounceMs)); + let videos; + try { + videos = await fetchVideos(contextid); + } catch (error) { + const message = await getString('loaderror', component); + region.textContent = message; + return; } - // Initial load: the most recent videos. - await renderList(''); + const {html, js} = await renderForPromise(`${component}/videos`, { + videos, + hasvideos: videos.length > 0, + }); + replaceNodeContents(region, html, js); }; diff --git a/classes/external/get_my_videos.php b/classes/external/get_my_videos.php index 721b0d9..7d5c1bb 100644 --- a/classes/external/get_my_videos.php +++ b/classes/external/get_my_videos.php @@ -15,7 +15,7 @@ // along with Moodle. If not, see . /** - * External function: list the author's embeddable FastPix videos. + * External function: list the current course's videos the caller uploaded. * * @package tiny_fastpix * @copyright 2026 FastPix Inc. @@ -31,19 +31,30 @@ use core_external\external_value; /** - * Returns the current user's ready, public FastPix videos for the picker. + * Returns the signed-in user's ready, embeddable FastPix videos that belong to + * the course the editor is being used in. * - * The list is sourced from local_fastpix (asset_service::list_for_owner). Only - * PUBLIC videos are offered: filter_fastpix plays public videos in casual - * embeds, but renders the "unavailable" placeholder for private and DRM ones, - * so offering anything non-public would only let an author insert a shortcode - * that never plays. + * Scope (all must hold): + * - owner == the current user (asset_service::list_for_owner), + * - course == the editor's course (the asset is referenced by a mod_fastpix + * activity in that course), + * - state == ready, + * - embeddable == public with a playback id (filter_fastpix plays public + * videos in casual embeds but renders "unavailable" for private/DRM, so + * offering those would only insert a dead embed). + * + * Asset data is read exclusively through local_fastpix's service methods; the + * only direct DB read is against mod_fastpix's own {fastpix} table, because the + * course association lives there (local_fastpix assets have no course). * * @package tiny_fastpix * @copyright 2026 FastPix Inc. * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class get_my_videos extends external_api { + /** @var int Upper bound on the owner list scanned; the course scope keeps the real count small. */ + private const MAX_VIDEOS = 500; + /** * Parameter definition. * @@ -52,13 +63,6 @@ class get_my_videos extends external_api { 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.'), - 'query' => new external_value( - PARAM_TEXT, - 'Optional search term. Empty returns the most recent videos; otherwise the ' - . 'whole owned library is searched by activity name and asset title.', - VALUE_DEFAULT, - '' - ), ]); } @@ -79,52 +83,70 @@ public static function execute_returns(): external_single_structure { } /** - * List the current user's ready, embeddable videos. + * List the caller's ready, embeddable videos that belong to this course. * * @param int $contextid The context the editor is used in. - * @param string $query Optional search term; empty returns the recent list. * @return array The list of videos. */ - public static function execute(int $contextid, string $query = ''): array { - global $USER; + public static function execute(int $contextid): array { + global $USER, $DB; $params = self::validate_parameters( self::execute_parameters(), - ['contextid' => $contextid, 'query' => $query] + ['contextid' => $contextid] ); $context = \context::instance_by_id($params['contextid']); self::validate_context($context); - require_capability('mod/fastpix:uploadmedia', $context); - $search = trim($params['query']); - if ($search === '') { - // No search: the most-recent owned, ready assets. - $assets = \local_fastpix\service\asset_service::list_for_owner((int)$USER->id, 'ready'); - } else { - // Search the whole owned library by activity name and asset title. - $assets = self::search_assets((int)$USER->id, $search); + // Course scope: the picker only lists videos belonging to the course the + // editor lives in. Outside a course (user/system context) nothing is in + // scope, so return empty rather than leak anything cross-course. + $coursecontext = $context->get_course_context(false); + if (!$coursecontext) { + return ['videos' => []]; + } + require_capability('mod/fastpix:uploadmedia', $coursecontext); + $courseid = (int)$coursecontext->instanceid; + + // Owner + ready scope from local_fastpix's own list method (no direct + // local_fastpix table queries). + $ownedbyid = []; + foreach (\local_fastpix\service\asset_service::list_for_owner((int)$USER->id, 'ready', self::MAX_VIDEOS) as $asset) { + $ownedbyid[(int)$asset->id] = $asset; + } + if (empty($ownedbyid)) { + return ['videos' => []]; } - // The local_fastpix asset title is frequently the "Asset " - // fallback, which tells an author nothing in the picker. The name they - // actually typed lives on the mod_fastpix activity that uploaded the - // asset, so resolve a display name from there. Read-only lookup — this - // plugin writes nothing to mod_fastpix or local_fastpix. - $names = self::resolve_display_names($assets); + // Walk this course's FastPix activities (mod_fastpix's own {fastpix} + // table — the course lives there, not in local_fastpix). id DESC so the + // most recent activity name wins; $seen dedupes a reused asset. + $activities = $DB->get_records( + 'fastpix', + ['course' => $courseid], + 'id DESC', + 'id, name, fastpix_asset_id, upload_session_id' + ); $videos = []; - foreach ($assets as $asset) { - // Embeddable = has a playback id AND is public. Private and DRM - // videos are excluded: filter_fastpix only plays public videos in - // casual embeds (a private/DRM shortcode renders "unavailable"), so - // offering them here would only let an author insert a dead embed. + $seen = []; + foreach ($activities as $activity) { + $assetid = self::resolve_asset_id($activity); + if ($assetid === 0 || isset($seen[$assetid]) || !isset($ownedbyid[$assetid])) { + continue; + } + $asset = $ownedbyid[$assetid]; + // Embeddable = public + has a playback id. Private/DRM render + // "unavailable" in filter_fastpix, so offering them would only let an + // author insert a dead embed. if (empty($asset->playback_id) || ($asset->access_policy ?? '') !== 'public') { continue; } + $seen[$assetid] = true; $videos[] = [ 'playbackid' => (string)$asset->playback_id, - 'title' => self::display_title($asset, $names[(int)$asset->id] ?? ''), + 'title' => self::display_title($asset, trim((string)$activity->name)), ]; } @@ -132,229 +154,52 @@ public static function execute(int $contextid, string $query = ''): array { } /** - * Pick the label shown in the picker for one asset. - * - * Preference order: the resolved activity name; then the asset's own title - * if it is a real one; then a localised "Untitled video" placeholder. The - * local_fastpix projector writes the literal "Asset " when an - * upload arrives without a title, so we treat exactly that value as "no - * title" rather than show a raw UUID to authors. - * - * @param \stdClass $asset The asset record (needs ->title and ->fastpix_id). - * @param string $resolvedname The activity name resolved upstream, or ''. - * @return string The label to display. - */ - private static function display_title(\stdClass $asset, string $resolvedname): string { - if ($resolvedname !== '') { - return $resolvedname; - } - - $title = (string)$asset->title; - $placeholder = 'Asset ' . (string)$asset->fastpix_id; - if ($title !== '' && $title !== $placeholder) { - return $title; - } - - return get_string('untitledvideo', 'tiny_fastpix'); - } - - /** - * Resolve a human display name for each asset, asset id => name. + * Resolve a mod_fastpix activity row to its local_fastpix asset id. * - * The author-facing name lives on mod_fastpix.name; the local_fastpix asset - * title is often the "Asset " fallback. We read the name here so the - * picker shows something a human recognises. Two read-only lookups, in - * order of reliability — no writes to mod_fastpix or local_fastpix: + * Prefers the direct link (fastpix_asset_id); falls back to the upload + * session that produced the asset. The session lookup goes through + * local_fastpix's sanctioned service method, never a direct table query. * - * 1. Direct link: the activity points at the asset - * (mod_fastpix.fastpix_asset_id = asset.id). - * 2. Upload-session fallback: used when the direct link was never set - * (fastpix_asset_id NULL). Recover the name through the upload session - * that produced the asset - * (asset.fastpix_id -> upload_session.id -> activity.upload_session_id). - * - * When several activities resolve to one asset, the most recent name wins. - * Assets with no activity on either path keep the "Asset " fallback. - * - * @param array $assets Asset records from asset_service::list_for_owner. - * @return array Asset id => display name (only non-empty names). + * @param \stdClass $activity A {fastpix} row (needs ->fastpix_asset_id, ->upload_session_id). + * @return int The local_fastpix_asset id, or 0 when the activity links to no asset. */ - private static function resolve_display_names(array $assets): array { - global $DB; - - if (empty($assets)) { - return []; + private static function resolve_asset_id(\stdClass $activity): int { + if (!empty($activity->fastpix_asset_id)) { + return (int)$activity->fastpix_asset_id; } - - // Seed every asset id as unresolved, and index fastpix_id -> asset id - // for the second-pass session lookup. - $names = []; - $assetidbyfpid = []; - foreach ($assets as $asset) { - $names[(int)$asset->id] = ''; - if (!empty($asset->fastpix_id)) { - $assetidbyfpid[(string)$asset->fastpix_id] = (int)$asset->id; - } - } - - // Pass 1 — direct activity link. Ascending id order means a later (more - // recent) activity overwrites an earlier one for a reused asset. - [$insql, $params] = $DB->get_in_or_equal(array_keys($names), SQL_PARAMS_NAMED); - $rows = $DB->get_records_select( - 'fastpix', - "fastpix_asset_id {$insql}", - $params, - 'id ASC', - 'id, fastpix_asset_id, name' - ); - foreach ($rows as $row) { - $name = trim((string)$row->name); - if ($name !== '') { - $names[(int)$row->fastpix_asset_id] = $name; + if (!empty($activity->upload_session_id)) { + $asset = \local_fastpix\service\asset_service::get_by_upload_session_id((int)$activity->upload_session_id); + if ($asset) { + return (int)$asset->id; } } - - // Pass 2 — upload-session fallback for assets the direct link missed. - $unresolved = array_filter($assetidbyfpid, static fn($aid) => $names[$aid] === ''); - if (!empty($unresolved)) { - [$insql2, $params2] = $DB->get_in_or_equal(array_keys($unresolved), SQL_PARAMS_NAMED); - $sessrows = $DB->get_records_sql( - "SELECT m.id, us.fastpix_id, m.name - FROM {local_fastpix_upload_session} us - JOIN {fastpix} m ON m.upload_session_id = us.id - WHERE us.fastpix_id {$insql2} - ORDER BY m.id ASC", - $params2 - ); - foreach ($sessrows as $row) { - $name = trim((string)$row->name); - $aid = $unresolved[(string)$row->fastpix_id] ?? null; - if ($name !== '' && $aid !== null) { - $names[$aid] = $name; - } - } - } - - return array_filter($names, static fn($name) => $name !== ''); + return 0; } /** - * Search the user's whole library for ready assets matching a term. - * - * Real search (not just the loaded recent list). Three owner-safe sources - * are merged, deduplicated by asset id and capped, most-recent first: - * 1. Asset title — via local_fastpix's own search - * (asset_service::list_for_owner_paginated), already owner-scoped. - * 2. Activity name, direct link — mod_fastpix.name LIKE term, mapped to - * the asset through mod_fastpix.fastpix_asset_id. - * 3. Activity name, upload-session fallback — mod_fastpix.name LIKE term, - * mapped through mod_fastpix.upload_session_id -> upload_session.fastpix_id. - * - * The author-typed name lives on mod_fastpix and may have been typed by any - * teacher, but the matched assets are loaded with owner_userid = $userid in - * load_owned_ready_assets(), so search can never surface another user's - * videos. The embeddable filter (playback id, non-DRM) is applied by the - * caller's loop. Read-only — no writes to mod_fastpix or local_fastpix. - * - * @param int $userid The owner whose library is searched. - * @param string $query The (already trimmed, non-empty) search term. - * @param int $limit Maximum assets to return. - * @return array Asset records, most-recent first. - */ - private static function search_assets(int $userid, string $query, int $limit = 50): array { - global $DB; - - // 1. Title matches, straight from local_fastpix (owner-scoped, ready). - $assets = []; - $bytitle = \local_fastpix\service\asset_service::list_for_owner_paginated( - $userid, - 'ready', - 0, - $limit, - $query - ); - foreach ($bytitle as $asset) { - $assets[(int)$asset->id] = $asset; - } - - // 2 + 3. Activity-name matches -> candidate asset identifiers. Owner - // scoping is enforced when these are loaded, not here. - $like = '%' . $DB->sql_like_escape($query) . '%'; - $namelike = $DB->sql_like('m.name', ':q', false); - - $directids = $DB->get_fieldset_sql( - "SELECT DISTINCT m.fastpix_asset_id - FROM {fastpix} m - WHERE {$namelike} AND m.fastpix_asset_id IS NOT NULL AND m.fastpix_asset_id <> 0", - ['q' => $like] - ); - - $sessfpids = $DB->get_fieldset_sql( - "SELECT DISTINCT us.fastpix_id - FROM {fastpix} m - JOIN {local_fastpix_upload_session} us ON us.id = m.upload_session_id - WHERE {$namelike} AND us.fastpix_id IS NOT NULL", - ['q' => $like] - ); - - foreach (self::load_owned_ready_assets($userid, $directids, $sessfpids) as $asset) { - $assets[(int)$asset->id] = $asset; - } - - // Most-recent first, then cap. - $assets = array_values($assets); - usort($assets, static fn($a, $b) => ($b->timecreated <=> $a->timecreated)); - - return array_slice($assets, 0, $limit); - } - - /** - * Load owned, ready, live asset records by id and by fastpix_id. + * Pick the label shown in the picker for one asset. * - * This is the owner-scope gate for name search: candidate identifiers come - * from mod_fastpix (any author), but only assets owned by $userid are - * returned. Soft-deleted assets are excluded. + * Preference order: the activity name; then the asset's own title if it is a + * real one; then a localised "Untitled video" placeholder. The local_fastpix + * projector writes the literal "Asset " when an upload arrives + * without a title, so we treat exactly that value as "no title" rather than + * show a raw UUID to authors. * - * @param int $userid The owner. - * @param array $ids Candidate local_fastpix_asset ids (direct link). - * @param array $fpids Candidate fastpix_id values (upload-session fallback). - * @return array Asset id => asset record. + * @param \stdClass $asset The asset record (needs ->title and ->fastpix_id). + * @param string $activityname The activity name, or '' when unavailable. + * @return string The label to display. */ - private static function load_owned_ready_assets(int $userid, array $ids, array $fpids): array { - global $DB; - - $out = []; - - if (!empty($ids)) { - [$insql, $params] = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED, 'aid'); - $params['userid'] = $userid; - $params['st'] = 'ready'; - $rows = $DB->get_records_select( - 'local_fastpix_asset', - "id {$insql} AND owner_userid = :userid AND status = :st " - . "AND (deleted_at IS NULL OR deleted_at = 0)", - $params - ); - foreach ($rows as $row) { - $out[(int)$row->id] = $row; - } + private static function display_title(\stdClass $asset, string $activityname): string { + if ($activityname !== '') { + return $activityname; } - if (!empty($fpids)) { - [$insql, $params] = $DB->get_in_or_equal($fpids, SQL_PARAMS_NAMED, 'fp'); - $params['userid'] = $userid; - $params['st'] = 'ready'; - $rows = $DB->get_records_select( - 'local_fastpix_asset', - "fastpix_id {$insql} AND owner_userid = :userid AND status = :st " - . "AND (deleted_at IS NULL OR deleted_at = 0)", - $params - ); - foreach ($rows as $row) { - $out[(int)$row->id] = $row; - } + $title = (string)$asset->title; + $placeholder = 'Asset ' . (string)$asset->fastpix_id; + if ($title !== '' && $title !== $placeholder) { + return $title; } - return $out; + return get_string('untitledvideo', 'tiny_fastpix'); } } diff --git a/classes/plugininfo.php b/classes/plugininfo.php index 503ffd9..b72c89d 100644 --- a/classes/plugininfo.php +++ b/classes/plugininfo.php @@ -100,9 +100,12 @@ public static function get_plugin_configuration_for_context( array $fpoptions, ?\editor_tiny\editor $editor = null ): array { + $coursecontext = $context->get_course_context(false); + return [ 'contextid' => $context->id, - 'canupload' => has_capability('mod/fastpix:uploadmedia', $context), + 'courseid' => $coursecontext ? $coursecontext->instanceid : 0, + 'canembed' => has_capability('mod/fastpix:uploadmedia', $context), ]; } } diff --git a/lang/en/tiny_fastpix.php b/lang/en/tiny_fastpix.php index 39a13c7..d044bf1 100644 --- a/lang/en/tiny_fastpix.php +++ b/lang/en/tiny_fastpix.php @@ -28,9 +28,7 @@ $string['loaderror'] = 'Could not load your videos. Please try again.'; $string['loading'] = 'Loading your videos…'; $string['modaltitle'] = 'Insert FastPix video'; -$string['noresults'] = 'No videos match your search.'; -$string['novideos'] = 'You have no ready videos to embed yet. Upload a video in a FastPix activity first.'; +$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['searchplaceholder'] = 'Search your videos…'; $string['untitledvideo'] = 'Untitled video'; diff --git a/templates/modal.mustache b/templates/modal.mustache index 00d3f1c..bcb6017 100644 --- a/templates/modal.mustache +++ b/templates/modal.mustache @@ -35,14 +35,6 @@ {{/title}} {{$body}} -
- -
{{#str}} loading, tiny_fastpix {{/str}}
diff --git a/templates/videos.mustache b/templates/videos.mustache index 9097f00..7944c26 100644 --- a/templates/videos.mustache +++ b/templates/videos.mustache @@ -24,13 +24,10 @@ Context variables required for this template: * hasvideos - bool, whether the videos array is non-empty. * videos - array of { playbackid, title }. - * issearch - bool, whether this list is the result of a search (changes the - empty-state message from "no videos" to "no results"). Example context (json): { "hasvideos": true, - "issearch": false, "videos": [ { "playbackid": "abc123", "title": "Welcome video" } ] @@ -51,7 +48,6 @@ {{/hasvideos}} {{^hasvideos}}
- {{#issearch}}{{#str}} noresults, tiny_fastpix {{/str}}{{/issearch}} - {{^issearch}}{{#str}} novideos, tiny_fastpix {{/str}}{{/issearch}} + {{#str}} novideos, tiny_fastpix {{/str}}
{{/hasvideos}} diff --git a/tests/behat/insert_shortcode.feature b/tests/behat/insert_shortcode.feature index e4f4305..0754a29 100644 --- a/tests/behat/insert_shortcode.feature +++ b/tests/behat/insert_shortcode.feature @@ -58,8 +58,8 @@ Feature: Insert a FastPix shortcode via the TinyMCE picker | activity | name | intro | introformat | course | content | contentformat | idnumber | | page | PageName1 | PageDesc1 | 1 | C1 | PageContent | 1 | 1 | And the following "tiny_fastpix > assets" exist: - | user | playback_id | title | - | teacher1 | keepme01 | My lecture | + | user | course | playback_id | title | + | teacher1 | C1 | keepme01 | My lecture | # --------------------------------------------------------------------------- # Capability gate — an uploading teacher sees the button; a teacher whose diff --git a/tests/external/get_my_videos_test.php b/tests/external/get_my_videos_test.php index 260ccf2..7030e55 100644 --- a/tests/external/get_my_videos_test.php +++ b/tests/external/get_my_videos_test.php @@ -24,45 +24,25 @@ /** * Tests for the tiny_fastpix_get_my_videos web service. * + * The picker lists ONLY videos that (a) the caller owns, (b) belong to the + * editor's course (referenced by a mod_fastpix activity in it), (c) are ready + * and (d) are embeddable (public + has a playback id). These tests lock each + * facet, with the owner-scope and course-scope cases as the P0 security floor. + * * @package tiny_fastpix * @copyright 2026 FastPix Inc. * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \tiny_fastpix\external\get_my_videos */ final class get_my_videos_test extends \externallib_advanced_testcase { - /** - * Insert an asset row into local_fastpix_asset. - * - * @param array $overrides Column overrides; owner_userid + playback_id matter. - * @return void - */ - private function make_asset(array $overrides): void { - global $DB; - $now = time(); - $DB->insert_record('local_fastpix_asset', (object)array_merge([ - 'fastpix_id' => 'fp' . random_string(10), - 'playback_id' => 'play' . random_string(8), - 'owner_userid' => 0, - 'title' => 'Untitled', - 'status' => 'ready', - 'access_policy' => 'public', - 'drm_required' => 0, - 'no_skip_required' => 0, - 'has_captions' => 0, - 'gdpr_delete_attempts' => 0, - 'timecreated' => $now, - 'timemodified' => $now, - ], $overrides)); - } - /** * Insert an asset and return it, including the generated id and fastpix_id - * (needed to link mod_fastpix activities for the name-search tests). + * (needed to link mod_fastpix activities). * - * @param array $overrides Column overrides. + * @param array $overrides Column overrides; owner_userid + playback_id matter. * @return \stdClass The inserted asset (with ->id and ->fastpix_id set). */ - private function make_asset_linked(array $overrides): \stdClass { + private function make_asset(array $overrides): \stdClass { global $DB; $now = time(); $record = (object)array_merge([ @@ -84,8 +64,8 @@ private function make_asset_linked(array $overrides): \stdClass { } /** - * Insert a mod_fastpix activity row carrying the author-typed name. Only the - * fields the name search reads are populated. + * Insert a mod_fastpix activity row carrying the author-typed name and the + * link columns the resolver reads. * * @param int $courseid The course the activity lives in. * @param string $name The author-typed activity name. @@ -146,53 +126,136 @@ private function teacher_in_course(): array { } /** - * Run the web service with a search term and clean the return value. + * Run the web service and clean the return value. * * @param \context $context The editor context. - * @param string $query The search term. * @return array The cleaned result. */ - private function run_search(\context $context, string $query): array { - $result = get_my_videos::execute($context->id, $query); + private function list_videos(\context $context): array { + $result = get_my_videos::execute($context->id); return \core_external\external_api::clean_returnvalue(get_my_videos::execute_returns(), $result); } /** - * The service returns only the caller's own ready, non-DRM, embeddable videos. + * The happy path: an owned, ready, public video linked to an activity in the + * editor's course is listed, labelled with the activity name. */ - public function test_returns_only_own_embeddable_videos(): void { + public function test_lists_owned_ready_public_video_in_course(): void { $this->resetAfterTest(); - $course = $this->getDataGenerator()->create_course(); - $context = \context_course::instance($course->id); - $teacher = $this->getDataGenerator()->create_user(); + [$context, $teacher, $course] = $this->teacher_in_course(); + $this->setUser($teacher); + + $asset = $this->make_asset(['owner_userid' => $teacher->id, 'playback_id' => 'keepme01', 'title' => 'zzz']); + $this->make_activity($course->id, 'Photosynthesis lecture', ['fastpix_asset_id' => $asset->id]); + + $result = $this->list_videos($context); + + $this->assertCount(1, $result['videos']); + $this->assertSame('keepme01', $result['videos'][0]['playbackid']); + $this->assertSame('Photosynthesis lecture', $result['videos'][0]['title']); + } + + /** + * The activity may link to the asset only through its upload session (the + * direct fastpix_asset_id was never set); the video must still be listed. + */ + public function test_resolves_asset_via_upload_session(): void { + $this->resetAfterTest(); + [$context, $teacher, $course] = $this->teacher_in_course(); + $this->setUser($teacher); + + $asset = $this->make_asset(['owner_userid' => $teacher->id, 'playback_id' => 'sess0001', 'title' => 'zzz']); + $sessionid = $this->make_upload_session($asset->fastpix_id, $teacher->id); + $this->make_activity($course->id, 'Mitosis recap', ['upload_session_id' => $sessionid]); + + $result = $this->list_videos($context); + + $this->assertCount(1, $result['videos']); + $this->assertSame('sess0001', $result['videos'][0]['playbackid']); + $this->assertSame('Mitosis recap', $result['videos'][0]['title']); + } + + /** + * Owner scope (P0): a video owned by another user, even when its activity is + * in this course, must never appear. + */ + public function test_excludes_video_owned_by_other_user(): void { + $this->resetAfterTest(); + [$context, $teacher, $course] = $this->teacher_in_course(); $other = $this->getDataGenerator()->create_user(); - $this->getDataGenerator()->enrol_user($teacher->id, $course->id, 'editingteacher'); $this->setUser($teacher); - // Should appear: own, ready, public, has playback id. - $this->make_asset(['owner_userid' => $teacher->id, 'playback_id' => 'keepme01', 'title' => 'Keep me']); - // Excluded: DRM-required (filter can't play it). - $this->make_asset([ + $otherasset = $this->make_asset(['owner_userid' => $other->id, 'playback_id' => 'leak0001', 'title' => 'zzz']); + $this->make_activity($course->id, 'Confidential briefing', ['fastpix_asset_id' => $otherasset->id]); + + $result = $this->list_videos($context); + + $this->assertCount(0, $result['videos']); + } + + /** + * Course scope (P0): the caller's own video linked to an activity in a + * different course must not appear in this course's picker. + */ + public function test_excludes_video_in_other_course(): void { + $this->resetAfterTest(); + [$context, $teacher] = $this->teacher_in_course(); + $othercourse = $this->getDataGenerator()->create_course(); + $this->setUser($teacher); + + $asset = $this->make_asset(['owner_userid' => $teacher->id, 'playback_id' => 'elsew001', 'title' => 'zzz']); + $this->make_activity($othercourse->id, 'Lecture in another course', ['fastpix_asset_id' => $asset->id]); + + $result = $this->list_videos($context); + + $this->assertCount(0, $result['videos']); + } + + /** + * An owned, ready, public video that is not referenced by any activity in + * the course is out of scope (course membership comes from the activity). + */ + public function test_excludes_video_with_no_activity(): void { + $this->resetAfterTest(); + [$context, $teacher] = $this->teacher_in_course(); + $this->setUser($teacher); + + $this->make_asset(['owner_userid' => $teacher->id, 'playback_id' => 'noact001', 'title' => 'Orphan']); + + $result = $this->list_videos($context); + + $this->assertCount(0, $result['videos']); + } + + /** + * Non-ready, DRM/non-public, and playback-less assets are all excluded even + * when correctly owned and linked in the course. + */ + public function test_excludes_unembeddable_states(): void { + $this->resetAfterTest(); + [$context, $teacher, $course] = $this->teacher_in_course(); + $this->setUser($teacher); + + $notready = $this->make_asset([ + 'owner_userid' => $teacher->id, 'playback_id' => 'prep0001', 'status' => 'preparing', + ]); + $drm = $this->make_asset([ 'owner_userid' => $teacher->id, 'playback_id' => 'drmone01', 'drm_required' => 1, 'access_policy' => 'drm', ]); - // Excluded: not ready yet. - $this->make_asset(['owner_userid' => $teacher->id, 'playback_id' => 'prep0001', 'status' => 'preparing']); - // Excluded: no playback id. - $this->make_asset(['owner_userid' => $teacher->id, 'playback_id' => null]); - // Excluded: belongs to another user. - $this->make_asset(['owner_userid' => $other->id, 'playback_id' => 'other001']); + $noplayback = $this->make_asset(['owner_userid' => $teacher->id, 'playback_id' => null]); - $result = get_my_videos::execute($context->id); - $result = \core_external\external_api::clean_returnvalue(get_my_videos::execute_returns(), $result); + $this->make_activity($course->id, 'Not ready', ['fastpix_asset_id' => $notready->id]); + $this->make_activity($course->id, 'DRM', ['fastpix_asset_id' => $drm->id]); + $this->make_activity($course->id, 'No playback', ['fastpix_asset_id' => $noplayback->id]); - $this->assertCount(1, $result['videos']); - $this->assertSame('keepme01', $result['videos'][0]['playbackid']); - $this->assertSame('Keep me', $result['videos'][0]['title']); + $result = $this->list_videos($context); + + $this->assertCount(0, $result['videos']); } /** - * A user without mod/fastpix:uploadmedia is refused. + * A user without mod/fastpix:uploadmedia in the course is refused. */ public function test_requires_uploadmedia_capability(): void { $this->resetAfterTest(); @@ -207,96 +270,95 @@ public function test_requires_uploadmedia_capability(): void { } /** - * Search matches the asset title. + * Outside a course (e.g. a user context) there is nothing in scope, so the + * service returns an empty list rather than leaking anything. */ - public function test_search_matches_asset_title(): void { + public function test_non_course_context_returns_empty(): void { $this->resetAfterTest(); - [$context, $teacher] = $this->teacher_in_course(); - $this->setUser($teacher); + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); - $this->make_asset_linked(['owner_userid' => $teacher->id, 'playback_id' => 'titlem01', 'title' => 'Annual report 2026']); - $this->make_asset_linked(['owner_userid' => $teacher->id, 'playback_id' => 'titlem02', 'title' => 'Holiday party']); + $result = $this->list_videos(\context_user::instance($user->id)); - $result = $this->run_search($context, 'annual'); - - $this->assertCount(1, $result['videos']); - $this->assertSame('titlem01', $result['videos'][0]['playbackid']); + $this->assertCount(0, $result['videos']); } /** - * Search matches the mod_fastpix activity name via the direct asset link, - * even when the asset title itself does not contain the term. + * A capability holder in a course who owns no ready videos gets an empty + * list (the owner-list short-circuit). */ - public function test_search_matches_activity_name_direct(): void { + public function test_returns_empty_when_user_owns_no_videos(): void { $this->resetAfterTest(); [$context, $teacher, $course] = $this->teacher_in_course(); $this->setUser($teacher); - $asset = $this->make_asset_linked([ - 'owner_userid' => $teacher->id, 'playback_id' => 'namedir1', 'title' => 'zzz', - ]); - $this->make_activity($course->id, 'Photosynthesis lecture', ['fastpix_asset_id' => $asset->id]); + // An activity exists in the course, but it references no owned asset. + $this->make_activity($course->id, 'Empty activity'); - $result = $this->run_search($context, 'photosynthesis'); + $result = $this->list_videos($context); - $this->assertCount(1, $result['videos']); - $this->assertSame('namedir1', $result['videos'][0]['playbackid']); - $this->assertSame('Photosynthesis lecture', $result['videos'][0]['title']); + $this->assertCount(0, $result['videos']); } /** - * Search matches the activity name reached only through the upload session - * (the direct fastpix_asset_id link was never set). + * An activity in the course that links to no asset (neither a direct asset + * id nor an upload session) is skipped without affecting valid results. */ - public function test_search_matches_activity_name_via_upload_session(): void { + public function test_ignores_activity_with_no_asset_link(): void { $this->resetAfterTest(); [$context, $teacher, $course] = $this->teacher_in_course(); $this->setUser($teacher); - $asset = $this->make_asset_linked([ - 'owner_userid' => $teacher->id, 'playback_id' => 'namesess', 'title' => 'zzz', - ]); - $sessionid = $this->make_upload_session($asset->fastpix_id, $teacher->id); - $this->make_activity($course->id, 'Mitosis recap', ['upload_session_id' => $sessionid]); + $asset = $this->make_asset(['owner_userid' => $teacher->id, 'playback_id' => 'valid001']); + $this->make_activity($course->id, 'Good video', ['fastpix_asset_id' => $asset->id]); + // No fastpix_asset_id and no upload_session_id -> resolves to nothing. + $this->make_activity($course->id, 'Dangling activity'); - $result = $this->run_search($context, 'mitosis'); + $result = $this->list_videos($context); $this->assertCount(1, $result['videos']); - $this->assertSame('namesess', $result['videos'][0]['playbackid']); - $this->assertSame('Mitosis recap', $result['videos'][0]['title']); + $this->assertSame('valid001', $result['videos'][0]['playbackid']); } /** - * Search is owner-scoped: another user's asset must never surface, even when - * its activity name matches the search term. (P0 security invariant.) + * When the activity name is blank, the label falls back to the asset's own + * title (provided it is a real title, not the "Asset " placeholder). */ - public function test_search_is_owner_scoped(): void { + public function test_label_falls_back_to_asset_title(): void { $this->resetAfterTest(); [$context, $teacher, $course] = $this->teacher_in_course(); - $other = $this->getDataGenerator()->create_user(); $this->setUser($teacher); - $otherasset = $this->make_asset_linked([ - 'owner_userid' => $other->id, 'playback_id' => 'leak0001', 'title' => 'zzz', + $asset = $this->make_asset([ + 'owner_userid' => $teacher->id, 'playback_id' => 'fallbk01', 'title' => 'Real asset title', ]); - $this->make_activity($course->id, 'Confidential briefing', ['fastpix_asset_id' => $otherasset->id]); + $this->make_activity($course->id, ' ', ['fastpix_asset_id' => $asset->id]); - $result = $this->run_search($context, 'confidential'); + $result = $this->list_videos($context); - $this->assertCount(0, $result['videos']); + $this->assertCount(1, $result['videos']); + $this->assertSame('Real asset title', $result['videos'][0]['title']); } /** - * A search term that matches nothing returns an empty list. + * When the activity name is blank and the asset only carries the projector's + * "Asset " placeholder title, the label is the localised + * "Untitled video" rather than a raw id. */ - public function test_search_no_match_returns_empty(): void { + public function test_label_falls_back_to_untitled(): void { $this->resetAfterTest(); - [$context, $teacher] = $this->teacher_in_course(); + [$context, $teacher, $course] = $this->teacher_in_course(); $this->setUser($teacher); - $this->make_asset_linked(['owner_userid' => $teacher->id, 'playback_id' => 'present1', 'title' => 'Welcome video']); - $result = $this->run_search($context, 'nonexistentterm'); + $asset = $this->make_asset([ + 'owner_userid' => $teacher->id, 'playback_id' => 'untitl01', + 'fastpix_id' => 'fpplaceholder', 'title' => 'Asset fpplaceholder', + ]); + $this->make_activity($course->id, '', ['fastpix_asset_id' => $asset->id]); - $this->assertCount(0, $result['videos']); + $result = $this->list_videos($context); + + $this->assertCount(1, $result['videos']); + $this->assertSame(get_string('untitledvideo', 'tiny_fastpix'), $result['videos'][0]['title']); } } diff --git a/tests/generator/behat_tiny_fastpix_generator.php b/tests/generator/behat_tiny_fastpix_generator.php index ce1be59..0eb2bd7 100644 --- a/tests/generator/behat_tiny_fastpix_generator.php +++ b/tests/generator/behat_tiny_fastpix_generator.php @@ -74,11 +74,12 @@ protected function get_creatable_entities(): array { 'singular' => 'asset', 'datagenerator' => 'asset', 'required' => ['playback_id', 'user'], - // The 'user' column (a username string) is resolved to - // 'owner_userid' (an integer) by behat_generator_base::get_user_id(). - // See lib/behat/classes/behat_generator_base.php, the switchids - // processing loop and the get_user_id() method. - 'switchids' => ['user' => 'owner_userid'], + // The 'user' column (a username) resolves to 'owner_userid'; the + // optional 'course' column (a shortname) resolves to 'courseid', + // which makes the generator link the asset to a mod_fastpix + // activity in that course so the course-scoped picker lists it. + // Resolution is the switchids mechanism in behat_generator_base. + 'switchids' => ['user' => 'owner_userid', 'course' => 'courseid'], ], ]; } diff --git a/tests/generator/lib.php b/tests/generator/lib.php index 218bb6e..684b125 100644 --- a/tests/generator/lib.php +++ b/tests/generator/lib.php @@ -85,6 +85,14 @@ public function create_asset($record = null): int { ); } + // The picker is course-scoped: an asset only appears when a mod_fastpix + // activity in that course references it. The optional 'course' column + // (resolved to 'courseid' via switchids) creates that link. Pulled out + // before the asset insert — it is not a local_fastpix_asset column. + $courseid = (int)($record['courseid'] ?? 0); + $title = $record['title'] ?? null; + unset($record['courseid']); + $now = time(); // Apply defaults for every NOT-NULL column that has no schema default @@ -108,6 +116,44 @@ public function create_asset($record = null): int { } } - return $DB->insert_record('local_fastpix_asset', (object) $record); + $assetid = $DB->insert_record('local_fastpix_asset', (object) $record); + + if ($courseid > 0) { + $this->link_course_activity($courseid, $assetid, (string)($title ?? $record['title'])); + } + + return $assetid; + } + + /** + * Insert a minimal mod_fastpix activity row in a course that references the + * asset, so the course-scoped picker (get_my_videos) finds it. + * + * Only the columns the picker reads ({fastpix}.course, .name, + * .fastpix_asset_id) plus the table's NOT-NULL fields are populated — the + * web service queries this table directly and does not need a course module. + * + * @param int $courseid The course the activity (and so the video) belongs to. + * @param int $assetid The local_fastpix_asset id to reference. + * @param string $name The activity name used as the picker label. + * @return void + */ + private function link_course_activity(int $courseid, int $assetid, string $name): void { + global $DB; + $now = time(); + $DB->insert_record('fastpix', (object)[ + 'course' => $courseid, + 'name' => $name, + 'intro' => '', + 'introformat' => FORMAT_HTML, + 'fastpix_asset_id' => $assetid, + 'upload_session_id' => null, + 'completion_watch_percent' => 0, + 'no_skip_required' => 0, + 'default_show_captions' => 0, + 'grademax' => 0, + 'timecreated' => $now, + 'timemodified' => $now, + ]); } } diff --git a/tests/plugininfo_test.php b/tests/plugininfo_test.php index a25e4d3..85164c4 100644 --- a/tests/plugininfo_test.php +++ b/tests/plugininfo_test.php @@ -43,7 +43,7 @@ public function test_button_hidden_without_capability(): void { $this->assertFalse(plugininfo::is_enabled($context, [], [])); $this->assertFalse( - plugininfo::get_plugin_configuration_for_context($context, [], [])['canupload'] + plugininfo::get_plugin_configuration_for_context($context, [], [])['canembed'] ); } @@ -60,7 +60,7 @@ public function test_button_visible_with_capability(): void { $this->assertTrue(plugininfo::is_enabled($context, [], [])); $this->assertTrue( - plugininfo::get_plugin_configuration_for_context($context, [], [])['canupload'] + plugininfo::get_plugin_configuration_for_context($context, [], [])['canembed'] ); } } diff --git a/version.php b/version.php index 5c19111..4da2423 100644 --- a/version.php +++ b/version.php @@ -25,10 +25,10 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'tiny_fastpix'; -$plugin->version = 2026060500; +$plugin->version = 2026061010; $plugin->requires = 2024100100; // Moodle 4.5 LTS. $plugin->maturity = MATURITY_STABLE; -$plugin->release = '1.0.0'; +$plugin->release = '1.1.0'; $plugin->dependencies = [ // Reuses mod/fastpix:uploadmedia (capability) and lists the author's assets // through local_fastpix's asset_service. Inserts the {fastpix:pb_}