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