From 5781a2700a0f272934028579f27f59c8ebd72331 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Mon, 9 Feb 2026 06:59:12 +0000 Subject: [PATCH 1/9] Feature/embedded editor --- , | 21 ++ .distignore | 21 ++ .env.dist | 21 ++ .github/workflows/release.yml | 39 +++ .gitignore | 13 + .gitmodules | 4 + Makefile | 205 +++++++++++++++ amd/build/editor_modal.min.js | 3 + amd/build/editor_modal.min.js.map | 1 + amd/src/editor_modal.js | 237 +++++++++++++++++ amd/src/moodle_exe_bridge.js | 355 ++++++++++++++++++++++++++ classes/exescorm_package.php | 27 ++ docker-compose.yml | 89 +++++++ editor/index.php | 113 ++++++++ editor/save.php | 104 ++++++++ editor/static.php | 99 +++++++ exelearning | 1 + lang/ca/exescorm.php | 17 ++ lang/en/exescorm.php | 17 ++ lang/es/exescorm.php | 17 ++ lang/eu/exescorm.php | 17 ++ lang/gl/exescorm.php | 17 ++ lib.php | 75 +++++- locallib.php | 5 +- mod_form.php | 25 +- player.php | 1 + renderer.php | 16 +- settings.php | 41 ++- styles.css | 43 ++++ templates/player_editexitbar.mustache | 22 +- version.php | 2 +- 31 files changed, 1648 insertions(+), 20 deletions(-) create mode 100644 , create mode 100644 .distignore create mode 100644 .env.dist create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 Makefile create mode 100644 amd/build/editor_modal.min.js create mode 100644 amd/build/editor_modal.min.js.map create mode 100644 amd/src/editor_modal.js create mode 100644 amd/src/moodle_exe_bridge.js create mode 100644 docker-compose.yml create mode 100644 editor/index.php create mode 100644 editor/save.php create mode 100644 editor/static.php create mode 160000 exelearning diff --git a/, b/, new file mode 100644 index 0000000..93647fa --- /dev/null +++ b/, @@ -0,0 +1,21 @@ +# This file (.env.dist) is an example template for the environment variables required by the application. +# The .env file is not versioned in the repository and should be created by duplicating this file. +# To use it, copy this file as .env and define the appropriate values. +# The environment variables defined in .env will be automatically loaded by Docker Compose. + +APP_ENV=prod +APP_DEBUG=0 +APP_SECRET=CHANGE_THIS_TO_A_SECRET +APP_PORT=8080 +APP_ONLINE_MODE=1 +XDEBUG_MODE=off # You can enable it by changing to "debug" +XDEBUG_CONFIG="client_host=host.docker.internal" + +EXELEARNING_WEB_SOURCECODE_PATH= +EXELEARNING_WEB_CONTAINER_TAG=latest + +# Test user data +TEST_USER_EMAIL=user@exelearning.net +TEST_USER_USERNAME=user +TEST_USER_PASSWORD=1234 + diff --git a/.distignore b/.distignore new file mode 100644 index 0000000..7f67e2e --- /dev/null +++ b/.distignore @@ -0,0 +1,21 @@ +.git +.github +.gitignore +.gitmodules +.aider* +.DS_Store +.distignore +.env +exelearning/ +vendor/ +node_modules/ +phpmd-rules.xml +phpmd.xml +Makefile +docker-compose.yml +Dockerfile +composer.json +composer.lock +composer.phar +CLAUDE.md +*.zip diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..93647fa --- /dev/null +++ b/.env.dist @@ -0,0 +1,21 @@ +# This file (.env.dist) is an example template for the environment variables required by the application. +# The .env file is not versioned in the repository and should be created by duplicating this file. +# To use it, copy this file as .env and define the appropriate values. +# The environment variables defined in .env will be automatically loaded by Docker Compose. + +APP_ENV=prod +APP_DEBUG=0 +APP_SECRET=CHANGE_THIS_TO_A_SECRET +APP_PORT=8080 +APP_ONLINE_MODE=1 +XDEBUG_MODE=off # You can enable it by changing to "debug" +XDEBUG_CONFIG="client_host=host.docker.internal" + +EXELEARNING_WEB_SOURCECODE_PATH= +EXELEARNING_WEB_CONTAINER_TAG=latest + +# Test user data +TEST_USER_EMAIL=user@exelearning.net +TEST_USER_USERNAME=user +TEST_USER_PASSWORD=1234 + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0556a78 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +--- +name: Release + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + build_and_upload: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Set environment variables + run: | + RAW_TAG="${GITHUB_REF##*/}" + VERSION_TAG="${RAW_TAG#v}" + echo "RELEASE_TAG=${VERSION_TAG}" >> $GITHUB_ENV + + - name: Build static editor + run: make build-editor-no-update + + - name: Create package + run: make package VERSION=${RELEASE_TAG} + + - name: Upload ZIP to release + uses: softprops/action-gh-release@v2 + with: + files: mod_exescorm-${{ env.RELEASE_TAG }}.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b4aa80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.aider* +.env + +# mess detector rules +phpmd-rules.xml + +# Composer ignores +/vendor/ +/composer.lock +/composer.phar + +# Built static editor files +dist/static/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..641118b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "exelearning"] + path = exelearning + url = https://github.com/exelearning/exelearning.git + branch = feature/embedded-static-editor-enhancements diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5a6c383 --- /dev/null +++ b/Makefile @@ -0,0 +1,205 @@ +# Makefile for mod_exescorm Moodle plugin + +# Define SED_INPLACE based on the operating system +ifeq ($(shell uname), Darwin) + SED_INPLACE = sed -i '' +else + SED_INPLACE = sed -i +endif + +# Detect the operating system and shell environment +ifeq ($(OS),Windows_NT) + # Initially assume Windows shell + SHELLTYPE := windows + # Check if we are in Cygwin or MSYS (e.g., Git Bash) + ifdef MSYSTEM + SHELLTYPE := unix + else ifdef CYGWIN + SHELLTYPE := unix + endif +else + SHELLTYPE := unix +endif + +# Check if Docker is running +# This target verifies if Docker is installed and running on the system. +check-docker: +ifeq ($(SHELLTYPE),windows) + @echo "Detected system: Windows (cmd, powershell)" + @docker version > NUL 2>&1 || (echo. & echo Error: Docker is not running. Please make sure Docker is installed and running. & echo. & exit 1) +else + @echo "Detected system: Unix (Linux/macOS/Cygwin/MinGW)" + @docker version > /dev/null 2>&1 || (echo "" && echo "Error: Docker is not running. Please make sure Docker is installed and running." && echo "" && exit 1) +endif + +# Check if the .env file exists, if not, copy from .env.dist +# This target ensures that the .env file is present by copying it from .env.dist if it doesn't exist. +check-env: +ifeq ($(SHELLTYPE),windows) + @if not exist .env ( \ + echo The .env file does not exist. Copying from .env.dist... && \ + copy .env.dist .env \ + ) 2>nul +else + @if [ ! -f .env ]; then \ + echo "The .env file does not exist. Copying from .env.dist..."; \ + cp .env.dist .env; \ + fi +endif + +# Start Docker containers in interactive mode +# This target builds and starts the Docker containers, allowing interaction with the terminal. +up: check-docker check-env + docker compose up + +# Start Docker containers in background mode (daemon) +# This target builds and starts the Docker containers in the background. +upd: check-docker check-env + docker compose up -d + +# Stop and remove Docker containers +# This target stops and removes all running Docker containers. +down: check-docker check-env + docker compose down + +# Pull the latest images from the registry +# This target pulls the latest Docker images from the registry. +pull: check-docker check-env + docker compose -f docker-compose.yml pull + +# Build or rebuild Docker containers +# This target builds or rebuilds the Docker containers. +build: check-docker check-env + @if [ -z "$$(grep ^EXELEARNING_WEB_SOURCECODE_PATH .env | cut -d '=' -f2)" ]; then \ + echo "Error: EXELEARNING_WEB_SOURCECODE_PATH is not defined or empty in the .env file"; \ + exit 1; \ + fi + docker compose build + +# Open a shell inside the moodle container +# This target opens an interactive shell session inside the running Moodle container. +shell: check-docker check-env + docker compose exec moodle sh + +# Clean up and stop Docker containers, removing volumes and orphan containers +# This target stops all containers and removes them along with their volumes and any orphan containers. +clean: check-docker + docker compose down -v --remove-orphans + +# Install PHP dependencies using Composer +install-deps: + COMPOSER_ALLOW_SUPERUSER=1 composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress + +# Run code linting using Composer +lint: + composer lint + +# Automatically fix code style issues using Composer +fix: + composer fix + +# Run tests using Composer +test: + composer test + +# Run PHP Mess Detector using Composer +phpmd: + composer phpmd + +# Run Behat tests using Composer +behat: + composer behat +# ------------------------------------------------------- +# Embedded static editor build targets +# ------------------------------------------------------- + +EDITOR_SUBMODULE_PATH = exelearning +EDITOR_DIST_PATH = dist/static + +# Check if bun is installed +check-bun: + @command -v bun > /dev/null 2>&1 || (echo "Error: bun is not installed. Please install bun: https://bun.sh" && exit 1) + +# Initialize submodule if not present +update-submodule: + @if [ ! -f $(EDITOR_SUBMODULE_PATH)/.gitignore ]; then \ + echo "Initializing submodule..."; \ + git submodule update --init $(EDITOR_SUBMODULE_PATH); \ + fi + +# Force update submodule to configured branch +force-update-submodule: + git submodule update --init --remote $(EDITOR_SUBMODULE_PATH) + +# Build static editor to dist/static/ +build-editor: check-bun update-submodule + cd $(EDITOR_SUBMODULE_PATH) && bun install && bun run build:static + @mkdir -p $(EDITOR_DIST_PATH) + @rm -rf $(EDITOR_DIST_PATH)/* + cp -r $(EDITOR_SUBMODULE_PATH)/dist/static/* $(EDITOR_DIST_PATH)/ + +# Build without submodule update (for CI/CD) +build-editor-no-update: check-bun + cd $(EDITOR_SUBMODULE_PATH) && bun install && bun run build:static + @mkdir -p $(EDITOR_DIST_PATH) + @rm -rf $(EDITOR_DIST_PATH)/* + cp -r $(EDITOR_SUBMODULE_PATH)/dist/static/* $(EDITOR_DIST_PATH)/ + +# Remove build artifacts +clean-editor: + rm -rf $(EDITOR_DIST_PATH) + +# ------------------------------------------------------- +# Packaging +# ------------------------------------------------------- + +PLUGIN_NAME = mod_exescorm + +# Create a distributable ZIP package +package: + @if [ -z "$(VERSION)" ]; then \ + echo "Error: VERSION not specified. Use 'make package VERSION=2026020800'"; \ + exit 1; \ + fi + @echo "Updating version to $(VERSION) in version.php..." + $(SED_INPLACE) "s/\(plugin->version[[:space:]]*=[[:space:]]*\)[0-9]*/\1$(VERSION)/" version.php + @echo "Creating ZIP archive: $(PLUGIN_NAME)-$(VERSION).zip..." + rm -rf /tmp/exescorm-package + mkdir -p /tmp/exescorm-package/exescorm + rsync -av --exclude-from=.distignore ./ /tmp/exescorm-package/exescorm/ + cd /tmp/exescorm-package && zip -qr "$(CURDIR)/$(PLUGIN_NAME)-$(VERSION).zip" exescorm + rm -rf /tmp/exescorm-package + @echo "Restoring version in version.php..." + $(SED_INPLACE) "s/\(plugin->version[[:space:]]*=[[:space:]]*\)[0-9]*/\10000000000/" version.php + @echo "Package created: $(PLUGIN_NAME)-$(VERSION).zip" + +# ------------------------------------------------------- + +# Display help with available commands +# This target lists all available Makefile commands with a brief description. +help: + @echo "Available commands:" + @echo " up - Start Docker containers in interactive mode" + @echo " upd - Start Docker containers in background mode (daemon)" + @echo " down - Stop and remove Docker containers" + @echo " build - Build or rebuild Docker containers" + @echo " pull - Pull the latest images from the registry" + @echo " clean - Clean up and stop Docker containers, removing volumes and orphan containers" + @echo " shell - Open a shell inside the exelearning-web container" + @echo " install-deps - Install PHP dependencies using Composer" + @echo " lint - Run code linting using Composer" + @echo " fix - Automatically fix code style issues using Composer" + @echo " test - Run tests using Composer" + @echo " phpmd - Run PHP Mess Detector using Composer" + @echo " behat - Run Behat tests using Composer" + @echo " build-editor - Build embedded static editor" + @echo " build-editor-no-update - Build editor without submodule update (CI/CD)" + @echo " clean-editor - Remove editor build artifacts" + @echo " update-submodule - Initialize editor submodule" + @echo " force-update-submodule - Force update editor submodule to latest" + @echo " package - Create distributable ZIP (VERSION=X required)" + @echo " help - Display this help with available commands" + + +# Set help as the default goal if no target is specified +.DEFAULT_GOAL := help diff --git a/amd/build/editor_modal.min.js b/amd/build/editor_modal.min.js new file mode 100644 index 0000000..4d86311 --- /dev/null +++ b/amd/build/editor_modal.min.js @@ -0,0 +1,3 @@ +define("mod_exescorm/editor_modal",["exports","core/str","core/log","core/prefetch"],(function(_exports,_str,_log,_prefetch){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_exports.open=void 0,_exports.close=void 0,_log=_interopRequireDefault(_log),_prefetch=_interopRequireDefault(_prefetch);let overlay=null,iframe=null;const prefetchStrings=()=>{_prefetch.default.prefetchStrings("mod_exescorm",["editembedded","saving","savedsuccess","savetomoodle"]),_prefetch.default.prefetchStrings("core",["close"])},triggerSave=saveBtn=>{iframe&&iframe.contentWindow&&(saveBtn.disabled=!0,(0,_str.get_string)("saving","mod_exescorm").then((label=>{saveBtn.textContent=label})).catch(),iframe.contentWindow.postMessage({source:"exescorm-modal",type:"save"},"*"))},handleMessage=event=>{if(!event.data||"exescorm-editor"!==event.data.source)return;const saveBtn=document.getElementById("exescorm-editor-save");switch(event.data.type){case"save-complete":_log.default.debug("[editor_modal] Save complete, revision:",event.data.data.revision),saveBtn&&(0,_str.get_string)("savedsuccess","mod_exescorm").then((label=>{saveBtn.textContent=label,saveBtn.disabled=!1,setTimeout((()=>{_close(),window.location.reload()}),1e3)})).catch();break;case"save-error":_log.default.error("[editor_modal] Save error:",event.data.data.error),saveBtn&&(0,_str.get_string)("savetomoodle","mod_exescorm").then((label=>{saveBtn.textContent=label,saveBtn.disabled=!1})).catch();break;case"save-start":_log.default.debug("[editor_modal] Save started");break;case"editor-ready":_log.default.debug("[editor_modal] Editor is ready")}},handleKeydown=event=>{"Escape"===event.key&&_close()},_close=()=>{overlay&&(overlay.remove(),overlay=null,iframe=null,document.body.style.overflow="",window.removeEventListener("message",handleMessage),document.removeEventListener("keydown",handleKeydown))},_open=(cmid,editorUrl,activityName)=>{if(_log.default.debug("[editor_modal] Opening editor for cmid:",cmid),overlay)return void _log.default.debug("[editor_modal] Modal already open");overlay=document.createElement("div"),overlay.id="exescorm-editor-overlay",overlay.className="exescorm-editor-overlay";const header=document.createElement("div");header.className="exescorm-editor-header";const title=document.createElement("span");title.className="exescorm-editor-title",title.textContent=activityName||"",header.appendChild(title);const buttonGroup=document.createElement("div");buttonGroup.className="exescorm-editor-buttons";const saveBtn=document.createElement("button");saveBtn.className="btn btn-primary mr-2",saveBtn.id="exescorm-editor-save",(0,_str.get_string)("savetomoodle","mod_exescorm").then((label=>{saveBtn.textContent=label})).catch(),saveBtn.addEventListener("click",(()=>{triggerSave(saveBtn)}));const closeBtn=document.createElement("button");closeBtn.className="btn btn-secondary",closeBtn.id="exescorm-editor-close",(0,_str.get_string)("close","core").then((label=>{closeBtn.textContent=label})).catch(),closeBtn.addEventListener("click",(()=>{_close()})),buttonGroup.appendChild(saveBtn),buttonGroup.appendChild(closeBtn),header.appendChild(buttonGroup),overlay.appendChild(header),iframe=document.createElement("iframe"),iframe.className="exescorm-editor-iframe",iframe.src=editorUrl,iframe.setAttribute("allow","fullscreen"),iframe.setAttribute("frameborder","0"),overlay.appendChild(iframe),document.body.appendChild(overlay),document.body.style.overflow="hidden",window.addEventListener("message",handleMessage),document.addEventListener("keydown",handleKeydown)};_exports.close=_close;_exports.open=_open;_exports.init=()=>{prefetchStrings(),document.addEventListener("click",(e=>{const btn=e.target.closest('[data-action="mod_exescorm/editor-open"]');btn&&(e.preventDefault(),_open(btn.dataset.cmid,btn.dataset.editorurl,btn.dataset.activityname))}))}})); + +//# sourceMappingURL=editor_modal.min.js.map diff --git a/amd/build/editor_modal.min.js.map b/amd/build/editor_modal.min.js.map new file mode 100644 index 0000000..49489a5 --- /dev/null +++ b/amd/build/editor_modal.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"editor_modal.min.js","sources":["../src/editor_modal.js"],"names":[],"mappings":""} diff --git a/amd/src/editor_modal.js b/amd/src/editor_modal.js new file mode 100644 index 0000000..fd0d9b8 --- /dev/null +++ b/amd/src/editor_modal.js @@ -0,0 +1,237 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Modal controller for the embedded eXeLearning editor. + * + * Creates a fullscreen overlay with an iframe loading the editor, + * a save button and a close button. Handles postMessage communication + * with the editor bridge running inside the iframe. + * + * @module mod_exescorm/editor_modal + * @copyright 2025 eXeLearning + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/* eslint-disable no-console */ + +import {get_string as getString} from 'core/str'; +import Log from 'core/log'; +import Prefetch from 'core/prefetch'; + +let overlay = null; +let iframe = null; + +/** + * Prefetch language strings used by the modal. + */ +const prefetchStrings = () => { + Prefetch.prefetchStrings('mod_exescorm', ['editembedded', 'saving', 'savedsuccess', 'savetomoodle']); + Prefetch.prefetchStrings('core', ['close']); +}; + +/** + * Open the embedded editor in a fullscreen modal overlay. + * + * @param {number} cmid Course module ID + * @param {string} editorUrl URL of the editor bootstrap page + * @param {string} activityName Activity name for the title bar + */ +export const open = (cmid, editorUrl, activityName) => { + Log.debug('[editor_modal] Opening editor for cmid:', cmid); + + if (overlay) { + Log.debug('[editor_modal] Modal already open'); + return; + } + + // Create the overlay container. + overlay = document.createElement('div'); + overlay.id = 'exescorm-editor-overlay'; + overlay.className = 'exescorm-editor-overlay'; + + // Build the header bar. + const header = document.createElement('div'); + header.className = 'exescorm-editor-header'; + + const title = document.createElement('span'); + title.className = 'exescorm-editor-title'; + title.textContent = activityName || ''; + header.appendChild(title); + + const buttonGroup = document.createElement('div'); + buttonGroup.className = 'exescorm-editor-buttons'; + + // Save button. + const saveBtn = document.createElement('button'); + saveBtn.className = 'btn btn-primary mr-2'; + saveBtn.id = 'exescorm-editor-save'; + getString('savetomoodle', 'mod_exescorm').then((label) => { + saveBtn.textContent = label; + return; + }).catch(); + + saveBtn.addEventListener('click', () => { + triggerSave(saveBtn); + }); + + // Close button. + const closeBtn = document.createElement('button'); + closeBtn.className = 'btn btn-secondary'; + closeBtn.id = 'exescorm-editor-close'; + getString('close', 'core').then((label) => { + closeBtn.textContent = label; + return; + }).catch(); + + closeBtn.addEventListener('click', () => { + close(); + }); + + buttonGroup.appendChild(saveBtn); + buttonGroup.appendChild(closeBtn); + header.appendChild(buttonGroup); + overlay.appendChild(header); + + // Create the iframe. + iframe = document.createElement('iframe'); + iframe.className = 'exescorm-editor-iframe'; + iframe.src = editorUrl; + iframe.setAttribute('allow', 'fullscreen'); + iframe.setAttribute('frameborder', '0'); + overlay.appendChild(iframe); + + // Append to body. + document.body.appendChild(overlay); + document.body.style.overflow = 'hidden'; + + // Listen for messages from the editor iframe. + window.addEventListener('message', handleMessage); + + // Listen for Escape key. + document.addEventListener('keydown', handleKeydown); +}; + +/** + * Send a save request to the editor iframe. + * @param {HTMLElement} saveBtn + */ +const triggerSave = (saveBtn) => { + if (!iframe || !iframe.contentWindow) { + return; + } + saveBtn.disabled = true; + getString('saving', 'mod_exescorm').then((label) => { + saveBtn.textContent = label; + return; + }).catch(); + + iframe.contentWindow.postMessage({ + source: 'exescorm-modal', + type: 'save', + }, '*'); +}; + +/** + * Handle postMessage events from the editor iframe. + * @param {MessageEvent} event + */ +const handleMessage = (event) => { + if (!event.data || event.data.source !== 'exescorm-editor') { + return; + } + + const saveBtn = document.getElementById('exescorm-editor-save'); + + switch (event.data.type) { + case 'save-complete': + Log.debug('[editor_modal] Save complete, revision:', event.data.data.revision); + if (saveBtn) { + getString('savedsuccess', 'mod_exescorm').then((label) => { + saveBtn.textContent = label; + saveBtn.disabled = false; + // Reload the page after a short delay to show updated content. + setTimeout(() => { + close(); + window.location.reload(); + }, 1000); + return; + }).catch(); + } + break; + + case 'save-error': + Log.error('[editor_modal] Save error:', event.data.data.error); + if (saveBtn) { + getString('savetomoodle', 'mod_exescorm').then((label) => { + saveBtn.textContent = label; + saveBtn.disabled = false; + return; + }).catch(); + } + break; + + case 'save-start': + Log.debug('[editor_modal] Save started'); + break; + + case 'editor-ready': + Log.debug('[editor_modal] Editor is ready'); + break; + } +}; + +/** + * Handle keydown events (Escape to close). + * @param {KeyboardEvent} event + */ +const handleKeydown = (event) => { + if (event.key === 'Escape') { + close(); + } +}; + +/** + * Close the editor modal and clean up. + */ +export const close = () => { + if (overlay) { + overlay.remove(); + overlay = null; + iframe = null; + document.body.style.overflow = ''; + window.removeEventListener('message', handleMessage); + document.removeEventListener('keydown', handleKeydown); + } +}; + +/** + * Initialize the module by setting up click handlers for editor buttons. + */ +export const init = () => { + prefetchStrings(); + + // Delegate click events for embedded editor buttons. + document.addEventListener('click', (e) => { + const btn = e.target.closest('[data-action="mod_exescorm/editor-open"]'); + if (btn) { + e.preventDefault(); + const cmid = btn.dataset.cmid; + const editorUrl = btn.dataset.editorurl; + const name = btn.dataset.activityname; + open(cmid, editorUrl, name); + } + }); +}; diff --git a/amd/src/moodle_exe_bridge.js b/amd/src/moodle_exe_bridge.js new file mode 100644 index 0000000..c59e1b5 --- /dev/null +++ b/amd/src/moodle_exe_bridge.js @@ -0,0 +1,355 @@ +/** + * Bridge between the embedded eXeLearning editor and Moodle. + * + * This script runs inside the editor iframe. It reads Moodle configuration + * injected by editor/index.php, handles importing the current package into + * the editor, and saves edited packages back to Moodle via AJAX. + * + * Based on the same bridge pattern used in wp-exelearning and omeka-s-exelearning. + * + * @module mod_exescorm/moodle_exe_bridge + * @copyright 2025 eXeLearning + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/* eslint-disable no-console */ + +(function() { + 'use strict'; + + var config = window.__MOODLE_EXE_CONFIG__; + if (!config) { + console.error('[moodle-exe-bridge] No __MOODLE_EXE_CONFIG__ found'); + return; + } + + console.log('[moodle-exe-bridge] Initializing with config:', config); + + /** + * Wait for the eXeLearning app to be ready (legacy fallback). + * + * @param {number} maxAttempts Maximum attempts before giving up. + * @return {Promise} Resolves with the app instance. + */ + function waitForAppLegacy(maxAttempts) { + maxAttempts = maxAttempts || 100; + return new Promise(function(resolve, reject) { + var attempts = 0; + var check = function() { + attempts++; + if (window.eXeLearning && window.eXeLearning.app) { + resolve(window.eXeLearning.app); + } else if (attempts < maxAttempts) { + setTimeout(check, 100); + } else { + reject(new Error('App did not initialize')); + } + }; + check(); + }); + } + + /** + * Wait for the Yjs project bridge to be ready. + * + * @param {number} maxAttempts Maximum attempts before giving up. + * @return {Promise} Resolves with the bridge instance. + */ + function waitForBridge(maxAttempts) { + maxAttempts = maxAttempts || 150; + return new Promise(function(resolve, reject) { + var attempts = 0; + var check = function() { + attempts++; + var bridge = window.eXeLearning?.app?.project?._yjsBridge + || window.YjsModules?.getBridge?.(); + if (bridge) { + console.log('[moodle-exe-bridge] Bridge found after', attempts, 'attempts'); + resolve(bridge); + } else if (attempts < maxAttempts) { + setTimeout(check, 200); + } else { + reject(new Error('Project bridge did not initialize')); + } + }; + check(); + }); + } + + /** + * Show or update the loading screen. + * + * @param {string} message Message to display. + * @param {boolean} show Whether to show or hide. + */ + function updateLoadScreen(message, show) { + if (show === undefined) { + show = true; + } + var loadScreen = document.getElementById('load-screen-main'); + var loadMessage = loadScreen?.querySelector('.loading-message, p'); + + if (loadScreen) { + if (show) { + loadScreen.classList.remove('hide'); + } else { + loadScreen.classList.add('hide'); + } + } + + if (loadMessage && message) { + loadMessage.textContent = message; + } + } + + /** + * Import the ELP package from Moodle into the editor. + */ + async function importPackageFromMoodle() { + var packageUrl = config.packageUrl; + if (!packageUrl) { + console.log('[moodle-exe-bridge] No package URL, starting with empty project'); + return; + } + + console.log('[moodle-exe-bridge] Starting import from:', packageUrl); + + try { + updateLoadScreen('Loading project...'); + + // Wait for the Yjs bridge to be initialized. + updateLoadScreen('Waiting for editor...'); + var bridge = await waitForBridge(); + + // Fetch the package file. + updateLoadScreen('Downloading file...'); + var response = await fetch(packageUrl, {credentials: 'include'}); + if (!response.ok) { + throw new Error('HTTP ' + response.status + ': ' + response.statusText); + } + + // Convert to File object. + var blob = await response.blob(); + console.log('[moodle-exe-bridge] File downloaded, size:', blob.size); + var filename = packageUrl.split('/').pop().split('?')[0] || 'project.elpx'; + var file = new File([blob], filename, {type: 'application/zip'}); + + // Import using the project API or bridge directly. + updateLoadScreen('Importing content...'); + var project = window.eXeLearning?.app?.project; + if (typeof project?.importElpxFile === 'function') { + console.log('[moodle-exe-bridge] Using project.importElpxFile...'); + await project.importElpxFile(file); + } else if (typeof project?.importFromElpxViaYjs === 'function') { + console.log('[moodle-exe-bridge] Using project.importFromElpxViaYjs...'); + await project.importFromElpxViaYjs(file, {clearExisting: true}); + } else { + console.log('[moodle-exe-bridge] Using bridge.importFromElpx...'); + await bridge.importFromElpx(file, {clearExisting: true}); + } + + console.log('[moodle-exe-bridge] Package imported successfully'); + } catch (error) { + console.error('[moodle-exe-bridge] Import failed:', error); + updateLoadScreen('Error loading project'); + } finally { + setTimeout(function() { + updateLoadScreen('', false); + }, 500); + } + } + + /** + * Export the current project and save it back to Moodle. + */ + async function saveToMoodle() { + // Notify parent window that save is starting. + notifyParent('save-start'); + + try { + console.log('[moodle-exe-bridge] Starting save...'); + + // Get the project bridge for export. + var project = window.eXeLearning?.app?.project; + var yjsBridge = project?._yjsBridge + || window.YjsModules?.getBridge?.() + || project?.bridge; + + if (!yjsBridge) { + throw new Error('Project bridge not available'); + } + + // Export using SharedExporters (scorm12 for SCORM packages). + var blob; + if (window.SharedExporters?.quickExport) { + console.log('[moodle-exe-bridge] Using SharedExporters.quickExport...'); + var result = await window.SharedExporters.quickExport( + 'scorm12', + yjsBridge.documentManager, + null, + yjsBridge.resourceFetcher, + {}, + yjsBridge.assetManager + ); + if (!result.success || !result.data) { + throw new Error('Export failed'); + } + blob = new Blob([result.data], {type: 'application/zip'}); + } else if (window.SharedExporters?.createExporter) { + console.log('[moodle-exe-bridge] Using SharedExporters.createExporter...'); + var exporter = window.SharedExporters.createExporter( + 'scorm12', + yjsBridge.documentManager, + yjsBridge.assetCache, + yjsBridge.resourceFetcher, + yjsBridge.assetManager + ); + var exportResult = await exporter.export(); + if (!exportResult.success || !exportResult.data) { + throw new Error('Export failed'); + } + blob = new Blob([exportResult.data], {type: 'application/zip'}); + } else { + throw new Error('No exporter available'); + } + + console.log('[moodle-exe-bridge] Export complete, size:', blob.size); + + // Upload to Moodle. + var formData = new FormData(); + formData.append('package', blob, 'package.zip'); + formData.append('cmid', config.cmid); + formData.append('sesskey', config.sesskey); + + console.log('[moodle-exe-bridge] Uploading to:', config.saveUrl); + + var saveResponse = await fetch(config.saveUrl, { + method: 'POST', + body: formData, + credentials: 'include', + }); + + var saveResult = await saveResponse.json(); + + if (saveResult.success) { + console.log('[moodle-exe-bridge] Save successful, revision:', saveResult.revision); + showNotification('success', 'Saved successfully!'); + notifyParent('save-complete', {revision: saveResult.revision}); + } else { + throw new Error(saveResult.error || 'Save failed'); + } + } catch (error) { + console.error('[moodle-exe-bridge] Save failed:', error); + showNotification('error', 'Error: ' + error.message); + notifyParent('save-error', {error: error.message}); + } + } + + /** + * Send a message to the parent window (the Moodle modal). + * + * @param {string} type Message type. + * @param {Object} data Optional payload. + */ + function notifyParent(type, data) { + if (window.parent && window.parent !== window) { + window.parent.postMessage({ + source: 'exescorm-editor', + type: type, + data: data || {}, + }, '*'); + } + } + + /** + * Show a notification inside the editor. + * + * @param {string} type Notification type (success, error). + * @param {string} message Message to display. + */ + function showNotification(type, message) { + var existing = document.getElementById('moodle-exe-notification'); + if (existing) { + existing.remove(); + } + + var notification = document.createElement('div'); + notification.id = 'moodle-exe-notification'; + notification.style.cssText = 'position:fixed;top:10px;right:10px;z-index:99999;padding:12px 20px;' + + 'border-radius:4px;color:#fff;font-size:14px;' + + (type === 'success' ? 'background:#28a745;' : 'background:#dc3545;'); + notification.textContent = message; + document.body.appendChild(notification); + + setTimeout(function() { + notification.style.transition = 'opacity 0.3s'; + notification.style.opacity = '0'; + setTimeout(function() { + notification.remove(); + }, 300); + }, 3000); + } + + /** + * Initialize the bridge. + */ + async function init() { + try { + console.log('[moodle-exe-bridge] Starting initialization...'); + + // Wait for app initialization using the ready promise or legacy polling. + if (window.eXeLearning?.ready) { + await window.eXeLearning.ready; + } else { + await waitForAppLegacy(); + } + console.log('[moodle-exe-bridge] App initialized'); + + // Import package if URL provided. + if (config.packageUrl) { + await importPackageFromMoodle(); + } else { + console.log('[moodle-exe-bridge] No packageUrl in config, skipping import'); + } + + // Notify parent window that bridge is ready. + notifyParent('editor-ready'); + + // Listen for save shortcuts (Ctrl+S / Cmd+S). + document.addEventListener('keydown', function(e) { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + saveToMoodle(); + } + }); + + // Listen for messages from parent window (modal save button). + window.addEventListener('message', function(event) { + if (event.data && event.data.source === 'exescorm-modal') { + if (event.data.type === 'save') { + saveToMoodle(); + } + } + }); + + console.log('[moodle-exe-bridge] Initialization complete'); + } catch (error) { + console.error('[moodle-exe-bridge] Initialization failed:', error); + } + } + + // Initialize when DOM is ready. + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + // Expose for debugging. + window.moodleExeBridge = { + config: config, + save: saveToMoodle, + import: importPackageFromMoodle, + }; + +})(); diff --git a/classes/exescorm_package.php b/classes/exescorm_package.php index 588fb8c..1604f84 100644 --- a/classes/exescorm_package.php +++ b/classes/exescorm_package.php @@ -29,6 +29,33 @@ class exescorm_package { + /** + * Check if a stored file is a valid package file (ZIP or ELPX). + * + * ELPX files are ZIP archives with a different extension. Browsers may + * report them as application/octet-stream, so we also check the extension. + * + * @param \stored_file $file + * @return bool + */ + public static function is_valid_package_file(\stored_file $file) { + $mimetype = $file->get_mimetype(); + $filename = $file->get_filename(); + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + // Accept ZIP mimetype or ELPX extension (which is a ZIP archive). + $validmimes = ['application/zip', 'application/x-zip-compressed', 'application/octet-stream']; + $validexts = ['zip', 'elpx']; + + if (in_array($ext, $validexts) && in_array($mimetype, $validmimes)) { + return true; + } + if ($mimetype === 'application/zip') { + return true; + } + return false; + } + public static function validate_file_list($filelist) { $errors = []; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1f93fb7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,89 @@ +--- +version: "3" +services: + + exelearning-web: + image: ghcr.io/exelearning/exelearning:${EXELEARNING_WEB_CONTAINER_TAG} + build: ${EXELEARNING_WEB_SOURCECODE_PATH:-} # If EXELEARNING_WEB__SOURCECODE_PATH is not defined, skip build + ports: + - ${APP_PORT}:8080 + restart: unless-stopped # Restart the container unless it is stopped manually + volumes: + - mnt-data:/mnt/data:rw # Mount the volume for persistent data + environment: + APP_ENV: ${APP_ENV} + APP_DEBUG: ${APP_DEBUG} + XDEBUG_MODE: ${XDEBUG_MODE} + APP_SECRET: ${APP_SECRET} + PRE_CONFIGURE_COMMANDS: + POST_CONFIGURE_COMMANDS: | + echo "this is a test line 1" + echo "this is a test line 2" + php bin/console app:create-user ${TEST_USER_EMAIL} ${TEST_USER_PASSWORD} ${TEST_USER_USERNAME} --no-fail + + moodle: + image: erseco/alpine-moodle:v5.0.5 + restart: unless-stopped + environment: + LANG: es_ES.UTF-8 + LANGUAGE: es_ES:es + SITE_URL: http://localhost + DB_TYPE: mariadb + DB_HOST: db + DB_PORT: 3306 + DB_NAME: moodle + DB_USER: root + DB_PASS: moodle + DB_PREFIX: mdl_ + DEBUG: true + MOODLE_EMAIL: ${TEST_USER_EMAIL} + MOODLE_LANGUAGE: es + MOODLE_SITENAME: Moodle-eXeLearning + MOODLE_USERNAME: ${TEST_USER_USERNAME} + MOODLE_PASSWORD: ${TEST_USER_PASSWORD} + PRE_CONFIGURE_COMMANDS: | + echo 'This is a pre-configure command' + POST_CONFIGURE_COMMANDS: | + echo 'This is a post-configure command' + echo 'Forcing upgrade to re-install exe plugin...' + php admin/cli/upgrade.php --non-interactive + php admin/cli/cfg.php --component=exescorm --name=exeonlinebaseuri --set=http://localhost:${APP_PORT} + php admin/cli/cfg.php --component=exescorm --name=hmackey1 --set=${APP_SECRET} + ports: + - 80:8080 + volumes: + - moodledata:/var/www/moodledata + - moodlehtml:/var/www/html + - ./:/var/www/html/mod/exescorm:rw # Mount local plugin on the container + depends_on: + - db + + db: + image: mariadb:latest + restart: unless-stopped # Restart the container unless it is stopped manually + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + environment: + MYSQL_DATABASE: moodle + MYSQL_ROOT_PASSWORD: moodle + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + volumes: + - dbdata:/var/lib/mysql + + phpmyadmin: + image: phpmyadmin + ports: + - 8002:80 # Maps the host's port 8002 to the container's port 80 + environment: + PMA_HOST: db + PMA_USER: root + PMA_PASSWORD: moodle + UPLOAD_LIMIT: 300M + depends_on: + - db + +volumes: + dbdata: + mnt-data: + moodledata: + moodlehtml: diff --git a/editor/index.php b/editor/index.php new file mode 100644 index 0000000..8039081 --- /dev/null +++ b/editor/index.php @@ -0,0 +1,113 @@ +. + +/** + * Embedded eXeLearning editor bootstrap page. + * + * Loads the static editor and injects Moodle configuration so the editor + * can communicate with Moodle (load/save packages). + * + * @package mod_exescorm + * @copyright 2025 eXeLearning + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require('../../../config.php'); +require_once($CFG->dirroot . '/mod/exescorm/lib.php'); + +$id = required_param('id', PARAM_INT); // Course module ID. + +$cm = get_coursemodule_from_id('exescorm', $id, 0, false, MUST_EXIST); +$course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST); +$exescorm = $DB->get_record('exescorm', ['id' => $cm->instance], '*', MUST_EXIST); + +require_login($course, true, $cm); +$context = context_module::instance($cm->id); +require_capability('moodle/course:manageactivities', $context); +require_sesskey(); + +// Verify the embedded editor is available. +$editorpath = $CFG->dirroot . '/mod/exescorm/dist/static/index.html'; +if (!file_exists($editorpath)) { + throw new moodle_exception('editormissing', 'mod_exescorm'); +} + +// Build the package URL for the editor to import. +$packageurl = exescorm_get_package_url($exescorm, $context); + +// Build the save endpoint URL. +$saveurl = new moodle_url('/mod/exescorm/editor/save.php'); + +// Base URL pointing directly to the static editor directory (web-accessible). +$editorbaseurl = $CFG->wwwroot . '/mod/exescorm/dist/static'; + +// Read the editor template. +$html = file_get_contents($editorpath); + +// Inject tag pointing directly to the static directory. +$basetag = ''; +$html = preg_replace('/(]*>)/i', '$1' . $basetag, $html); + +// Fix explicit "./" relative paths in attributes (same pattern used by WP and Omeka-S). +$html = preg_replace( + '/(?<=["\'])\.\//', + htmlspecialchars($editorbaseurl, ENT_QUOTES, 'UTF-8') . '/', + $html +); + +// Build Moodle configuration for the bridge script. +$moodleconfig = json_encode([ + 'cmid' => $cm->id, + 'contextid' => $context->id, + 'sesskey' => sesskey(), + 'packageUrl' => $packageurl ? $packageurl->out(false) : '', + 'saveUrl' => $saveurl->out(false), + 'activityName' => format_string($exescorm->name), + 'wwwroot' => $CFG->wwwroot, + 'editorBaseUrl' => $editorbaseurl, +]); + +$embeddingconfig = json_encode([ + 'basePath' => $editorbaseurl, + 'parentOrigin' => $CFG->wwwroot, + 'trustedOrigins' => [$CFG->wwwroot], + 'hideUI' => [ + 'fileMenu' => true, + 'saveButton' => true, + 'userMenu' => true, + ], + 'platform' => 'moodle', + 'pluginVersion' => get_config('mod_exescorm', 'version'), +]); + +// Inject configuration scripts before . +$configscript = << + window.__MOODLE_EXE_CONFIG__ = $moodleconfig; + window.__EXE_EMBEDDING_CONFIG__ = $embeddingconfig; + +EOT; + +// Inject bridge script before . +$bridgescript = ''; + +$html = str_replace('', $configscript . "\n" . '', $html); +$html = str_replace('', $bridgescript . "\n" . '', $html); + +// Output the processed HTML. +header('Content-Type: text/html; charset=utf-8'); +header('X-Frame-Options: SAMEORIGIN'); +echo $html; diff --git a/editor/save.php b/editor/save.php new file mode 100644 index 0000000..d08ee47 --- /dev/null +++ b/editor/save.php @@ -0,0 +1,104 @@ +. + +/** + * AJAX endpoint for saving SCORM packages from the embedded eXeLearning editor. + * + * Receives an uploaded SCORM ZIP file, saves it to the package filearea, + * and calls exescorm_parse() to extract content and parse the manifest. + * + * @package mod_exescorm + * @copyright 2025 eXeLearning + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +define('AJAX_SCRIPT', true); + +require('../../../config.php'); +require_once($CFG->dirroot . '/mod/exescorm/lib.php'); +require_once($CFG->dirroot . '/mod/exescorm/locallib.php'); + +$cmid = required_param('cmid', PARAM_INT); + +$cm = get_coursemodule_from_id('exescorm', $cmid, 0, false, MUST_EXIST); +$course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST); +$exescorm = $DB->get_record('exescorm', ['id' => $cm->instance], '*', MUST_EXIST); + +require_login($course, true, $cm); +require_sesskey(); +$context = context_module::instance($cm->id); +require_capability('moodle/course:manageactivities', $context); + +header('Content-Type: application/json; charset=utf-8'); + +try { + // Check that a file was uploaded. + if (empty($_FILES['package'])) { + throw new moodle_exception('nofile', 'error'); + } + + $uploadedfile = $_FILES['package']; + if ($uploadedfile['error'] !== UPLOAD_ERR_OK) { + throw new moodle_exception('uploadproblem', 'error'); + } + + $fs = get_file_storage(); + + // Update revision and timestamp. + $exescorm->timemodified = time(); + + // Clean old package files. + $fs->delete_area_files($context->id, 'mod_exescorm', 'package'); + + // Save the uploaded file as the new package (itemid=0 as exescorm expects). + $fileinfo = [ + 'contextid' => $context->id, + 'component' => 'mod_exescorm', + 'filearea' => 'package', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => clean_filename($uploadedfile['name']), + 'userid' => $USER->id, + 'source' => clean_filename($uploadedfile['name']), + 'author' => fullname($USER), + 'license' => 'unknown', + ]; + + $package = $fs->create_file_from_pathname($fileinfo, $uploadedfile['tmp_name']); + + // Store filename as reference. + $exescorm->reference = clean_filename($uploadedfile['name']); + $DB->update_record('exescorm', $exescorm); + + // Parse the SCORM package: extracts ZIP to content, finds imsmanifest.xml, + // parses SCOs, and sets $exescorm->version. + exescorm_parse($exescorm, true); + + // Re-read to get updated version/data after parse. + $exescorm = $DB->get_record('exescorm', ['id' => $exescorm->id]); + + echo json_encode([ + 'success' => true, + 'revision' => $exescorm->timemodified, + ]); + +} catch (Exception $e) { + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ]); +} diff --git a/editor/static.php b/editor/static.php new file mode 100644 index 0000000..e632be2 --- /dev/null +++ b/editor/static.php @@ -0,0 +1,99 @@ +. + +/** + * Serve static files from the embedded eXeLearning editor (dist/static/). + * + * @package mod_exescorm + * @copyright 2025 eXeLearning + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require('../../../config.php'); + +$file = required_param('file', PARAM_PATH); +$id = required_param('id', PARAM_INT); + +$cm = get_coursemodule_from_id('exescorm', $id, 0, false, MUST_EXIST); +$course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST); + +require_login($course, true, $cm); +$context = context_module::instance($cm->id); +require_capability('moodle/course:manageactivities', $context); + +// Sanitize the file path to prevent directory traversal. +$file = clean_param($file, PARAM_PATH); +$file = ltrim($file, '/'); + +// Prevent directory traversal. +if (strpos($file, '..') !== false) { + send_header_404(); + die('File not found'); +} + +$staticdir = $CFG->dirroot . '/mod/exescorm/dist/static'; +$filepath = realpath($staticdir . '/' . $file); + +// Ensure the resolved path is within the static directory. +if ($filepath === false || strpos($filepath, realpath($staticdir)) !== 0) { + send_header_404(); + die('File not found'); +} + +if (!is_file($filepath)) { + send_header_404(); + die('File not found'); +} + +// Determine content type. +$mimetypes = [ + 'html' => 'text/html', + 'htm' => 'text/html', + 'css' => 'text/css', + 'js' => 'application/javascript', + 'mjs' => 'application/javascript', + 'json' => 'application/json', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'svg' => 'image/svg+xml', + 'ico' => 'image/x-icon', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'eot' => 'application/vnd.ms-fontobject', + 'webp' => 'image/webp', + 'mp3' => 'audio/mpeg', + 'mp4' => 'video/mp4', + 'webm' => 'video/webm', + 'ogg' => 'audio/ogg', + 'wav' => 'audio/wav', + 'pdf' => 'application/pdf', + 'xml' => 'application/xml', + 'wasm' => 'application/wasm', +]; + +$ext = strtolower(pathinfo($filepath, PATHINFO_EXTENSION)); +$contenttype = isset($mimetypes[$ext]) ? $mimetypes[$ext] : 'application/octet-stream'; + +// Send the file with appropriate headers. +header('Content-Type: ' . $contenttype); +header('Content-Length: ' . filesize($filepath)); +header('Cache-Control: public, max-age=604800'); // Cache for 1 week. +header('X-Frame-Options: SAMEORIGIN'); + +readfile($filepath); diff --git a/exelearning b/exelearning new file mode 160000 index 0000000..aee0532 --- /dev/null +++ b/exelearning @@ -0,0 +1 @@ +Subproject commit aee05328f489e1b5b86ee462e2fcc7d231b8d016 diff --git a/lang/ca/exescorm.php b/lang/ca/exescorm.php index 45ec595..662c0ff 100644 --- a/lang/ca/exescorm.php +++ b/lang/ca/exescorm.php @@ -498,6 +498,23 @@ $string['width'] = 'Amplada'; $string['window'] = 'Finestra'; $string['youmustselectastatus'] = 'Ha de seleccionar un estat que serà requerit'; +// Embedded editor strings. +$string['embeddededitorsettings'] = 'Tipus d\'editor'; +$string['editormode'] = 'Mode d\'editor'; +$string['editormodedesc'] = 'Seleccioneu quin editor voleu utilitzar per crear i editar contingut eXeLearning. La configuració de connexió online només s\'aplica quan es selecciona el mode "eXeLearning Online".'; +$string['editormodeonline'] = 'eXeLearning Online (servidor remot)'; +$string['editormodeembedded'] = 'Editor integrat (incrustat)'; +$string['embeddednotinstalled'] = 'Els fitxers de l\'editor integrat no estan instal·lats. Executeu "make build-editor" per generar-los.'; +$string['editembedded'] = 'Editar amb eXeLearning'; +$string['editembedded_integrated'] = 'Integrat'; +$string['editembedded_help'] = 'Obre l\'editor eXeLearning integrat per editar el contingut directament dins de Moodle.'; +$string['editormissing'] = 'L\'editor integrat eXeLearning no està instal·lat. Contacteu amb l\'administrador.'; +$string['embeddedtypehelp'] = 'Es crearà l\'activitat i podreu editar-la amb l\'editor eXeLearning integrat des de la pàgina de visualització de l\'activitat.'; +$string['saving'] = 'Desant...'; +$string['savedsuccess'] = 'Canvis desats correctament'; +$string['savetomoodle'] = 'Desar a Moodle'; +$string['typeembedded'] = 'Crear amb eXeLearning (editor integrat)'; + $string['info'] = 'Info'; $string['displayactivityname'] = 'Mostra el nom de l\'activitat'; $string['displayactivityname_help'] = 'Si cal mostrar o no el nom de l\'activitat al damunt del reproductor eXeLearning'; diff --git a/lang/en/exescorm.php b/lang/en/exescorm.php index 7d34b2e..93c6b97 100644 --- a/lang/en/exescorm.php +++ b/lang/en/exescorm.php @@ -491,6 +491,23 @@ $string['window'] = 'Window'; $string['youmustselectastatus'] = 'You must select a status to require'; +// Embedded editor strings. +$string['embeddededitorsettings'] = 'Editor type'; +$string['editormode'] = 'Editor mode'; +$string['editormodedesc'] = 'Select which editor to use for creating and editing eXeLearning content. Online connection settings only apply when "eXeLearning Online" mode is selected.'; +$string['editormodeonline'] = 'eXeLearning Online (remote server)'; +$string['editormodeembedded'] = 'Integrated editor (embedded)'; +$string['embeddednotinstalled'] = 'The embedded editor files are not installed. Run "make build-editor" to build them.'; +$string['editembedded'] = 'Edit with eXeLearning'; +$string['editembedded_integrated'] = 'Integrated'; +$string['editembedded_help'] = 'Open the embedded eXeLearning editor to edit the content directly within Moodle.'; +$string['editormissing'] = 'The eXeLearning embedded editor is not installed. Please contact your administrator.'; +$string['embeddedtypehelp'] = 'The activity will be created and you can then edit it using the embedded eXeLearning editor from the activity view page.'; +$string['typeembedded'] = 'Create with eXeLearning (embedded editor)'; +$string['saving'] = 'Saving...'; +$string['savedsuccess'] = 'Changes saved successfully'; +$string['savetomoodle'] = 'Save to Moodle'; + // Deprecated since Moodle 4.0. $string['info'] = 'Info'; $string['displayactivityname'] = 'Display activity name'; diff --git a/lang/es/exescorm.php b/lang/es/exescorm.php index e1fadeb..3e0bba8 100644 --- a/lang/es/exescorm.php +++ b/lang/es/exescorm.php @@ -491,6 +491,23 @@ $string['window'] = 'Ventana'; $string['youmustselectastatus'] = 'Debe seleccionar un estado que será requerido'; +// Embedded editor strings. +$string['embeddededitorsettings'] = 'Tipo de editor'; +$string['editormode'] = 'Modo de editor'; +$string['editormodedesc'] = 'Seleccione qué editor usar para crear y editar contenido eXeLearning. La configuración de conexión online solo aplica cuando se selecciona el modo "eXeLearning Online".'; +$string['editormodeonline'] = 'eXeLearning Online (servidor remoto)'; +$string['editormodeembedded'] = 'Editor integrado (embebido)'; +$string['embeddednotinstalled'] = 'Los archivos del editor integrado no están instalados. Ejecute "make build-editor" para generarlos.'; +$string['editembedded'] = 'Editar con eXeLearning'; +$string['editembedded_integrated'] = 'Integrado'; +$string['editembedded_help'] = 'Abre el editor eXeLearning integrado para editar el contenido directamente dentro de Moodle.'; +$string['editormissing'] = 'El editor integrado eXeLearning no está instalado. Contacte con el administrador.'; +$string['embeddedtypehelp'] = 'Se creará la actividad y podrá editarla usando el editor eXeLearning integrado desde la página de visualización de la actividad.'; +$string['saving'] = 'Guardando...'; +$string['savedsuccess'] = 'Cambios guardados correctamente'; +$string['savetomoodle'] = 'Guardar en Moodle'; +$string['typeembedded'] = 'Crear con eXeLearning (editor integrado)'; + // Deprecated since Moodle 4.0. $string['info'] = 'Info'; $string['displayactivityname'] = 'Mostrar el nombre de la actividad'; diff --git a/lang/eu/exescorm.php b/lang/eu/exescorm.php index c04676e..05ca228 100644 --- a/lang/eu/exescorm.php +++ b/lang/eu/exescorm.php @@ -491,6 +491,23 @@ $string['window'] = 'Leihoa'; $string['youmustselectastatus'] = 'Eskatuko den egoera bat hautatu behar duzu'; +// Embedded editor strings. +$string['embeddededitorsettings'] = 'Editore mota'; +$string['editormode'] = 'Editore modua'; +$string['editormodedesc'] = 'Aukeratu zein editore erabili eXeLearning edukia sortu eta editatzeko. Online konexio-ezarpenak soilik aplikatzen dira "eXeLearning Online" modua hautatzen denean.'; +$string['editormodeonline'] = 'eXeLearning Online (urruneko zerbitzaria)'; +$string['editormodeembedded'] = 'Editore txertatua (integratua)'; +$string['embeddednotinstalled'] = 'Editore txertatuaren fitxategiak ez daude instalatuta. Exekutatu "make build-editor" sortzeko.'; +$string['editembedded'] = 'Editatu eXeLearning-ekin'; +$string['editembedded_integrated'] = 'Integratua'; +$string['editembedded_help'] = 'Ireki eXeLearning editore txertatua edukia zuzenean Moodle-n editatzeko.'; +$string['editormissing'] = 'eXeLearning editore txertatua ez dago instalatuta. Jarri harremanetan administratzailearekin.'; +$string['embeddedtypehelp'] = 'Jarduera sortuko da eta eXeLearning editore txertatuarekin editatu ahal izango duzu jardueraren ikuspegi-orritik.'; +$string['saving'] = 'Gordetzen...'; +$string['savedsuccess'] = 'Aldaketak ondo gorde dira'; +$string['savetomoodle'] = 'Moodle-n gorde'; +$string['typeembedded'] = 'Sortu eXeLearning-ekin (editore txertatua)'; + // Deprecated since Moodle 4.0. $string['info'] = 'Informazioa'; $string['displayactivityname'] = 'Erakutsi jardueraren izena'; diff --git a/lang/gl/exescorm.php b/lang/gl/exescorm.php index 992b420..052e71b 100644 --- a/lang/gl/exescorm.php +++ b/lang/gl/exescorm.php @@ -491,6 +491,23 @@ $string['window'] = 'Xanela'; $string['youmustselectastatus'] = 'Debe seleccionar un estado que será requirido'; +// Embedded editor strings. +$string['embeddededitorsettings'] = 'Tipo de editor'; +$string['editormode'] = 'Modo de editor'; +$string['editormodedesc'] = 'Seleccione que editor usar para crear e editar contido eXeLearning. A configuración de conexión online só aplica cando se selecciona o modo "eXeLearning Online".'; +$string['editormodeonline'] = 'eXeLearning Online (servidor remoto)'; +$string['editormodeembedded'] = 'Editor integrado (embebido)'; +$string['embeddednotinstalled'] = 'Os ficheiros do editor integrado non están instalados. Execute "make build-editor" para xeralos.'; +$string['editembedded'] = 'Editar con eXeLearning'; +$string['editembedded_integrated'] = 'Integrado'; +$string['editembedded_help'] = 'Abre o editor eXeLearning integrado para editar o contido directamente dentro de Moodle.'; +$string['editormissing'] = 'O editor integrado eXeLearning non está instalado. Contacte co administrador.'; +$string['embeddedtypehelp'] = 'Crearase a actividade e poderá editala usando o editor eXeLearning integrado dende a páxina de visualización da actividade.'; +$string['saving'] = 'Gardando...'; +$string['savedsuccess'] = 'Cambios gardados correctamente'; +$string['savetomoodle'] = 'Gardar en Moodle'; +$string['typeembedded'] = 'Crear con eXeLearning (editor integrado)'; + // Deprecated since Moodle 4.0. $string['info'] = 'Info'; $string['displayactivityname'] = 'Amosar o nome da actividade'; diff --git a/lib.php b/lib.php index 7b11844..a8d26a8 100644 --- a/lib.php +++ b/lib.php @@ -31,6 +31,8 @@ define('EXESCORM_TYPE_EXTERNAL', 'external'); /** EXESCORM_TYPE_AICCURL = external AICC url */ define('EXESCORM_TYPE_AICCURL', 'aiccurl'); +/** EXESCORM_TYPE_EMBEDDED = embedded static editor */ +define('EXESCORM_TYPE_EMBEDDED', 'embedded'); define('EXESCORM_TOC_SIDE', 0); define('EXESCORM_TOC_HIDDEN', 1); @@ -136,8 +138,11 @@ function exescorm_add_instance($exescorm, $mform=null) { // Reload exescorm instance. $record = $DB->get_record('exescorm', array('id' => $id)); - if ($exescorm->exescormtype === EXESCORM_TYPE_EXESCORMNET) { - $record->exescormtype = EXESCORM_TYPE_LOCAL; + if ($exescorm->exescormtype === EXESCORM_TYPE_EXESCORMNET + || $exescorm->exescormtype === EXESCORM_TYPE_EMBEDDED) { + if ($exescorm->exescormtype === EXESCORM_TYPE_EXESCORMNET) { + $record->exescormtype = EXESCORM_TYPE_LOCAL; + } $fs = get_file_storage(); $templatename = get_config('exescorm', 'template'); @@ -259,7 +264,9 @@ function exescorm_update_instance($exescorm, $mform=null) { $exescorm->exescormtype = EXESCORM_TYPE_LOCAL; } - if ($exescorm->exescormtype === EXESCORM_TYPE_LOCAL) { + if ($exescorm->exescormtype === EXESCORM_TYPE_EMBEDDED) { + // Embedded editor saves are handled via editor/save.php, nothing to do here. + } else if ($exescorm->exescormtype === EXESCORM_TYPE_LOCAL) { if (!empty($exescorm->packagefile)) { $fs = get_file_storage(); $fs->delete_area_files($context->id, 'mod_exescorm', 'package'); @@ -1020,7 +1027,12 @@ function exescorm_pluginfile($course, $cm, $context, $filearea, $args, $forcedow } $revision = (int)array_shift($args); // Prevents caching problems - ignored here. $relativepath = implode('/', $args); - $fullpath = "/$context->id/mod_exescorm/package/0/$relativepath"; + // Try with revision first (used by embedded editor), fallback to itemid=0. + $fullpath = "/$context->id/mod_exescorm/package/$revision/$relativepath"; + $fs = get_file_storage(); + if (!$fs->get_file_by_hash(sha1($fullpath))) { + $fullpath = "/$context->id/mod_exescorm/package/0/$relativepath"; + } $lifetime = 0; // No caching here. } else if ($filearea === 'imsmanifest') { // This isn't a real filearea, it's a url parameter for this type of package. @@ -1202,7 +1214,8 @@ function exescorm_version_check($exescormversion, $version='') { */ function exescorm_dndupload_register() { return array('files' => array( - array('extension' => 'zip', 'message' => get_string('dnduploadexescorm', 'mod_exescorm')) + array('extension' => 'zip', 'message' => get_string('dnduploadexescorm', 'mod_exescorm')), + array('extension' => 'elpx', 'message' => get_string('dnduploadexescorm', 'mod_exescorm')), )); } @@ -1881,6 +1894,58 @@ function mod_exescorm_core_calendar_get_event_action_string(string $eventtype): return get_string($identifier, 'mod_exescorm', $modulename); } +/** + * Check if the embedded static editor is available. + * + * Checks both the admin editor mode setting and the existence of the editor files. + * + * @return bool True if the editor mode is 'embedded' and dist/static/index.html exists. + */ +function exescorm_embedded_editor_available() { + global $CFG; + $mode = get_config('exescorm', 'editormode'); + if ($mode === false) { + $mode = 'online'; + } + return ($mode === 'embedded') && file_exists($CFG->dirroot . '/mod/exescorm/dist/static/index.html'); +} + +/** + * Check if the online eXeLearning editor is available. + * + * Checks that the editor mode is not 'embedded' and that the online base URI is configured. + * + * @return bool True if online editor mode is active and base URI is configured. + */ +function exescorm_online_editor_available() { + $mode = get_config('exescorm', 'editormode'); + if ($mode === false) { + $mode = 'online'; + } + return ($mode !== 'embedded') && !empty(get_config('exescorm', 'exeonlinebaseuri')); +} + +/** + * Get the URL for the package file of an exescorm instance. + * + * @param stdClass $exescorm The exescorm record. + * @param context_module $context The module context. + * @return moodle_url|null The URL to the package file, or null if not found. + */ +function exescorm_get_package_url($exescorm, $context) { + $fs = get_file_storage(); + $files = $fs->get_area_files($context->id, 'mod_exescorm', 'package', false, 'sortorder DESC, id ASC', false); + $package = reset($files); + if (!$package) { + return null; + } + $revision = isset($exescorm->revision) ? $exescorm->revision : 0; + return moodle_url::make_pluginfile_url( + $context->id, 'mod_exescorm', 'package', $revision, + $package->get_filepath(), $package->get_filename() + ); +} + /** * This function extends the settings navigation block for the site. * diff --git a/locallib.php b/locallib.php index 0ee0536..d0b3075 100644 --- a/locallib.php +++ b/locallib.php @@ -231,13 +231,14 @@ function exescorm_parse($exescorm, $full) { $context = context_module::instance($exescorm->cmid); $newhash = $exescorm->sha1hash; - if ($exescorm->exescormtype === EXESCORM_TYPE_LOCAL || $exescorm->exescormtype === EXESCORM_TYPE_LOCALSYNC) { + if ($exescorm->exescormtype === EXESCORM_TYPE_LOCAL || $exescorm->exescormtype === EXESCORM_TYPE_LOCALSYNC + || $exescorm->exescormtype === EXESCORM_TYPE_EMBEDDED) { $fs = get_file_storage(); $packagefile = false; $packagefileimsmanifest = false; - if ($exescorm->exescormtype === EXESCORM_TYPE_LOCAL) { + if ($exescorm->exescormtype === EXESCORM_TYPE_LOCAL || $exescorm->exescormtype === EXESCORM_TYPE_EMBEDDED) { if ($packagefile = $fs->get_file($context->id, 'mod_exescorm', 'package', 0, '/', $exescorm->reference)) { if ($packagefile->is_external_file()) { // Get zip file so we can check it is correct. $packagefile->import_external_file_contents(); diff --git a/mod_form.php b/mod_form.php index eaf3a10..eb11c97 100644 --- a/mod_form.php +++ b/mod_form.php @@ -66,6 +66,15 @@ public function definition() { $defaulttype = EXESCORM_TYPE_EXESCORMNET; } } + if (exescorm_embedded_editor_available()) { + if ($editmode) { + $exescormtypes[EXESCORM_TYPE_EMBEDDED] = get_string('typeexescormedit', 'mod_exescorm'); + } else { + $exescormtypes[EXESCORM_TYPE_EMBEDDED] = get_string('typeembedded', 'mod_exescorm'); + $defaulttype = EXESCORM_TYPE_EMBEDDED; + } + } + if ($cfgexescorm->allowtypeexternal) { $exescormtypes[EXESCORM_TYPE_EXTERNAL] = get_string('typeexternal', 'mod_exescorm'); } @@ -80,6 +89,7 @@ public function definition() { $nonfilepickertypes = [ EXESCORM_TYPE_EXESCORMNET, + EXESCORM_TYPE_EMBEDDED, ]; // Reference. $mform->addElement('select', 'exescormtype', get_string('exescormtype', 'mod_exescorm'), $exescormtypes); @@ -98,9 +108,17 @@ public function definition() { $group[] =& $staticelement; $mform->addGroup($group, 'typehelpgroup', '', ' ', false); $mform->hideIf('typehelpgroup', 'exescormtype', 'noteq', EXESCORM_TYPE_EXESCORMNET); + // Embedded editor help text. + $embeddedgroup = []; + $embeddedelement = $mform->createElement('static', 'embeddedtypehelp', '', + get_string('embeddedtypehelp', 'mod_exescorm')); + $embeddedelement->updateAttributes(['class' => 'font-weight-bold']); + $embeddedgroup[] =& $embeddedelement; + $mform->addGroup($embeddedgroup, 'embeddedtypehelpgroup', '', ' ', false); + $mform->hideIf('embeddedtypehelpgroup', 'exescormtype', 'noteq', EXESCORM_TYPE_EMBEDDED); // New local package upload. $filemanageroptions = array(); - $filemanageroptions['accepted_types'] = array('.zip', '.xml'); + $filemanageroptions['accepted_types'] = array('.zip', '.xml', '.elpx'); $filemanageroptions['maxbytes'] = 0; $filemanageroptions['maxfiles'] = 1; $filemanageroptions['subdirs'] = 0; @@ -304,6 +322,9 @@ public function definition() { $this->add_edit_online_buttons('editonlinearr'); $mform->hideIf('editonlinearr', 'exescormtype', 'noteq', EXESCORM_TYPE_EXESCORMNET); + + // Hide updatefreq for embedded type. + $mform->hideIf('updatefreq', 'exescormtype', 'eq', EXESCORM_TYPE_EMBEDDED); } /** @@ -469,6 +490,8 @@ public function validation($data, $files) { // Make sure updatefreq is not set if using normal local file, as exescormnet received file will be local. $errors['updatefreq'] = get_string('updatefreq_error', 'mod_exescorm'); } + } else if ($type === EXESCORM_TYPE_EMBEDDED) { + // Embedded editor handles everything via editor/save.php, no validation needed here. } else if ($type === EXESCORM_TYPE_EXTERNAL) { $reference = $data['packageurl']; // Syntax check. diff --git a/player.php b/player.php index 804a48d..2942cf1 100644 --- a/player.php +++ b/player.php @@ -314,6 +314,7 @@ \core\session\manager::keepalive('networkdropped', 'mod_exescorm', 30, 10); $PAGE->requires->js_call_amd('mod_exescorm/fullscreen', 'init'); +$PAGE->requires->js_call_amd('mod_exescorm/editor_modal', 'init'); echo $OUTPUT->footer(); diff --git a/renderer.php b/renderer.php index fa990c0..650e9a7 100644 --- a/renderer.php +++ b/renderer.php @@ -118,13 +118,17 @@ public function generate_editexitbar(string $url, \stdClass $cm): string { $context['hasgrades'] = exescorm_get_user_grades($exescorm, 0); $capability = has_capability('moodle/course:update', context_course::instance($cm->course)); - if ($capability && get_config('exescorm', 'exeonlinebaseuri')) { - $returnto = new moodle_url("/mod/exescorm/view.php", ['id' => $cm->id, 'forceview' => 1]); - $exeonlineurl = get_config('exescorm', 'exeonlinebaseuri'); - if (empty($exeonlineurl)) { - $context['editaction'] = false; - } else { + if ($capability) { + if (exescorm_online_editor_available()) { + $returnto = new moodle_url("/mod/exescorm/view.php", ['id' => $cm->id, 'forceview' => 1]); $context['editaction'] = exescorm_redirector::get_redirection_url($cm->id, $returnto)->out(false); + } else if (exescorm_embedded_editor_available()) { + $context['editorurl'] = (new moodle_url('/mod/exescorm/editor/index.php', [ + 'id' => $cm->id, + 'sesskey' => sesskey(), + ]))->out(false); + $context['cmid'] = $cm->id; + $context['activityname'] = $exescorm ? format_string($exescorm->name) : ''; } } return $this->render_from_template('mod_exescorm/player_editexitbar', $context); diff --git a/settings.php b/settings.php index a15d144..de5373a 100644 --- a/settings.php +++ b/settings.php @@ -21,9 +21,48 @@ $yesno = [0 => get_string('no'), 1 => get_string('yes')]; + // Embedded editor settings. + $editoravailable = file_exists($CFG->dirroot . '/mod/exescorm/dist/static/index.html'); + $settings->add(new admin_setting_heading('exescorm/embeddededitorsettings', + get_string('embeddededitorsettings', 'mod_exescorm'), '')); + + $editormodedesc = get_string('editormodedesc', 'mod_exescorm'); + if (!$editoravailable) { + $editormodedesc .= '
' . get_string('embeddednotinstalled', 'mod_exescorm') . ''; + } + + $editormodes = [ + 'online' => get_string('editormodeonline', 'mod_exescorm'), + 'embedded' => get_string('editormodeembedded', 'mod_exescorm'), + ]; + $settings->add(new admin_setting_configselect('exescorm/editormode', + get_string('editormode', 'mod_exescorm'), $editormodedesc, + 'online', $editormodes)); + + // JavaScript to toggle connection settings visibility based on editor mode. + $connectionsettingsdesc = ''; + // Connection settings. $settings->add(new admin_setting_heading('exescorm/connectionsettings', - get_string('exeonline:connectionsettings', 'mod_exescorm'), '')); + get_string('exeonline:connectionsettings', 'mod_exescorm'), $connectionsettingsdesc)); $settings->add(new admin_setting_configtext('exescorm/exeonlinebaseuri', get_string('exeonline:baseuri', 'mod_exescorm'), diff --git a/styles.css b/styles.css index 9e7e904..bf1a0dd 100644 --- a/styles.css +++ b/styles.css @@ -521,3 +521,46 @@ right: 145px; } +/* Embedded editor modal overlay */ +.exescorm-editor-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 9999; + background: #fff; + display: flex; + flex-direction: column; +} + +.exescorm-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: #f8f9fa; + border-bottom: 1px solid #dee2e6; + flex-shrink: 0; +} + +.exescorm-editor-title { + font-weight: bold; + font-size: 1.1em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.exescorm-editor-buttons { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.exescorm-editor-iframe { + flex: 1; + border: none; + width: 100%; +} + diff --git a/templates/player_editexitbar.mustache b/templates/player_editexitbar.mustache index 1e20305..8fc861c 100644 --- a/templates/player_editexitbar.mustache +++ b/templates/player_editexitbar.mustache @@ -12,23 +12,37 @@ along with Moodle. If not, see . }} {{! - @template mod_exescorm/player_exitbar - Actions bar for the user reports page UI. + @template mod_exescorm/player_editexitbar + Actions bar for the player page UI. Classes required for JS: * none Data attributes required for JS: * none Context variables required for this template: - * action + * returnaction - URL for exit button + * editaction - URL for eXeLearning Online edit (optional) + * editorurl - URL for embedded editor (optional) + * cmid - Course module ID (for embedded editor) + * activityname - Activity name (for embedded editor) + * hasgrades - Whether activity has grades Example context (json): { - "action": "http://localhost/moodle/mod/exescorm/report.php?id=70&mode=interactions" + "returnaction": "http://localhost/moodle/course/view.php?id=2" } }}
{{#str}}exitactivity, mod_exescorm{{/str}} + {{#editorurl}} + + {{/editorurl}} {{#editaction}} {{#hasgrades}}