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..d62104c --- /dev/null +++ b/.distignore @@ -0,0 +1,20 @@ +.git +.github +.gitignore +.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..5b7515e --- /dev/null +++ b/.env.dist @@ -0,0 +1,24 @@ +# 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 +EXELEARNING_EDITOR_REPO_URL=https://github.com/exelearning/exelearning.git +EXELEARNING_EDITOR_DEFAULT_BRANCH=main +EXELEARNING_EDITOR_REF= +EXELEARNING_EDITOR_REF_TYPE=auto + +# 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..4ebdcb4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +--- +name: Release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + release_tag: + description: "Release label for package name (e.g. 1.2.3 or 1.2.3-beta)" + required: false + default: "" + editor_repo_url: + description: "Editor source repository URL" + required: false + default: "https://github.com/exelearning/exelearning.git" + editor_ref: + description: "Editor ref value (main, branch name, or tag)" + required: false + default: "main" + editor_ref_type: + description: "Type of editor ref" + required: false + default: "auto" + type: choice + options: + - auto + - branch + - tag + +permissions: + contents: write + +jobs: + build_and_upload: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Set environment variables + run: | + if [ "${{ github.event_name }}" = "release" ]; then + RAW_TAG="${GITHUB_REF##*/}" + VERSION_TAG="${RAW_TAG#v}" + echo "RELEASE_TAG=${VERSION_TAG}" >> $GITHUB_ENV + echo "EXELEARNING_EDITOR_REPO_URL=https://github.com/exelearning/exelearning.git" >> $GITHUB_ENV + echo "EXELEARNING_EDITOR_REF=main" >> $GITHUB_ENV + echo "EXELEARNING_EDITOR_REF_TYPE=branch" >> $GITHUB_ENV + else + INPUT_RELEASE="${{ github.event.inputs.release_tag }}" + if [ -z "$INPUT_RELEASE" ]; then + INPUT_RELEASE="manual-$(date +%Y%m%d)-${GITHUB_SHA::7}" + fi + echo "RELEASE_TAG=${INPUT_RELEASE}" >> $GITHUB_ENV + echo "EXELEARNING_EDITOR_REPO_URL=${{ github.event.inputs.editor_repo_url }}" >> $GITHUB_ENV + echo "EXELEARNING_EDITOR_REF=${{ github.event.inputs.editor_ref }}" >> $GITHUB_ENV + echo "EXELEARNING_EDITOR_REF_TYPE=${{ github.event.inputs.editor_ref_type }}" >> $GITHUB_ENV + fi + + - name: Build static editor + run: make build-editor + + - name: Create package + run: make package RELEASE=${RELEASE_TAG} + + - name: Upload ZIP as workflow artifact + uses: actions/upload-artifact@v4 + with: + name: mod_exescorm-${{ env.RELEASE_TAG }} + path: mod_exescorm-${{ env.RELEASE_TAG }}.zip + + - name: Upload ZIP to release + if: github.event_name == '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..c289286 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.aider* +.env + +# mess detector rules +phpmd-rules.xml + +# Composer ignores +/vendor/ +/composer.lock +/composer.phar + +# Built static editor files +dist/static/ + +# Local editor checkout fetched during build +exelearning/ diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..0fa5391 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,35 @@ +# Development Guide + +This document covers the development setup for the eXeLearning SCORM plugin. + +> **Important:** Do not install this plugin by cloning the repository directly. The repository does not include the embedded editor, which is built during the release process. Always install from a [release ZIP](https://github.com/exelearning/mod_exescorm/releases). + +## Development using Makefile + +To facilitate development, a `Makefile` is included to simplify Docker-based workflows. + +### Environment Variables + +You can configure various settings using the `.env` file. If this file does not exist, it will be automatically generated by copying from `.env.dist`. Key variables to configure: + +- `EXELEARNING_WEB_SOURCECODE_PATH`: Define the path to the eXeLearning source code if you want to work with a local version. +- `APP_PORT`: Define the port on which the application will run. +- `APP_SECRET`: Set a secret key for the application. +- `EXELEARNING_EDITOR_REPO_URL`: Repository used to fetch embedded editor source code. +- `EXELEARNING_EDITOR_DEFAULT_BRANCH`: Fallback branch when no specific ref is defined. +- `EXELEARNING_EDITOR_REF`: Specific branch or tag to build from (if empty, fallback to default branch). +- `EXELEARNING_EDITOR_REF_TYPE`: `auto`, `branch`, or `tag` to resolve `EXELEARNING_EDITOR_REF`. + +## Embedded Editor Source Strategy + +The embedded static editor is no longer tied to a git submodule. During `make build-editor` the source is fetched as a shallow checkout from `EXELEARNING_EDITOR_REPO_URL` using: + +- `EXELEARNING_EDITOR_REF_TYPE=branch` to force branch mode. +- `EXELEARNING_EDITOR_REF_TYPE=tag` to force tag mode. +- `EXELEARNING_EDITOR_REF_TYPE=auto` to try tag first, then branch. + +This keeps the plugin repo lighter and lets CI/manual builds choose `main`, a specific tag, or a feature branch without submodule maintenance. + +## Manual Plugin Build in GitHub Actions + +The `Release` workflow now supports manual execution (`workflow_dispatch`) from the `main` branch. It accepts editor source parameters (`editor_repo_url`, `editor_ref`, `editor_ref_type`) and a `release_tag` for the generated ZIP name. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9ca6227 --- /dev/null +++ b/Makefile @@ -0,0 +1,240 @@ +# 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 +EDITOR_REPO_DEFAULT = https://github.com/exelearning/exelearning.git +EDITOR_REF_DEFAULT = main + +# 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) + +# Fetch editor source code from remote repository (branch/tag, shallow clone) +fetch-editor-source: + @set -e; \ + get_env() { \ + if [ -f .env ]; then \ + grep -E "^$$1=" .env | tail -n1 | cut -d '=' -f2-; \ + fi; \ + }; \ + REPO_URL="$${EXELEARNING_EDITOR_REPO_URL:-$$(get_env EXELEARNING_EDITOR_REPO_URL)}"; \ + REF="$${EXELEARNING_EDITOR_REF:-$$(get_env EXELEARNING_EDITOR_REF)}"; \ + REF_TYPE="$${EXELEARNING_EDITOR_REF_TYPE:-$$(get_env EXELEARNING_EDITOR_REF_TYPE)}"; \ + if [ -z "$$REPO_URL" ]; then REPO_URL="$(EDITOR_REPO_DEFAULT)"; fi; \ + if [ -z "$$REF" ]; then REF="$${EXELEARNING_EDITOR_DEFAULT_BRANCH:-$$(get_env EXELEARNING_EDITOR_DEFAULT_BRANCH)}"; fi; \ + if [ -z "$$REF" ]; then REF="$(EDITOR_REF_DEFAULT)"; fi; \ + if [ -z "$$REF_TYPE" ]; then REF_TYPE="auto"; fi; \ + echo "Fetching editor source from $$REPO_URL (ref=$$REF, type=$$REF_TYPE)"; \ + rm -rf $(EDITOR_SUBMODULE_PATH); \ + git init -q $(EDITOR_SUBMODULE_PATH); \ + git -C $(EDITOR_SUBMODULE_PATH) remote add origin "$$REPO_URL"; \ + case "$$REF_TYPE" in \ + tag) \ + git -C $(EDITOR_SUBMODULE_PATH) fetch --depth 1 origin "refs/tags/$$REF:refs/tags/$$REF"; \ + git -C $(EDITOR_SUBMODULE_PATH) checkout -q "tags/$$REF"; \ + ;; \ + branch) \ + git -C $(EDITOR_SUBMODULE_PATH) fetch --depth 1 origin "$$REF"; \ + git -C $(EDITOR_SUBMODULE_PATH) checkout -q FETCH_HEAD; \ + ;; \ + auto) \ + if git -C $(EDITOR_SUBMODULE_PATH) fetch --depth 1 origin "refs/tags/$$REF:refs/tags/$$REF" > /dev/null 2>&1; then \ + echo "Resolved $$REF as tag"; \ + git -C $(EDITOR_SUBMODULE_PATH) checkout -q "tags/$$REF"; \ + else \ + echo "Resolved $$REF as branch"; \ + git -C $(EDITOR_SUBMODULE_PATH) fetch --depth 1 origin "$$REF"; \ + git -C $(EDITOR_SUBMODULE_PATH) checkout -q FETCH_HEAD; \ + fi; \ + ;; \ + *) \ + echo "Error: EXELEARNING_EDITOR_REF_TYPE must be one of: auto, branch, tag"; \ + exit 1; \ + ;; \ + esac + +# Build static editor to dist/static/ +build-editor: check-bun fetch-editor-source + 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)/ + +# Backward-compatible alias +build-editor-no-update: build-editor + +# Remove build artifacts +clean-editor: + rm -rf $(EDITOR_DIST_PATH) + +# ------------------------------------------------------- +# Packaging +# ------------------------------------------------------- + +PLUGIN_NAME = mod_exescorm + + +# Create a distributable ZIP package +# Usage: make package RELEASE=0.0.2 +# VERSION (YYYYMMDDXX) is auto-generated from current date +package: + @if [ -z "$(RELEASE)" ]; then \ + echo "Error: RELEASE not specified. Use 'make package RELEASE=0.0.2'"; \ + exit 1; \ + fi + $(eval DATE_VERSION := $(shell date +%Y%m%d)00) + @echo "Packaging release $(RELEASE) (version $(DATE_VERSION))..." + $(SED_INPLACE) "s/\(plugin->version[[:space:]]*=[[:space:]]*\)[0-9]*/\1$(DATE_VERSION)/" version.php + $(SED_INPLACE) "s/\(plugin->release[[:space:]]*=[[:space:]]*'\)[^']*/\1$(RELEASE)/" version.php + @echo "Creating ZIP archive: $(PLUGIN_NAME)-$(RELEASE).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)-$(RELEASE).zip" exescorm + rm -rf /tmp/exescorm-package + @echo "Restoring development values in version.php..." + $(SED_INPLACE) "s/\(plugin->version[[:space:]]*=[[:space:]]*\)[0-9]*/\19999999999/" version.php + $(SED_INPLACE) "s/\(plugin->release[[:space:]]*=[[:space:]]*'\)[^']*/\1dev/" version.php + @echo "Package created: $(PLUGIN_NAME)-$(RELEASE).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 - Alias of build-editor" + @echo " clean-editor - Remove editor build artifacts" + @echo " fetch-editor-source - Download editor source from configured repo/ref" + @echo " package - Create distributable ZIP (RELEASE=X.Y.Z 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/README.md b/README.md index 9d7aafc..7926cfa 100644 --- a/README.md +++ b/README.md @@ -13,22 +13,25 @@ This plugin version is tested for: * Moodle 3.11.10+ (Build: 20221007) * Moodle 3.9.2+ (Build: 20200929) -## Installing via uploaded ZIP file ## +## Installation -1. Log in to your Moodle site as an admin and go to _Site administration > - Plugins > Install plugins_. -2. Upload the ZIP file with the plugin code. You should only be prompted to add - extra details if your plugin type is not automatically detected. -3. Check the plugin validation report and finish the installation. +> **Important:** Always install from a [release ZIP](https://github.com/exelearning/mod_exescorm/releases). Do not clone the repository directly, as it does not include the embedded editor which is built during the release process. -## Installing manually ## +### Installing via uploaded ZIP file -The plugin can be also installed by putting the contents of this directory to +1. Download the latest ZIP from [Releases](https://github.com/exelearning/mod_exescorm/releases). +2. Log in to your Moodle site as an admin and go to _Site administration > + Plugins > Install plugins_. +3. Upload the ZIP file with the plugin code. You should only be prompted to add + extra details if your plugin type is not automatically detected. +4. Check the plugin validation report and finish the installation. - {your/moodle/dirroot}/mod/exescorm +### Installing manually -Afterwards, log in to your Moodle site as an admin and go to _Site administration > -Notifications_ to complete the installation. +1. Download and extract the latest ZIP from [Releases](https://github.com/exelearning/mod_exescorm/releases). +2. Place the extracted contents in `{your/moodle/dirroot}/mod/exescorm`. +3. Log in to your Moodle site as an admin and go to _Site administration > + Notifications_ to complete the installation. Alternatively, you can run @@ -61,9 +64,12 @@ Go to the URL: * A mandatory files list can be configurad here. Enter each mandatory file as a PHP regular expression (RE) on a new line. * Forbidden files RE list: *exescorm | forbiddenfileslist* - * A forbidden files list can be configurad here. Enter each forbidden file as a PHP regular expression (RE) on a new line. +## Development + +For development setup, build instructions, and contributing guidelines, see [DEVELOPMENT.md](DEVELOPMENT.md). + ## About Copyright 2023: diff --git a/amd/build/editor_modal.min.js b/amd/build/editor_modal.min.js new file mode 100644 index 0000000..def0e77 --- /dev/null +++ b/amd/build/editor_modal.min.js @@ -0,0 +1 @@ +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"])},refreshScormPlayerIframe=revision=>{const playerFrame=document.getElementById("exescorm_object")||document.getElementById("main")||document.querySelector('iframe.scoframe[name="main"]')||document.getElementById("exewebobject");if(!playerFrame||!playerFrame.src)return;const cacheValue=revision?String(revision):String(Date.now());try{const url=new URL(playerFrame.src,window.location.href);url.searchParams.set("exescormrev",cacheValue+"-"+Date.now()),playerFrame.src=url.toString()}catch(e){const separator=playerFrame.src.includes("?")?"&":"?";playerFrame.src=`${playerFrame.src}${separator}exescormrev=${cacheValue}-${Date.now()}`}},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()}),400)})).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");break;default:break}},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))}))}})); 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..144a788 --- /dev/null +++ b/amd/src/editor_modal.js @@ -0,0 +1,241 @@ +// 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; + setTimeout(() => { + close(); + window.location.reload(); + }, 400); + 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; + + default: + 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..021983f --- /dev/null +++ b/amd/src/moodle_exe_bridge.js @@ -0,0 +1,336 @@ +/** + * Bridge between embedded eXeLearning and Moodle save endpoint. + * + * This script does not access editor internals. It talks to eXe exclusively + * through EmbeddingBridge postMessage protocol (OPEN_FILE / REQUEST_EXPORT). + * + * @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] Missing __MOODLE_EXE_CONFIG__'); + return; + } + + var embeddingConfig = window.__EXE_EMBEDDING_CONFIG__ || {}; + var hasInitialProjectUrl = !!embeddingConfig.initialProjectUrl; + + var editorWindow = window; + var parentWindow = window.parent && window.parent !== window ? window.parent : null; + var state = { + ready: false, + importing: false, + imported: hasInitialProjectUrl, + saving: false, + }; + + var pendingRequests = Object.create(null); + + function createRequestId(prefix) { + return (prefix || 'req') + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10); + } + + function updateLoadScreen(message, visible) { + if (visible === undefined) { + visible = true; + } + + var loadScreen = document.getElementById('load-screen-main'); + if (!loadScreen) { + return; + } + + var loadMessage = loadScreen.querySelector('.loading-message, p'); + if (loadMessage && message) { + loadMessage.textContent = message; + } + + if (visible) { + loadScreen.classList.remove('hide'); + } else { + loadScreen.classList.add('hide'); + } + } + + function notifyParent(type, data) { + if (!parentWindow) { + return; + } + + parentWindow.postMessage({ + source: 'exescorm-editor', + type: type, + data: data || {}, + }, '*'); + } + + function postToEditor(type, data, transfer, timeoutMs) { + if (!type) { + return Promise.reject(new Error('Missing message type')); + } + + var requestId = createRequestId(type.toLowerCase()); + + return new Promise(function(resolve, reject) { + var timer = setTimeout(function() { + delete pendingRequests[requestId]; + reject(new Error(type + ' timed out')); + }, timeoutMs || 30000); + + pendingRequests[requestId] = { + resolve: resolve, + reject: reject, + timer: timer, + requestType: type, + }; + + try { + if (transfer && transfer.length) { + editorWindow.postMessage({type: type, requestId: requestId, data: data || {}}, window.location.origin, transfer); + } else { + editorWindow.postMessage({type: type, requestId: requestId, data: data || {}}, window.location.origin); + } + } catch (error) { + clearTimeout(timer); + delete pendingRequests[requestId]; + reject(error); + } + }); + } + + function settleRequest(requestId, error, payload) { + var pending = pendingRequests[requestId]; + if (!pending) { + return false; + } + + clearTimeout(pending.timer); + delete pendingRequests[requestId]; + + if (error) { + pending.reject(error instanceof Error ? error : new Error(String(error))); + } else { + pending.resolve(payload || {}); + } + + return true; + } + + function getFilenameFromUrl(url) { + if (!url) { + return 'project.elpx'; + } + + var clean = url.split('?')[0] || ''; + var parts = clean.split('/'); + return parts[parts.length - 1] || 'project.elpx'; + } + + async function importPackageFromMoodle() { + if (!config.packageUrl || state.importing || state.imported) { + return; + } + + state.importing = true; + + try { + updateLoadScreen('Downloading project...', true); + + var response = await fetch(config.packageUrl, {credentials: 'include'}); + if (!response.ok) { + throw new Error('Could not download package (HTTP ' + response.status + ')'); + } + + var bytes = await response.arrayBuffer(); + var filename = getFilenameFromUrl(config.packageUrl); + + updateLoadScreen('Opening project...', true); + + await postToEditor('OPEN_FILE', { + bytes: bytes, + filename: filename, + }, [bytes], 60000); + + state.imported = true; + console.log('[moodle-exe-bridge] Package opened:', filename); + } finally { + state.importing = false; + updateLoadScreen('', false); + } + } + + async function uploadExportToMoodle(bytes, filename) { + if (!bytes || !bytes.byteLength) { + throw new Error('Export is empty'); + } + + var uploadName = filename || 'package.zip'; + var blob = bytes instanceof Blob ? bytes : new Blob([bytes], {type: 'application/zip'}); + + var formData = new FormData(); + formData.append('package', blob, uploadName); + formData.append('cmid', String(config.cmid)); + formData.append('sesskey', config.sesskey); + + var response = await fetch(config.saveUrl, { + method: 'POST', + credentials: 'include', + body: formData, + }); + + var result; + try { + result = await response.json(); + } catch (jsonError) { + throw new Error('Invalid save response from Moodle'); + } + + if (!response.ok || !result || !result.success) { + throw new Error((result && result.error) ? result.error : ('Save failed (HTTP ' + response.status + ')')); + } + + return result; + } + + async function saveToMoodle() { + if (state.saving) { + return; + } + + state.saving = true; + notifyParent('save-start'); + + try { + var exportResponse = await postToEditor('REQUEST_EXPORT', { + format: 'scorm12', + filename: 'package.zip', + }, null, 120000); + + var bytes = exportResponse.bytes; + if (!bytes && exportResponse.blob) { + bytes = await exportResponse.blob.arrayBuffer(); + } + + var saveResult = await uploadExportToMoodle(bytes, exportResponse.filename || 'package.zip'); + + notifyParent('save-complete', { + revision: saveResult.revision, + }); + } catch (error) { + console.error('[moodle-exe-bridge] Save failed:', error); + notifyParent('save-error', { + error: error.message || 'Unknown error', + }); + } finally { + state.saving = false; + } + } + + async function maybeImport() { + if (hasInitialProjectUrl) { + // Fast-path: eXe bootstraps initial package via __EXE_EMBEDDING_CONFIG__.initialProjectUrl. + state.imported = true; + return; + } + if (!state.ready || state.imported || state.importing) { + return; + } + + try { + await importPackageFromMoodle(); + } catch (error) { + console.error('[moodle-exe-bridge] Import failed:', error); + notifyParent('save-error', {error: 'Import failed: ' + (error.message || 'Unknown error')}); + } + } + + function handleProtocolMessage(message) { + if (!message || !message.requestId || !message.type) { + return; + } + + if (message.type === 'OPEN_FILE_SUCCESS' || message.type === 'SAVE_FILE' || message.type === 'EXPORT_FILE' || message.type === 'PROJECT_INFO' + || message.type === 'STATE' || message.type === 'CONFIGURE_SUCCESS' || message.type === 'SET_TRUSTED_ORIGINS_SUCCESS') { + settleRequest(message.requestId, null, message); + return; + } + + if (message.type.endsWith('_ERROR')) { + settleRequest(message.requestId, message.error || (message.type + ' failed')); + } + } + + function handleParentMessage(event) { + if (!event || !event.data) { + return; + } + + var message = event.data; + + if (message.type === 'EXELEARNING_READY') { + state.ready = true; + notifyParent('editor-ready'); + maybeImport(); + return; + } + + handleProtocolMessage(message); + } + + function handleFrameMessage(event) { + if (!event || !event.data) { + return; + } + + var message = event.data; + + if (message.source === 'exescorm-modal' && message.type === 'save') { + saveToMoodle(); + return; + } + + handleProtocolMessage(message); + } + + async function init() { + window.addEventListener('message', handleFrameMessage); + + if (parentWindow && typeof parentWindow.addEventListener === 'function') { + parentWindow.addEventListener('message', handleParentMessage); + } + + // Fallback probe in case EXELEARNING_READY was emitted before listeners attached. + var probeAttempts = 0; + var probe = setInterval(function() { + probeAttempts++; + if (state.ready || probeAttempts > 20) { + clearInterval(probe); + return; + } + + postToEditor('GET_STATE', {}, null, 3000).then(function() { + if (!state.ready) { + state.ready = true; + notifyParent('editor-ready'); + maybeImport(); + } + }).catch(function() { + // Ignore until next probe. + }); + }, 1000); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/backup/moodle2/backup_exescorm_stepslib.php b/backup/moodle2/backup_exescorm_stepslib.php index d7cff6d..c2b0a26 100644 --- a/backup/moodle2/backup_exescorm_stepslib.php +++ b/backup/moodle2/backup_exescorm_stepslib.php @@ -40,7 +40,8 @@ protected function define_structure() { 'name', 'exescormtype', 'reference', 'intro', 'introformat', 'version', 'maxgrade', 'grademethod', 'whatgrade', 'maxattempt', 'forcecompleted', 'forcenewattempt', - 'lastattemptlock', 'masteryoverride', 'displayattemptstatus', 'displaycoursestructure', 'updatefreq', + 'lastattemptlock', 'masteryoverride', 'displayattemptstatus', 'displaycoursestructure', + 'teachermodevisible', 'updatefreq', 'sha1hash', 'md5hash', 'revision', 'launch', 'skipview', 'hidebrowse', 'hidetoc', 'nav', 'navpositionleft', 'navpositiontop', 'auto', 'popup', 'options', 'width', diff --git a/classes/event/attempt_deleted.php b/classes/event/attempt_deleted.php index e225d3c..a944b98 100644 --- a/classes/event/attempt_deleted.php +++ b/classes/event/attempt_deleted.php @@ -81,10 +81,6 @@ public function get_url() { * * @return array of parameters to be passed to legacy add_to_log() function. */ - protected function get_legacy_logdata() { - return array($this->courseid, 'exescorm', 'delete attempts', 'report.php?id=' . $this->contextinstanceid, - $this->other['attemptid'], $this->contextinstanceid); - } /** * Custom validation. diff --git a/classes/event/course_module_viewed.php b/classes/event/course_module_viewed.php index 0411c3b..e9f4881 100644 --- a/classes/event/course_module_viewed.php +++ b/classes/event/course_module_viewed.php @@ -44,17 +44,11 @@ protected function init() { } /** - * Replace add_to_log() statement. + * Object ID mapping for restore. * - * @return array of parameters to be passed to legacy add_to_log() function. + * @return array */ - protected function get_legacy_logdata() { - return array($this->courseid, 'exescorm', 'pre-view', 'view.php?id=' . $this->contextinstanceid, $this->objectid, - $this->contextinstanceid); - } - public static function get_objectid_mapping() { return array('db' => 'exescorm', 'restore' => 'exescorm'); } } - diff --git a/classes/event/interactions_viewed.php b/classes/event/interactions_viewed.php index 82ca7fb..5065001 100644 --- a/classes/event/interactions_viewed.php +++ b/classes/event/interactions_viewed.php @@ -87,11 +87,6 @@ public function get_url() { * * @return array */ - protected function get_legacy_logdata() { - return array($this->courseid, 'exescorm', 'userreportinteractions', 'report/userreportinteractions.php?id=' . - $this->contextinstanceid . '&user=' . $this->relateduserid . '&attempt=' . $this->other['attemptid'], - $this->other['instanceid'], $this->contextinstanceid); - } /** * Custom validation. diff --git a/classes/event/report_viewed.php b/classes/event/report_viewed.php index 0cff4de..20daf46 100644 --- a/classes/event/report_viewed.php +++ b/classes/event/report_viewed.php @@ -82,10 +82,6 @@ public function get_url() { * * @return array of parameters to be passed to legacy add_to_log() function. */ - protected function get_legacy_logdata() { - return array($this->courseid, 'exescorm', 'report', 'report.php?id=' . $this->contextinstanceid . - '&mode=' . $this->other['mode'], $this->other['exescormid'], $this->contextinstanceid); - } /** * Custom validation. diff --git a/classes/event/sco_launched.php b/classes/event/sco_launched.php index 049acbf..c7b93bc 100644 --- a/classes/event/sco_launched.php +++ b/classes/event/sco_launched.php @@ -70,7 +70,7 @@ public static function get_name() { } /** - * Get URL related to the action + * Get URL related to the action. * * @return \moodle_url */ @@ -78,16 +78,6 @@ public function get_url() { return new \moodle_url('/mod/exescorm/player.php', array('cm' => $this->contextinstanceid, 'scoid' => $this->objectid)); } - /** - * Replace add_to_log() statement. - * - * @return array of parameters to be passed to legacy add_to_log() function. - */ - protected function get_legacy_logdata() { - return array($this->courseid, 'exescorm', 'launch', 'view.php?id=' . $this->contextinstanceid, - $this->other['loadedcontent'], $this->contextinstanceid); - } - /** * Custom validation. * @@ -102,10 +92,20 @@ protected function validate_data() { } } + /** + * Object ID mapping for restore. + * + * @return array + */ public static function get_objectid_mapping() { return array('db' => 'exescorm_scoes', 'restore' => 'exescorm_sco'); } + /** + * Other mapping for restore. + * + * @return array + */ public static function get_other_mapping() { $othermapped = array(); $othermapped['instanceid'] = array('db' => 'exescorm', 'restore' => 'exescorm'); diff --git a/classes/event/tracks_viewed.php b/classes/event/tracks_viewed.php index 6939438..c135053 100644 --- a/classes/event/tracks_viewed.php +++ b/classes/event/tracks_viewed.php @@ -89,13 +89,6 @@ public function get_url() { * * @return array */ - protected function get_legacy_logdata() { - return [ - $this->courseid, 'exescorm', 'userreporttracks', 'report/userreporttracks.php?id=' . $this->contextinstanceid - . '&user=' . $this->relateduserid . '&attempt=' . $this->other['attemptid'] . '&scoid=' . $this->other['scoid'] - . '&mode=' . $this->other['mode'], $this->other['instanceid'], $this->contextinstanceid - ]; - } /** * Custom validation. diff --git a/classes/event/user_report_viewed.php b/classes/event/user_report_viewed.php index 5c06259..1dfebbd 100644 --- a/classes/event/user_report_viewed.php +++ b/classes/event/user_report_viewed.php @@ -86,11 +86,6 @@ public function get_url() { * * @return array */ - protected function get_legacy_logdata() { - return array($this->courseid, 'exescorm', 'userreport', 'report/userreport.php?id=' . - $this->contextinstanceid . '&user=' . $this->relateduserid . '&attempt=' . $this->other['attemptid'], - $this->other['instanceid'], $this->contextinstanceid); - } /** * Custom validation. 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/db/install.xml b/db/install.xml index e307b5d..3707056 100644 --- a/db/install.xml +++ b/db/install.xml @@ -24,6 +24,7 @@ + diff --git a/db/upgrade.php b/db/upgrade.php index ba4354f..7fab8ca 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -50,5 +50,17 @@ function xmldb_exescorm_upgrade($oldversion) { // Automatically generated Moodle v4.1.0 release upgrade line. // Put any upgrade step following this. + if ($oldversion < 2026021200) { + $table = new xmldb_table('exescorm'); + $field = new xmldb_field('teachermodevisible', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '1', + 'displaycoursestructure'); + + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + upgrade_mod_savepoint(true, 2026021200, 'exescorm'); + } + return true; } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a13161c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,88 @@ +--- +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..e4db3ad --- /dev/null +++ b/editor/index.php @@ -0,0 +1,118 @@ +. + +/** + * 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'); + +// Serve editor resources through static.php (slash arguments) to ensure +// files are always accessible regardless of web server configuration. +$editorbaseurl = $CFG->wwwroot . '/mod/exescorm/editor/static.php/' . $cm->id; + +// Read the editor template. +$html = @file_get_contents($editorpath); +if ($html === false || empty($html)) { + throw new moodle_exception('editormissing', 'mod_exescorm'); +} + +// Inject tag pointing directly to the static directory. +$basetag = ''; +$html = preg_replace('/(]*>)/i', '$1' . $basetag, $html); + +// Fix explicit "./" relative paths in attributes. +$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], + 'initialProjectUrl' => $packageurl ? $packageurl->out(false) : '', + '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..e02a315 --- /dev/null +++ b/editor/save.php @@ -0,0 +1,107 @@ +. + +/** + * 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 { + if (empty($_FILES['package'])) { + throw new moodle_exception('nofile', 'error'); + } + + $uploadedfile = $_FILES['package']; + if ((int)$uploadedfile['error'] !== UPLOAD_ERR_OK) { + throw new moodle_exception('uploadproblem', 'error'); + } + + if (empty($uploadedfile['tmp_name']) || !is_uploaded_file($uploadedfile['tmp_name'])) { + throw new moodle_exception('uploadproblem', 'error'); + } + + $filename = clean_filename($uploadedfile['name'] ?? 'package.zip'); + if ($filename === '') { + $filename = 'package.zip'; + } + if (core_text::strtolower(pathinfo($filename, PATHINFO_EXTENSION)) !== 'zip') { + throw new moodle_exception('uploadproblem', 'error', '', null, 'Uploaded file must be a ZIP package'); + } + + $fs = get_file_storage(); + $exescorm->timemodified = time(); + + // Overwrite current package. + $fs->delete_area_files($context->id, 'mod_exescorm', 'package'); + + $fileinfo = [ + 'contextid' => $context->id, + 'component' => 'mod_exescorm', + 'filearea' => 'package', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => $filename, + 'userid' => $USER->id, + 'source' => $filename, + 'author' => fullname($USER), + 'license' => 'unknown', + ]; + $fs->create_file_from_pathname($fileinfo, $uploadedfile['tmp_name']); + + // Keep package name in SCORM reference and trigger re-parse. + $exescorm->reference = $filename; + $DB->update_record('exescorm', $exescorm); + exescorm_parse($exescorm, true); + + $updated = $DB->get_record('exescorm', ['id' => $exescorm->id], 'id,timemodified,version', MUST_EXIST); + + echo json_encode([ + 'success' => true, + 'revision' => (int)$updated->timemodified, + 'version' => $updated->version, + ]); +} 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..8b651f6 --- /dev/null +++ b/editor/static.php @@ -0,0 +1,134 @@ +. + +/** + * 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'); + +// Support both slash arguments (PATH_INFO) and query params. +// Slash arguments: /static.php/{cmid}/{filepath} +// Query params: /static.php?id={cmid}&file={filepath} +$pathinfo = !empty($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] + : (!empty($_SERVER['ORIG_PATH_INFO']) ? $_SERVER['ORIG_PATH_INFO'] : ''); + +// Fallback: parse from REQUEST_URI when PATH_INFO is not available. +if (empty($pathinfo) && !empty($_SERVER['REQUEST_URI'])) { + $requesturi = $_SERVER['REQUEST_URI']; + $qpos = strpos($requesturi, '?'); + if ($qpos !== false) { + $requesturi = substr($requesturi, 0, $qpos); + } + $marker = 'static.php/'; + $mpos = strpos($requesturi, $marker); + if ($mpos !== false) { + $pathinfo = '/' . substr($requesturi, $mpos + strlen($marker)); + } +} + +if (!empty($pathinfo)) { + $parts = explode('/', ltrim($pathinfo, '/'), 2); + if (count($parts) < 2 || !is_numeric($parts[0]) || empty($parts[1])) { + send_header_404(); + die('Invalid path'); + } + $id = (int)$parts[0]; + $file = $parts[1]; +} else { + $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, '/'); + +if (strpos($file, '..') !== false) { + send_header_404(); + die('File not found'); +} + +$staticdir = $CFG->dirroot . '/mod/exescorm/dist/static'; +$filepath = realpath($staticdir . '/' . $file); + +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'); +} + +$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', + 'zip' => 'application/zip', + 'md' => 'text/plain', +]; + +$ext = strtolower(pathinfo($filepath, PATHINFO_EXTENSION)); +$contenttype = isset($mimetypes[$ext]) ? $mimetypes[$ext] : 'application/octet-stream'; + +// Release session lock early so parallel requests are not blocked. +\core\session\manager::write_close(); + +header('Content-Type: ' . $contenttype); +header('Content-Length: ' . filesize($filepath)); +header('Cache-Control: public, max-age=604800'); +header('X-Frame-Options: SAMEORIGIN'); + +if (basename($file) === 'preview-sw.js') { + header('Service-Worker-Allowed: /'); +} + +readfile($filepath); diff --git a/lang/ca/exescorm.php b/lang/ca/exescorm.php index 45ec595..51522d3 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. Opcionalment podeu pujar un fitxer .elpx per importar contingut existent.'; +$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..e790717 100644 --- a/lang/en/exescorm.php +++ b/lang/en/exescorm.php @@ -118,6 +118,8 @@ $string['displayattemptstatus'] = 'Display attempt status'; $string['displayattemptstatus_help'] = 'This preference allows a summary of the users attempts to show in the course overview block in Dashboard and/or the eXeLearning entry page.'; $string['displayattemptstatusdesc'] = 'Whether a summary of the user\'s attempts is shown in the course overview block in Dashboard and/or the eXeLearning entry page.'; +$string['teachermodevisible'] = 'Show Teacher Mode toggler'; +$string['teachermodevisible_help'] = 'If disabled, the Teacher Mode toggler is hidden in the embedded eXeLearning content.'; $string['displaycoursestructure'] = 'Display course structure on entry page'; $string['displaycoursestructure_help'] = 'If enabled, the table of contents is displayed on the eXeLearning outline page.'; $string['displaycoursestructuredesc'] = 'If enabled, the table of contents is displayed on the eXeLearning outline page.'; @@ -491,7 +493,25 @@ $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. You can optionally upload an .elpx project file to import existing content.'; +$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'; $string['displayactivityname_help'] = 'Whether or not to display the activity name above the eXeLearning player.'; +$string['elpxneedsconversion'] = 'This activity currently contains an .elpx project. Open it in the embedded eXeLearning editor and save to convert it to a SCORM 1.2 package for playback and tracking.'; diff --git a/lang/es/exescorm.php b/lang/es/exescorm.php index e1fadeb..b48514c 100644 --- a/lang/es/exescorm.php +++ b/lang/es/exescorm.php @@ -118,6 +118,8 @@ $string['displayattemptstatus'] = 'Mostrar estado de intentos'; $string['displayattemptstatus_help'] = 'Esta preferencia permite mostrar un resumen de los intentos de los usuarios en el bloque Vista general del curso en Mi Tablero y/o en la página de entrada del eXeLearning.'; $string['displayattemptstatusdesc'] = 'Mostrar un resumen de los intentos del usuario en el bloque de descripción general del curso en el Tablero y / o la página de entrada eXeLearning.'; +$string['teachermodevisible'] = 'Mostrar el conmutador de Modo Profesor'; +$string['teachermodevisible_help'] = 'Si se desactiva, el conmutador de Modo Profesor se ocultará en el contenido eXeLearning embebido.'; $string['displaycoursestructure'] = 'Mostrar estructura del curso en la página de entrada'; $string['displaycoursestructure_help'] = 'Si está activado, la tabla de contenidos se mostrará en la página de resumen SCORM.'; $string['displaycoursestructuredesc'] = 'Si está habilitado, la tabla de contenidos se mostrará en la página de resumen de la actividad.'; @@ -491,7 +493,25 @@ $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. Opcionalmente puede subir un archivo .elpx para importar contenido existente.'; +$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'; $string['displayactivityname_help'] = 'Mostrar o no mostrar el nombre de la actividad sobre el visor de eXeLearning.'; +$string['elpxneedsconversion'] = 'Esta actividad contiene actualmente un proyecto .elpx. Abrelo en el editor eXeLearning integrado y guardalo para convertirlo a un paquete SCORM 1.2 reproducible y trazable.'; diff --git a/lang/eu/exescorm.php b/lang/eu/exescorm.php index c04676e..e5bf81b 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. Aukeran .elpx fitxategi bat igo dezakezu lehendik dagoen edukia inportatzeko.'; +$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..fecb2e9 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. Opcionalmente pode subir un ficheiro .elpx para importar contido existente.'; +$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..ee437c9 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); @@ -171,6 +173,46 @@ function exescorm_add_instance($exescorm, $mform=null) { if ($filename !== false) { $record->reference = $filename; } + } else if ($exescorm->exescormtype === EXESCORM_TYPE_EMBEDDED) { + // Embedded type: user may optionally upload an .elpx project file. + if (!empty($exescorm->packagefile)) { + $fs = get_file_storage(); + $fs->delete_area_files($context->id, 'mod_exescorm', 'package'); + file_save_draft_area_files($exescorm->packagefile, $context->id, 'mod_exescorm', 'package', + 0, array('subdirs' => 0, 'maxfiles' => 1)); + $files = $fs->get_area_files($context->id, 'mod_exescorm', 'package', 0, '', false); + $file = reset($files); + if ($file) { + $record->reference = $file->get_filename(); + } + } else { + // No file uploaded: create default package so there is something to parse. + $fs = get_file_storage(); + $templatename = get_config('exescorm', 'template'); + $templatefile = false; + $fileinfo = [ + 'contextid' => $context->id, + 'component' => 'mod_exescorm', + 'filearea' => 'package', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'default_package.zip', + 'userid' => $USER->id, + 'source' => 'default_package.zip', + 'author' => fullname($USER), + 'license' => 'unknown', + ]; + if (! empty($templatename)) { + $templatefile = $fs->get_file(1, 'exescorm', 'config', 0, '/', ltrim($templatename, '/')); + } + if ($templatefile) { + $file = $fs->create_file_from_storedfile($fileinfo, $templatefile); + } else { + $defaultpackagepath = $CFG->dirroot . '/mod/exescorm/data/default_package.zip'; + $file = $fs->create_file_from_pathname($fileinfo, $defaultpackagepath); + } + $record->reference = $file->get_filename(); + } } else if ($record->exescormtype === EXESCORM_TYPE_LOCAL) { // Store the package and verify. if (!empty($exescorm->packagefile)) { @@ -206,7 +248,18 @@ function exescorm_add_instance($exescorm, $mform=null) { $record->cmidnumber = $cmidnumber; $record->cmid = $cmid; - exescorm_parse($record, true); + // Skip parse for .elpx files: they are eXeLearning project files, not SCORM packages. + // The embedded editor will import the .elpx and export a proper SCORM package on save. + $skipparse = false; + if (!empty($record->reference)) { + $ext = strtolower(pathinfo($record->reference, PATHINFO_EXTENSION)); + if ($ext === 'elpx') { + $skipparse = true; + } + } + if (!$skipparse) { + exescorm_parse($record, true); + } exescorm_grade_item_update($record); exescorm_update_calendar($record, $cmid); @@ -259,7 +312,21 @@ 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. + // But user may re-upload an .elpx file via the settings form. + if (!empty($exescorm->packagefile)) { + $fs = get_file_storage(); + $fs->delete_area_files($context->id, 'mod_exescorm', 'package'); + file_save_draft_area_files($exescorm->packagefile, $context->id, 'mod_exescorm', 'package', + 0, array('subdirs' => 0, 'maxfiles' => 1)); + $files = $fs->get_area_files($context->id, 'mod_exescorm', 'package', 0, '', false); + $file = reset($files); + if ($file) { + $exescorm->reference = $file->get_filename(); + } + } + } else if ($exescorm->exescormtype === EXESCORM_TYPE_LOCAL) { if (!empty($exescorm->packagefile)) { $fs = get_file_storage(); $fs->delete_area_files($context->id, 'mod_exescorm', 'package'); @@ -305,7 +372,18 @@ function exescorm_update_instance($exescorm, $mform=null) { $exescorm->idnumber = $cmidnumber; $exescorm->cmid = $cmid; - exescorm_parse($exescorm, (bool)$exescorm->updatefreq); + // Skip parse for .elpx files: they are eXeLearning project files, not SCORM packages. + // The embedded editor will import the .elpx and export a proper SCORM package on save. + $skipparse = false; + if (!empty($exescorm->reference)) { + $ext = strtolower(pathinfo($exescorm->reference, PATHINFO_EXTENSION)); + if ($ext === 'elpx') { + $skipparse = true; + } + } + if (!$skipparse) { + exescorm_parse($exescorm, (bool)$exescorm->updatefreq); + } exescorm_grade_item_update($exescorm); exescorm_update_grades($exescorm); @@ -1020,7 +1098,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 +1285,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 +1965,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..b2c2806 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(); @@ -2251,6 +2252,7 @@ function exescorm_get_sco_and_launch_url($exescorm, $scoid, $context) { } $connector = ''; + $scolaunchurl = ''; $version = substr($exescorm->version, 0, 4); if ((isset($sco->parameters) && (!empty($sco->parameters))) || ($version == 'AICC')) { if (stripos($sco->launch, '?') !== false) { @@ -2296,7 +2298,8 @@ function exescorm_get_sco_and_launch_url($exescorm, $scoid, $context) { $scolaunchurl = "$CFG->wwwroot/pluginfile.php/$context->id/mod_exescorm/imsmanifest/$exescorm->revision/$launcher"; } else if ( $exescorm->exescormtype === EXESCORM_TYPE_LOCAL || - $exescorm->exescormtype === EXESCORM_TYPE_LOCALSYNC + $exescorm->exescormtype === EXESCORM_TYPE_LOCALSYNC || + $exescorm->exescormtype === EXESCORM_TYPE_EMBEDDED ) { // Note: do not convert this to use moodle_url(). // EXESCORM does not work without slasharguments and moodle_url() encodes querystring vars. @@ -2320,7 +2323,7 @@ function exescorm_launch_sco($exescorm, $sco, $cm, $context, $scourl) { $event = \mod_exescorm\event\sco_launched::create(array( 'objectid' => $sco->id, 'context' => $context, - 'other' => array('instanceid' => $exescorm->id, 'loadedcontent' => $scourl) + 'other' => array('instanceid' => $exescorm->id, 'loadedcontent' => !empty($scourl) ? $scourl : (string)$sco->launch) )); $event->add_record_snapshot('course_modules', $cm); $event->add_record_snapshot('exescorm', $exescorm); diff --git a/mod_form.php b/mod_form.php index eaf3a10..335d72e 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'); } @@ -98,9 +107,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; @@ -204,6 +221,11 @@ public function definition() { $mform->setDefault('displayattemptstatus', $cfgexescorm->displayattemptstatus); $mform->setAdvanced('displayattemptstatus', $cfgexescorm->displayattemptstatus_adv); + // Teacher mode toggler visibility. + $mform->addElement('advcheckbox', 'teachermodevisible', get_string('teachermodevisible', 'mod_exescorm')); + $mform->addHelpButton('teachermodevisible', 'teachermodevisible', 'mod_exescorm'); + $mform->setDefault('teachermodevisible', 1); + // Availability. $mform->addElement('header', 'availability', get_string('availability')); @@ -304,6 +326,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); } /** @@ -459,6 +484,9 @@ public function validation($data, $files) { } } else if (strtolower(substr($file->get_filename(), -3)) == 'xml') { $errors['packagefile'] = get_string('invalidmanifestname', 'mod_exescorm'); + } else if (strtolower(pathinfo($file->get_filename(), PATHINFO_EXTENSION)) === 'elpx') { + // .elpx is an eXeLearning project file, not a SCORM package. + // It will be imported by the embedded editor and exported as SCORM on save. } else { // Validate this EXESCORM package. $errors = array_merge($errors, exescorm_validate_package($file)); @@ -469,6 +497,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/module.js b/module.js index 0be53e5..ab4389f 100644 --- a/module.js +++ b/module.js @@ -966,12 +966,36 @@ var exescorm_resize = function() { } }; +/** + * Injects CSS to hide the teacher mode toggler when configured. + * + * @param {Element} iFrame + */ +var exescorm_apply_teacher_mode_visibility = function(iFrame) { + if (!iFrame || !iFrame.contentWindow || !iFrame.contentWindow.document) { + return; + } + if (typeof exescormplayerdata === 'undefined' || Number(exescormplayerdata.teachermodevisible) === 1) { + return; + } + var doc = iFrame.contentWindow.document; + if (doc.getElementById('exescorm-teacher-mode-style')) { + return; + } + + var style = doc.createElement('style'); + style.id = 'exescorm-teacher-mode-style'; + style.textContent = '#teacher-mode-toggler-wrapper { visibility: hidden !important; }'; + (doc.head || doc.body || doc.documentElement).appendChild(style); +}; + /** * IFrame's onload handler. Used to keep iFrame's height dynamic, varying on iFrame's contents. * * @param {Element} iFrame */ var exescorm_iframe_onload = function(iFrame) { + exescorm_apply_teacher_mode_visibility(iFrame); exescorm_resize([], null); // Set a mutation observer, so we can adapt to changes from iFrame's javascript (such // as tab clicks o hide/show sections). diff --git a/player.php b/player.php index 804a48d..dc3eb36 100644 --- a/player.php +++ b/player.php @@ -187,7 +187,8 @@ 'courseid' => $exescorm->course, 'cwidth' => $exescorm->width, 'cheight' => $exescorm->height, - 'popupoptions' => $exescorm->options), true); + 'popupoptions' => $exescorm->options, + 'teachermodevisible' => (int)($exescorm->teachermodevisible ?? 1)), true); $PAGE->requires->js('/mod/exescorm/request.js', true); $PAGE->requires->js('/lib/cookies.js', true); @@ -314,6 +315,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}}