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..e7ed48b
--- /dev/null
+++ b/.env.dist
@@ -0,0 +1,29 @@
+# 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
+
+# To use a specific Moodle version, set MOODLE_VERSION to git release tag.
+# You can find the list of available tags at:
+# https://api.github.com/repos/moodle/moodle/tags
+MOODLE_VERSION=v5.0.5
+
+# Test user data
+TEST_USER_EMAIL=user@exelearning.net
+TEST_USER_USERNAME=user
+TEST_USER_PASSWORD=1234
diff --git a/.github/workflows/check-editor-releases.yml b/.github/workflows/check-editor-releases.yml
new file mode 100644
index 0000000..a16254d
--- /dev/null
+++ b/.github/workflows/check-editor-releases.yml
@@ -0,0 +1,95 @@
+---
+name: Check Editor Releases
+
+on:
+ schedule:
+ - cron: "0 8 * * *" # Daily at 8:00 UTC
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ actions: write
+
+jobs:
+ check_and_build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Get latest exelearning release
+ id: check
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ # Fetch latest release from exelearning/exelearning
+ LATEST=$(gh api repos/exelearning/exelearning/releases/latest --jq '.tag_name' 2>/dev/null || echo "")
+ if [ -z "$LATEST" ]; then
+ echo "No release found"
+ echo "found=false" >> $GITHUB_OUTPUT
+ exit 0
+ fi
+ echo "Latest editor release: $LATEST"
+ echo "tag=$LATEST" >> $GITHUB_OUTPUT
+
+ # Check if we already built this version
+ MARKER_FILE=".editor-version"
+ CURRENT=""
+ if [ -f "$MARKER_FILE" ]; then
+ CURRENT=$(cat "$MARKER_FILE")
+ fi
+ echo "Current built version: $CURRENT"
+
+ if [ "$LATEST" = "$CURRENT" ]; then
+ echo "Already up to date"
+ echo "found=false" >> $GITHUB_OUTPUT
+ else
+ echo "New version available"
+ echo "found=true" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Setup Bun
+ if: steps.check.outputs.found == 'true'
+ uses: oven-sh/setup-bun@v2
+
+ - name: Build static editor
+ if: steps.check.outputs.found == 'true'
+ env:
+ EXELEARNING_EDITOR_REPO_URL: https://github.com/exelearning/exelearning.git
+ EXELEARNING_EDITOR_REF: ${{ steps.check.outputs.tag }}
+ EXELEARNING_EDITOR_REF_TYPE: tag
+ run: make build-editor
+
+ - name: Compute version
+ if: steps.check.outputs.found == 'true'
+ id: version
+ run: |
+ TAG="${{ steps.check.outputs.tag }}"
+ VERSION="${TAG#v}"
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+ echo "tag=$TAG" >> $GITHUB_OUTPUT
+
+ - name: Create package
+ if: steps.check.outputs.found == 'true'
+ run: make package RELEASE=${{ steps.version.outputs.version }}
+
+ - name: Update editor version marker
+ if: steps.check.outputs.found == 'true'
+ run: |
+ echo "${{ steps.check.outputs.tag }}" > .editor-version
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add .editor-version
+ git commit -m "Update editor version to ${{ steps.check.outputs.tag }}"
+ git push
+
+ - name: Create GitHub Release
+ if: steps.check.outputs.found == 'true'
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ steps.version.outputs.tag }}
+ name: "${{ steps.version.outputs.tag }}"
+ body: |
+ Automated build with eXeLearning editor ${{ steps.version.outputs.tag }}.
+ files: mod_exescorm-${{ steps.version.outputs.version }}.zip
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/pr-playground-preview.yml b/.github/workflows/pr-playground-preview.yml
new file mode 100644
index 0000000..2f0a7f5
--- /dev/null
+++ b/.github/workflows/pr-playground-preview.yml
@@ -0,0 +1,44 @@
+---
+name: PR Playground Preview
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+concurrency:
+ group: playground-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ playground-preview:
+ if: github.event.pull_request.head.repo.full_name == github.repository
+ runs-on: ubuntu-latest
+ timeout-minutes: 2
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+
+ - name: Build PR blueprint
+ env:
+ PLUGIN_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
+ PLUGIN_REPO_NAME: ${{ github.event.pull_request.head.repo.name }}
+ PLUGIN_BRANCH_NAME: ${{ github.event.pull_request.head.ref }}
+ run: |
+ plugin_url="https://github.com/${PLUGIN_REPO_OWNER}/${PLUGIN_REPO_NAME}/archive/refs/heads/${PLUGIN_BRANCH_NAME}.zip"
+ sed "s|https://github.com/exelearning/mod_exescorm/archive/refs/heads/main.zip|${plugin_url}|" blueprint.json > blueprint.pr.json
+
+ - uses: ateeducacion/action-moodle-playground-pr-preview@main
+ with:
+ blueprint-file: blueprint.pr.json
+ mode: append-to-description
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ extra-text: >
+ ⚠️ The embedded eXeLearning editor is not included in this preview.
+ You can install it from **Modules > eXeLearning SCORM > Configure**
+ using the "Download & Install Editor" button.
+ All other module features (ELPX upload, viewer, preview) work normally.
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..93c5d9c 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# eXeLearning SCORM activities for Moodle
+[](https://ateeducacion.github.io/moodle-playground/?blueprint-url=https://raw.githubusercontent.com/exelearning/mod_exescorm/refs/heads/main/blueprint.json)
+
Activity-type module to create and edit SCORM packages with eXeLearning (online).
You need the eXeLearning online version installed (ws28 or higher) and access to its configuration files to run
@@ -13,22 +15,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:** It is recommended to install from a [release ZIP](https://github.com/exelearning/mod_exescorm/releases), which includes the embedded editor pre-built for optimal performance. If the release ZIP does not include the editor, or if you want to install a newer version, administrators can download it from GitHub Releases via the embedded editor controls in the plugin settings.
-## 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 +66,21 @@ 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.
+## Embedded Editor Management
+
+The plugin supports two local editor sources with the following precedence:
+
+1. **Admin-installed** (moodledata): Downloaded from GitHub Releases via the plugin settings.
+2. **Bundled** (plugin): Included in the plugin release ZIP at `dist/static/`.
+
+The admin-installed version takes priority over the bundled one. If neither source is available, the embedded editor mode cannot be used.
+
+## Development
+
+For development setup, build instructions, and contributing guidelines, see [DEVELOPMENT.md](DEVELOPMENT.md).
+
## About
Copyright 2023:
diff --git a/amd/build/admin_embedded_editor.min.js b/amd/build/admin_embedded_editor.min.js
new file mode 100644
index 0000000..b1d6076
--- /dev/null
+++ b/amd/build/admin_embedded_editor.min.js
@@ -0,0 +1,569 @@
+// 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 .
+
+/**
+ * AMD module for the admin embedded editor settings widget.
+ *
+ * All install/update flows go through the plugin's AJAX endpoints. In Moodle
+ * Playground, outbound requests are handled by the PHP WASM networking layer
+ * configured by the runtime.
+ *
+ * @module mod_exescorm/admin_embedded_editor
+ * @copyright 2025 eXeLearning
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/ajax', 'core/notification', 'core/str'], function($, Ajax, Notification, Str) {
+
+ /** Maximum seconds to wait before switching to polling. */
+ var ACTION_TIMEOUT_MS = 120000;
+
+ /** Polling interval in milliseconds. */
+ var POLL_INTERVAL_MS = 10000;
+
+ /** Maximum number of polling iterations (5 minutes). */
+ var MAX_POLL_ITERATIONS = 30;
+
+ /**
+ * Find the widget container element.
+ *
+ * @returns {jQuery} The container element.
+ */
+ var getContainer = function() {
+ return $('.mod_exescorm-admin-embedded-editor').first();
+ };
+
+ /**
+ * Return runtime configuration derived from PHP context.
+ *
+ * @param {Object} config Configuration object passed from PHP.
+ * @returns {Object}
+ */
+ var getRuntimeConfig = function(config) {
+ return config || {};
+ };
+
+ /**
+ * Return the latest-version UI elements used by the widget.
+ *
+ * @param {jQuery} container The widget container.
+ * @returns {{spinnerEl: jQuery, textEl: jQuery}}
+ */
+ var getLatestVersionElements = function(container) {
+ return {
+ spinnerEl: container.find('.mod_exescorm-latest-version-spinner'),
+ textEl: container.find('.mod_exescorm-latest-version-text'),
+ };
+ };
+
+ /**
+ * Disable all action buttons in the widget.
+ *
+ * @param {jQuery} container The widget container.
+ */
+ var disableButtons = function(container) {
+ container.find('[data-action]').prop('disabled', true);
+ };
+
+ /**
+ * Enable action buttons based on current status data.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {Object} statusData Status object from get_status response.
+ */
+ var enableButtons = function(container, statusData) {
+ container.find('.mod_exescorm-btn-install').prop('disabled', true).hide();
+ container.find('.mod_exescorm-btn-update').prop('disabled', true).hide();
+ container.find('.mod_exescorm-btn-uninstall').prop('disabled', true).hide();
+
+ if (statusData.can_install) {
+ container.find('.mod_exescorm-btn-install').prop('disabled', false).show();
+ }
+ if (statusData.can_update) {
+ container.find('.mod_exescorm-btn-update').prop('disabled', false).show();
+ }
+ if (statusData.can_uninstall) {
+ container.find('.mod_exescorm-btn-uninstall').prop('disabled', false).show();
+ }
+ };
+
+ /**
+ * Show the progress bar area and hide the result area.
+ *
+ * @param {jQuery} container The widget container.
+ */
+ var showProgress = function(container) {
+ container.find('.mod_exescorm-progress-container').show();
+ container.find('.mod_exescorm-result-area').hide();
+ };
+
+ /**
+ * Hide the progress bar area.
+ *
+ * @param {jQuery} container The widget container.
+ */
+ var hideProgress = function(container) {
+ container.find('.mod_exescorm-progress-container').hide();
+ };
+
+ /**
+ * Set the spinner area visual state.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {string} state One of active, success, or error.
+ * @param {string} message Optional message to display next to the spinner.
+ */
+ var setProgressState = function(container, state, message) {
+ var msgEl = container.find('.mod_exescorm-progress-message');
+ var spinnerEl = container.find('.mod_exescorm-progress-container .spinner-border');
+
+ if (state === 'active') {
+ spinnerEl.show();
+ } else {
+ spinnerEl.hide();
+ }
+
+ msgEl.text(message || '');
+ };
+
+ /**
+ * Render the latest-version text, optionally with an update badge.
+ *
+ * @param {jQuery} textEl Target element.
+ * @param {string} text Main text to show.
+ * @param {string|null} badgeText Optional badge label.
+ */
+ var setLatestVersionText = function(textEl, text, badgeText) {
+ if (badgeText) {
+ textEl.html(text + ' ' + badgeText + ' ');
+ } else {
+ textEl.text(text);
+ }
+ };
+
+ /**
+ * Escape HTML special characters in plain text.
+ *
+ * @param {string} value Plain text.
+ * @returns {string}
+ */
+ var escapeHtml = function(value) {
+ return $('
').text(value || '').html();
+ };
+
+ /**
+ * Update the primary status summary shown on the left of the header row.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {Object} statusData Status object from get_status response.
+ */
+ var updateStatusSummary = function(container, statusData) {
+ var summaryEl = container.find('.mod_exescorm-status-primary');
+ if (!summaryEl.length) {
+ return;
+ }
+
+ Str.get_strings([
+ {key: 'editormoodledatasource', component: 'mod_exescorm'},
+ {key: 'editorinstalledat', component: 'mod_exescorm'},
+ {key: 'editorbundledsource', component: 'mod_exescorm'},
+ {key: 'editorbundleddesc', component: 'mod_exescorm'},
+ {key: 'noeditorinstalled', component: 'mod_exescorm'},
+ {key: 'editornotinstalleddesc', component: 'mod_exescorm'},
+ ]).then(function(strings) {
+ var html = '';
+
+ if (statusData.active_source === 'moodledata') {
+ html += '
' + escapeHtml(strings[0]) + ' ';
+ if (statusData.moodledata_version) {
+ html += '
v' +
+ escapeHtml(statusData.moodledata_version) + ' ';
+ }
+ if (statusData.moodledata_installed_at) {
+ html += '
' + escapeHtml(strings[1]) + ' ' +
+ escapeHtml(statusData.moodledata_installed_at) + ' ';
+ }
+ } else if (statusData.active_source === 'bundled') {
+ html += '
' + escapeHtml(strings[2]) + ' ';
+ html += '
' + escapeHtml(strings[3]) + ' ';
+ } else {
+ html += '
' + escapeHtml(strings[4]) + ' ';
+ html += '
' + escapeHtml(strings[5]) + ' ';
+ }
+
+ summaryEl.html(html);
+ }).catch(function() {
+ // Keep the current markup if the strings cannot be loaded.
+ });
+ };
+
+ /**
+ * Fetch the latest-version prefix label.
+ *
+ * @param {string} version Version string.
+ * @returns {Promise
}
+ */
+ var getLatestVersionLabel = function(version) {
+ return Str.get_string('editorlatestversionongithub', 'mod_exescorm')
+ .then(function(prefix) {
+ return prefix + ' v' + version;
+ })
+ .catch(function() {
+ return 'v' + version;
+ });
+ };
+
+ /**
+ * Fetch the optional update-available badge label.
+ *
+ * @param {Object} statusData Status object from get_status response.
+ * @returns {Promise}
+ */
+ var getLatestVersionBadgeLabel = function(statusData) {
+ if (!statusData.update_available) {
+ return Promise.resolve(null);
+ }
+
+ return Str.get_string('updateavailable', 'mod_exescorm')
+ .catch(function() {
+ return Str.get_string('editorupdateavailable', 'mod_exescorm', statusData.latest_version);
+ })
+ .catch(function() {
+ return null;
+ });
+ };
+
+ /**
+ * Update the latest version area in the DOM.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {Object} statusData Status object from get_status response.
+ */
+ var updateLatestVersionArea = function(container, statusData) {
+ var elements = getLatestVersionElements(container);
+ var spinnerEl = elements.spinnerEl;
+ var textEl = elements.textEl;
+
+ spinnerEl.hide();
+ textEl.show();
+
+ if (statusData.latest_error) {
+ textEl.text('(' + statusData.latest_error + ')');
+ } else if (statusData.latest_version) {
+ Promise.all([
+ getLatestVersionLabel(statusData.latest_version),
+ getLatestVersionBadgeLabel(statusData),
+ ]).then(function(results) {
+ setLatestVersionText(textEl, results[0], results[1]);
+ }).catch(function() {
+ textEl.text('v' + statusData.latest_version);
+ });
+ } else {
+ textEl.text('');
+ }
+ };
+
+ /**
+ * Update all DOM elements based on a get_status response.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {Object} statusData Status object from get_status response.
+ */
+ var updateStatus = function(container, statusData) {
+ updateStatusSummary(container, statusData);
+ updateLatestVersionArea(container, statusData);
+ enableButtons(container, statusData);
+ };
+
+ /**
+ * Show a result message in the result area.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {string} message The message text.
+ * @param {string} type Bootstrap alert type.
+ */
+ var showResultMessage = function(container, message, type) {
+ var resultArea = container.find('.mod_exescorm-result-area');
+ var msgEl = container.find('.mod_exescorm-result-message');
+
+ msgEl.removeClass('alert-success alert-danger alert-warning')
+ .addClass('alert-' + type)
+ .text(message);
+ resultArea.show();
+ };
+
+ /**
+ * Call get_status via AJAX and return a Promise.
+ *
+ * @param {boolean} checklatest Whether to query GitHub for the latest version.
+ * @returns {Promise}
+ */
+ var callGetStatus = function(checklatest) {
+ var requests = Ajax.call([{
+ methodname: 'mod_exescorm_manage_embedded_editor_status',
+ args: {checklatest: !!checklatest},
+ }]);
+ return requests[0];
+ };
+
+ /**
+ * Call execute_action via AJAX and return a Promise.
+ *
+ * @param {string} action One of install, update, repair, uninstall.
+ * @returns {Promise}
+ */
+ var callExecuteAction = function(action) {
+ var requests = Ajax.call([{
+ methodname: 'mod_exescorm_manage_embedded_editor_action',
+ args: {action: action},
+ }]);
+ return requests[0];
+ };
+
+ /**
+ * Refresh widget status from the server and optionally enrich latest version.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {boolean} checklatest Whether latest version should be queried.
+ * @param {Object} runtimeConfig Runtime config.
+ * @returns {Promise}
+ */
+ var refreshStatus = function(container, checklatest, runtimeConfig) {
+ return callGetStatus(!!checklatest).then(function(statusData) {
+ updateStatus(container, statusData);
+ return statusData;
+ });
+ };
+
+ /**
+ * Begin a polling loop after an action timeout.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {Object} runtimeConfig Runtime config.
+ */
+ var startPolling = function(container, runtimeConfig) {
+ var iterations = 0;
+
+ var poll = function() {
+ iterations++;
+
+ if (iterations > MAX_POLL_ITERATIONS) {
+ Str.get_string('operationtimedout', 'mod_exescorm').then(function(msg) {
+ setProgressState(container, 'error', msg);
+ showResultMessage(container, msg, 'danger');
+ Notification.addNotification({message: msg, type: 'error'});
+ }).catch(function() {
+ var msg = 'Timeout';
+ setProgressState(container, 'error', msg);
+ showResultMessage(container, msg, 'danger');
+ Notification.addNotification({message: msg, type: 'error'});
+ });
+ callGetStatus(false).then(function(statusData) {
+ enableButtons(container, statusData);
+ }).catch(function() {
+ container.find('[data-action]').prop('disabled', false);
+ });
+ return;
+ }
+
+ callGetStatus(false).then(function(statusData) {
+ if (statusData.install_stale) {
+ Str.get_string('installstale', 'mod_exescorm').then(function(msg) {
+ setProgressState(container, 'error', msg);
+ showResultMessage(container, msg, 'warning');
+ Notification.addNotification({message: msg, type: 'warning'});
+ }).catch(function() {
+ var msg = 'Error';
+ setProgressState(container, 'error', msg);
+ showResultMessage(container, msg, 'warning');
+ });
+ updateStatus(container, statusData);
+ return;
+ }
+
+ if (!statusData.installing) {
+ setProgressState(container, 'success');
+ hideProgress(container);
+ refreshStatus(container, true, runtimeConfig).catch(function() {
+ updateStatus(container, statusData);
+ });
+ return;
+ }
+
+ Str.get_string('stillworking', 'mod_exescorm').then(function(msg) {
+ setProgressState(container, 'active', msg);
+ }).catch(function() {
+ setProgressState(container, 'active');
+ });
+
+ window.setTimeout(poll, POLL_INTERVAL_MS);
+ }).catch(function() {
+ window.setTimeout(poll, POLL_INTERVAL_MS);
+ });
+ };
+
+ Str.get_string('operationtakinglong', 'mod_exescorm').then(function(msg) {
+ setProgressState(container, 'active', msg);
+ }).catch(function() {
+ setProgressState(container, 'active');
+ });
+
+ window.setTimeout(poll, POLL_INTERVAL_MS);
+ };
+
+ /**
+ * Execute an action via AJAX or the Playground upload flow.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {string} action The action to perform.
+ * @param {Object} runtimeConfig Runtime config.
+ */
+ var executeAction = function(container, action, runtimeConfig) {
+ var clickedBtn = container.find('[data-action="' + action + '"]');
+ var originalBtnHtml = clickedBtn.html();
+
+ // Choose the right lang strings for the button and spinner message.
+ var btnStringKey = (action === 'uninstall') ? 'editoruninstalling' : 'editorinstalling';
+ var msgStringKey = (action === 'uninstall') ? 'editoruninstallingmessage' : 'editordownloadingmessage';
+
+ Str.get_strings([
+ {key: btnStringKey, component: 'mod_exescorm'},
+ {key: msgStringKey, component: 'mod_exescorm'},
+ ]).then(function(strings) {
+ clickedBtn.html(strings[0]);
+ setProgressState(container, 'active', strings[1]);
+ }).catch(function() {
+ setProgressState(container, 'active');
+ });
+
+ disableButtons(container);
+ showProgress(container);
+
+ var timeoutHandle = null;
+ var timedOut = false;
+ var actionPromise = null;
+
+ timeoutHandle = window.setTimeout(function() {
+ timedOut = true;
+ startPolling(container, runtimeConfig);
+ }, ACTION_TIMEOUT_MS);
+
+ actionPromise = callExecuteAction(action);
+
+ actionPromise.then(function(result) {
+ if (timedOut) {
+ return result;
+ }
+ window.clearTimeout(timeoutHandle);
+
+ clickedBtn.html(originalBtnHtml);
+ hideProgress(container);
+ var message = result.message || 'Done.';
+ showResultMessage(container, message, 'success');
+ Notification.addNotification({message: message, type: 'success'});
+
+ refreshStatus(container, true, runtimeConfig).catch(function() {
+ // Keep current state.
+ });
+
+ return result;
+ }).catch(function(err) {
+ if (timedOut) {
+ return;
+ }
+ window.clearTimeout(timeoutHandle);
+
+ clickedBtn.html(originalBtnHtml);
+ hideProgress(container);
+ var message = (err && err.message) ? err.message : 'An error occurred.';
+ showResultMessage(container, message, 'danger');
+ Notification.addNotification({message: message, type: 'error'});
+
+ callGetStatus(false).then(function(statusData) {
+ enableButtons(container, statusData);
+ }).catch(function() {
+ container.find('[data-action]').prop('disabled', false);
+ });
+ });
+ };
+
+ /**
+ * Handle a button click.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {string} action The action string from data-action.
+ * @param {Object} runtimeConfig Runtime config.
+ */
+ var handleActionClick = function(container, action, runtimeConfig) {
+ if (action === 'uninstall') {
+ Str.get_strings([
+ {key: 'confirmuninstalltitle', component: 'mod_exescorm'},
+ {key: 'confirmuninstall', component: 'mod_exescorm'},
+ {key: 'yes', component: 'core'},
+ {key: 'no', component: 'core'},
+ ]).then(function(strings) {
+ Notification.confirm(
+ strings[0],
+ strings[1],
+ strings[2],
+ strings[3],
+ function() {
+ executeAction(container, action, runtimeConfig);
+ }
+ );
+ }).catch(function() {
+ if (window.confirm(container.attr('data-confirm-uninstall') || '')) {
+ executeAction(container, action, runtimeConfig);
+ }
+ });
+ } else {
+ executeAction(container, action, runtimeConfig);
+ }
+ };
+
+ /**
+ * Initialise the widget.
+ *
+ * @param {Object} config Configuration object passed from PHP.
+ */
+ var init = function(config) {
+ var runtimeConfig = getRuntimeConfig(config || {});
+ var container = getContainer();
+ if (!container.length) {
+ return;
+ }
+
+ var elements = getLatestVersionElements(container);
+ elements.spinnerEl.show();
+ elements.textEl.hide();
+
+ refreshStatus(container, true, runtimeConfig).catch(function() {
+ elements.spinnerEl.hide();
+ elements.textEl.show();
+ });
+
+ container.on('click', '[data-action]', function(e) {
+ e.preventDefault();
+ var action = $(this).data('action');
+ if (!action) {
+ return;
+ }
+ handleActionClick(container, action, runtimeConfig);
+ });
+ };
+
+ return {
+ init: init,
+ };
+});
diff --git a/amd/build/edit_confirmation.min.js b/amd/build/edit_confirmation.min.js
index 4877882..c2e5fef 100644
--- a/amd/build/edit_confirmation.min.js
+++ b/amd/build/edit_confirmation.min.js
@@ -1,10 +1,88 @@
-define("mod_exescorm/edit_confirmation",["exports","core/str","core/modal_factory","core/modal_events"],(function(_exports,Str,_modal_factory,_modal_events){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.showConfirmation=_exports.init=void 0,Str=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}
+// 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 .
+
/**
- * Confirmation modal for editing ExeScorm activities.
- *
- * @module mod_exescorm/edit_confirmation
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- * @copyright 3iPunt
- */(Str),_modal_factory=_interopRequireDefault(_modal_factory),_modal_events=_interopRequireDefault(_modal_events);_exports.init=()=>{document.querySelectorAll('[data-action="edit-exescorm"]').forEach((button=>{button.addEventListener("click",(e=>{e.preventDefault();const targetUrl=button.getAttribute("data-editurl");showConfirmation(targetUrl)}))}))};const showConfirmation=targetUrl=>{Promise.all([Str.get_string("edit","core"),Str.get_string("yes","core"),Str.get_string("cancel","core"),Str.get_string("editdialogcontent","mod_exescorm"),Str.get_string("editdialogcontent:caution","mod_exescorm"),Str.get_string("editdialogcontent:continue","mod_exescorm")]).then((strings=>{const bodyContent=strings[3]+' '+strings[4]+''+strings[5]+"
";return _modal_factory.default.create({type:_modal_factory.default.types.SAVE_CANCEL,title:strings[0],body:bodyContent,buttons:{save:strings[1],cancel:strings[2]}})})).then((modal=>(modal.getRoot().on(_modal_events.default.save,(()=>{window.location.href=targetUrl})),modal.getRoot().on(_modal_events.default.cancel,(()=>{modal.hide()})),modal.show(),modal))).catch((()=>{window.location.href=targetUrl}))};_exports.showConfirmation=showConfirmation}));
-
-//# sourceMappingURL=edit_confirmation.min.js.map
\ No newline at end of file
+ * Confirmation modal for editing ExeScorm activities.
+ *
+ * @module mod_exescorm/edit_confirmation
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @copyright 3iPunt
+ */
+define(['core/str', 'core/modal_factory', 'core/modal_events'], function(Str, ModalFactory, ModalEvents) {
+
+ /**
+ * Initialize the edit confirmation functionality.
+ */
+ var init = function() {
+ var editButtons = document.querySelectorAll('[data-action="edit-exescorm"]');
+
+ editButtons.forEach(function(button) {
+ button.addEventListener('click', function(e) {
+ e.preventDefault();
+ var targetUrl = button.getAttribute('data-editurl');
+ showConfirmation(targetUrl);
+ });
+ });
+ };
+
+ /**
+ * Show confirmation modal before redirecting to edit.
+ *
+ * @param {string} targetUrl The URL to redirect to if confirmed
+ */
+ var showConfirmation = function(targetUrl) {
+ Promise.all([
+ Str.get_string('edit', 'core'),
+ Str.get_string('yes', 'core'),
+ Str.get_string('cancel', 'core'),
+ Str.get_string('editdialogcontent', 'mod_exescorm'),
+ Str.get_string('editdialogcontent:caution', 'mod_exescorm'),
+ Str.get_string('editdialogcontent:continue', 'mod_exescorm'),
+ ]).then(function(strings) {
+ // Center the body content using a div with text-center class.
+ var warnIcon = ' ';
+ var bodyContent = strings[3] + '' + warnIcon + strings[4] +
+ ' ' + '' + strings[5] +
+ '
';
+ return ModalFactory.create({
+ type: ModalFactory.types.SAVE_CANCEL,
+ title: strings[0],
+ body: bodyContent,
+ buttons: {
+ save: strings[1],
+ cancel: strings[2],
+ },
+ });
+ }).then(function(modal) {
+ modal.getRoot().on(ModalEvents.save, function() {
+ window.location.href = targetUrl;
+ });
+
+ modal.getRoot().on(ModalEvents.cancel, function() {
+ modal.hide();
+ });
+
+ modal.show();
+ return modal;
+ }).catch(function() {
+ window.location.href = targetUrl;
+ });
+ };
+
+ return {
+ init: init,
+ showConfirmation: showConfirmation,
+ };
+});
diff --git a/amd/build/editor_modal.min.js b/amd/build/editor_modal.min.js
new file mode 100644
index 0000000..9519862
--- /dev/null
+++ b/amd/build/editor_modal.min.js
@@ -0,0 +1,353 @@
+// 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 */
+
+define(['core/str', 'core/log', 'core/prefetch'], function(Str, Log, Prefetch) {
+
+ var overlay = null;
+ var iframe = null;
+ var saveBtn = null;
+ var loadingModal = null;
+ var isSaving = false;
+ var hasUnsavedChanges = false;
+
+ var getString = Str.get_string;
+
+ /**
+ * Prefetch language strings used by the modal.
+ */
+ var prefetchStrings = function() {
+ Prefetch.prefetchStrings('mod_exescorm', [
+ 'editembedded', 'saving', 'savedsuccess', 'savetomoodle', 'savingwait', 'unsavedchanges',
+ ]);
+ Prefetch.prefetchStrings('core', ['closebuttontitle']);
+ };
+
+ var setSaveLabel = function(key, fallback) {
+ if (!saveBtn) {
+ return Promise.resolve();
+ }
+ var component = key === 'closebuttontitle' ? 'core' : 'mod_exescorm';
+ return getString(key, component).then(function(text) {
+ saveBtn.innerHTML = ' ' + text;
+ }).catch(function() {
+ saveBtn.innerHTML = ' ' + fallback;
+ });
+ };
+
+ var createLoadingModal = function() {
+ var modal = document.createElement('div');
+ modal.className = 'exescorm-loading-modal';
+ modal.id = 'exescorm-loading-modal';
+
+ return Promise.all([
+ getString('saving', 'mod_exescorm').catch(function() { return 'Saving...'; }),
+ getString('savingwait', 'mod_exescorm').catch(function() {
+ return 'Please wait while the file is being saved.';
+ }),
+ ]).then(function(strings) {
+ modal.innerHTML =
+ '' +
+ '
' +
+ '
' + strings[0] + ' ' +
+ '
' + strings[1] + '
' +
+ '
';
+ document.body.appendChild(modal);
+ return modal;
+ });
+ };
+
+ var showLoadingModal = function() {
+ if (loadingModal) {
+ loadingModal.classList.add('is-visible');
+ return Promise.resolve();
+ }
+ return createLoadingModal().then(function(modal) {
+ loadingModal = modal;
+ loadingModal.classList.add('is-visible');
+ });
+ };
+
+ var hideLoadingModal = function() {
+ if (loadingModal) {
+ loadingModal.classList.remove('is-visible');
+ }
+ };
+
+ var removeLoadingModal = function() {
+ if (loadingModal) {
+ loadingModal.remove();
+ loadingModal = null;
+ }
+ };
+
+ var checkUnsavedChanges = function() {
+ if (!hasUnsavedChanges) {
+ return Promise.resolve(false);
+ }
+ return getString('unsavedchanges', 'mod_exescorm').catch(function() {
+ return 'You have unsaved changes. Are you sure you want to close?';
+ }).then(function(message) {
+ return !window.confirm(message);
+ });
+ };
+
+ /**
+ * Send a save request to the editor iframe.
+ */
+ var triggerSave = function() {
+ if (isSaving || !iframe || !iframe.contentWindow) {
+ return;
+ }
+
+ isSaving = true;
+ if (saveBtn) {
+ saveBtn.disabled = true;
+ }
+ setSaveLabel('saving', 'Saving...').then(function() {
+ return showLoadingModal();
+ }).then(function() {
+ iframe.contentWindow.postMessage({
+ source: 'exescorm-modal',
+ type: 'save',
+ }, '*');
+ });
+ };
+
+ /**
+ * Close the editor modal and clean up.
+ * @param {boolean} skipConfirm
+ */
+ var closeModal = function(skipConfirm) {
+ if (!overlay) {
+ return Promise.resolve();
+ }
+ var doClose = function() {
+ var wasShowingLoader = isSaving || (skipConfirm === true);
+
+ overlay.remove();
+ overlay = null;
+ iframe = null;
+ saveBtn = null;
+ isSaving = false;
+ hasUnsavedChanges = false;
+
+ document.body.style.overflow = '';
+ window.removeEventListener('message', handleMessage);
+ document.removeEventListener('keydown', handleKeydown);
+
+ if (wasShowingLoader) {
+ setTimeout(function() {
+ hideLoadingModal();
+ removeLoadingModal();
+ }, 1500);
+ } else {
+ hideLoadingModal();
+ removeLoadingModal();
+ }
+ };
+
+ if (!skipConfirm) {
+ return checkUnsavedChanges().then(function(shouldCancel) {
+ if (!shouldCancel) {
+ doClose();
+ }
+ });
+ }
+ doClose();
+ return Promise.resolve();
+ };
+
+ /**
+ * Handle postMessage events from the editor iframe.
+ * @param {MessageEvent} event
+ */
+ var handleMessage = function(event) {
+ if (!event.data) {
+ return;
+ }
+
+ // Handle protocol messages (DOCUMENT_CHANGED, DOCUMENT_LOADED).
+ if (event.data.type === 'DOCUMENT_CHANGED') {
+ hasUnsavedChanges = true;
+ return;
+ }
+
+ if (event.data.type === 'DOCUMENT_LOADED') {
+ if (saveBtn && !isSaving) {
+ saveBtn.disabled = false;
+ }
+ return;
+ }
+
+ // Handle bridge messages.
+ if (event.data.source !== 'exescorm-editor') {
+ return;
+ }
+
+ switch (event.data.type) {
+ case 'save-complete':
+ Log.debug('[editor_modal] Save complete, revision:', event.data.data.revision);
+ hasUnsavedChanges = false;
+ setSaveLabel('savedsuccess', 'Saved successfully');
+ closeModal(true);
+ setTimeout(function() {
+ window.location.reload();
+ }, 400);
+ break;
+
+ case 'save-error':
+ Log.error('[editor_modal] Save error:', event.data.data.error);
+ isSaving = false;
+ hideLoadingModal();
+ if (saveBtn) {
+ saveBtn.disabled = false;
+ }
+ setSaveLabel('savetomoodle', 'Save to Moodle');
+ 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
+ */
+ var handleKeydown = function(event) {
+ if (event.key === 'Escape') {
+ closeModal(false);
+ }
+ };
+
+ /**
+ * 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
+ */
+ var openModal = function(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.
+ var header = document.createElement('div');
+ header.className = 'exescorm-editor-header';
+
+ var title = document.createElement('span');
+ title.className = 'exescorm-editor-title';
+ title.textContent = activityName || '';
+ header.appendChild(title);
+
+ var buttonGroup = document.createElement('div');
+ buttonGroup.className = 'exescorm-editor-buttons';
+
+ // Save button.
+ saveBtn = document.createElement('button');
+ saveBtn.className = 'btn btn-primary mr-2';
+ saveBtn.id = 'exescorm-editor-save';
+ saveBtn.disabled = true;
+ setSaveLabel('savetomoodle', 'Save to Moodle');
+ saveBtn.addEventListener('click', triggerSave);
+
+ // Close button.
+ var closeBtn = document.createElement('button');
+ closeBtn.className = 'btn btn-secondary';
+ closeBtn.id = 'exescorm-editor-close';
+ getString('closebuttontitle', 'core').then(function(text) {
+ closeBtn.textContent = text;
+ }).catch(function() {
+ closeBtn.textContent = 'Close';
+ });
+ closeBtn.addEventListener('click', function() {
+ closeModal(false);
+ });
+
+ 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);
+ };
+
+ return {
+ init: function() {
+ prefetchStrings();
+
+ // Delegate click events for embedded editor buttons.
+ document.addEventListener('click', function(e) {
+ var btn = e.target.closest('[data-action="mod_exescorm/editor-open"]');
+ if (btn) {
+ e.preventDefault();
+ var cmid = btn.dataset.cmid;
+ var editorUrl = btn.dataset.editorurl;
+ var name = btn.dataset.activityname;
+ openModal(cmid, editorUrl, name);
+ }
+ });
+ },
+ open: openModal,
+ close: closeModal,
+ };
+});
diff --git a/amd/build/moodle_exe_bridge.min.js b/amd/build/moodle_exe_bridge.min.js
new file mode 100644
index 0000000..0c413b5
--- /dev/null
+++ b/amd/build/moodle_exe_bridge.min.js
@@ -0,0 +1,399 @@
+/**
+ * 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 monitoredYdoc = null;
+ var changeNotified = 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 postProtocolMessage(message) {
+ if (!parentWindow) {
+ return;
+ }
+ parentWindow.postMessage(message, '*');
+ }
+
+ function monitorDocumentChanges() {
+ try {
+ var app = window.eXeLearning && window.eXeLearning.app;
+ var ydoc = app && app.project && app.project._yjsBridge
+ && app.project._yjsBridge.documentManager && app.project._yjsBridge.documentManager.ydoc;
+ if (!ydoc || typeof ydoc.on !== 'function') {
+ return;
+ }
+ if (ydoc === monitoredYdoc) {
+ return;
+ }
+ monitoredYdoc = ydoc;
+ changeNotified = false;
+ ydoc.on('update', function() {
+ if (!changeNotified) {
+ changeNotified = true;
+ postProtocolMessage({type: 'DOCUMENT_CHANGED'});
+ }
+ });
+ } catch (error) {
+ console.warn('[moodle-exe-bridge] Change monitor failed:', error);
+ }
+ }
+
+ async function notifyWhenDocumentLoaded() {
+ try {
+ var timeout = 30000;
+ var start = Date.now();
+ while (Date.now() - start < timeout) {
+ var app = window.eXeLearning && window.eXeLearning.app;
+ var manager = app && app.project && app.project._yjsBridge
+ && app.project._yjsBridge.documentManager;
+ if (manager) {
+ postProtocolMessage({type: 'DOCUMENT_LOADED'});
+ monitorDocumentChanges();
+ return;
+ }
+ await new Promise(function(resolve) {
+ setTimeout(resolve, 150);
+ });
+ }
+ } catch (error) {
+ console.warn('[moodle-exe-bridge] DOCUMENT_LOADED monitor failed:', error);
+ }
+ }
+
+ // Re-attach ydoc monitor when the parent sends messages that may replace the document.
+ window.addEventListener('message', function() {
+ setTimeout(monitorDocumentChanges, 500);
+ });
+
+ 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);
+ }
+
+ notifyWhenDocumentLoaded();
+
+ // 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/amd/src/admin_embedded_editor.js b/amd/src/admin_embedded_editor.js
new file mode 100644
index 0000000..b1d6076
--- /dev/null
+++ b/amd/src/admin_embedded_editor.js
@@ -0,0 +1,569 @@
+// 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 .
+
+/**
+ * AMD module for the admin embedded editor settings widget.
+ *
+ * All install/update flows go through the plugin's AJAX endpoints. In Moodle
+ * Playground, outbound requests are handled by the PHP WASM networking layer
+ * configured by the runtime.
+ *
+ * @module mod_exescorm/admin_embedded_editor
+ * @copyright 2025 eXeLearning
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/ajax', 'core/notification', 'core/str'], function($, Ajax, Notification, Str) {
+
+ /** Maximum seconds to wait before switching to polling. */
+ var ACTION_TIMEOUT_MS = 120000;
+
+ /** Polling interval in milliseconds. */
+ var POLL_INTERVAL_MS = 10000;
+
+ /** Maximum number of polling iterations (5 minutes). */
+ var MAX_POLL_ITERATIONS = 30;
+
+ /**
+ * Find the widget container element.
+ *
+ * @returns {jQuery} The container element.
+ */
+ var getContainer = function() {
+ return $('.mod_exescorm-admin-embedded-editor').first();
+ };
+
+ /**
+ * Return runtime configuration derived from PHP context.
+ *
+ * @param {Object} config Configuration object passed from PHP.
+ * @returns {Object}
+ */
+ var getRuntimeConfig = function(config) {
+ return config || {};
+ };
+
+ /**
+ * Return the latest-version UI elements used by the widget.
+ *
+ * @param {jQuery} container The widget container.
+ * @returns {{spinnerEl: jQuery, textEl: jQuery}}
+ */
+ var getLatestVersionElements = function(container) {
+ return {
+ spinnerEl: container.find('.mod_exescorm-latest-version-spinner'),
+ textEl: container.find('.mod_exescorm-latest-version-text'),
+ };
+ };
+
+ /**
+ * Disable all action buttons in the widget.
+ *
+ * @param {jQuery} container The widget container.
+ */
+ var disableButtons = function(container) {
+ container.find('[data-action]').prop('disabled', true);
+ };
+
+ /**
+ * Enable action buttons based on current status data.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {Object} statusData Status object from get_status response.
+ */
+ var enableButtons = function(container, statusData) {
+ container.find('.mod_exescorm-btn-install').prop('disabled', true).hide();
+ container.find('.mod_exescorm-btn-update').prop('disabled', true).hide();
+ container.find('.mod_exescorm-btn-uninstall').prop('disabled', true).hide();
+
+ if (statusData.can_install) {
+ container.find('.mod_exescorm-btn-install').prop('disabled', false).show();
+ }
+ if (statusData.can_update) {
+ container.find('.mod_exescorm-btn-update').prop('disabled', false).show();
+ }
+ if (statusData.can_uninstall) {
+ container.find('.mod_exescorm-btn-uninstall').prop('disabled', false).show();
+ }
+ };
+
+ /**
+ * Show the progress bar area and hide the result area.
+ *
+ * @param {jQuery} container The widget container.
+ */
+ var showProgress = function(container) {
+ container.find('.mod_exescorm-progress-container').show();
+ container.find('.mod_exescorm-result-area').hide();
+ };
+
+ /**
+ * Hide the progress bar area.
+ *
+ * @param {jQuery} container The widget container.
+ */
+ var hideProgress = function(container) {
+ container.find('.mod_exescorm-progress-container').hide();
+ };
+
+ /**
+ * Set the spinner area visual state.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {string} state One of active, success, or error.
+ * @param {string} message Optional message to display next to the spinner.
+ */
+ var setProgressState = function(container, state, message) {
+ var msgEl = container.find('.mod_exescorm-progress-message');
+ var spinnerEl = container.find('.mod_exescorm-progress-container .spinner-border');
+
+ if (state === 'active') {
+ spinnerEl.show();
+ } else {
+ spinnerEl.hide();
+ }
+
+ msgEl.text(message || '');
+ };
+
+ /**
+ * Render the latest-version text, optionally with an update badge.
+ *
+ * @param {jQuery} textEl Target element.
+ * @param {string} text Main text to show.
+ * @param {string|null} badgeText Optional badge label.
+ */
+ var setLatestVersionText = function(textEl, text, badgeText) {
+ if (badgeText) {
+ textEl.html(text + ' ' + badgeText + ' ');
+ } else {
+ textEl.text(text);
+ }
+ };
+
+ /**
+ * Escape HTML special characters in plain text.
+ *
+ * @param {string} value Plain text.
+ * @returns {string}
+ */
+ var escapeHtml = function(value) {
+ return $('').text(value || '').html();
+ };
+
+ /**
+ * Update the primary status summary shown on the left of the header row.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {Object} statusData Status object from get_status response.
+ */
+ var updateStatusSummary = function(container, statusData) {
+ var summaryEl = container.find('.mod_exescorm-status-primary');
+ if (!summaryEl.length) {
+ return;
+ }
+
+ Str.get_strings([
+ {key: 'editormoodledatasource', component: 'mod_exescorm'},
+ {key: 'editorinstalledat', component: 'mod_exescorm'},
+ {key: 'editorbundledsource', component: 'mod_exescorm'},
+ {key: 'editorbundleddesc', component: 'mod_exescorm'},
+ {key: 'noeditorinstalled', component: 'mod_exescorm'},
+ {key: 'editornotinstalleddesc', component: 'mod_exescorm'},
+ ]).then(function(strings) {
+ var html = '';
+
+ if (statusData.active_source === 'moodledata') {
+ html += '
' + escapeHtml(strings[0]) + ' ';
+ if (statusData.moodledata_version) {
+ html += '
v' +
+ escapeHtml(statusData.moodledata_version) + ' ';
+ }
+ if (statusData.moodledata_installed_at) {
+ html += '
' + escapeHtml(strings[1]) + ' ' +
+ escapeHtml(statusData.moodledata_installed_at) + ' ';
+ }
+ } else if (statusData.active_source === 'bundled') {
+ html += '
' + escapeHtml(strings[2]) + ' ';
+ html += '
' + escapeHtml(strings[3]) + ' ';
+ } else {
+ html += '
' + escapeHtml(strings[4]) + ' ';
+ html += '
' + escapeHtml(strings[5]) + ' ';
+ }
+
+ summaryEl.html(html);
+ }).catch(function() {
+ // Keep the current markup if the strings cannot be loaded.
+ });
+ };
+
+ /**
+ * Fetch the latest-version prefix label.
+ *
+ * @param {string} version Version string.
+ * @returns {Promise
}
+ */
+ var getLatestVersionLabel = function(version) {
+ return Str.get_string('editorlatestversionongithub', 'mod_exescorm')
+ .then(function(prefix) {
+ return prefix + ' v' + version;
+ })
+ .catch(function() {
+ return 'v' + version;
+ });
+ };
+
+ /**
+ * Fetch the optional update-available badge label.
+ *
+ * @param {Object} statusData Status object from get_status response.
+ * @returns {Promise}
+ */
+ var getLatestVersionBadgeLabel = function(statusData) {
+ if (!statusData.update_available) {
+ return Promise.resolve(null);
+ }
+
+ return Str.get_string('updateavailable', 'mod_exescorm')
+ .catch(function() {
+ return Str.get_string('editorupdateavailable', 'mod_exescorm', statusData.latest_version);
+ })
+ .catch(function() {
+ return null;
+ });
+ };
+
+ /**
+ * Update the latest version area in the DOM.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {Object} statusData Status object from get_status response.
+ */
+ var updateLatestVersionArea = function(container, statusData) {
+ var elements = getLatestVersionElements(container);
+ var spinnerEl = elements.spinnerEl;
+ var textEl = elements.textEl;
+
+ spinnerEl.hide();
+ textEl.show();
+
+ if (statusData.latest_error) {
+ textEl.text('(' + statusData.latest_error + ')');
+ } else if (statusData.latest_version) {
+ Promise.all([
+ getLatestVersionLabel(statusData.latest_version),
+ getLatestVersionBadgeLabel(statusData),
+ ]).then(function(results) {
+ setLatestVersionText(textEl, results[0], results[1]);
+ }).catch(function() {
+ textEl.text('v' + statusData.latest_version);
+ });
+ } else {
+ textEl.text('');
+ }
+ };
+
+ /**
+ * Update all DOM elements based on a get_status response.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {Object} statusData Status object from get_status response.
+ */
+ var updateStatus = function(container, statusData) {
+ updateStatusSummary(container, statusData);
+ updateLatestVersionArea(container, statusData);
+ enableButtons(container, statusData);
+ };
+
+ /**
+ * Show a result message in the result area.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {string} message The message text.
+ * @param {string} type Bootstrap alert type.
+ */
+ var showResultMessage = function(container, message, type) {
+ var resultArea = container.find('.mod_exescorm-result-area');
+ var msgEl = container.find('.mod_exescorm-result-message');
+
+ msgEl.removeClass('alert-success alert-danger alert-warning')
+ .addClass('alert-' + type)
+ .text(message);
+ resultArea.show();
+ };
+
+ /**
+ * Call get_status via AJAX and return a Promise.
+ *
+ * @param {boolean} checklatest Whether to query GitHub for the latest version.
+ * @returns {Promise}
+ */
+ var callGetStatus = function(checklatest) {
+ var requests = Ajax.call([{
+ methodname: 'mod_exescorm_manage_embedded_editor_status',
+ args: {checklatest: !!checklatest},
+ }]);
+ return requests[0];
+ };
+
+ /**
+ * Call execute_action via AJAX and return a Promise.
+ *
+ * @param {string} action One of install, update, repair, uninstall.
+ * @returns {Promise}
+ */
+ var callExecuteAction = function(action) {
+ var requests = Ajax.call([{
+ methodname: 'mod_exescorm_manage_embedded_editor_action',
+ args: {action: action},
+ }]);
+ return requests[0];
+ };
+
+ /**
+ * Refresh widget status from the server and optionally enrich latest version.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {boolean} checklatest Whether latest version should be queried.
+ * @param {Object} runtimeConfig Runtime config.
+ * @returns {Promise}
+ */
+ var refreshStatus = function(container, checklatest, runtimeConfig) {
+ return callGetStatus(!!checklatest).then(function(statusData) {
+ updateStatus(container, statusData);
+ return statusData;
+ });
+ };
+
+ /**
+ * Begin a polling loop after an action timeout.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {Object} runtimeConfig Runtime config.
+ */
+ var startPolling = function(container, runtimeConfig) {
+ var iterations = 0;
+
+ var poll = function() {
+ iterations++;
+
+ if (iterations > MAX_POLL_ITERATIONS) {
+ Str.get_string('operationtimedout', 'mod_exescorm').then(function(msg) {
+ setProgressState(container, 'error', msg);
+ showResultMessage(container, msg, 'danger');
+ Notification.addNotification({message: msg, type: 'error'});
+ }).catch(function() {
+ var msg = 'Timeout';
+ setProgressState(container, 'error', msg);
+ showResultMessage(container, msg, 'danger');
+ Notification.addNotification({message: msg, type: 'error'});
+ });
+ callGetStatus(false).then(function(statusData) {
+ enableButtons(container, statusData);
+ }).catch(function() {
+ container.find('[data-action]').prop('disabled', false);
+ });
+ return;
+ }
+
+ callGetStatus(false).then(function(statusData) {
+ if (statusData.install_stale) {
+ Str.get_string('installstale', 'mod_exescorm').then(function(msg) {
+ setProgressState(container, 'error', msg);
+ showResultMessage(container, msg, 'warning');
+ Notification.addNotification({message: msg, type: 'warning'});
+ }).catch(function() {
+ var msg = 'Error';
+ setProgressState(container, 'error', msg);
+ showResultMessage(container, msg, 'warning');
+ });
+ updateStatus(container, statusData);
+ return;
+ }
+
+ if (!statusData.installing) {
+ setProgressState(container, 'success');
+ hideProgress(container);
+ refreshStatus(container, true, runtimeConfig).catch(function() {
+ updateStatus(container, statusData);
+ });
+ return;
+ }
+
+ Str.get_string('stillworking', 'mod_exescorm').then(function(msg) {
+ setProgressState(container, 'active', msg);
+ }).catch(function() {
+ setProgressState(container, 'active');
+ });
+
+ window.setTimeout(poll, POLL_INTERVAL_MS);
+ }).catch(function() {
+ window.setTimeout(poll, POLL_INTERVAL_MS);
+ });
+ };
+
+ Str.get_string('operationtakinglong', 'mod_exescorm').then(function(msg) {
+ setProgressState(container, 'active', msg);
+ }).catch(function() {
+ setProgressState(container, 'active');
+ });
+
+ window.setTimeout(poll, POLL_INTERVAL_MS);
+ };
+
+ /**
+ * Execute an action via AJAX or the Playground upload flow.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {string} action The action to perform.
+ * @param {Object} runtimeConfig Runtime config.
+ */
+ var executeAction = function(container, action, runtimeConfig) {
+ var clickedBtn = container.find('[data-action="' + action + '"]');
+ var originalBtnHtml = clickedBtn.html();
+
+ // Choose the right lang strings for the button and spinner message.
+ var btnStringKey = (action === 'uninstall') ? 'editoruninstalling' : 'editorinstalling';
+ var msgStringKey = (action === 'uninstall') ? 'editoruninstallingmessage' : 'editordownloadingmessage';
+
+ Str.get_strings([
+ {key: btnStringKey, component: 'mod_exescorm'},
+ {key: msgStringKey, component: 'mod_exescorm'},
+ ]).then(function(strings) {
+ clickedBtn.html(strings[0]);
+ setProgressState(container, 'active', strings[1]);
+ }).catch(function() {
+ setProgressState(container, 'active');
+ });
+
+ disableButtons(container);
+ showProgress(container);
+
+ var timeoutHandle = null;
+ var timedOut = false;
+ var actionPromise = null;
+
+ timeoutHandle = window.setTimeout(function() {
+ timedOut = true;
+ startPolling(container, runtimeConfig);
+ }, ACTION_TIMEOUT_MS);
+
+ actionPromise = callExecuteAction(action);
+
+ actionPromise.then(function(result) {
+ if (timedOut) {
+ return result;
+ }
+ window.clearTimeout(timeoutHandle);
+
+ clickedBtn.html(originalBtnHtml);
+ hideProgress(container);
+ var message = result.message || 'Done.';
+ showResultMessage(container, message, 'success');
+ Notification.addNotification({message: message, type: 'success'});
+
+ refreshStatus(container, true, runtimeConfig).catch(function() {
+ // Keep current state.
+ });
+
+ return result;
+ }).catch(function(err) {
+ if (timedOut) {
+ return;
+ }
+ window.clearTimeout(timeoutHandle);
+
+ clickedBtn.html(originalBtnHtml);
+ hideProgress(container);
+ var message = (err && err.message) ? err.message : 'An error occurred.';
+ showResultMessage(container, message, 'danger');
+ Notification.addNotification({message: message, type: 'error'});
+
+ callGetStatus(false).then(function(statusData) {
+ enableButtons(container, statusData);
+ }).catch(function() {
+ container.find('[data-action]').prop('disabled', false);
+ });
+ });
+ };
+
+ /**
+ * Handle a button click.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {string} action The action string from data-action.
+ * @param {Object} runtimeConfig Runtime config.
+ */
+ var handleActionClick = function(container, action, runtimeConfig) {
+ if (action === 'uninstall') {
+ Str.get_strings([
+ {key: 'confirmuninstalltitle', component: 'mod_exescorm'},
+ {key: 'confirmuninstall', component: 'mod_exescorm'},
+ {key: 'yes', component: 'core'},
+ {key: 'no', component: 'core'},
+ ]).then(function(strings) {
+ Notification.confirm(
+ strings[0],
+ strings[1],
+ strings[2],
+ strings[3],
+ function() {
+ executeAction(container, action, runtimeConfig);
+ }
+ );
+ }).catch(function() {
+ if (window.confirm(container.attr('data-confirm-uninstall') || '')) {
+ executeAction(container, action, runtimeConfig);
+ }
+ });
+ } else {
+ executeAction(container, action, runtimeConfig);
+ }
+ };
+
+ /**
+ * Initialise the widget.
+ *
+ * @param {Object} config Configuration object passed from PHP.
+ */
+ var init = function(config) {
+ var runtimeConfig = getRuntimeConfig(config || {});
+ var container = getContainer();
+ if (!container.length) {
+ return;
+ }
+
+ var elements = getLatestVersionElements(container);
+ elements.spinnerEl.show();
+ elements.textEl.hide();
+
+ refreshStatus(container, true, runtimeConfig).catch(function() {
+ elements.spinnerEl.hide();
+ elements.textEl.show();
+ });
+
+ container.on('click', '[data-action]', function(e) {
+ e.preventDefault();
+ var action = $(this).data('action');
+ if (!action) {
+ return;
+ }
+ handleActionClick(container, action, runtimeConfig);
+ });
+ };
+
+ return {
+ init: init,
+ };
+});
diff --git a/amd/src/edit_confirmation.js b/amd/src/edit_confirmation.js
index 4cdfac5..c2e5fef 100644
--- a/amd/src/edit_confirmation.js
+++ b/amd/src/edit_confirmation.js
@@ -20,66 +20,69 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @copyright 3iPunt
*/
-import * as Str from 'core/str';
-import ModalFactory from 'core/modal_factory';
-import ModalEvents from 'core/modal_events';
+define(['core/str', 'core/modal_factory', 'core/modal_events'], function(Str, ModalFactory, ModalEvents) {
+ /**
+ * Initialize the edit confirmation functionality.
+ */
+ var init = function() {
+ var editButtons = document.querySelectorAll('[data-action="edit-exescorm"]');
-/**
- * Initialize the edit confirmation functionality.
- */
-export const init = () => {
- const editButtons = document.querySelectorAll('[data-action="edit-exescorm"]');
-
- editButtons.forEach(button => {
- button.addEventListener('click', (e) => {
- e.preventDefault();
- const targetUrl = button.getAttribute('data-editurl');
- showConfirmation(targetUrl);
+ editButtons.forEach(function(button) {
+ button.addEventListener('click', function(e) {
+ e.preventDefault();
+ var targetUrl = button.getAttribute('data-editurl');
+ showConfirmation(targetUrl);
+ });
});
- });
-};
+ };
-/**
- * Show confirmation modal before redirecting to edit.
- *
- * @param {string} targetUrl The URL to redirect to if confirmed
- */
-export const showConfirmation = (targetUrl) => {
- Promise.all([
- Str.get_string('edit', 'core'),
- Str.get_string('yes', 'core'),
- Str.get_string('cancel', 'core'),
- Str.get_string('editdialogcontent', 'mod_exescorm'),
- Str.get_string('editdialogcontent:caution', 'mod_exescorm'),
- Str.get_string('editdialogcontent:continue', 'mod_exescorm'),
- ]).then((strings) => {
- // Center the body content using a div with text-center class.
- const warnIcon = ' ';
- const bodyContent = strings[3] + '' + warnIcon + strings[4] +
- ' ' + '' + strings[5] +
- '
';
- return ModalFactory.create({
- type: ModalFactory.types.SAVE_CANCEL,
- title: strings[0],
- body: bodyContent,
- buttons: {
- save: strings[1],
- cancel: strings[2]
- }
- });
- }).then((modal) => {
- modal.getRoot().on(ModalEvents.save, () => {
- window.location.href = targetUrl;
- });
+ /**
+ * Show confirmation modal before redirecting to edit.
+ *
+ * @param {string} targetUrl The URL to redirect to if confirmed
+ */
+ var showConfirmation = function(targetUrl) {
+ Promise.all([
+ Str.get_string('edit', 'core'),
+ Str.get_string('yes', 'core'),
+ Str.get_string('cancel', 'core'),
+ Str.get_string('editdialogcontent', 'mod_exescorm'),
+ Str.get_string('editdialogcontent:caution', 'mod_exescorm'),
+ Str.get_string('editdialogcontent:continue', 'mod_exescorm'),
+ ]).then(function(strings) {
+ // Center the body content using a div with text-center class.
+ var warnIcon = ' ';
+ var bodyContent = strings[3] + '' + warnIcon + strings[4] +
+ ' ' + '' + strings[5] +
+ '
';
+ return ModalFactory.create({
+ type: ModalFactory.types.SAVE_CANCEL,
+ title: strings[0],
+ body: bodyContent,
+ buttons: {
+ save: strings[1],
+ cancel: strings[2],
+ },
+ });
+ }).then(function(modal) {
+ modal.getRoot().on(ModalEvents.save, function() {
+ window.location.href = targetUrl;
+ });
- modal.getRoot().on(ModalEvents.cancel, () => {
- modal.hide();
+ modal.getRoot().on(ModalEvents.cancel, function() {
+ modal.hide();
+ });
+
+ modal.show();
+ return modal;
+ }).catch(function() {
+ window.location.href = targetUrl;
});
+ };
- modal.show();
- return modal;
- }).catch(() => {
- window.location.href = targetUrl;
- });
-};
+ return {
+ init: init,
+ showConfirmation: showConfirmation,
+ };
+});
diff --git a/amd/src/editor_modal.js b/amd/src/editor_modal.js
new file mode 100644
index 0000000..9519862
--- /dev/null
+++ b/amd/src/editor_modal.js
@@ -0,0 +1,353 @@
+// 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 */
+
+define(['core/str', 'core/log', 'core/prefetch'], function(Str, Log, Prefetch) {
+
+ var overlay = null;
+ var iframe = null;
+ var saveBtn = null;
+ var loadingModal = null;
+ var isSaving = false;
+ var hasUnsavedChanges = false;
+
+ var getString = Str.get_string;
+
+ /**
+ * Prefetch language strings used by the modal.
+ */
+ var prefetchStrings = function() {
+ Prefetch.prefetchStrings('mod_exescorm', [
+ 'editembedded', 'saving', 'savedsuccess', 'savetomoodle', 'savingwait', 'unsavedchanges',
+ ]);
+ Prefetch.prefetchStrings('core', ['closebuttontitle']);
+ };
+
+ var setSaveLabel = function(key, fallback) {
+ if (!saveBtn) {
+ return Promise.resolve();
+ }
+ var component = key === 'closebuttontitle' ? 'core' : 'mod_exescorm';
+ return getString(key, component).then(function(text) {
+ saveBtn.innerHTML = ' ' + text;
+ }).catch(function() {
+ saveBtn.innerHTML = ' ' + fallback;
+ });
+ };
+
+ var createLoadingModal = function() {
+ var modal = document.createElement('div');
+ modal.className = 'exescorm-loading-modal';
+ modal.id = 'exescorm-loading-modal';
+
+ return Promise.all([
+ getString('saving', 'mod_exescorm').catch(function() { return 'Saving...'; }),
+ getString('savingwait', 'mod_exescorm').catch(function() {
+ return 'Please wait while the file is being saved.';
+ }),
+ ]).then(function(strings) {
+ modal.innerHTML =
+ '' +
+ '
' +
+ '
' + strings[0] + ' ' +
+ '
' + strings[1] + '
' +
+ '
';
+ document.body.appendChild(modal);
+ return modal;
+ });
+ };
+
+ var showLoadingModal = function() {
+ if (loadingModal) {
+ loadingModal.classList.add('is-visible');
+ return Promise.resolve();
+ }
+ return createLoadingModal().then(function(modal) {
+ loadingModal = modal;
+ loadingModal.classList.add('is-visible');
+ });
+ };
+
+ var hideLoadingModal = function() {
+ if (loadingModal) {
+ loadingModal.classList.remove('is-visible');
+ }
+ };
+
+ var removeLoadingModal = function() {
+ if (loadingModal) {
+ loadingModal.remove();
+ loadingModal = null;
+ }
+ };
+
+ var checkUnsavedChanges = function() {
+ if (!hasUnsavedChanges) {
+ return Promise.resolve(false);
+ }
+ return getString('unsavedchanges', 'mod_exescorm').catch(function() {
+ return 'You have unsaved changes. Are you sure you want to close?';
+ }).then(function(message) {
+ return !window.confirm(message);
+ });
+ };
+
+ /**
+ * Send a save request to the editor iframe.
+ */
+ var triggerSave = function() {
+ if (isSaving || !iframe || !iframe.contentWindow) {
+ return;
+ }
+
+ isSaving = true;
+ if (saveBtn) {
+ saveBtn.disabled = true;
+ }
+ setSaveLabel('saving', 'Saving...').then(function() {
+ return showLoadingModal();
+ }).then(function() {
+ iframe.contentWindow.postMessage({
+ source: 'exescorm-modal',
+ type: 'save',
+ }, '*');
+ });
+ };
+
+ /**
+ * Close the editor modal and clean up.
+ * @param {boolean} skipConfirm
+ */
+ var closeModal = function(skipConfirm) {
+ if (!overlay) {
+ return Promise.resolve();
+ }
+ var doClose = function() {
+ var wasShowingLoader = isSaving || (skipConfirm === true);
+
+ overlay.remove();
+ overlay = null;
+ iframe = null;
+ saveBtn = null;
+ isSaving = false;
+ hasUnsavedChanges = false;
+
+ document.body.style.overflow = '';
+ window.removeEventListener('message', handleMessage);
+ document.removeEventListener('keydown', handleKeydown);
+
+ if (wasShowingLoader) {
+ setTimeout(function() {
+ hideLoadingModal();
+ removeLoadingModal();
+ }, 1500);
+ } else {
+ hideLoadingModal();
+ removeLoadingModal();
+ }
+ };
+
+ if (!skipConfirm) {
+ return checkUnsavedChanges().then(function(shouldCancel) {
+ if (!shouldCancel) {
+ doClose();
+ }
+ });
+ }
+ doClose();
+ return Promise.resolve();
+ };
+
+ /**
+ * Handle postMessage events from the editor iframe.
+ * @param {MessageEvent} event
+ */
+ var handleMessage = function(event) {
+ if (!event.data) {
+ return;
+ }
+
+ // Handle protocol messages (DOCUMENT_CHANGED, DOCUMENT_LOADED).
+ if (event.data.type === 'DOCUMENT_CHANGED') {
+ hasUnsavedChanges = true;
+ return;
+ }
+
+ if (event.data.type === 'DOCUMENT_LOADED') {
+ if (saveBtn && !isSaving) {
+ saveBtn.disabled = false;
+ }
+ return;
+ }
+
+ // Handle bridge messages.
+ if (event.data.source !== 'exescorm-editor') {
+ return;
+ }
+
+ switch (event.data.type) {
+ case 'save-complete':
+ Log.debug('[editor_modal] Save complete, revision:', event.data.data.revision);
+ hasUnsavedChanges = false;
+ setSaveLabel('savedsuccess', 'Saved successfully');
+ closeModal(true);
+ setTimeout(function() {
+ window.location.reload();
+ }, 400);
+ break;
+
+ case 'save-error':
+ Log.error('[editor_modal] Save error:', event.data.data.error);
+ isSaving = false;
+ hideLoadingModal();
+ if (saveBtn) {
+ saveBtn.disabled = false;
+ }
+ setSaveLabel('savetomoodle', 'Save to Moodle');
+ 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
+ */
+ var handleKeydown = function(event) {
+ if (event.key === 'Escape') {
+ closeModal(false);
+ }
+ };
+
+ /**
+ * 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
+ */
+ var openModal = function(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.
+ var header = document.createElement('div');
+ header.className = 'exescorm-editor-header';
+
+ var title = document.createElement('span');
+ title.className = 'exescorm-editor-title';
+ title.textContent = activityName || '';
+ header.appendChild(title);
+
+ var buttonGroup = document.createElement('div');
+ buttonGroup.className = 'exescorm-editor-buttons';
+
+ // Save button.
+ saveBtn = document.createElement('button');
+ saveBtn.className = 'btn btn-primary mr-2';
+ saveBtn.id = 'exescorm-editor-save';
+ saveBtn.disabled = true;
+ setSaveLabel('savetomoodle', 'Save to Moodle');
+ saveBtn.addEventListener('click', triggerSave);
+
+ // Close button.
+ var closeBtn = document.createElement('button');
+ closeBtn.className = 'btn btn-secondary';
+ closeBtn.id = 'exescorm-editor-close';
+ getString('closebuttontitle', 'core').then(function(text) {
+ closeBtn.textContent = text;
+ }).catch(function() {
+ closeBtn.textContent = 'Close';
+ });
+ closeBtn.addEventListener('click', function() {
+ closeModal(false);
+ });
+
+ 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);
+ };
+
+ return {
+ init: function() {
+ prefetchStrings();
+
+ // Delegate click events for embedded editor buttons.
+ document.addEventListener('click', function(e) {
+ var btn = e.target.closest('[data-action="mod_exescorm/editor-open"]');
+ if (btn) {
+ e.preventDefault();
+ var cmid = btn.dataset.cmid;
+ var editorUrl = btn.dataset.editorurl;
+ var name = btn.dataset.activityname;
+ openModal(cmid, editorUrl, name);
+ }
+ });
+ },
+ open: openModal,
+ close: closeModal,
+ };
+});
diff --git a/amd/src/moodle_exe_bridge.js b/amd/src/moodle_exe_bridge.js
new file mode 100644
index 0000000..0c413b5
--- /dev/null
+++ b/amd/src/moodle_exe_bridge.js
@@ -0,0 +1,399 @@
+/**
+ * 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 monitoredYdoc = null;
+ var changeNotified = 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 postProtocolMessage(message) {
+ if (!parentWindow) {
+ return;
+ }
+ parentWindow.postMessage(message, '*');
+ }
+
+ function monitorDocumentChanges() {
+ try {
+ var app = window.eXeLearning && window.eXeLearning.app;
+ var ydoc = app && app.project && app.project._yjsBridge
+ && app.project._yjsBridge.documentManager && app.project._yjsBridge.documentManager.ydoc;
+ if (!ydoc || typeof ydoc.on !== 'function') {
+ return;
+ }
+ if (ydoc === monitoredYdoc) {
+ return;
+ }
+ monitoredYdoc = ydoc;
+ changeNotified = false;
+ ydoc.on('update', function() {
+ if (!changeNotified) {
+ changeNotified = true;
+ postProtocolMessage({type: 'DOCUMENT_CHANGED'});
+ }
+ });
+ } catch (error) {
+ console.warn('[moodle-exe-bridge] Change monitor failed:', error);
+ }
+ }
+
+ async function notifyWhenDocumentLoaded() {
+ try {
+ var timeout = 30000;
+ var start = Date.now();
+ while (Date.now() - start < timeout) {
+ var app = window.eXeLearning && window.eXeLearning.app;
+ var manager = app && app.project && app.project._yjsBridge
+ && app.project._yjsBridge.documentManager;
+ if (manager) {
+ postProtocolMessage({type: 'DOCUMENT_LOADED'});
+ monitorDocumentChanges();
+ return;
+ }
+ await new Promise(function(resolve) {
+ setTimeout(resolve, 150);
+ });
+ }
+ } catch (error) {
+ console.warn('[moodle-exe-bridge] DOCUMENT_LOADED monitor failed:', error);
+ }
+ }
+
+ // Re-attach ydoc monitor when the parent sends messages that may replace the document.
+ window.addEventListener('message', function() {
+ setTimeout(monitorDocumentChanges, 500);
+ });
+
+ 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);
+ }
+
+ notifyWhenDocumentLoaded();
+
+ // 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/blueprint.json b/blueprint.json
new file mode 100644
index 0000000..cfdc2cb
--- /dev/null
+++ b/blueprint.json
@@ -0,0 +1,146 @@
+{
+ "$schema": "https://raw.githubusercontent.com/ateeducacion/moodle-playground/refs/heads/main/assets/blueprints/blueprint-schema.json",
+ "preferredVersions": { "php": "8.3", "moodle": "5.0" },
+ "landingPage": "/course/view.php?id=2",
+ "constants": {
+ "ADMIN_USER": "admin",
+ "ADMIN_PASS": "password",
+ "ADMIN_EMAIL": "admin@example.com"
+ },
+ "steps": [
+ {
+ "step": "installMoodle",
+ "options": {
+ "adminUser": "{{ADMIN_USER}}",
+ "adminPass": "{{ADMIN_PASS}}",
+ "adminEmail": "{{ADMIN_EMAIL}}",
+ "siteName": "eXeLearning SCORM Demo",
+ "locale": "es",
+ "timezone": "Europe/Madrid"
+ }
+ },
+ { "step": "login", "username": "{{ADMIN_USER}}" },
+ {
+ "step": "installMoodlePlugin",
+ "pluginType": "mod",
+ "pluginName": "exescorm",
+ "url": "https://github.com/exelearning/mod_exescorm/archive/refs/heads/main.zip"
+ },
+ { "step": "setConfig", "name": "editormode", "value": "embedded", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "providername", "value": "Moodle", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "providerversion", "value": "5.0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "sendtemplate", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "mandatoryfileslist", "value": "/^content(v\\d+)?\\.xml$/", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "forbiddenfileslist", "value": "/.*\\.php$/", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "displaycoursestructure", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "popup", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "framewidth", "value": "100", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "frameheight", "value": "500", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "winoptgrp_adv", "value": "1", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "skipview", "value": "2", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "hidebrowse", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "hidetoc", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "nav", "value": "1", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "navpositionleft", "value": "-100", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "navpositiontop", "value": "-100", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "collapsetocwinsize", "value": "767", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "displayattemptstatus", "value": "1", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "grademethod", "value": "1", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "maxgrade", "value": "100", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "maxattempt", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "whatgrade", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "forcecompleted", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "forcenewattempt", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "autocommit", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "masteryoverride", "value": "1", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "lastattemptlock", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "auto", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "updatefreq", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "exescormstandard", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "allowtypeexternal", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "allowtypelocalsync", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "allowtypeexternalaicc", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "allowaicchacp", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "aicchacptimeout", "value": "30", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "aicchacpkeepsessiondata", "value": "1", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "aiccuserid", "value": "1", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "forcejavascript", "value": "1", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "allowapidebug", "value": "0", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "apidebugmask", "value": ".*", "plugin": "exescorm" },
+ { "step": "setConfig", "name": "protectpackagedownloads", "value": "0", "plugin": "exescorm" },
+ { "step": "createCategory", "name": "Test Courses" },
+ {
+ "step": "createCourse",
+ "fullname": "eXeLearning SCORM Test Course",
+ "shortname": "EXESCORM01",
+ "category": "Test Courses",
+ "summary": "Test course for the eXeLearning SCORM plugin."
+ },
+ {
+ "step": "createUser",
+ "username": "student",
+ "password": "password",
+ "email": "student@example.com",
+ "firstname": "Demo",
+ "lastname": "Student"
+ },
+ {
+ "step": "enrolUser",
+ "username": "{{ADMIN_USER}}",
+ "course": "EXESCORM01",
+ "role": "editingteacher"
+ },
+ {
+ "step": "enrolUser",
+ "username": "student",
+ "course": "EXESCORM01",
+ "role": "student"
+ },
+ {
+ "step": "createSection",
+ "course": "EXESCORM01",
+ "name": "Example SCORM Activities"
+ },
+ {
+ "step": "addModule",
+ "module": "exescorm",
+ "course": "EXESCORM01",
+ "section": 1,
+ "name": "Test Content",
+ "intro": "Sample eXeLearning SCORM content for testing.",
+ "exescormtype": "local",
+ "reference": "test-content.elpx",
+ "display": 1,
+ "files": [
+ {
+ "filearea": "package",
+ "filename": "test-content.elpx",
+ "data": {
+ "url": "https://raw.githubusercontent.com/exelearning/wp-exelearning/refs/heads/main/tests/fixtures/test-content.elpx"
+ }
+ }
+ ]
+ },
+ {
+ "step": "addModule",
+ "module": "exescorm",
+ "course": "EXESCORM01",
+ "section": 1,
+ "name": "Propiedades",
+ "intro": "Example content demonstrating eXeLearning properties.",
+ "exescormtype": "local",
+ "reference": "propiedades.elpx",
+ "display": 1,
+ "files": [
+ {
+ "filearea": "package",
+ "filename": "propiedades.elpx",
+ "data": {
+ "url": "https://raw.githubusercontent.com/exelearning/wp-exelearning/refs/heads/main/tests/fixtures/propiedades.elpx"
+ }
+ }
+ ]
+ },
+ { "step": "setLandingPage", "path": "/course/view.php?id=2" }
+ ]
+}
diff --git a/classes/admin/admin_setting_embeddededitor.php b/classes/admin/admin_setting_embeddededitor.php
new file mode 100644
index 0000000..12c1ff2
--- /dev/null
+++ b/classes/admin/admin_setting_embeddededitor.php
@@ -0,0 +1,138 @@
+.
+
+/**
+ * Admin setting widget for the embedded eXeLearning editor.
+ *
+ * Renders a status card with action buttons inside the admin settings page.
+ * Network I/O is executed through the plugin's AJAX services. In Moodle
+ * Playground, outbound requests are handled by the PHP WASM networking layer
+ * configured by the runtime.
+ *
+ * @package mod_exescorm
+ * @copyright 2025 eXeLearning
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_exescorm\admin;
+
+/**
+ * Custom admin setting that renders the embedded editor management widget.
+ *
+ * This setting stores no value itself (get_setting returns '' and
+ * write_setting always succeeds). Its sole purpose is to output the
+ * interactive status/action card into the admin settings page.
+ */
+class admin_setting_embeddededitor extends \admin_setting {
+
+ /**
+ * Constructor.
+ *
+ * @param string $visiblename Visible name shown as the setting label.
+ * @param string $description Short description shown below the label.
+ */
+ public function __construct(string $visiblename, string $description) {
+ parent::__construct('exescorm/embeddededitorwidget', $visiblename, $description, '');
+ }
+
+ /**
+ * Returns the current value of this setting.
+ *
+ * This widget stores no persistent value; return empty string so
+ * admin_setting machinery treats it as "has a value" (not null).
+ *
+ * @return string Always empty string.
+ */
+ public function get_setting(): string {
+ return '';
+ }
+
+ /**
+ * Persists a new value for this setting (no-op).
+ *
+ * All real actions are performed via AJAX; the form submit path is unused.
+ *
+ * @param mixed $data Submitted form data (ignored).
+ * @return string Empty string signals success to the settings framework.
+ */
+ public function write_setting($data): string {
+ return '';
+ }
+
+ /**
+ * Render the embedded editor status widget as HTML.
+ *
+ * Reads locally-cached state only (no GitHub API call). The AMD module
+ * mod_exescorm/admin_embedded_editor is initialised with a JS context
+ * object so it can wire up action buttons and the "latest version" area.
+ *
+ * @param mixed $data Current setting value (unused).
+ * @param string $query Admin search query string (unused).
+ * @return string Rendered HTML for the widget.
+ */
+ public function output_html($data, $query = ''): string {
+ global $PAGE, $OUTPUT;
+
+ $status = \mod_exescorm\local\embedded_editor_source_resolver::get_status();
+
+ // Determine source flags for the template.
+ $sourcemoodledata = ($status->active_source ===
+ \mod_exescorm\local\embedded_editor_source_resolver::SOURCE_MOODLEDATA);
+ $sourcebundled = ($status->active_source ===
+ \mod_exescorm\local\embedded_editor_source_resolver::SOURCE_BUNDLED);
+ $sourcenone = ($status->active_source ===
+ \mod_exescorm\local\embedded_editor_source_resolver::SOURCE_NONE);
+
+ // Determine which action buttons are available.
+ $caninstall = !$status->moodledata_available;
+ $canupdate = false; // JS will enable this after checking latest version.
+ $canuninstall = $status->moodledata_available;
+
+ // Build template context.
+ $context = [
+ 'sesskey' => sesskey(),
+ 'active_source' => $status->active_source,
+ 'active_source_moodledata' => $sourcemoodledata,
+ 'active_source_bundled' => $sourcebundled,
+ 'active_source_none' => $sourcenone,
+ 'moodledata_available' => (bool) $status->moodledata_available,
+ 'moodledata_version' => $status->moodledata_version ?? '',
+ 'moodledata_installed_at' => $status->moodledata_installed_at ?? '',
+ 'bundled_available' => (bool) $status->bundled_available,
+ 'can_install' => $caninstall,
+ 'can_update' => $canupdate,
+ 'can_uninstall' => $canuninstall,
+ ];
+
+ // JS context passed to AMD init.
+ $jscontext = [
+ 'sesskey' => sesskey(),
+ 'activesource' => $status->active_source,
+ 'caninstall' => $caninstall,
+ 'canuninstall' => $canuninstall,
+ ];
+
+ $PAGE->requires->js_call_amd('mod_exescorm/admin_embedded_editor', 'init', [$jscontext]);
+
+ $widgethtml = $OUTPUT->render_from_template('mod_exescorm/admin_embedded_editor', $context);
+ $labelhtml = \html_writer::tag('label', s($this->visiblename));
+ $labelcolumn = \html_writer::div($labelhtml, 'form-label col-md-3 text-md-right');
+ $contentcolumn = \html_writer::div($widgethtml, 'form-setting col-md-9');
+ $rowhtml = \html_writer::div($labelcolumn . $contentcolumn, 'form-item row');
+
+ return \html_writer::div($rowhtml, 'mod_exescorm-admin-embedded-editor-setting');
+ }
+}
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/classes/external/manage_embedded_editor.php b/classes/external/manage_embedded_editor.php
new file mode 100644
index 0000000..cb58530
--- /dev/null
+++ b/classes/external/manage_embedded_editor.php
@@ -0,0 +1,280 @@
+.
+
+/**
+ * External functions for managing the embedded editor in mod_exescorm.
+ *
+ * @package mod_exescorm
+ * @category external
+ * @copyright 2025 eXeLearning
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_exescorm\external;
+
+use core_external\external_api;
+use core_external\external_function_parameters;
+use core_external\external_single_structure;
+use core_external\external_value;
+use mod_exescorm\local\embedded_editor_installer;
+use mod_exescorm\local\embedded_editor_source_resolver;
+
+/**
+ * External API for managing the embedded eXeLearning editor.
+ *
+ * Provides AJAX-accessible functions to install, update, repair, and uninstall
+ * the admin-installed embedded editor, and to query its current status.
+ */
+class manage_embedded_editor extends external_api {
+
+ // -------------------------------------------------------------------------
+ // execute_action
+ // -------------------------------------------------------------------------
+
+ /**
+ * Parameter definition for execute_action.
+ *
+ * @return external_function_parameters
+ */
+ public static function execute_action_parameters(): external_function_parameters {
+ return new external_function_parameters([
+ 'action' => new external_value(
+ PARAM_ALPHA,
+ 'Action to perform: install, update, repair, or uninstall'
+ ),
+ ]);
+ }
+
+ /**
+ * Execute an install/update/repair/uninstall action on the embedded editor.
+ *
+ * Requires both moodle/site:config AND mod/exescorm:manageembeddededitor
+ * capabilities in the system context.
+ *
+ * @param string $action One of: install, update, repair, uninstall.
+ * @return array Result array with success, action, message, version, installed_at.
+ * @throws \invalid_parameter_exception If action is not valid.
+ * @throws \required_capability_exception If the user lacks required capabilities.
+ * @throws \moodle_exception On installer failure.
+ */
+ public static function execute_action(string $action): array {
+ $params = self::validate_parameters(self::execute_action_parameters(), ['action' => $action]);
+ $action = $params['action'];
+
+ $validactions = ['install', 'update', 'repair', 'uninstall'];
+ if (!in_array($action, $validactions, true)) {
+ throw new \invalid_parameter_exception(
+ get_string('invalidaction', 'mod_exescorm', $action)
+ );
+ }
+
+ $context = \context_system::instance();
+ self::validate_context($context);
+ require_capability('moodle/site:config', $context);
+ require_capability('mod/exescorm:manageembeddededitor', $context);
+
+ $installer = new embedded_editor_installer();
+
+ $version = '';
+ $installedat = '';
+
+ switch ($action) {
+ case 'install':
+ case 'update':
+ $result = $installer->install_latest();
+ $version = $result['version'] ?? '';
+ $installedat = $result['installed_at'] ?? '';
+ break;
+
+ case 'repair':
+ $installer->uninstall();
+ $result = $installer->install_latest();
+ $version = $result['version'] ?? '';
+ $installedat = $result['installed_at'] ?? '';
+ break;
+
+ case 'uninstall':
+ $installer->uninstall();
+ break;
+ }
+
+ return [
+ 'success' => true,
+ 'action' => $action,
+ 'message' => self::get_action_success_string($action),
+ 'version' => $version,
+ 'installed_at' => $installedat,
+ ];
+ }
+
+ /**
+ * Map action name to its success lang string key.
+ *
+ * @param string $action The action that was performed.
+ * @return string The localised success message.
+ */
+ public static function get_action_success_string(string $action): string {
+ $map = [
+ 'install' => 'editorinstalledsuccess',
+ 'update' => 'editorupdatedsuccess',
+ 'repair' => 'editorrepairsuccess',
+ 'uninstall' => 'editoruninstalledsuccess',
+ ];
+ return get_string($map[$action], 'mod_exescorm');
+ }
+
+ /**
+ * Return value definition for execute_action.
+ *
+ * @return external_single_structure
+ */
+ public static function execute_action_returns(): external_single_structure {
+ return new external_single_structure([
+ 'success' => new external_value(PARAM_BOOL, 'Whether the action succeeded'),
+ 'action' => new external_value(PARAM_ALPHA, 'The action that was performed'),
+ 'message' => new external_value(PARAM_TEXT, 'Human-readable result message'),
+ 'version' => new external_value(PARAM_TEXT, 'Installed version, empty when uninstalling', VALUE_OPTIONAL, ''),
+ 'installed_at' => new external_value(PARAM_TEXT, 'Installation timestamp, empty when uninstalling', VALUE_OPTIONAL, ''),
+ ]);
+ }
+
+ // -------------------------------------------------------------------------
+ // get_status
+ // -------------------------------------------------------------------------
+
+ /**
+ * Parameter definition for get_status.
+ *
+ * @return external_function_parameters
+ */
+ public static function get_status_parameters(): external_function_parameters {
+ return new external_function_parameters([
+ 'checklatest' => new external_value(
+ PARAM_BOOL,
+ 'When true, query GitHub for the latest available version',
+ VALUE_DEFAULT,
+ false
+ ),
+ ]);
+ }
+
+ /**
+ * Return the current status of the embedded editor installation.
+ *
+ * When checklatest is true, queries GitHub to discover the latest release
+ * version from the public Atom feed. Otherwise returns only locally
+ * cached/config state.
+ *
+ * Checks the CONFIG_INSTALLING lock and reports installing=true when the
+ * lock is active, and install_stale=true when the lock age exceeds
+ * INSTALL_LOCK_TIMEOUT seconds.
+ *
+ * @param bool $checklatest When true, call discover_latest_version() via the GitHub Atom feed.
+ * @return array Status array.
+ */
+ public static function get_status(bool $checklatest): array {
+ $params = self::validate_parameters(self::get_status_parameters(), ['checklatest' => $checklatest]);
+ $checklatest = $params['checklatest'];
+
+ $context = \context_system::instance();
+ self::validate_context($context);
+ require_capability('moodle/site:config', $context);
+ require_capability('mod/exescorm:manageembeddededitor', $context);
+
+ // Resolve source state.
+ $localstatus = embedded_editor_source_resolver::get_status();
+
+ // Install lock state.
+ $locktime = get_config('exescorm', embedded_editor_installer::CONFIG_INSTALLING);
+ $installing = false;
+ $installstale = false;
+ if ($locktime !== false && $locktime !== '') {
+ $elapsed = time() - (int)$locktime;
+ if ($elapsed < embedded_editor_installer::INSTALL_LOCK_TIMEOUT) {
+ $installing = true;
+ } else {
+ $installstale = true;
+ }
+ }
+
+ // Latest version from GitHub (optional).
+ $latestversion = '';
+ $latesterror = '';
+ if ($checklatest) {
+ try {
+ $installer = new embedded_editor_installer();
+ $latestversion = $installer->discover_latest_version();
+ } catch (\moodle_exception $e) {
+ $latesterror = $e->getMessage();
+ }
+ }
+
+ // Derive update_available.
+ $moodledataversion = $localstatus->moodledata_version ?? '';
+ $updateavailable = (
+ $latestversion !== ''
+ && $moodledataversion !== ''
+ && version_compare($latestversion, $moodledataversion, '>')
+ );
+
+ // Capability flags.
+ $canconfigure = has_capability('moodle/site:config', $context)
+ && has_capability('mod/exescorm:manageembeddededitor', $context);
+
+ $moodledataavailable = $localstatus->moodledata_available ?? false;
+
+ return [
+ 'active_source' => $localstatus->active_source,
+ 'moodledata_available' => (bool)$moodledataavailable,
+ 'moodledata_version' => (string)($moodledataversion ?? ''),
+ 'moodledata_installed_at' => (string)($localstatus->moodledata_installed_at ?? ''),
+ 'bundled_available' => (bool)($localstatus->bundled_available ?? false),
+ 'latest_version' => $latestversion,
+ 'latest_error' => $latesterror,
+ 'update_available' => $updateavailable,
+ 'installing' => $installing,
+ 'install_stale' => $installstale,
+ 'can_install' => $canconfigure && !$moodledataavailable,
+ 'can_update' => $canconfigure && $moodledataavailable && $updateavailable,
+ 'can_repair' => $canconfigure && $moodledataavailable,
+ 'can_uninstall' => $canconfigure && $moodledataavailable,
+ ];
+ }
+
+ /**
+ * Return value definition for get_status.
+ *
+ * @return external_single_structure
+ */
+ public static function get_status_returns(): external_single_structure {
+ return new external_single_structure([
+ 'active_source' => new external_value(PARAM_TEXT, 'Active source: moodledata, bundled, or none'),
+ 'moodledata_available' => new external_value(PARAM_BOOL, 'Whether the admin-installed editor is present and valid'),
+ 'moodledata_version' => new external_value(PARAM_TEXT, 'Installed version in moodledata, empty if unknown'),
+ 'moodledata_installed_at' => new external_value(PARAM_TEXT, 'Installation timestamp for moodledata editor, empty if unknown'),
+ 'bundled_available' => new external_value(PARAM_BOOL, 'Whether the bundled editor is present and valid'),
+ 'latest_version' => new external_value(PARAM_TEXT, 'Latest version from GitHub, empty if not checked or on error'),
+ 'latest_error' => new external_value(PARAM_TEXT, 'Error message from GitHub check, empty on success'),
+ 'update_available' => new external_value(PARAM_BOOL, 'Whether a newer version is available on GitHub'),
+ 'installing' => new external_value(PARAM_BOOL, 'Whether an installation is currently in progress'),
+ 'install_stale' => new external_value(PARAM_BOOL, 'Whether the install lock is stale (exceeded timeout)'),
+ 'can_install' => new external_value(PARAM_BOOL, 'Whether the current user can perform an install'),
+ 'can_update' => new external_value(PARAM_BOOL, 'Whether the current user can perform an update'),
+ 'can_repair' => new external_value(PARAM_BOOL, 'Whether the current user can perform a repair'),
+ 'can_uninstall' => new external_value(PARAM_BOOL, 'Whether the current user can perform an uninstall'),
+ ]);
+ }
+}
diff --git a/classes/local/embedded_editor_installer.php b/classes/local/embedded_editor_installer.php
new file mode 100644
index 0000000..7cb9cfd
--- /dev/null
+++ b/classes/local/embedded_editor_installer.php
@@ -0,0 +1,711 @@
+.
+
+/**
+ * Embedded editor installer for mod_exescorm.
+ *
+ * Downloads, validates, and installs the static eXeLearning editor from
+ * GitHub Releases into the moodledata directory.
+ *
+ * @package mod_exescorm
+ * @copyright 2025 eXeLearning
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_exescorm\local;
+
+/**
+ * Handles downloading, validating, and installing the static eXeLearning editor.
+ *
+ * Installs into the moodledata directory so that the Moodle code tree is never
+ * modified at runtime. Supports backup/rollback for safe replacement.
+ */
+class embedded_editor_installer {
+
+ /** @var string Repository that publishes the static editor releases. */
+ const GITHUB_RELEASES_REPOSITORY = 'exelearning/exelearning';
+
+ /** @var string GitHub Atom feed for published releases. */
+ const GITHUB_RELEASES_FEED_URL = 'https://github.com/exelearning/exelearning/releases.atom';
+
+ /** @var string Filename prefix for the static editor ZIP asset. */
+ const ASSET_PREFIX = 'exelearning-static-v';
+
+ /** @var string Config key for the installed editor version. */
+ const CONFIG_VERSION = 'embedded_editor_version';
+
+ /** @var string Config key for the installation timestamp. */
+ const CONFIG_INSTALLED_AT = 'embedded_editor_installed_at';
+
+ /** @var string Config key for the concurrent-install lock. */
+ const CONFIG_INSTALLING = 'embedded_editor_installing';
+
+ /** @var int Maximum seconds to allow an install lock before considering it stale. */
+ const INSTALL_LOCK_TIMEOUT = 300;
+
+ /**
+ * Install the latest static editor from GitHub Releases into moodledata.
+ *
+ * Orchestrates the full pipeline: discover version, download, validate,
+ * extract, normalize, validate contents, safe-install, store metadata.
+ *
+ * @return array Associative array with 'version' and 'installed_at' keys.
+ * @throws \moodle_exception On any failure during the pipeline.
+ */
+ public function install_latest(): array {
+ $this->acquire_lock();
+
+ try {
+ $result = $this->do_install();
+ } finally {
+ $this->release_lock();
+ }
+
+ return $result;
+ }
+
+ /**
+ * Install a specific version from GitHub Releases.
+ *
+ * @param string $version Version string (without leading 'v').
+ * @return array Associative array with 'version' and 'installed_at' keys.
+ * @throws \moodle_exception On any failure.
+ */
+ public function install_version(string $version): array {
+ $this->acquire_lock();
+
+ try {
+ $result = $this->do_install($version);
+ } finally {
+ $this->release_lock();
+ }
+
+ return $result;
+ }
+
+ /**
+ * Install a specific version from a ZIP file already available on disk.
+ *
+ * @param string $zippath Absolute path to a local ZIP file.
+ * @param string $version Version string (without leading 'v').
+ * @return array Associative array with 'version' and 'installed_at' keys.
+ * @throws \moodle_exception On any failure.
+ */
+ public function install_from_local_zip(string $zippath, string $version): array {
+ $this->acquire_lock();
+
+ try {
+ $result = $this->install_from_zip_path($zippath, $version, false);
+ } finally {
+ $this->release_lock();
+ }
+
+ return $result;
+ }
+
+ /**
+ * Internal install logic, called within a lock.
+ *
+ * @param string|null $version Specific version to install, or null for latest.
+ * @return array Associative array with 'version' and 'installed_at' keys.
+ * @throws \moodle_exception On any failure.
+ */
+ private function do_install(?string $version = null): array {
+ global $CFG;
+ require_once($CFG->libdir . '/filelib.php');
+ \core_php_time_limit::raise(self::INSTALL_LOCK_TIMEOUT);
+
+ if ($version === null) {
+ $version = $this->discover_latest_version();
+ }
+
+ $asseturl = $this->get_asset_url($version);
+ $tmpfile = $this->download_to_temp($asseturl);
+
+ return $this->install_from_zip_path($tmpfile, $version, true);
+ }
+
+ /**
+ * Discover the latest release version from the GitHub Atom feed.
+ *
+ * @return string Version string without leading 'v'.
+ * @throws \moodle_exception If GitHub is unreachable or the feed response is invalid.
+ */
+ public function discover_latest_version(): string {
+ return $this->extract_latest_version_from_feed($this->fetch_releases_feed());
+ }
+
+ /**
+ * Build the GitHub releases Atom feed URL.
+ *
+ * Moodle Playground now supports direct outbound PHP requests to the
+ * eXeLearning GitHub feed through the configured php-wasm networking
+ * fallback, so the plugin no longer needs a playground-specific URL.
+ *
+ * @return string
+ */
+ private function get_releases_feed_url(): string {
+ return self::GITHUB_RELEASES_FEED_URL;
+ }
+
+ /**
+ * Extract the latest release version from a GitHub releases Atom feed body.
+ *
+ * Uses the first because the feed is ordered newest-first. The
+ * version is derived from the release tag link when available, with a
+ * secondary fallback to the entry title.
+ *
+ * @param string $feedbody Atom feed XML as text.
+ * @return string Version string without leading "v".
+ * @throws \moodle_exception If no valid version can be extracted.
+ */
+ private function extract_latest_version_from_feed(string $feedbody): string {
+ if ($feedbody === '') {
+ throw new \moodle_exception('editorgithubparseerror', 'mod_exescorm');
+ }
+
+ $entrybody = $this->extract_first_feed_entry($feedbody);
+ $candidate = $this->extract_version_candidate_from_entry($entrybody);
+ if ($candidate === null) {
+ throw new \moodle_exception('editorgithubparseerror', 'mod_exescorm');
+ }
+
+ $version = $this->normalize_version_candidate($candidate);
+ if ($version === null) {
+ throw new \moodle_exception('editorgithubparseerror', 'mod_exescorm', '', $candidate);
+ }
+
+ return $version;
+ }
+
+ /**
+ * Download the GitHub releases Atom feed body.
+ *
+ * @return string
+ * @throws \moodle_exception
+ */
+ private function fetch_releases_feed(): string {
+ global $CFG;
+ require_once($CFG->libdir . '/filelib.php');
+
+ $curl = new \curl(['ignoresecurity' => true]);
+ $curl->setopt([
+ 'CURLOPT_TIMEOUT' => 30,
+ 'CURLOPT_HTTPHEADER' => [
+ 'Accept: application/atom+xml, application/xml;q=0.9, text/xml;q=0.8',
+ 'User-Agent: Moodle mod_exescorm',
+ ],
+ ]);
+
+ $response = $curl->get($this->get_releases_feed_url());
+ if ($curl->get_errno()) {
+ throw new \moodle_exception('editorgithubconnecterror', 'mod_exescorm', '', $curl->error);
+ }
+
+ $info = $curl->get_info();
+ if (!isset($info['http_code']) || (int)$info['http_code'] !== 200) {
+ $code = isset($info['http_code']) ? (int)$info['http_code'] : 0;
+ throw new \moodle_exception('editorgithubapierror', 'mod_exescorm', '', $code);
+ }
+
+ return $response;
+ }
+
+ /**
+ * Extract the first entry body from a GitHub Atom feed.
+ *
+ * @param string $feedbody Atom feed XML as text.
+ * @return string
+ * @throws \moodle_exception
+ */
+ private function extract_first_feed_entry(string $feedbody): string {
+ if (!preg_match('/]*>(.*?)<\/entry>/si', $feedbody, $entrymatch)) {
+ throw new \moodle_exception('editorgithubparseerror', 'mod_exescorm');
+ }
+
+ return $entrymatch[1];
+ }
+
+ /**
+ * Extract a version candidate from the first available entry source.
+ *
+ * @param string $entrybody Entry XML fragment.
+ * @return string|null
+ */
+ private function extract_version_candidate_from_entry(string $entrybody): ?string {
+ foreach ([
+ 'extract_version_candidate_from_entry_link',
+ 'extract_version_candidate_from_entry_title',
+ ] as $extractor) {
+ $candidate = $this->{$extractor}($entrybody);
+ if ($candidate !== null) {
+ return $candidate;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Extract a version candidate from the release link inside a feed entry.
+ *
+ * @param string $entrybody Entry XML fragment.
+ * @return string|null
+ */
+ private function extract_version_candidate_from_entry_link(string $entrybody): ?string {
+ if (!preg_match(
+ '# ]+href="https://github\.com/exelearning/exelearning/releases/tag/([^"]+)"#i',
+ $entrybody,
+ $matches
+ )) {
+ return null;
+ }
+
+ return html_entity_decode(rawurldecode($matches[1]), ENT_QUOTES | ENT_XML1, 'UTF-8');
+ }
+
+ /**
+ * Extract a version candidate from the title inside a feed entry.
+ *
+ * @param string $entrybody Entry XML fragment.
+ * @return string|null
+ */
+ private function extract_version_candidate_from_entry_title(string $entrybody): ?string {
+ if (!preg_match('/]*>(.*?)<\/title>/si', $entrybody, $matches)) {
+ return null;
+ }
+
+ $title = html_entity_decode(trim(strip_tags($matches[1])), ENT_QUOTES | ENT_XML1, 'UTF-8');
+ $title = preg_replace('/^release\s+/i', '', $title);
+
+ return $title !== '' ? $title : null;
+ }
+
+ /**
+ * Normalize a GitHub tag/title candidate into a version string.
+ *
+ * @param string $candidate Raw candidate value.
+ * @return string|null
+ */
+ private function normalize_version_candidate(string $candidate): ?string {
+ $candidate = trim($candidate);
+ $candidate = preg_replace('/^refs\/tags\//i', '', $candidate);
+ $candidate = ltrim($candidate, 'v');
+
+ if (!preg_match('/^\d+\.\d+(?:\.\d+)?(?:[-+._A-Za-z0-9]+)?$/', $candidate)) {
+ return null;
+ }
+
+ return $candidate;
+ }
+
+ /**
+ * Build the download URL for the static editor ZIP asset.
+ *
+ * @param string $version Version string without leading 'v'.
+ * @return string Full download URL.
+ */
+ public function get_asset_url(string $version): string {
+ $filename = self::ASSET_PREFIX . $version . '.zip';
+
+ return 'https://github.com/exelearning/exelearning/releases/download/v' . $version . '/' . $filename;
+ }
+
+ /**
+ * Download a file to a temporary location using streaming.
+ *
+ * @param string $url URL to download.
+ * @return string Path to the downloaded temporary file.
+ * @throws \moodle_exception If the download fails.
+ */
+ public function download_to_temp(string $url): string {
+ $tempdir = make_temp_directory('mod_exescorm');
+ $tmpfile = $tempdir . '/editor-download-' . random_string(12) . '.zip';
+
+ $curl = new \curl(['ignoresecurity' => true]);
+ $curl->setopt([
+ 'CURLOPT_TIMEOUT' => self::INSTALL_LOCK_TIMEOUT,
+ 'CURLOPT_FOLLOWLOCATION' => true,
+ 'CURLOPT_MAXREDIRS' => 5,
+ ]);
+
+ $result = $curl->download_one($url, null, ['filepath' => $tmpfile]);
+
+ if ($result === false || $curl->get_errno()) {
+ $this->cleanup_temp_file($tmpfile);
+ throw new \moodle_exception('editordownloaderror', 'mod_exescorm', '', $curl->error);
+ }
+
+ if (!is_file($tmpfile) || filesize($tmpfile) === 0) {
+ $this->cleanup_temp_file($tmpfile);
+ throw new \moodle_exception('editordownloaderror', 'mod_exescorm', '',
+ get_string('editordownloademptyfile', 'mod_exescorm'));
+ }
+
+ return $tmpfile;
+ }
+
+ /**
+ * Validate that a file is a ZIP archive by checking the PK magic bytes.
+ *
+ * @param string $filepath Path to the file to check.
+ * @throws \moodle_exception If the file is not a valid ZIP.
+ */
+ public function validate_zip(string $filepath): void {
+ $handle = fopen($filepath, 'rb');
+ if ($handle === false) {
+ throw new \moodle_exception('editorinvalidzip', 'mod_exescorm');
+ }
+ $header = fread($handle, 4);
+ fclose($handle);
+
+ if ($header !== "PK\x03\x04") {
+ throw new \moodle_exception('editorinvalidzip', 'mod_exescorm');
+ }
+ }
+
+
+ /**
+ * Install the editor from a ZIP file path.
+ *
+ * @param string $zippath Path to the ZIP file.
+ * @param string $version Version string without leading 'v'.
+ * @param bool $cleanupzip Whether to remove the ZIP file afterwards.
+ * @return array Associative array with 'version' and 'installed_at' keys.
+ * @throws \moodle_exception If validation or installation fails.
+ */
+ private function install_from_zip_path(string $zippath, string $version, bool $cleanupzip): array {
+ try {
+ $this->validate_zip($zippath);
+ } catch (\moodle_exception $e) {
+ if ($cleanupzip) {
+ $this->cleanup_temp_file($zippath);
+ }
+ throw $e;
+ }
+
+ $tmpdir = null;
+ try {
+ $tmpdir = $this->extract_to_temp($zippath);
+ if ($cleanupzip) {
+ $this->cleanup_temp_file($zippath);
+ }
+
+ $sourcedir = $this->normalize_extraction($tmpdir);
+ $this->validate_editor_contents($sourcedir);
+ $this->safe_install($sourcedir);
+ } catch (\moodle_exception $e) {
+ if ($cleanupzip) {
+ $this->cleanup_temp_file($zippath);
+ }
+ if ($tmpdir !== null) {
+ $this->cleanup_temp_dir($tmpdir);
+ }
+ throw $e;
+ }
+
+ if ($tmpdir !== null) {
+ $this->cleanup_temp_dir($tmpdir);
+ }
+ $this->store_metadata($version);
+
+ return [
+ 'version' => $version,
+ 'installed_at' => self::get_installed_at(),
+ ];
+ }
+
+ /**
+ * Extract a ZIP file to a temporary directory.
+ *
+ * @param string $zippath Path to the ZIP file.
+ * @return string Path to the temporary extraction directory.
+ * @throws \moodle_exception If extraction fails.
+ */
+ public function extract_to_temp(string $zippath): string {
+ if (!class_exists('ZipArchive')) {
+ throw new \moodle_exception('editorzipextensionmissing', 'mod_exescorm');
+ }
+
+ $tmpdir = make_temp_directory('mod_exescorm/extract-' . random_string(12));
+
+ $zip = new \ZipArchive();
+ $result = $zip->open($zippath);
+ if ($result !== true) {
+ throw new \moodle_exception('editorextractfailed', 'mod_exescorm', '', $result);
+ }
+
+ if (!$zip->extractTo($tmpdir)) {
+ $zip->close();
+ $this->cleanup_temp_dir($tmpdir);
+ throw new \moodle_exception('editorextractfailed', 'mod_exescorm', '',
+ get_string('editorextractwriteerror', 'mod_exescorm'));
+ }
+
+ $zip->close();
+ return $tmpdir;
+ }
+
+ /**
+ * Normalize the extracted directory layout to find the actual editor root.
+ *
+ * Handles three common patterns:
+ * 1. index.html at extraction root.
+ * 2. Single subdirectory containing index.html.
+ * 3. Double-nested single subdirectory containing index.html.
+ *
+ * @param string $tmpdir Path to the extraction directory.
+ * @return string Path to the directory containing index.html.
+ * @throws \moodle_exception If index.html cannot be found.
+ */
+ public function normalize_extraction(string $tmpdir): string {
+ $tmpdir = rtrim($tmpdir, '/');
+
+ // Pattern 1: files directly at root.
+ if (is_file($tmpdir . '/index.html')) {
+ return $tmpdir;
+ }
+
+ // Pattern 2: single top-level directory.
+ $entries = array_diff(scandir($tmpdir), ['.', '..']);
+ if (count($entries) === 1) {
+ $singleentry = $tmpdir . '/' . reset($entries);
+ if (is_dir($singleentry) && is_file($singleentry . '/index.html')) {
+ return $singleentry;
+ }
+ }
+
+ // Pattern 3: double-nested wrapper.
+ foreach ($entries as $entry) {
+ $entrypath = $tmpdir . '/' . $entry;
+ if (is_dir($entrypath)) {
+ $subentries = array_diff(scandir($entrypath), ['.', '..']);
+ if (count($subentries) === 1) {
+ $subentry = $entrypath . '/' . reset($subentries);
+ if (is_dir($subentry) && is_file($subentry . '/index.html')) {
+ return $subentry;
+ }
+ }
+ }
+ }
+
+ throw new \moodle_exception('editorinvalidlayout', 'mod_exescorm');
+ }
+
+ /**
+ * Validate that a directory contains the expected editor files.
+ *
+ * @param string $sourcedir Path to the editor directory.
+ * @throws \moodle_exception If validation fails.
+ */
+ public function validate_editor_contents(string $sourcedir): void {
+ if (!embedded_editor_source_resolver::validate_editor_dir($sourcedir)) {
+ throw new \moodle_exception('editorinvalidlayout', 'mod_exescorm');
+ }
+ }
+
+ /**
+ * Safely install the editor from a source directory to moodledata.
+ *
+ * Uses atomic rename with backup/rollback for reliability.
+ *
+ * @param string $sourcedir Path to the validated source directory.
+ * @throws \moodle_exception If installation fails.
+ */
+ public function safe_install(string $sourcedir): void {
+ $targetdir = embedded_editor_source_resolver::get_moodledata_dir();
+ $parentdir = dirname($targetdir);
+
+ // Ensure parent directory exists and is writable.
+ if (!is_dir($parentdir)) {
+ if (!make_writable_directory($parentdir)) {
+ throw new \moodle_exception('editorinstallfailed', 'mod_exescorm', '',
+ get_string('editormkdirerror', 'mod_exescorm', $parentdir));
+ }
+ }
+
+ $backupdir = null;
+ $hadexisting = is_dir($targetdir);
+
+ if ($hadexisting) {
+ $backupdir = $parentdir . '/embedded_editor-backup-' . time();
+ if (!@rename($targetdir, $backupdir)) {
+ throw new \moodle_exception('editorinstallfailed', 'mod_exescorm', '',
+ get_string('editorbackuperror', 'mod_exescorm'));
+ }
+ }
+
+ // Try atomic rename first (fast, same filesystem since both are under dataroot).
+ $installed = @rename(rtrim($sourcedir, '/'), $targetdir);
+
+ if (!$installed) {
+ // Fallback: recursive copy.
+ if (!is_dir($targetdir)) {
+ @mkdir($targetdir, 0777, true);
+ }
+ $installed = $this->recursive_copy($sourcedir, $targetdir);
+ }
+
+ if (!$installed) {
+ // Restore backup on failure.
+ if ($hadexisting && $backupdir !== null && is_dir($backupdir)) {
+ if (is_dir($targetdir)) {
+ remove_dir($targetdir);
+ }
+ @rename($backupdir, $targetdir);
+ }
+ throw new \moodle_exception('editorinstallfailed', 'mod_exescorm', '',
+ get_string('editorcopyfailed', 'mod_exescorm'));
+ }
+
+ // Clean up backup on success.
+ if ($hadexisting && $backupdir !== null && is_dir($backupdir)) {
+ remove_dir($backupdir);
+ }
+ }
+
+ /**
+ * Remove the admin-installed editor from moodledata and clear metadata.
+ */
+ public function uninstall(): void {
+ $dir = embedded_editor_source_resolver::get_moodledata_dir();
+ if (is_dir($dir)) {
+ remove_dir($dir);
+ }
+ $this->clear_metadata();
+ }
+
+ /**
+ * Store installation metadata in plugin config.
+ *
+ * @param string $version The installed version string.
+ */
+ public function store_metadata(string $version): void {
+ set_config(self::CONFIG_VERSION, $version, 'exescorm');
+ set_config(self::CONFIG_INSTALLED_AT, date('Y-m-d H:i:s'), 'exescorm');
+ }
+
+ /**
+ * Clear installation metadata from plugin config.
+ */
+ public function clear_metadata(): void {
+ unset_config(self::CONFIG_VERSION, 'exescorm');
+ unset_config(self::CONFIG_INSTALLED_AT, 'exescorm');
+ }
+
+ /**
+ * Get the currently stored installed version.
+ *
+ * @return string|null Version string or null.
+ */
+ public static function get_installed_version(): ?string {
+ return embedded_editor_source_resolver::get_moodledata_version();
+ }
+
+ /**
+ * Get the currently stored installation timestamp.
+ *
+ * @return string|null Datetime string or null.
+ */
+ public static function get_installed_at(): ?string {
+ return embedded_editor_source_resolver::get_moodledata_installed_at();
+ }
+
+ /**
+ * Acquire a lock to prevent concurrent installations.
+ *
+ * @throws \moodle_exception If another installation is already in progress.
+ */
+ private function acquire_lock(): void {
+ $locktime = get_config('exescorm', self::CONFIG_INSTALLING);
+ if ($locktime !== false && (time() - (int)$locktime) < self::INSTALL_LOCK_TIMEOUT) {
+ throw new \moodle_exception('editorinstallconcurrent', 'mod_exescorm');
+ }
+ set_config(self::CONFIG_INSTALLING, time(), 'exescorm');
+ }
+
+ /**
+ * Release the installation lock.
+ */
+ private function release_lock(): void {
+ unset_config(self::CONFIG_INSTALLING, 'exescorm');
+ }
+
+ /**
+ * Recursively copy a directory.
+ *
+ * @param string $src Source directory.
+ * @param string $dst Destination directory.
+ * @return bool True on success.
+ */
+ private function recursive_copy(string $src, string $dst): bool {
+ $src = rtrim($src, '/');
+ $dst = rtrim($dst, '/');
+
+ if (!is_dir($dst)) {
+ if (!@mkdir($dst, 0777, true)) {
+ return false;
+ }
+ }
+
+ $entries = scandir($src);
+ if ($entries === false) {
+ return false;
+ }
+
+ foreach ($entries as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+ $srcpath = $src . '/' . $entry;
+ $dstpath = $dst . '/' . $entry;
+
+ if (is_dir($srcpath)) {
+ if (!$this->recursive_copy($srcpath, $dstpath)) {
+ return false;
+ }
+ } else {
+ if (!@copy($srcpath, $dstpath)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Clean up a temporary file.
+ *
+ * @param string $filepath Path to the file.
+ */
+ private function cleanup_temp_file(string $filepath): void {
+ if (is_file($filepath)) {
+ @unlink($filepath);
+ }
+ }
+
+ /**
+ * Clean up a temporary directory.
+ *
+ * @param string $dir Path to the directory.
+ */
+ private function cleanup_temp_dir(string $dir): void {
+ if (is_dir($dir)) {
+ remove_dir($dir);
+ }
+ }
+}
diff --git a/classes/local/embedded_editor_source_resolver.php b/classes/local/embedded_editor_source_resolver.php
new file mode 100644
index 0000000..36e2bd6
--- /dev/null
+++ b/classes/local/embedded_editor_source_resolver.php
@@ -0,0 +1,215 @@
+.
+
+/**
+ * Embedded editor source resolver for mod_exescorm.
+ *
+ * Single source of truth for determining which embedded editor source is active.
+ * Implements a precedence policy: moodledata → bundled → none.
+ *
+ * @package mod_exescorm
+ * @copyright 2025 eXeLearning
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_exescorm\local;
+
+/**
+ * Resolves which embedded editor source should be used at runtime.
+ *
+ * Sources are checked in order:
+ * 1. Admin-installed editor in moodledata (highest priority).
+ * 2. Bundled editor inside the plugin dist/static/ directory.
+ * If neither is available the embedded editor is not usable.
+ */
+class embedded_editor_source_resolver {
+
+ /** @var string Active source is the admin-installed copy in moodledata. */
+ const SOURCE_MOODLEDATA = 'moodledata';
+
+ /** @var string Active source is the bundled copy inside the plugin. */
+ const SOURCE_BUNDLED = 'bundled';
+
+ /** @var string No usable source found. */
+ const SOURCE_NONE = 'none';
+
+ /** @var string Subdirectory under dataroot for admin-installed editor. */
+ const MOODLEDATA_SUBDIR = 'mod_exescorm/embedded_editor';
+
+ /**
+ * Directories expected in a valid static editor bundle.
+ * At least one must exist alongside index.html.
+ *
+ * @var string[]
+ */
+ const EXPECTED_ASSET_DIRS = ['app', 'libs', 'files'];
+
+ /**
+ * Get the moodledata directory for the admin-installed editor.
+ *
+ * @return string Absolute path.
+ */
+ public static function get_moodledata_dir(): string {
+ global $CFG;
+ return $CFG->dataroot . '/' . self::MOODLEDATA_SUBDIR;
+ }
+
+ /**
+ * Get the bundled editor directory inside the plugin.
+ *
+ * @return string Absolute path.
+ */
+ public static function get_bundled_dir(): string {
+ global $CFG;
+ return $CFG->dirroot . '/mod/exescorm/dist/static';
+ }
+
+ /**
+ * Validate that a directory contains a usable static editor installation.
+ *
+ * Checks that index.html exists and is readable, and that at least one
+ * of the expected asset directories (app, libs, files) is present.
+ *
+ * @param string $dir Absolute path to the editor directory.
+ * @return bool True if the directory passes integrity checks.
+ */
+ public static function validate_editor_dir(string $dir): bool {
+ if (!is_dir($dir)) {
+ return false;
+ }
+
+ $indexpath = rtrim($dir, '/') . '/index.html';
+ if (!is_file($indexpath) || !is_readable($indexpath)) {
+ return false;
+ }
+
+ // At least one expected asset directory must exist.
+ $dir = rtrim($dir, '/');
+ foreach (self::EXPECTED_ASSET_DIRS as $assetdir) {
+ if (is_dir($dir . '/' . $assetdir)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the installed version of the moodledata editor, if known.
+ *
+ * @return string|null Version string or null if not recorded.
+ */
+ public static function get_moodledata_version(): ?string {
+ $version = get_config('exescorm', 'embedded_editor_version');
+ return ($version !== false && $version !== '') ? $version : null;
+ }
+
+ /**
+ * Get the installation timestamp of the moodledata editor, if known.
+ *
+ * @return string|null Datetime string or null if not recorded.
+ */
+ public static function get_moodledata_installed_at(): ?string {
+ $installedat = get_config('exescorm', 'embedded_editor_installed_at');
+ return ($installedat !== false && $installedat !== '') ? $installedat : null;
+ }
+
+ /**
+ * Determine which source is active according to the precedence policy.
+ *
+ * Precedence: moodledata → bundled → none.
+ *
+ * @return string One of the SOURCE_* constants.
+ */
+ public static function get_active_source(): string {
+ if (self::validate_editor_dir(self::get_moodledata_dir())) {
+ return self::SOURCE_MOODLEDATA;
+ }
+
+ if (self::validate_editor_dir(self::get_bundled_dir())) {
+ return self::SOURCE_BUNDLED;
+ }
+
+ return self::SOURCE_NONE;
+ }
+
+ /**
+ * Get the filesystem path for the active local editor source.
+ *
+ * @return string|null Absolute path to the active editor directory, or null if no source is available.
+ */
+ public static function get_active_dir(): ?string {
+ $source = self::get_active_source();
+
+ switch ($source) {
+ case self::SOURCE_MOODLEDATA:
+ return self::get_moodledata_dir();
+ case self::SOURCE_BUNDLED:
+ return self::get_bundled_dir();
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Check whether any local editor source (moodledata or bundled) is available.
+ *
+ * @return bool True if at least one local source passes validation.
+ */
+ public static function has_local_source(): bool {
+ return self::get_active_dir() !== null;
+ }
+
+ /**
+ * Get the source used to read the editor index HTML.
+ *
+ * Returns a filesystem path when a local source is available, or null otherwise.
+ *
+ * @return string|null Path to index.html, or null if no source is available.
+ */
+ public static function get_index_source(): ?string {
+ $activedir = self::get_active_dir();
+ if ($activedir !== null) {
+ return $activedir . '/index.html';
+ }
+
+ return null;
+ }
+
+ /**
+ * Build a comprehensive status object for the admin UI.
+ *
+ * @return \stdClass Status information with all relevant fields.
+ */
+ public static function get_status(): \stdClass {
+ $status = new \stdClass();
+
+ $status->active_source = self::get_active_source();
+ $status->active_dir = self::get_active_dir();
+
+ // Moodledata source.
+ $status->moodledata_dir = self::get_moodledata_dir();
+ $status->moodledata_available = self::validate_editor_dir($status->moodledata_dir);
+ $status->moodledata_version = self::get_moodledata_version();
+ $status->moodledata_installed_at = self::get_moodledata_installed_at();
+
+ // Bundled source.
+ $status->bundled_dir = self::get_bundled_dir();
+ $status->bundled_available = self::validate_editor_dir($status->bundled_dir);
+
+ return $status;
+ }
+}
diff --git a/db/access.php b/db/access.php
index 99242b2..bb9ec93 100644
--- a/db/access.php
+++ b/db/access.php
@@ -96,6 +96,15 @@
'captype' => 'write',
'contextlevel' => CONTEXT_MODULE,
'archetypes' => array()
+ ),
+
+ 'mod/exescorm:manageembeddededitor' => array(
+ 'riskbitmask' => RISK_CONFIG | RISK_DATALOSS,
+
+ 'captype' => 'write',
+ 'contextlevel' => CONTEXT_SYSTEM,
+ 'archetypes' => array(
+ 'manager' => CAP_ALLOW
+ )
)
);
-
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/services.php b/db/services.php
index 004f54e..6ddf5b5 100644
--- a/db/services.php
+++ b/db/services.php
@@ -28,6 +28,23 @@
$functions = [
+ 'mod_exescorm_manage_embedded_editor_action' => [
+ 'classname' => 'mod_exescorm\external\manage_embedded_editor',
+ 'methodname' => 'execute_action',
+ 'description' => 'Execute an install, update, repair, or uninstall action on the embedded eXeLearning editor.',
+ 'type' => 'write',
+ 'ajax' => true,
+ 'capabilities' => 'moodle/site:config, mod/exescorm:manageembeddededitor',
+ ],
+ 'mod_exescorm_manage_embedded_editor_status' => [
+ 'classname' => 'mod_exescorm\external\manage_embedded_editor',
+ 'methodname' => 'get_status',
+ 'description' => 'Return the current installation status of the embedded eXeLearning editor.',
+ 'type' => 'read',
+ 'ajax' => true,
+ 'capabilities' => 'moodle/site:config, mod/exescorm:manageembeddededitor',
+ ],
+
'mod_exescorm_view_exescorm' => [
'classname' => 'mod_exescorm_external',
'methodname' => 'view_exescorm',
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..64a3b2d
--- /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:${MOODLE_VERSION:-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..7eb033d
--- /dev/null
+++ b/editor/index.php
@@ -0,0 +1,167 @@
+.
+
+/**
+ * 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');
+
+/**
+ * Output a visible error page inside the editor iframe.
+ *
+ * @param string $message The error message to display.
+ */
+function exescorm_editor_error_page(string $message): void {
+ $escapedmessage = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
+ header('Content-Type: text/html; charset=utf-8');
+ echo <<
+
+
+
+
+
+
+
+
+
⚠ Error
+
{$escapedmessage}
+
+
+
+HTML;
+ die;
+}
+
+$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();
+
+// 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 from the active local source.
+$editorindexsource = exescorm_get_embedded_editor_index_source();
+if ($editorindexsource === null) {
+ if (is_siteadmin()) {
+ exescorm_editor_error_page(get_string('embeddednotinstalledadmin', 'mod_exescorm'));
+ } else {
+ exescorm_editor_error_page(get_string('embeddednotinstalledcontactadmin', 'mod_exescorm'));
+ }
+}
+$html = @file_get_contents($editorindexsource);
+if ($html === false || empty($html)) {
+ exescorm_editor_error_page(get_string('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,
+]);
+
+// Extract the origin (scheme + host) from wwwroot for postMessage trust.
+$parsedwwwroot = parse_url($CFG->wwwroot);
+$wwwrootorigin = $parsedwwwroot['scheme'] . '://' . $parsedwwwroot['host']
+ . (!empty($parsedwwwroot['port']) ? ':' . $parsedwwwroot['port'] : '');
+
+$embeddingconfig = json_encode([
+ 'basePath' => $editorbaseurl,
+ 'parentOrigin' => $wwwrootorigin,
+ 'trustedOrigins' => [$wwwrootorigin],
+ '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('