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 +[![Preview in Moodle Playground](https://raw.githubusercontent.com/ateeducacion/action-moodle-playground-pr-preview/refs/heads/main/assets/playground-preview-button.svg)](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('', $bridgescript . "\n" . '', $html); + +// Output the processed HTML. +header('Content-Type: text/html; charset=utf-8'); +header('X-Frame-Options: SAMEORIGIN'); +echo $html; diff --git a/editor/save.php b/editor/save.php new file mode 100644 index 0000000..390a8cd --- /dev/null +++ b/editor/save.php @@ -0,0 +1,108 @@ +. + +/** + * AJAX endpoint for saving SCORM packages from the embedded eXeLearning editor. + * + * Receives an uploaded SCORM ZIP file, saves it to the package filearea, + * and calls exescorm_parse() to extract content and parse the manifest. + * + * @package mod_exescorm + * @copyright 2025 eXeLearning + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +define('AJAX_SCRIPT', true); + +require('../../../config.php'); +require_once($CFG->dirroot . '/mod/exescorm/lib.php'); +require_once($CFG->dirroot . '/mod/exescorm/locallib.php'); + +$cmid = required_param('cmid', PARAM_INT); + +$cm = get_coursemodule_from_id('exescorm', $cmid, 0, false, MUST_EXIST); +$course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST); +$exescorm = $DB->get_record('exescorm', ['id' => $cm->instance], '*', MUST_EXIST); + +require_login($course, true, $cm); +require_sesskey(); +$context = context_module::instance($cm->id); +require_capability('moodle/course:manageactivities', $context); + +header('Content-Type: application/json; charset=utf-8'); + +try { + if (empty($_FILES['package'])) { + throw new moodle_exception('nofile', 'error'); + } + + $uploadedfile = $_FILES['package']; + if ((int)$uploadedfile['error'] !== UPLOAD_ERR_OK) { + throw new moodle_exception('uploadproblem', 'error'); + } + + if (empty($uploadedfile['tmp_name']) || !is_uploaded_file($uploadedfile['tmp_name'])) { + throw new moodle_exception('uploadproblem', 'error'); + } + + $filename = clean_filename($uploadedfile['name'] ?? 'package.zip'); + if ($filename === '') { + $filename = 'package.zip'; + } + if (core_text::strtolower(pathinfo($filename, PATHINFO_EXTENSION)) !== 'zip') { + throw new moodle_exception('uploadproblem', 'error', '', null, 'Uploaded file must be a ZIP package'); + } + + $fs = get_file_storage(); + $exescorm->timemodified = time(); + + // Overwrite current package. + $fs->delete_area_files($context->id, 'mod_exescorm', 'package'); + + $fileinfo = [ + 'contextid' => $context->id, + 'component' => 'mod_exescorm', + 'filearea' => 'package', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => $filename, + 'userid' => $USER->id, + 'source' => $filename, + 'author' => fullname($USER), + 'license' => 'unknown', + ]; + $fs->create_file_from_pathname($fileinfo, $uploadedfile['tmp_name']); + + // Keep package name in SCORM reference and trigger re-parse. + $exescorm->reference = $filename; + $DB->update_record('exescorm', $exescorm); + exescorm_parse($exescorm, true); + + $updated = $DB->get_record('exescorm', ['id' => $exescorm->id], 'id,timemodified,version', MUST_EXIST); + + echo json_encode([ + 'success' => true, + 'revision' => (int)$updated->timemodified, + 'version' => $updated->version, + ]); +} catch (Exception $e) { + debugging('mod_exescorm editor save failed: ' . $e->getMessage(), DEBUG_DEVELOPER); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => get_string('error'), + ]); +} diff --git a/editor/static.php b/editor/static.php new file mode 100644 index 0000000..eb6c00f --- /dev/null +++ b/editor/static.php @@ -0,0 +1,142 @@ +. + +/** + * Serve static files from the embedded eXeLearning editor. + * + * @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'); + +// Support both slash arguments (PATH_INFO) and query params. +// Slash arguments: /static.php/{cmid}/{filepath} +// Query params: /static.php?id={cmid}&file={filepath} +$pathinfo = !empty($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] + : (!empty($_SERVER['ORIG_PATH_INFO']) ? $_SERVER['ORIG_PATH_INFO'] : ''); + +// Fallback: parse from REQUEST_URI when PATH_INFO is not available. +if (empty($pathinfo) && !empty($_SERVER['REQUEST_URI'])) { + $requesturi = $_SERVER['REQUEST_URI']; + $qpos = strpos($requesturi, '?'); + if ($qpos !== false) { + $requesturi = substr($requesturi, 0, $qpos); + } + $marker = 'static.php/'; + $mpos = strpos($requesturi, $marker); + if ($mpos !== false) { + $pathinfo = '/' . substr($requesturi, $mpos + strlen($marker)); + } +} + +if (!empty($pathinfo)) { + $parts = explode('/', ltrim($pathinfo, '/'), 2); + if (count($parts) < 2 || !is_numeric($parts[0]) || empty($parts[1])) { + send_header_404(); + die('Invalid path'); + } + $id = (int)$parts[0]; + $file = $parts[1]; +} else { + $file = required_param('file', PARAM_PATH); + $id = required_param('id', PARAM_INT); +} + +$cm = get_coursemodule_from_id('exescorm', $id, 0, false, MUST_EXIST); +$course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST); + +require_login($course, true, $cm); +$context = context_module::instance($cm->id); +require_capability('moodle/course:manageactivities', $context); + +// Sanitize the file path to prevent directory traversal. +$file = clean_param($file, PARAM_PATH); +$file = ltrim($file, '/'); + +if (strpos($file, '..') !== false) { + send_header_404(); + die('File not found'); +} + +$mimetypes = [ + 'html' => 'text/html', + 'htm' => 'text/html', + 'css' => 'text/css', + 'js' => 'application/javascript', + 'mjs' => 'application/javascript', + 'json' => 'application/json', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'svg' => 'image/svg+xml', + 'ico' => 'image/x-icon', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'eot' => 'application/vnd.ms-fontobject', + 'webp' => 'image/webp', + 'mp3' => 'audio/mpeg', + 'mp4' => 'video/mp4', + 'webm' => 'video/webm', + 'ogg' => 'audio/ogg', + 'wav' => 'audio/wav', + 'pdf' => 'application/pdf', + 'xml' => 'application/xml', + 'wasm' => 'application/wasm', + 'zip' => 'application/zip', + 'md' => 'text/plain', +]; + +$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); +$contenttype = isset($mimetypes[$ext]) ? $mimetypes[$ext] : 'application/octet-stream'; + +// Release session lock early so parallel requests are not blocked. +\core\session\manager::write_close(); + +$staticdir = \mod_exescorm\local\embedded_editor_source_resolver::get_active_dir(); +if ($staticdir === null) { + send_header_404(); + die('Embedded editor not installed'); +} + +$filepath = realpath($staticdir . '/' . $file); +$staticroot = realpath($staticdir); + +// Ensure the resolved path is within the static directory. +if ($filepath === false || $staticroot === false || strpos($filepath, $staticroot) !== 0) { + send_header_404(); + die('File not found'); +} + +if (!is_file($filepath)) { + send_header_404(); + die('File not found'); +} + +header('Content-Type: ' . $contenttype); +header('Content-Length: ' . filesize($filepath)); +header('Cache-Control: public, max-age=604800'); // Cache for 1 week. +header('X-Frame-Options: SAMEORIGIN'); + +if (basename($file) === 'preview-sw.js') { + header('Service-Worker-Allowed: /'); +} + +readfile($filepath); diff --git a/lang/ca/exescorm.php b/lang/ca/exescorm.php index 45ec595..bf2ef11 100644 --- a/lang/ca/exescorm.php +++ b/lang/ca/exescorm.php @@ -498,7 +498,119 @@ $string['width'] = 'Amplada'; $string['window'] = 'Finestra'; $string['youmustselectastatus'] = 'Ha de seleccionar un estat que serà requerit'; +// Embedded editor strings. +$string['embeddededitorsettings'] = 'Tipus d\'editor'; +$string['editormode'] = 'Mode d\'editor'; +$string['editormodedesc'] = 'Seleccioneu quin editor voleu utilitzar per crear i editar contingut eXeLearning. La configuració de connexió online només s\'aplica quan es selecciona el mode "eXeLearning Online".'; +$string['editormodeonline'] = 'eXeLearning Online (servidor remot)'; +$string['editormodeembedded'] = 'Editor integrat (incrustat)'; +$string['embeddednotinstalledcontactadmin'] = 'Els fitxers de l\'editor integrat no estan instal·lats. Contacteu amb l\'administrador del lloc per instal·lar-lo.'; +$string['embeddednotinstalledadmin'] = 'Els fitxers de l\'editor integrat no estan instal·lats. Podeu instal·lar-lo des de la configuració del connector.'; +$string['editembedded'] = 'Editar amb eXeLearning'; +$string['editembedded_integrated'] = 'Integrat'; +$string['editembedded_help'] = 'Obre l\'editor eXeLearning integrat per editar el contingut directament dins de Moodle.'; +$string['editormissing'] = 'L\'editor integrat eXeLearning no està instal·lat. Contacteu amb l\'administrador.'; +$string['embeddedtypehelp'] = 'Es crearà l\'activitat i podreu editar-la amb l\'editor eXeLearning integrat des de la pàgina de visualització de l\'activitat. Opcionalment podeu pujar un fitxer .elpx per importar contingut existent.'; +$string['saving'] = 'Desant...'; +$string['savedsuccess'] = 'Canvis desats correctament'; +$string['savetomoodle'] = 'Desar a Moodle'; +$string['savingwait'] = 'Si us plau, espereu mentre es desa el fitxer.'; +$string['unsavedchanges'] = 'Teniu canvis sense desar. Esteu segur que voleu tancar?'; +$string['typeembedded'] = 'Crear amb eXeLearning (editor integrat)'; + $string['info'] = 'Info'; $string['displayactivityname'] = 'Mostra el nom de l\'activitat'; $string['displayactivityname_help'] = 'Si cal mostrar o no el nom de l\'activitat al damunt del reproductor eXeLearning'; +$string['teachermodevisible'] = 'Mostrar el selector de capa docent'; +$string['teachermodevisible_help'] = 'Si es desactiva, el selector de capa docent s\'ocultarà al contingut eXeLearning incrustat.'; +$string['packageempty'] = 'Aquesta activitat encara no conté contingut reproduïble. Obriu-la a l\'editor d\'eXeLearning per afegir contingut.'; + +// Embedded editor management. +$string['manageembeddededitor'] = 'Gestiona l\'editor incrustat'; +$string['manageembeddededitor_desc'] = 'Instal·la, actualitza o repara l\'editor incrustat d\'eXeLearning.'; +$string['embeddededitorstatus'] = 'Editor incrustat'; +$string['editorlatestversionongithub'] = 'Última versió a GitHub:'; +$string['editorsource_moodledata'] = 'Instal·lat (gestionat per administració)'; +$string['editorsource_bundled'] = 'Inclòs amb el connector'; +$string['editorsource_none'] = 'No instal·lat'; +$string['editorinstall'] = 'Instal·la l\'última versió'; +$string['editorupdate'] = 'Actualitza l\'editor'; +$string['editoruninstall'] = 'Elimina'; +$string['editorinstallsuccess'] = 'L\'editor eXeLearning v{$a} s\'ha instal·lat correctament.'; +$string['editoruninstallsuccess'] = 'S\'ha eliminat la instal·lació de l\'editor incrustat.'; +$string['editorversion'] = 'Versió'; +$string['editorinstalledat'] = 'Instal·lat el'; +$string['editorsource'] = 'Origen'; +$string['editoractivesource'] = 'Origen actiu'; +$string['editormoodledatadir'] = 'Directori de dades'; +$string['editorbundleddir'] = 'Directori inclòs'; +$string['editorlatestversion'] = 'Última versió disponible'; +$string['editorstatusinfo'] = 'L\'editor incrustat serveix recursos estàtics per a l\'editor integrat d\'eXeLearning. Els orígens es comproven en aquest ordre: instal·lat per administració (moodledata) i després inclòs amb el connector (dist/).'; +$string['editorgithubconnecterror'] = 'No s\'ha pogut connectar amb GitHub: {$a}'; +$string['editorgithubapierror'] = 'GitHub ha retornat l\'estat HTTP {$a}. Torneu-ho a provar més tard.'; +$string['editorgithubparseerror'] = 'No s\'ha pogut interpretar la informació de l\'última versió publicada a GitHub.'; +$string['editordownloaderror'] = 'No s\'ha pogut descarregar el paquet de l\'editor: {$a}'; +$string['editordownloademptyfile'] = 'El fitxer descarregat és buit.'; +$string['editorinvalidzip'] = 'El fitxer descarregat no és un ZIP vàlid.'; +$string['editorzipextensionmissing'] = 'L\'extensió PHP ZipArchive no està disponible. Demaneu a l\'administrador del servidor que l\'habiliti.'; +$string['editorextractfailed'] = 'No s\'ha pogut extreure el paquet de l\'editor: {$a}'; +$string['editorextractwriteerror'] = 'No s\'han pogut escriure els fitxers extrets al directori temporal.'; +$string['editorinvalidlayout'] = 'El paquet no conté els fitxers esperats de l\'editor (index.html i directoris de recursos).'; +$string['editorinstallfailed'] = 'No s\'ha pogut instal·lar l\'editor: {$a}'; +$string['editormkdirerror'] = 'No s\'ha pogut crear el directori: {$a}'; +$string['editorbackuperror'] = 'No s\'ha pogut fer una còpia de seguretat de la instal·lació existent de l\'editor.'; +$string['editorcopyfailed'] = 'No s\'han pogut copiar els fitxers de l\'editor al directori de destinació.'; +$string['editorinstallconcurrent'] = 'Ja hi ha una instal·lació en curs. Espereu uns minuts i torneu-ho a provar.'; +$string['editorconfirmuninstall'] = 'Segur que voleu eliminar l\'editor instal·lat per administració? S\'utilitzarà la versió inclosa amb el connector.'; +$string['editorupdateavailable'] = 'Actualització disponible: v{$a}'; +$string['editorcurrentversion'] = 'Versió actual: v{$a}'; +$string['editornotyetinstalled'] = 'No s\'ha trobat cap editor instal·lat per administració.'; +$string['editormoodledatasource'] = 'Instal·lat per l\'administrador (moodledata)'; +$string['editorbundledsource'] = 'Inclòs amb el connector'; +$string['editoravailable'] = 'Disponible'; +$string['editornotavailable'] = 'No disponible'; +$string['editormanagelink'] = 'Gestiona l\'editor incrustat'; +$string['editorsourceprecedence'] = 'Prioritat d\'origen: instal·lat per administració > inclòs.'; +$string['exescorm:manageembeddededitor'] = 'Gestiona la instal·lació de l\'editor incrustat d\'eXeLearning'; +$string['editorcheckingerror'] = 'No s\'han pogut comprovar les actualitzacions. És possible que GitHub no estigui disponible temporalment.'; +$string['editorinstallconfirm'] = 'Això descarregarà i instal·larà l\'última versió de l\'editor eXeLearning (v{$a}) des de GitHub. Voleu continuar?'; +$string['editoradminrequired'] = 'L\'editor incrustat d\'eXeLearning no està instal·lat. Poseu-vos en contacte amb l\'administrador del lloc.'; +$string['editormanagementhelp'] = 'Descarregueu i instal·leu des de GitHub l\'última versió de l\'editor d\'eXeLearning. La versió instal·lada per l\'administrador té prioritat sobre la inclosa amb el connector.'; +$string['editorbundleddesc'] = 'El connector inclou una versió. Podeu instal·lar l\'última versió publicada a GitHub.'; +$string['editornotinstalleddesc'] = 'Instal·leu l\'editor des de GitHub per habilitar el mode d\'edició incrustada.'; +$string['invalidaction'] = 'Acció no vàlida: {$a}'; +$string['installing'] = 'Instal·lant...'; +$string['checkingforupdates'] = 'Comprovant actualitzacions...'; +$string['operationtakinglong'] = 'L\'operació està tardant més del que s\'esperava. S\'està comprovant l\'estat...'; +$string['checkingstatus'] = 'Comprovant l\'estat...'; +$string['stillworking'] = 'Encara s\'està processant...'; +$string['editorinstalling'] = 'Instal·lant...'; +$string['editordownloadingmessage'] = 'Descarregant i instal·lant l\'editor. Això pot trigar un minut...'; +$string['editoruninstalling'] = 'Eliminant...'; +$string['editoruninstallingmessage'] = 'Eliminant la instal·lació de l\'editor...'; +$string['operationtimedout'] = 'L\'operació ha superat el temps d\'espera. Comproveu l\'estat de l\'editor i torneu-ho a provar.'; +$string['latestversionchecking'] = 'Comprovant...'; +$string['latestversionerror'] = 'No s\'han pogut comprovar les actualitzacions'; +$string['updateavailable'] = 'Actualització disponible'; +$string['installstale'] = 'La instal·lació pot haver fallat. Torneu-ho a provar.'; +$string['noeditorinstalled'] = 'No hi ha cap editor instal·lat'; +$string['confirmuninstall'] = 'Segur que voleu desinstal·lar l\'editor incrustat? Això eliminarà la còpia instal·lada per administració de moodledata.'; +$string['confirmuninstalltitle'] = 'Confirma la desinstal·lació'; +$string['editorinstalledsuccess'] = 'Editor instal·lat correctament'; +$string['editoruninstalledsuccess'] = 'Editor desinstal·lat correctament'; +$string['editorupdatedsuccess'] = 'Editor actualitzat correctament'; +$string['editorrepairsuccess'] = 'Editor reparat correctament'; +$string['editormode'] = 'Mode d\'editor'; +$string['editormodedesc'] = 'Seleccioneu quin editor voleu utilitzar per crear i editar contingut eXeLearning. La configuració de connexió online només s\'aplica quan es selecciona el mode "eXeLearning Online".'; +$string['editormodeonline'] = 'eXeLearning Online (servidor remot)'; +$string['editormodeembedded'] = 'Editor integrat (incrustat)'; +$string['saving'] = 'Desant...'; +$string['savedsuccess'] = 'Canvis desats correctament'; +$string['savetomoodle'] = 'Desar a Moodle'; +$string['savingwait'] = 'Si us plau, espereu mentre es desa l\'arxiu.'; +$string['unsavedchanges'] = 'Teniu canvis sense desar. Esteu segurs que voleu tancar?'; +$string['typeembedded'] = 'Crear amb eXeLearning (editor integrat)'; +$string['typeexewebcreate'] = 'Crear con eXeLearning'; +$string['typeexewebedit'] = 'Editar con eXeLearning'; +$string['typelocal'] = 'Paquete subido'; diff --git a/lang/en/exescorm.php b/lang/en/exescorm.php index 7d34b2e..a6fbaf2 100644 --- a/lang/en/exescorm.php +++ b/lang/en/exescorm.php @@ -118,6 +118,8 @@ $string['displayattemptstatus'] = 'Display attempt status'; $string['displayattemptstatus_help'] = 'This preference allows a summary of the users attempts to show in the course overview block in Dashboard and/or the eXeLearning entry page.'; $string['displayattemptstatusdesc'] = 'Whether a summary of the user\'s attempts is shown in the course overview block in Dashboard and/or the eXeLearning entry page.'; +$string['teachermodevisible'] = 'Show teacher layer selector'; +$string['teachermodevisible_help'] = 'If disabled, the teacher layer selector is hidden in the embedded eXeLearning content.'; $string['displaycoursestructure'] = 'Display course structure on entry page'; $string['displaycoursestructure_help'] = 'If enabled, the table of contents is displayed on the eXeLearning outline page.'; $string['displaycoursestructuredesc'] = 'If enabled, the table of contents is displayed on the eXeLearning outline page.'; @@ -491,7 +493,107 @@ $string['window'] = 'Window'; $string['youmustselectastatus'] = 'You must select a status to require'; +// Embedded editor strings. +$string['embeddededitorsettings'] = 'Editor type'; +$string['editormode'] = 'Editor mode'; +$string['editormodedesc'] = 'Select which editor to use for creating and editing eXeLearning content. Online connection settings only apply when "eXeLearning Online" mode is selected.'; +$string['editormodeonline'] = 'eXeLearning Online (remote server)'; +$string['editormodeembedded'] = 'Integrated editor (embedded)'; +$string['embeddednotinstalledcontactadmin'] = 'The embedded editor files are not installed. Please contact your site administrator to install it.'; +$string['embeddednotinstalledadmin'] = 'The embedded editor files are not installed. You can install it from the plugin settings.'; +$string['editembedded'] = 'Edit with eXeLearning'; +$string['editembedded_integrated'] = 'Integrated'; +$string['editembedded_help'] = 'Open the embedded eXeLearning editor to edit the content directly within Moodle.'; +$string['editormissing'] = 'The eXeLearning embedded editor is not installed. Please contact your administrator.'; +$string['embeddedtypehelp'] = 'The activity will be created and you can then edit it using the embedded eXeLearning editor from the activity view page. You can optionally upload an .elpx project file to import existing content.'; +$string['typeembedded'] = 'Create with eXeLearning (embedded editor)'; +$string['saving'] = 'Saving...'; +$string['savedsuccess'] = 'Changes saved successfully'; +$string['savetomoodle'] = 'Save to Moodle'; +$string['savingwait'] = 'Please wait while the file is being saved.'; +$string['unsavedchanges'] = 'You have unsaved changes. Are you sure you want to close?'; + // Deprecated since Moodle 4.0. $string['info'] = 'Info'; $string['displayactivityname'] = 'Display activity name'; $string['displayactivityname_help'] = 'Whether or not to display the activity name above the eXeLearning player.'; +$string['elpxneedsconversion'] = 'This activity currently contains an .elpx project. Open it in the embedded eXeLearning editor and save to convert it to a SCORM 1.2 package for playback and tracking.'; +$string['packageempty'] = 'This activity does not contain any playable content yet. Open it in the eXeLearning editor to add content.'; + +// Embedded editor management. +$string['manageembeddededitor'] = 'Manage embedded editor'; +$string['manageembeddededitor_desc'] = 'Install, update, or repair the embedded eXeLearning editor.'; +$string['embeddededitorstatus'] = 'Embedded editor'; +$string['editorsource_moodledata'] = 'Installed (admin-managed)'; +$string['editorsource_bundled'] = 'Bundled with plugin'; +$string['editorsource_none'] = 'Not installed'; +$string['editorinstall'] = 'Install latest version'; +$string['editorupdate'] = 'Update editor'; +$string['editoruninstall'] = 'Remove'; +$string['editorinstallsuccess'] = 'eXeLearning editor v{$a} installed successfully.'; +$string['editoruninstallsuccess'] = 'Embedded editor installation removed.'; +$string['editorversion'] = 'Version'; +$string['editorinstalledat'] = 'Installed at'; +$string['editorsource'] = 'Source'; +$string['editoractivesource'] = 'Active source'; +$string['editormoodledatadir'] = 'Data directory'; +$string['editorbundleddir'] = 'Bundled directory'; +$string['editorlatestversion'] = 'Latest available version'; +$string['editorlatestversionongithub'] = 'Latest version on GitHub:'; +$string['editorstatusinfo'] = 'The embedded editor serves static assets for the integrated eXeLearning editor. Sources are checked in order: admin-installed (moodledata), then bundled (plugin dist/).'; +$string['editorgithubconnecterror'] = 'Could not connect to GitHub: {$a}'; +$string['editorgithubapierror'] = 'GitHub returned HTTP status {$a}. Please try again later.'; +$string['editorgithubparseerror'] = 'Could not parse the latest release information from GitHub.'; +$string['editordownloaderror'] = 'Failed to download the editor package: {$a}'; +$string['editordownloademptyfile'] = 'The downloaded file is empty.'; +$string['editorinvalidzip'] = 'The downloaded file is not a valid ZIP archive.'; +$string['editorzipextensionmissing'] = 'The PHP ZipArchive extension is not available. Please ask your server administrator to enable it.'; +$string['editorextractfailed'] = 'Failed to extract the editor package: {$a}'; +$string['editorextractwriteerror'] = 'Could not write extracted files to the temporary directory.'; +$string['editorinvalidlayout'] = 'The package does not contain the expected editor files (index.html and asset directories).'; +$string['editorinstallfailed'] = 'Failed to install the editor: {$a}'; +$string['editormkdirerror'] = 'Could not create directory: {$a}'; +$string['editorbackuperror'] = 'Could not back up the existing editor installation.'; +$string['editorcopyfailed'] = 'Could not copy editor files to the target directory.'; +$string['editorinstallconcurrent'] = 'An installation is already in progress. Please wait a few minutes and try again.'; +$string['editorconfirmuninstall'] = 'Are you sure you want to remove the admin-installed editor? The bundled version will be used instead.'; +$string['editorupdateavailable'] = 'Update available: v{$a}'; +$string['editorcurrentversion'] = 'Current version: v{$a}'; +$string['editornotyetinstalled'] = 'No admin-installed editor found.'; +$string['editormoodledatasource'] = 'Admin-installed (moodledata)'; +$string['editorbundledsource'] = 'Bundled with plugin'; +$string['editoravailable'] = 'Available'; +$string['editornotavailable'] = 'Not available'; +$string['editormanagelink'] = 'Manage embedded editor'; +$string['editorsourceprecedence'] = 'Source precedence: admin-installed > bundled.'; +$string['exescorm:manageembeddededitor'] = 'Manage the embedded eXeLearning editor installation'; +$string['editorcheckingerror'] = 'Could not check for updates. GitHub may be temporarily unreachable.'; +$string['editorinstallconfirm'] = 'This will download and install the latest eXeLearning editor (v{$a}) from GitHub. Continue?'; +$string['editoradminrequired'] = 'The embedded eXeLearning editor is not installed. Please contact your site administrator.'; +$string['editormanagementhelp'] = 'Download and install the latest eXeLearning editor from GitHub. The version installed by the administrator takes priority over the bundled one.'; +$string['editorbundleddesc'] = 'A version is included with the plugin. You can install the latest version published on GitHub.'; +$string['editornotinstalleddesc'] = 'Install the editor from GitHub to enable the embedded editing mode.'; +$string['invalidaction'] = 'Invalid action: {$a}'; +$string['installing'] = 'Installing...'; +$string['checkingforupdates'] = 'Checking for updates...'; +$string['operationtakinglong'] = 'Operation is taking longer than expected. Checking status...'; +$string['checkingstatus'] = 'Checking status...'; +$string['stillworking'] = 'Still working...'; +$string['editorinstalling'] = 'Installing...'; +$string['editordownloadingmessage'] = 'Downloading and installing the editor. This may take a minute...'; +$string['editoruninstalling'] = 'Removing...'; +$string['editoruninstallingmessage'] = 'Removing the editor installation...'; +$string['operationtimedout'] = 'Operation timed out. Please check the editor status and try again.'; +$string['latestversionchecking'] = 'Checking...'; +$string['latestversionerror'] = 'Could not check for updates'; +$string['updateavailable'] = 'Update available'; +$string['installstale'] = 'Installation may have failed. Please try again.'; +$string['noeditorinstalled'] = 'No editor installed'; +$string['confirmuninstall'] = 'Are you sure you want to uninstall the embedded editor? This will remove the admin-installed copy from moodledata.'; +$string['confirmuninstalltitle'] = 'Confirm uninstall'; +$string['editorinstalledsuccess'] = 'Editor installed successfully'; +$string['editoruninstalledsuccess'] = 'Editor uninstalled successfully'; +$string['editorupdatedsuccess'] = 'Editor updated successfully'; +$string['editorrepairsuccess'] = 'Editor repaired successfully'; + +$string['editoruploadmissingfile'] = 'No editor ZIP file was uploaded.'; diff --git a/lang/es/exescorm.php b/lang/es/exescorm.php index e1fadeb..fcc8dba 100644 --- a/lang/es/exescorm.php +++ b/lang/es/exescorm.php @@ -118,6 +118,8 @@ $string['displayattemptstatus'] = 'Mostrar estado de intentos'; $string['displayattemptstatus_help'] = 'Esta preferencia permite mostrar un resumen de los intentos de los usuarios en el bloque Vista general del curso en Mi Tablero y/o en la página de entrada del eXeLearning.'; $string['displayattemptstatusdesc'] = 'Mostrar un resumen de los intentos del usuario en el bloque de descripción general del curso en el Tablero y / o la página de entrada eXeLearning.'; +$string['teachermodevisible'] = 'Mostrar el selector de capa docente'; +$string['teachermodevisible_help'] = 'Si se desactiva, se ocultará el selector de capa docente en el contenido eXeLearning embebido.'; $string['displaycoursestructure'] = 'Mostrar estructura del curso en la página de entrada'; $string['displaycoursestructure_help'] = 'Si está activado, la tabla de contenidos se mostrará en la página de resumen SCORM.'; $string['displaycoursestructuredesc'] = 'Si está habilitado, la tabla de contenidos se mostrará en la página de resumen de la actividad.'; @@ -491,7 +493,123 @@ $string['window'] = 'Ventana'; $string['youmustselectastatus'] = 'Debe seleccionar un estado que será requerido'; +// Embedded editor strings. +$string['embeddededitorsettings'] = 'Tipo de editor'; +$string['editormode'] = 'Modo de editor'; +$string['editormodedesc'] = 'Seleccione qué editor usar para crear y editar contenido eXeLearning. La configuración de conexión online solo aplica cuando se selecciona el modo "eXeLearning Online".'; +$string['editormodeonline'] = 'eXeLearning Online (servidor remoto)'; +$string['editormodeembedded'] = 'Editor integrado (embebido)'; +$string['embeddednotinstalledcontactadmin'] = 'Los archivos del editor integrado no están instalados. Contacte con el administrador del sitio para instalarlo.'; +$string['embeddednotinstalledadmin'] = 'Los archivos del editor integrado no están instalados. Puede instalarlo desde la configuración del plugin.'; +$string['editembedded'] = 'Editar con eXeLearning'; +$string['editembedded_integrated'] = 'Integrado'; +$string['editembedded_help'] = 'Abre el editor eXeLearning integrado para editar el contenido directamente dentro de Moodle.'; +$string['editormissing'] = 'El editor integrado eXeLearning no está instalado. Contacte con el administrador.'; +$string['embeddedtypehelp'] = 'Se creará la actividad y podrá editarla usando el editor eXeLearning integrado desde la página de visualización de la actividad. Opcionalmente puede subir un archivo .elpx para importar contenido existente.'; +$string['saving'] = 'Guardando...'; +$string['savedsuccess'] = 'Cambios guardados correctamente'; +$string['savetomoodle'] = 'Guardar en Moodle'; +$string['savingwait'] = 'Por favor, espere mientras se guarda el archivo.'; +$string['unsavedchanges'] = 'Tiene cambios sin guardar. ¿Está seguro de que desea cerrar?'; +$string['typeembedded'] = 'Crear con eXeLearning (editor integrado)'; + // Deprecated since Moodle 4.0. $string['info'] = 'Info'; $string['displayactivityname'] = 'Mostrar el nombre de la actividad'; $string['displayactivityname_help'] = 'Mostrar o no mostrar el nombre de la actividad sobre el visor de eXeLearning.'; +$string['elpxneedsconversion'] = 'Esta actividad contiene actualmente un proyecto .elpx. Abrelo en el editor eXeLearning integrado y guardalo para convertirlo a un paquete SCORM 1.2 reproducible y trazable.'; +$string['packageempty'] = 'Esta actividad aún no contiene contenido reproducible. Ábrela en el editor de eXeLearning para añadir contenido.'; + +// Embedded editor management. +$string['manageembeddededitor'] = 'Gestionar editor embebido'; +$string['manageembeddededitor_desc'] = 'Instalar, actualizar o reparar el editor embebido de eXeLearning.'; +$string['embeddededitorstatus'] = 'Editor embebido'; +$string['editorlatestversionongithub'] = 'Última versión en GitHub:'; +$string['editorsource_moodledata'] = 'Instalado (gestionado por administración)'; +$string['editorsource_bundled'] = 'Incluido con el plugin'; +$string['editorsource_none'] = 'No instalado'; +$string['editorinstall'] = 'Instalar última versión'; +$string['editorupdate'] = 'Actualizar editor'; +$string['editoruninstall'] = 'Eliminar'; +$string['editorinstallsuccess'] = 'El editor eXeLearning v{$a} se ha instalado correctamente.'; +$string['editoruninstallsuccess'] = 'Se ha eliminado la instalación del editor embebido.'; +$string['editorversion'] = 'Versión'; +$string['editorinstalledat'] = 'Instalado el'; +$string['editorsource'] = 'Origen'; +$string['editoractivesource'] = 'Origen activo'; +$string['editormoodledatadir'] = 'Directorio de datos'; +$string['editorbundleddir'] = 'Directorio incluido'; +$string['editorlatestversion'] = 'Última versión disponible'; +$string['editorstatusinfo'] = 'El editor embebido sirve recursos estáticos para el editor integrado de eXeLearning. Los orígenes se comprueban en este orden: instalado por administración (moodledata) y después incluido en el plugin (dist/).'; +$string['editorgithubconnecterror'] = 'No se pudo conectar con GitHub: {$a}'; +$string['editorgithubapierror'] = 'GitHub devolvió el estado HTTP {$a}. Inténtelo de nuevo más tarde.'; +$string['editorgithubparseerror'] = 'No se pudo interpretar la información de la última versión publicada en GitHub.'; +$string['editordownloaderror'] = 'Error al descargar el paquete del editor: {$a}'; +$string['editordownloademptyfile'] = 'El archivo descargado está vacío.'; +$string['editorinvalidzip'] = 'El archivo descargado no es un ZIP válido.'; +$string['editorzipextensionmissing'] = 'La extensión PHP ZipArchive no está disponible. Pida al administrador del servidor que la habilite.'; +$string['editorextractfailed'] = 'Error al extraer el paquete del editor: {$a}'; +$string['editorextractwriteerror'] = 'No se pudieron escribir los archivos extraídos en el directorio temporal.'; +$string['editorinvalidlayout'] = 'El paquete no contiene los archivos esperados del editor (index.html y directorios de recursos).'; +$string['editorinstallfailed'] = 'Error al instalar el editor: {$a}'; +$string['editormkdirerror'] = 'No se pudo crear el directorio: {$a}'; +$string['editorbackuperror'] = 'No se pudo crear una copia de seguridad de la instalación existente del editor.'; +$string['editorcopyfailed'] = 'No se pudieron copiar los archivos del editor al directorio de destino.'; +$string['editorinstallconcurrent'] = 'Ya hay una instalación en curso. Espere unos minutos e inténtelo de nuevo.'; +$string['editorconfirmuninstall'] = '¿Está seguro de que desea eliminar el editor instalado por administración? Se utilizará la versión incluida con el plugin.'; +$string['editorupdateavailable'] = 'Actualización disponible: v{$a}'; +$string['editorcurrentversion'] = 'Versión actual: v{$a}'; +$string['editornotyetinstalled'] = 'No se ha encontrado ningún editor instalado por administración.'; +$string['editormoodledatasource'] = 'Instalado por el administrador (moodledata)'; +$string['editorbundledsource'] = 'Incluido con el plugin'; +$string['editoravailable'] = 'Disponible'; +$string['editornotavailable'] = 'No disponible'; +$string['editormanagelink'] = 'Gestionar editor embebido'; +$string['editorsourceprecedence'] = 'Prioridad de origen: instalado por administración > incluido.'; +$string['exescorm:manageembeddededitor'] = 'Gestionar la instalación del editor embebido de eXeLearning'; +$string['editorcheckingerror'] = 'No se pudieron comprobar las actualizaciones. Es posible que GitHub no esté disponible temporalmente.'; +$string['editorinstallconfirm'] = 'Esto descargará e instalará la última versión del editor eXeLearning (v{$a}) desde GitHub. ¿Desea continuar?'; +$string['editoradminrequired'] = 'El editor embebido de eXeLearning no está instalado. Contacte con el administrador del sitio.'; +$string['editormanagementhelp'] = 'Descargue e instale desde GitHub la última versión del editor de eXeLearning. La versión instalada por el administrador tiene prioridad sobre la incluida con el plugin.'; +$string['editorbundleddesc'] = 'El plugin incluye una versión. Puede instalar la última versión publicada en GitHub.'; +$string['editornotinstalleddesc'] = 'Instale el editor desde GitHub para habilitar el modo de edición embebido.'; +$string['invalidaction'] = 'Acción no válida: {$a}'; +$string['installing'] = 'Instalando...'; +$string['checkingforupdates'] = 'Comprobando actualizaciones...'; +$string['operationtakinglong'] = 'La operación está tardando más de lo esperado. Comprobando estado...'; +$string['checkingstatus'] = 'Comprobando estado...'; +$string['stillworking'] = 'Sigue en proceso...'; +$string['editorinstalling'] = 'Instalando...'; +$string['editordownloadingmessage'] = 'Descargando e instalando el editor. Esto puede tardar un minuto...'; +$string['editoruninstalling'] = 'Eliminando...'; +$string['editoruninstallingmessage'] = 'Eliminando la instalación del editor...'; +$string['operationtimedout'] = 'La operación ha superado el tiempo de espera. Compruebe el estado del editor e inténtelo de nuevo.'; +$string['latestversionchecking'] = 'Comprobando...'; +$string['latestversionerror'] = 'No se pudieron comprobar las actualizaciones'; +$string['updateavailable'] = 'Actualización disponible'; +$string['installstale'] = 'La instalación puede haber fallado. Inténtelo de nuevo.'; +$string['noeditorinstalled'] = 'No hay ningún editor instalado'; +$string['confirmuninstall'] = '¿Está seguro de que desea desinstalar el editor embebido? Esto eliminará la copia instalada por administración de moodledata.'; +$string['confirmuninstalltitle'] = 'Confirmar desinstalación'; +$string['editorinstalledsuccess'] = 'Editor instalado correctamente'; +$string['editoruninstalledsuccess'] = 'Editor desinstalado correctamente'; +$string['editorupdatedsuccess'] = 'Editor actualizado correctamente'; +$string['editorrepairsuccess'] = 'Editor reparado correctamente'; +$string['editormode'] = 'Modo de editor'; +$string['editormodedesc'] = 'Seleccione qué editor usar para crear y editar contenido eXeLearning. La configuración de conexión online solo aplica cuando se selecciona el modo "eXeLearning Online".'; +$string['editormodeonline'] = 'eXeLearning Online (servidor remoto)'; +$string['editormodeembedded'] = 'Editor integrado (embebido)'; +$string['saving'] = 'Guardando...'; +$string['savedsuccess'] = 'Cambios guardados correctamente'; +$string['savetomoodle'] = 'Guardar en Moodle'; +$string['savingwait'] = 'Por favor, espere mientras se guarda el archivo.'; +$string['unsavedchanges'] = 'Tiene cambios sin guardar. ¿Está seguro de que desea cerrar?'; +$string['typeembedded'] = 'Crear con eXeLearning (editor integrado)'; +$string['typeexewebcreate'] = 'Crear con eXeLearning'; +$string['typeexewebedit'] = 'Editar con eXeLearning'; +$string['typelocal'] = 'Paquete subido'; + +$string['teachermodevisible'] = 'Mostrar el selector de capa docente'; +$string['teachermodevisible_help'] = 'Si se desactiva, se ocultará el selector de capa docente dentro del recurso incrustado.'; + +$string['editoruploadmissingfile'] = 'No se ha subido ningún archivo ZIP del editor.'; diff --git a/lang/eu/exescorm.php b/lang/eu/exescorm.php index c04676e..7502910 100644 --- a/lang/eu/exescorm.php +++ b/lang/eu/exescorm.php @@ -491,7 +491,119 @@ $string['window'] = 'Leihoa'; $string['youmustselectastatus'] = 'Eskatuko den egoera bat hautatu behar duzu'; +// Embedded editor strings. +$string['embeddededitorsettings'] = 'Editore mota'; +$string['editormode'] = 'Editore modua'; +$string['editormodedesc'] = 'Aukeratu zein editore erabili eXeLearning edukia sortu eta editatzeko. Online konexio-ezarpenak soilik aplikatzen dira "eXeLearning Online" modua hautatzen denean.'; +$string['editormodeonline'] = 'eXeLearning Online (urruneko zerbitzaria)'; +$string['editormodeembedded'] = 'Editore txertatua (integratua)'; +$string['embeddednotinstalledcontactadmin'] = 'Editore txertatuaren fitxategiak ez daude instalatuta. Jarri harremanetan guneko administratzailearekin instalatzeko.'; +$string['embeddednotinstalledadmin'] = 'Editore txertatuaren fitxategiak ez daude instalatuta. Pluginaren ezarpenetan instala dezakezu.'; +$string['editembedded'] = 'Editatu eXeLearning-ekin'; +$string['editembedded_integrated'] = 'Integratua'; +$string['editembedded_help'] = 'Ireki eXeLearning editore txertatua edukia zuzenean Moodle-n editatzeko.'; +$string['editormissing'] = 'eXeLearning editore txertatua ez dago instalatuta. Jarri harremanetan administratzailearekin.'; +$string['embeddedtypehelp'] = 'Jarduera sortuko da eta eXeLearning editore txertatuarekin editatu ahal izango duzu jardueraren ikuspegi-orritik. Aukeran .elpx fitxategi bat igo dezakezu lehendik dagoen edukia inportatzeko.'; +$string['saving'] = 'Gordetzen...'; +$string['savedsuccess'] = 'Aldaketak ondo gorde dira'; +$string['savetomoodle'] = 'Moodle-n gorde'; +$string['savingwait'] = 'Mesedez, itxaron fitxategia gordetzen den bitartean.'; +$string['unsavedchanges'] = 'Gorde gabeko aldaketak dituzu. Ziur zaude itxi nahi duzula?'; +$string['typeembedded'] = 'Sortu eXeLearning-ekin (editore txertatua)'; + // Deprecated since Moodle 4.0. $string['info'] = 'Informazioa'; $string['displayactivityname'] = 'Erakutsi jardueraren izena'; $string['displayactivityname_help'] = 'Erakutsi edo ez erakutsi jardueraren izena eXeLearning-en bisorean.'; +$string['teachermodevisible'] = 'Erakutsi irakasle-geruza hautatzailea'; +$string['teachermodevisible_help'] = 'Desaktibatuta badago, irakasle-geruza hautatzailea ezkutatuko da eXeLearning eduki txertatuan.'; +$string['packageempty'] = 'Jarduera honek oraindik ez du eduki erreproduzigarririk. Ireki eXeLearning editorean edukia gehitzeko.'; + +// Embedded editor management. +$string['manageembeddededitor'] = 'Kudeatu editore txertatua'; +$string['manageembeddededitor_desc'] = 'Instalatu, eguneratu edo konpondu eXeLearning editore txertatua.'; +$string['embeddededitorstatus'] = 'Editore txertatua'; +$string['editorlatestversionongithub'] = 'GitHub-eko azken bertsioa:'; +$string['editorsource_moodledata'] = 'Instalatua (administrazioak kudeatua)'; +$string['editorsource_bundled'] = 'Pluginarekin batera dator'; +$string['editorsource_none'] = 'Instalatu gabe'; +$string['editorinstall'] = 'Instalatu azken bertsioa'; +$string['editorupdate'] = 'Eguneratu editorea'; +$string['editoruninstall'] = 'Ezabatu'; +$string['editorinstallsuccess'] = 'eXeLearning editorea ondo instalatu da: v{$a}.'; +$string['editoruninstallsuccess'] = 'Editore txertatuaren instalazioa ezabatu da.'; +$string['editorversion'] = 'Bertsioa'; +$string['editorinstalledat'] = 'Instalazio-data'; +$string['editorsource'] = 'Jatorria'; +$string['editoractivesource'] = 'Jatorri aktiboa'; +$string['editormoodledatadir'] = 'Datuen direktorioa'; +$string['editorbundleddir'] = 'Barneko direktorioa'; +$string['editorlatestversion'] = 'Eskuragarri dagoen azken bertsioa'; +$string['editorstatusinfo'] = 'Editore txertatuak baliabide estatikoak zerbitzatzen ditu eXeLearning editore integraturako. Jatorriak ordena honetan egiaztatzen dira: administrazioak instalatutakoa (moodledata) eta, ondoren, pluginarekin datorrena (dist/).'; +$string['editorgithubconnecterror'] = 'Ezin izan da GitHub-era konektatu: {$a}'; +$string['editorgithubapierror'] = 'GitHub-ek HTTP egoera hau itzuli du: {$a}. Saiatu berriro geroago.'; +$string['editorgithubparseerror'] = 'Ezin izan da GitHub-eko azken argitalpenaren informazioa interpretatu.'; +$string['editordownloaderror'] = 'Errorea editorearen paketea deskargatzean: {$a}'; +$string['editordownloademptyfile'] = 'Deskargatutako fitxategia hutsik dago.'; +$string['editorinvalidzip'] = 'Deskargatutako fitxategia ez da ZIP balioduna.'; +$string['editorzipextensionmissing'] = 'PHP ZipArchive hedapena ez dago erabilgarri. Eskatu zerbitzariaren administratzaileari gaitzeko.'; +$string['editorextractfailed'] = 'Errorea editorearen paketea erauztean: {$a}'; +$string['editorextractwriteerror'] = 'Ezin izan dira erauzitako fitxategiak aldi baterako direktorioan idatzi.'; +$string['editorinvalidlayout'] = 'Paketeak ez ditu espero diren editore-fitxategiak (index.html eta baliabideen direktorioak).'; +$string['editorinstallfailed'] = 'Errorea editorea instalatzean: {$a}'; +$string['editormkdirerror'] = 'Ezin izan da direktorioa sortu: {$a}'; +$string['editorbackuperror'] = 'Ezin izan da lehendik zegoen editore-instalazioaren babeskopia sortu.'; +$string['editorcopyfailed'] = 'Ezin izan dira editore-fitxategiak helmugako direktoriora kopiatu.'; +$string['editorinstallconcurrent'] = 'Badago beste instalazio bat martxan. Itxaron minutu batzuk eta saiatu berriro.'; +$string['editorconfirmuninstall'] = 'Ziur zaude administrazioak instalatutako editorea ezabatu nahi duzula? Pluginarekin batera datorren bertsioa erabiliko da.'; +$string['editorupdateavailable'] = 'Eguneraketa eskuragarri: v{$a}'; +$string['editorcurrentversion'] = 'Uneko bertsioa: v{$a}'; +$string['editornotyetinstalled'] = 'Ez da administrazioak instalatutako editorerik aurkitu.'; +$string['editormoodledatasource'] = 'Administratzaileak instalatua (moodledata)'; +$string['editorbundledsource'] = 'Pluginarekin batera dator'; +$string['editoravailable'] = 'Eskuragarri'; +$string['editornotavailable'] = 'Ez dago erabilgarri'; +$string['editormanagelink'] = 'Kudeatu editore txertatua'; +$string['editorsourceprecedence'] = 'Jatorri-lehentasuna: administrazioak instalatua > barnekoa.'; +$string['exescorm:manageembeddededitor'] = 'Kudeatu eXeLearning editore txertatuaren instalazioa'; +$string['editorcheckingerror'] = 'Ezin izan dira eguneraketak egiaztatu. Baliteke GitHub aldi baterako erabilgarri ez egotea.'; +$string['editorinstallconfirm'] = 'Honek GitHub-etik eXeLearning editorearen azken bertsioa (v{$a}) deskargatu eta instalatuko du. Jarraitu?'; +$string['editoradminrequired'] = 'eXeLearning editore txertatua ez dago instalatuta. Jarri harremanetan guneko administratzailearekin.'; +$string['editormanagementhelp'] = 'Deskargatu eta instalatu GitHub-etik eXeLearning editorearen azken bertsioa. Administratzaileak instalatutako bertsioak lehentasuna du pluginarekin datorrenaren aurrean.'; +$string['editorbundleddesc'] = 'Pluginak bertsio bat dakar. GitHub-en argitaratutako azken bertsioa instala dezakezu.'; +$string['editornotinstalleddesc'] = 'Instalatu editorea GitHub-etik edizio txertatuko modua gaitzeko.'; +$string['invalidaction'] = 'Ekintza baliogabea: {$a}'; +$string['installing'] = 'Instalatzen...'; +$string['checkingforupdates'] = 'Eguneraketak egiaztatzen...'; +$string['operationtakinglong'] = 'Eragiketa espero baino gehiago ari da irauten. Egoera egiaztatzen...'; +$string['checkingstatus'] = 'Egoera egiaztatzen...'; +$string['stillworking'] = 'Oraindik lanean...'; +$string['editorinstalling'] = 'Instalatzen...'; +$string['editordownloadingmessage'] = 'Editorea deskargatzen eta instalatzen. Minutu bat behar izan dezake...'; +$string['editoruninstalling'] = 'Ezabatzen...'; +$string['editoruninstallingmessage'] = 'Editorearen instalazioa ezabatzen...'; +$string['operationtimedout'] = 'Eragiketak denbora-muga gainditu du. Egiaztatu editorearen egoera eta saiatu berriro.'; +$string['latestversionchecking'] = 'Egiaztatzen...'; +$string['latestversionerror'] = 'Ezin izan dira eguneraketak egiaztatu'; +$string['updateavailable'] = 'Eguneraketa eskuragarri'; +$string['installstale'] = 'Baliteke instalazioak huts egin izana. Saiatu berriro.'; +$string['noeditorinstalled'] = 'Ez dago editorerik instalatuta'; +$string['confirmuninstall'] = 'Ziur zaude editore txertatua desinstalatu nahi duzula? Honek moodledata-ko administrazioak instalatutako kopia ezabatuko du.'; +$string['confirmuninstalltitle'] = 'Berretsi desinstalazioa'; +$string['editorinstalledsuccess'] = 'Editorea ondo instalatu da'; +$string['editoruninstalledsuccess'] = 'Editorea ondo desinstalatu da'; +$string['editorupdatedsuccess'] = 'Editorea ondo eguneratu da'; +$string['editorrepairsuccess'] = 'Editorea ondo konpondu da'; +$string['editormode'] = 'Editore modua'; +$string['editormodedesc'] = 'Aukeratu zein editore erabili eXeLearning edukia sortu eta editatzeko. Online konexio-ezarpenak soilik aplikatzen dira "eXeLearning Online" modua hautatzen denean.'; +$string['editormodeonline'] = 'eXeLearning Online (urruneko zerbitzaria)'; +$string['editormodeembedded'] = 'Editore txertatua (integratua)'; +$string['saving'] = 'Gordetzen...'; +$string['savedsuccess'] = 'Aldaketak ondo gorde dira'; +$string['savetomoodle'] = 'Moodle-n gorde'; +$string['savingwait'] = 'Mesedez, itxaron fitxategia gordetzen den bitartean.'; +$string['unsavedchanges'] = 'Gorde gabeko aldaketak dituzu. Ziur zaude itxi nahi duzula?'; +$string['typeembedded'] = 'Sortu eXeLearning-ekin (editore txertatua)'; +$string['typeexewebcreate'] = 'Crear con eXeLearning'; +$string['typeexewebedit'] = 'Editar con eXeLearning'; +$string['typelocal'] = 'Paquete subido'; diff --git a/lang/gl/exescorm.php b/lang/gl/exescorm.php index 992b420..9729399 100644 --- a/lang/gl/exescorm.php +++ b/lang/gl/exescorm.php @@ -491,7 +491,119 @@ $string['window'] = 'Xanela'; $string['youmustselectastatus'] = 'Debe seleccionar un estado que será requirido'; +// Embedded editor strings. +$string['embeddededitorsettings'] = 'Tipo de editor'; +$string['editormode'] = 'Modo de editor'; +$string['editormodedesc'] = 'Seleccione que editor usar para crear e editar contido eXeLearning. A configuración de conexión online só aplica cando se selecciona o modo "eXeLearning Online".'; +$string['editormodeonline'] = 'eXeLearning Online (servidor remoto)'; +$string['editormodeembedded'] = 'Editor integrado (embebido)'; +$string['embeddednotinstalledcontactadmin'] = 'Os ficheiros do editor integrado non están instalados. Contacte co administrador do sitio para instalalo.'; +$string['embeddednotinstalledadmin'] = 'Os ficheiros do editor integrado non están instalados. Pode instalalo desde a configuración do complemento.'; +$string['editembedded'] = 'Editar con eXeLearning'; +$string['editembedded_integrated'] = 'Integrado'; +$string['editembedded_help'] = 'Abre o editor eXeLearning integrado para editar o contido directamente dentro de Moodle.'; +$string['editormissing'] = 'O editor integrado eXeLearning non está instalado. Contacte co administrador.'; +$string['embeddedtypehelp'] = 'Crearase a actividade e poderá editala usando o editor eXeLearning integrado dende a páxina de visualización da actividade. Opcionalmente pode subir un ficheiro .elpx para importar contido existente.'; +$string['saving'] = 'Gardando...'; +$string['savedsuccess'] = 'Cambios gardados correctamente'; +$string['savetomoodle'] = 'Gardar en Moodle'; +$string['savingwait'] = 'Por favor, agarde mentres se garda o ficheiro.'; +$string['unsavedchanges'] = 'Ten cambios sen gardar. Está seguro de que desexa pechar?'; +$string['typeembedded'] = 'Crear con eXeLearning (editor integrado)'; + // Deprecated since Moodle 4.0. $string['info'] = 'Info'; $string['displayactivityname'] = 'Amosar o nome da actividade'; $string['displayactivityname_help'] = 'Amosar ou non o nome da actividade sobre o visor de eXeLearning.'; +$string['teachermodevisible'] = 'Amosar o selector de capa docente'; +$string['teachermodevisible_help'] = 'Se se desactiva, ocultarase o selector de capa docente no contido eXeLearning embebido.'; +$string['packageempty'] = 'Esta actividade aínda non contén contido reproducible. Ábrea no editor de eXeLearning para engadir contido.'; + +// Embedded editor management. +$string['manageembeddededitor'] = 'Xestionar editor embebido'; +$string['manageembeddededitor_desc'] = 'Instalar, actualizar ou reparar o editor embebido de eXeLearning.'; +$string['embeddededitorstatus'] = 'Editor embebido'; +$string['editorlatestversionongithub'] = 'Última versión en GitHub:'; +$string['editorsource_moodledata'] = 'Instalado (xestionado pola administración)'; +$string['editorsource_bundled'] = 'Incluído co plugin'; +$string['editorsource_none'] = 'Non instalado'; +$string['editorinstall'] = 'Instalar a última versión'; +$string['editorupdate'] = 'Actualizar editor'; +$string['editoruninstall'] = 'Eliminar'; +$string['editorinstallsuccess'] = 'O editor eXeLearning v{$a} instalouse correctamente.'; +$string['editoruninstallsuccess'] = 'Eliminouse a instalación do editor embebido.'; +$string['editorversion'] = 'Versión'; +$string['editorinstalledat'] = 'Instalado o'; +$string['editorsource'] = 'Orixe'; +$string['editoractivesource'] = 'Orixe activa'; +$string['editormoodledatadir'] = 'Directorio de datos'; +$string['editorbundleddir'] = 'Directorio incluído'; +$string['editorlatestversion'] = 'Última versión dispoñible'; +$string['editorstatusinfo'] = 'O editor embebido serve recursos estáticos para o editor integrado de eXeLearning. As orixes compróbanse nesta orde: instalado pola administración (moodledata) e despois incluído co plugin (dist/).'; +$string['editorgithubconnecterror'] = 'Non se puido conectar con GitHub: {$a}'; +$string['editorgithubapierror'] = 'GitHub devolveu o estado HTTP {$a}. Ténteo de novo máis tarde.'; +$string['editorgithubparseerror'] = 'Non se puido interpretar a información da última versión publicada en GitHub.'; +$string['editordownloaderror'] = 'Produciuse un erro ao descargar o paquete do editor: {$a}'; +$string['editordownloademptyfile'] = 'O ficheiro descargado está baleiro.'; +$string['editorinvalidzip'] = 'O ficheiro descargado non é un ZIP válido.'; +$string['editorzipextensionmissing'] = 'A extensión PHP ZipArchive non está dispoñible. Pídalle ao administrador do servidor que a habilite.'; +$string['editorextractfailed'] = 'Produciuse un erro ao extraer o paquete do editor: {$a}'; +$string['editorextractwriteerror'] = 'Non se puideron escribir os ficheiros extraídos no directorio temporal.'; +$string['editorinvalidlayout'] = 'O paquete non contén os ficheiros esperados do editor (index.html e directorios de recursos).'; +$string['editorinstallfailed'] = 'Produciuse un erro ao instalar o editor: {$a}'; +$string['editormkdirerror'] = 'Non se puido crear o directorio: {$a}'; +$string['editorbackuperror'] = 'Non se puido crear unha copia de seguridade da instalación existente do editor.'; +$string['editorcopyfailed'] = 'Non se puideron copiar os ficheiros do editor ao directorio de destino.'; +$string['editorinstallconcurrent'] = 'Xa hai unha instalación en curso. Agarde uns minutos e ténteo de novo.'; +$string['editorconfirmuninstall'] = 'Está seguro de que desexa eliminar o editor instalado pola administración? Utilizarase a versión incluída co plugin.'; +$string['editorupdateavailable'] = 'Actualización dispoñible: v{$a}'; +$string['editorcurrentversion'] = 'Versión actual: v{$a}'; +$string['editornotyetinstalled'] = 'Non se atopou ningún editor instalado pola administración.'; +$string['editormoodledatasource'] = 'Instalado polo administrador (moodledata)'; +$string['editorbundledsource'] = 'Incluído co plugin'; +$string['editoravailable'] = 'Dispoñible'; +$string['editornotavailable'] = 'Non dispoñible'; +$string['editormanagelink'] = 'Xestionar editor embebido'; +$string['editorsourceprecedence'] = 'Prioridade de orixe: instalado pola administración > incluído.'; +$string['exescorm:manageembeddededitor'] = 'Xestionar a instalación do editor embebido de eXeLearning'; +$string['editorcheckingerror'] = 'Non se puideron comprobar as actualizacións. É posible que GitHub non estea dispoñible temporalmente.'; +$string['editorinstallconfirm'] = 'Isto descargará e instalará a última versión do editor eXeLearning (v{$a}) desde GitHub. Desexa continuar?'; +$string['editoradminrequired'] = 'O editor embebido de eXeLearning non está instalado. Contacte co administrador do sitio.'; +$string['editormanagementhelp'] = 'Descargue e instale desde GitHub a última versión do editor de eXeLearning. A versión instalada polo administrador ten prioridade sobre a incluída co plugin.'; +$string['editorbundleddesc'] = 'O plugin inclúe unha versión. Pode instalar a última versión publicada en GitHub.'; +$string['editornotinstalleddesc'] = 'Instale o editor desde GitHub para habilitar o modo de edición embebido.'; +$string['invalidaction'] = 'Acción non válida: {$a}'; +$string['installing'] = 'Instalando...'; +$string['checkingforupdates'] = 'Comprobando actualizacións...'; +$string['operationtakinglong'] = 'A operación está tardando máis do esperado. Comprobando estado...'; +$string['checkingstatus'] = 'Comprobando estado...'; +$string['stillworking'] = 'Segue en proceso...'; +$string['editorinstalling'] = 'Instalando...'; +$string['editordownloadingmessage'] = 'Descargando e instalando o editor. Isto pode tardar un minuto...'; +$string['editoruninstalling'] = 'Eliminando...'; +$string['editoruninstallingmessage'] = 'Eliminando a instalación do editor...'; +$string['operationtimedout'] = 'A operación superou o tempo de espera. Comprobe o estado do editor e ténteo de novo.'; +$string['latestversionchecking'] = 'Comprobando...'; +$string['latestversionerror'] = 'Non se puideron comprobar as actualizacións'; +$string['updateavailable'] = 'Actualización dispoñible'; +$string['installstale'] = 'A instalación pode fallar. Ténteo de novo.'; +$string['noeditorinstalled'] = 'Non hai ningún editor instalado'; +$string['confirmuninstall'] = 'Está seguro de que desexa desinstalar o editor embebido? Isto eliminará a copia instalada pola administración de moodledata.'; +$string['confirmuninstalltitle'] = 'Confirmar desinstalación'; +$string['editorinstalledsuccess'] = 'Editor instalado correctamente'; +$string['editoruninstalledsuccess'] = 'Editor desinstalado correctamente'; +$string['editorupdatedsuccess'] = 'Editor actualizado correctamente'; +$string['editorrepairsuccess'] = 'Editor reparado correctamente'; +$string['editormode'] = 'Modo de editor'; +$string['editormodedesc'] = 'Seleccione que editor usar para crear e editar contido eXeLearning. A configuración de conexión online só aplica cando se selecciona o modo "eXeLearning Online".'; +$string['editormodeonline'] = 'eXeLearning Online (servidor remoto)'; +$string['editormodeembedded'] = 'Editor integrado (embebido)'; +$string['saving'] = 'Gardando...'; +$string['savedsuccess'] = 'Cambios gardados correctamente'; +$string['savetomoodle'] = 'Gardar en Moodle'; +$string['savingwait'] = 'Por favor, agarde mentres se garda o ficheiro.'; +$string['unsavedchanges'] = 'Ten cambios sen gardar. Está seguro de que desexa pechar?'; +$string['typeembedded'] = 'Crear con eXeLearning (editor integrado)'; +$string['typeexewebcreate'] = 'Crear con eXeLearning'; +$string['typeexewebedit'] = 'Editar con eXeLearning'; +$string['typelocal'] = 'Paquete enviado'; diff --git a/lib.php b/lib.php index 7b11844..6269294 100644 --- a/lib.php +++ b/lib.php @@ -31,6 +31,8 @@ define('EXESCORM_TYPE_EXTERNAL', 'external'); /** EXESCORM_TYPE_AICCURL = external AICC url */ define('EXESCORM_TYPE_AICCURL', 'aiccurl'); +/** EXESCORM_TYPE_EMBEDDED = embedded static editor */ +define('EXESCORM_TYPE_EMBEDDED', 'embedded'); define('EXESCORM_TOC_SIDE', 0); define('EXESCORM_TOC_HIDDEN', 1); @@ -171,6 +173,46 @@ function exescorm_add_instance($exescorm, $mform=null) { if ($filename !== false) { $record->reference = $filename; } + } else if ($exescorm->exescormtype === EXESCORM_TYPE_EMBEDDED) { + // Embedded type: user may optionally upload an .elpx project file. + if (!empty($exescorm->packagefile)) { + $fs = get_file_storage(); + $fs->delete_area_files($context->id, 'mod_exescorm', 'package'); + file_save_draft_area_files($exescorm->packagefile, $context->id, 'mod_exescorm', 'package', + 0, array('subdirs' => 0, 'maxfiles' => 1)); + $files = $fs->get_area_files($context->id, 'mod_exescorm', 'package', 0, '', false); + $file = reset($files); + if ($file) { + $record->reference = $file->get_filename(); + } + } else { + // No file uploaded: create default package so there is something to parse. + $fs = get_file_storage(); + $templatename = get_config('exescorm', 'template'); + $templatefile = false; + $fileinfo = [ + 'contextid' => $context->id, + 'component' => 'mod_exescorm', + 'filearea' => 'package', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'default_package.zip', + 'userid' => $USER->id, + 'source' => 'default_package.zip', + 'author' => fullname($USER), + 'license' => 'unknown', + ]; + if (! empty($templatename)) { + $templatefile = $fs->get_file(1, 'exescorm', 'config', 0, '/', ltrim($templatename, '/')); + } + if ($templatefile) { + $file = $fs->create_file_from_storedfile($fileinfo, $templatefile); + } else { + $defaultpackagepath = $CFG->dirroot . '/mod/exescorm/data/default_package.zip'; + $file = $fs->create_file_from_pathname($fileinfo, $defaultpackagepath); + } + $record->reference = $file->get_filename(); + } } else if ($record->exescormtype === EXESCORM_TYPE_LOCAL) { // Store the package and verify. if (!empty($exescorm->packagefile)) { @@ -206,7 +248,18 @@ function exescorm_add_instance($exescorm, $mform=null) { $record->cmidnumber = $cmidnumber; $record->cmid = $cmid; - exescorm_parse($record, true); + // Skip parse for .elpx files: they are eXeLearning project files, not SCORM packages. + // The embedded editor will import the .elpx and export a proper SCORM package on save. + $skipparse = false; + if (!empty($record->reference)) { + $ext = strtolower(pathinfo($record->reference, PATHINFO_EXTENSION)); + if ($ext === 'elpx') { + $skipparse = true; + } + } + if (!$skipparse) { + exescorm_parse($record, true); + } exescorm_grade_item_update($record); exescorm_update_calendar($record, $cmid); @@ -259,7 +312,21 @@ function exescorm_update_instance($exescorm, $mform=null) { $exescorm->exescormtype = EXESCORM_TYPE_LOCAL; } - if ($exescorm->exescormtype === EXESCORM_TYPE_LOCAL) { + if ($exescorm->exescormtype === EXESCORM_TYPE_EMBEDDED) { + // Embedded editor saves are handled via editor/save.php. + // But user may re-upload an .elpx file via the settings form. + if (!empty($exescorm->packagefile)) { + $fs = get_file_storage(); + $fs->delete_area_files($context->id, 'mod_exescorm', 'package'); + file_save_draft_area_files($exescorm->packagefile, $context->id, 'mod_exescorm', 'package', + 0, array('subdirs' => 0, 'maxfiles' => 1)); + $files = $fs->get_area_files($context->id, 'mod_exescorm', 'package', 0, '', false); + $file = reset($files); + if ($file) { + $exescorm->reference = $file->get_filename(); + } + } + } else if ($exescorm->exescormtype === EXESCORM_TYPE_LOCAL) { if (!empty($exescorm->packagefile)) { $fs = get_file_storage(); $fs->delete_area_files($context->id, 'mod_exescorm', 'package'); @@ -305,7 +372,18 @@ function exescorm_update_instance($exescorm, $mform=null) { $exescorm->idnumber = $cmidnumber; $exescorm->cmid = $cmid; - exescorm_parse($exescorm, (bool)$exescorm->updatefreq); + // Skip parse for .elpx files: they are eXeLearning project files, not SCORM packages. + // The embedded editor will import the .elpx and export a proper SCORM package on save. + $skipparse = false; + if (!empty($exescorm->reference)) { + $ext = strtolower(pathinfo($exescorm->reference, PATHINFO_EXTENSION)); + if ($ext === 'elpx') { + $skipparse = true; + } + } + if (!$skipparse) { + exescorm_parse($exescorm, (bool)$exescorm->updatefreq); + } exescorm_grade_item_update($exescorm); exescorm_update_grades($exescorm); @@ -1020,7 +1098,12 @@ function exescorm_pluginfile($course, $cm, $context, $filearea, $args, $forcedow } $revision = (int)array_shift($args); // Prevents caching problems - ignored here. $relativepath = implode('/', $args); - $fullpath = "/$context->id/mod_exescorm/package/0/$relativepath"; + // Try with revision first (used by embedded editor), fallback to itemid=0. + $fullpath = "/$context->id/mod_exescorm/package/$revision/$relativepath"; + $fs = get_file_storage(); + if (!$fs->get_file_by_hash(sha1($fullpath))) { + $fullpath = "/$context->id/mod_exescorm/package/0/$relativepath"; + } $lifetime = 0; // No caching here. } else if ($filearea === 'imsmanifest') { // This isn't a real filearea, it's a url parameter for this type of package. @@ -1202,7 +1285,8 @@ function exescorm_version_check($exescormversion, $version='') { */ function exescorm_dndupload_register() { return array('files' => array( - array('extension' => 'zip', 'message' => get_string('dnduploadexescorm', 'mod_exescorm')) + array('extension' => 'zip', 'message' => get_string('dnduploadexescorm', 'mod_exescorm')), + array('extension' => 'elpx', 'message' => get_string('dnduploadexescorm', 'mod_exescorm')), )); } @@ -1881,6 +1965,89 @@ function mod_exescorm_core_calendar_get_event_action_string(string $eventtype): return get_string($identifier, 'mod_exescorm', $modulename); } +/** + * Get the local dist/static directory for the embedded editor. + * + * @return string + */ +function exescorm_get_embedded_editor_local_static_dir(): string { + $dir = \mod_exescorm\local\embedded_editor_source_resolver::get_active_dir(); + if ($dir !== null) { + return $dir; + } + + return \mod_exescorm\local\embedded_editor_source_resolver::get_bundled_dir(); +} + +/** + * Check whether the embedded editor uses local assets. + * + * @return bool + */ +function exescorm_embedded_editor_uses_local_assets(): bool { + return \mod_exescorm\local\embedded_editor_source_resolver::has_local_source(); +} + +/** + * Get the source used to read the embedded editor index HTML. + * + * @return string|null + */ +function exescorm_get_embedded_editor_index_source(): ?string { + return \mod_exescorm\local\embedded_editor_source_resolver::get_index_source(); +} + +/** + * Check if the embedded static editor is available. + * + * Checks the admin editor mode setting. + * + * @return bool True if the editor mode is 'embedded'. + */ +function exescorm_embedded_editor_available() { + $mode = get_config('exescorm', 'editormode'); + if ($mode === false) { + $mode = 'online'; + } + return $mode === 'embedded'; +} + +/** + * Check if the online eXeLearning editor is available. + * + * Checks that the editor mode is not 'embedded' and that the online base URI is configured. + * + * @return bool True if online editor mode is active and base URI is configured. + */ +function exescorm_online_editor_available() { + $mode = get_config('exescorm', 'editormode'); + if ($mode === false) { + $mode = 'online'; + } + return ($mode !== 'embedded') && !empty(get_config('exescorm', 'exeonlinebaseuri')); +} + +/** + * Get the URL for the package file of an exescorm instance. + * + * @param stdClass $exescorm The exescorm record. + * @param context_module $context The module context. + * @return moodle_url|null The URL to the package file, or null if not found. + */ +function exescorm_get_package_url($exescorm, $context) { + $fs = get_file_storage(); + $files = $fs->get_area_files($context->id, 'mod_exescorm', 'package', false, 'sortorder DESC, id ASC', false); + $package = reset($files); + if (!$package) { + return null; + } + $revision = isset($exescorm->revision) ? $exescorm->revision : 0; + return moodle_url::make_pluginfile_url( + $context->id, 'mod_exescorm', 'package', $revision, + $package->get_filepath(), $package->get_filename() + ); +} + /** * This function extends the settings navigation block for the site. * diff --git a/locallib.php b/locallib.php index 0ee0536..7088093 100644 --- a/locallib.php +++ b/locallib.php @@ -231,13 +231,14 @@ function exescorm_parse($exescorm, $full) { $context = context_module::instance($exescorm->cmid); $newhash = $exescorm->sha1hash; - if ($exescorm->exescormtype === EXESCORM_TYPE_LOCAL || $exescorm->exescormtype === EXESCORM_TYPE_LOCALSYNC) { + if ($exescorm->exescormtype === EXESCORM_TYPE_LOCAL || $exescorm->exescormtype === EXESCORM_TYPE_LOCALSYNC + || $exescorm->exescormtype === EXESCORM_TYPE_EMBEDDED) { $fs = get_file_storage(); $packagefile = false; $packagefileimsmanifest = false; - if ($exescorm->exescormtype === EXESCORM_TYPE_LOCAL) { + if ($exescorm->exescormtype === EXESCORM_TYPE_LOCAL || $exescorm->exescormtype === EXESCORM_TYPE_EMBEDDED) { if ($packagefile = $fs->get_file($context->id, 'mod_exescorm', 'package', 0, '/', $exescorm->reference)) { if ($packagefile->is_external_file()) { // Get zip file so we can check it is correct. $packagefile->import_external_file_contents(); @@ -1558,6 +1559,7 @@ function exescorm_get_toc_object($user, $exescorm, $currentorg='', $scoid='', $m $result = array(); $incomplete = false; + $usertracks = array(); if (!empty($organizationsco)) { $result[0] = $organizationsco; @@ -1568,7 +1570,6 @@ function exescorm_get_toc_object($user, $exescorm, $currentorg='', $scoid='', $m if ($scoes = exescorm_get_scoes($exescorm->id, $currentorg)) { // Retrieve user tracking data for each learning object. - $usertracks = array(); foreach ($scoes as $sco) { if (!empty($sco->launch)) { if ($usertrack = exescorm_get_tracks($sco->id, $user->id, $attempt)) { @@ -1728,6 +1729,9 @@ function exescorm_get_toc_get_parent_child(&$result, $currentorg) { } for ($i = 0; $i <= $level; $i++) { + if (empty($final[$i])) { + continue; + } $prevparent = ''; foreach ($final[$i] as $ident => $sco) { if (empty($prevparent)) { @@ -1756,6 +1760,9 @@ function exescorm_get_toc_get_parent_child(&$result, $currentorg) { $results = array(); for ($i = 0; $i <= $level; $i++) { + if (empty($final[$i])) { + continue; + } $keys = array_keys($final[$i]); $results[] = $final[$i][$keys[0]]; } @@ -1968,13 +1975,17 @@ function exescorm_get_toc($user, $exescorm, $cmid, $toclink=EXESCORM_TOCJSLINK, $scoes = exescorm_get_toc_object($user, $exescorm, $currentorg, $scoid, $mode, $attempt, $play, $organizationsco); - $treeview = exescorm_format_toc_for_treeview($user, $exescorm, $scoes['scoes'][0]->children, $scoes['usertracks'], $cmid, - $toclink, $currentorg, $attempt, $play, $organizationsco, false); + $rootsco = !empty($scoes['scoes']) ? $scoes['scoes'][0] : null; - if ($tocheader) { - $result->toc .= $treeview->toc; - } else { - $result->toc = $treeview->toc; + if ($rootsco) { + $treeview = exescorm_format_toc_for_treeview($user, $exescorm, $rootsco->children ?? [], $scoes['usertracks'], $cmid, + $toclink, $currentorg, $attempt, $play, $organizationsco, false); + + if ($tocheader) { + $result->toc .= $treeview->toc; + } else { + $result->toc = $treeview->toc; + } } if (!empty($scoes['scoid'])) { @@ -1983,18 +1994,18 @@ function exescorm_get_toc($user, $exescorm, $cmid, $toclink=EXESCORM_TOCJSLINK, if (empty($scoid)) { // If this is a normal package with an org sco and child scos get the first child. - if (!empty($scoes['scoes'][0]->children)) { - $result->sco = $scoes['scoes'][0]->children[0]; - } else { // This package only has one sco - it may be a simple external AICC package. - $result->sco = $scoes['scoes'][0]; + if ($rootsco && !empty($rootsco->children)) { + $result->sco = $rootsco->children[0]; + } else if ($rootsco) { // This package only has one sco - it may be a simple external AICC package. + $result->sco = $rootsco; } } else { $result->sco = exescorm_get_sco($scoid); } - if ($exescorm->hidetoc == EXESCORM_TOC_POPUP) { - $tocmenu = exescorm_format_toc_for_droplist($exescorm, $scoes['scoes'][0]->children, $scoes['usertracks'], + if ($exescorm->hidetoc == EXESCORM_TOC_POPUP && $rootsco) { + $tocmenu = exescorm_format_toc_for_droplist($exescorm, $rootsco->children ?? [], $scoes['usertracks'], $currentorg, $organizationsco); $modestr = ''; @@ -2006,9 +2017,9 @@ function exescorm_get_toc($user, $exescorm, $cmid, $toclink=EXESCORM_TOCJSLINK, $result->tocmenu = $OUTPUT->single_select($url, 'scoid', $tocmenu, $result->sco->id, null, "tocmenu"); } - $result->prerequisites = $treeview->prerequisites; - $result->incomplete = $treeview->incomplete; - $result->attemptleft = $treeview->attemptleft; + $result->prerequisites = isset($treeview) ? $treeview->prerequisites : true; + $result->incomplete = isset($treeview) ? $treeview->incomplete : true; + $result->attemptleft = isset($treeview) ? $treeview->attemptleft : 1; if ($tocheader) { $result->toc .= html_writer::end_div().html_writer::end_div(); @@ -2251,6 +2262,7 @@ function exescorm_get_sco_and_launch_url($exescorm, $scoid, $context) { } $connector = ''; + $scolaunchurl = ''; $version = substr($exescorm->version, 0, 4); if ((isset($sco->parameters) && (!empty($sco->parameters))) || ($version == 'AICC')) { if (stripos($sco->launch, '?') !== false) { @@ -2296,7 +2308,8 @@ function exescorm_get_sco_and_launch_url($exescorm, $scoid, $context) { $scolaunchurl = "$CFG->wwwroot/pluginfile.php/$context->id/mod_exescorm/imsmanifest/$exescorm->revision/$launcher"; } else if ( $exescorm->exescormtype === EXESCORM_TYPE_LOCAL || - $exescorm->exescormtype === EXESCORM_TYPE_LOCALSYNC + $exescorm->exescormtype === EXESCORM_TYPE_LOCALSYNC || + $exescorm->exescormtype === EXESCORM_TYPE_EMBEDDED ) { // Note: do not convert this to use moodle_url(). // EXESCORM does not work without slasharguments and moodle_url() encodes querystring vars. @@ -2320,7 +2333,7 @@ function exescorm_launch_sco($exescorm, $sco, $cm, $context, $scourl) { $event = \mod_exescorm\event\sco_launched::create(array( 'objectid' => $sco->id, 'context' => $context, - 'other' => array('instanceid' => $exescorm->id, 'loadedcontent' => $scourl) + 'other' => array('instanceid' => $exescorm->id, 'loadedcontent' => !empty($scourl) ? $scourl : (string)$sco->launch) )); $event->add_record_snapshot('course_modules', $cm); $event->add_record_snapshot('exescorm', $exescorm); diff --git a/mod_form.php b/mod_form.php index eaf3a10..7e6ef45 100644 --- a/mod_form.php +++ b/mod_form.php @@ -58,7 +58,14 @@ public function definition() { EXESCORM_TYPE_LOCAL => get_string('typelocal', 'mod_exescorm'), ]; $defaulttype = EXESCORM_TYPE_LOCAL; - if (!empty($cfgexescorm->exeonlinebaseuri)) { + if (exescorm_embedded_editor_available()) { + if ($editmode) { + $exescormtypes[EXESCORM_TYPE_EMBEDDED] = get_string('typeexescormedit', 'mod_exescorm'); + } else { + $exescormtypes[EXESCORM_TYPE_EMBEDDED] = get_string('typeexescormcreate', 'mod_exescorm'); + $defaulttype = EXESCORM_TYPE_EMBEDDED; + } + } else if (!empty($cfgexescorm->exeonlinebaseuri)) { if ($editmode) { $exescormtypes[EXESCORM_TYPE_EXESCORMNET] = get_string('typeexescormedit', 'mod_exescorm'); } else { @@ -66,6 +73,7 @@ public function definition() { $defaulttype = EXESCORM_TYPE_EXESCORMNET; } } + if ($cfgexescorm->allowtypeexternal) { $exescormtypes[EXESCORM_TYPE_EXTERNAL] = get_string('typeexternal', 'mod_exescorm'); } @@ -80,6 +88,7 @@ public function definition() { $nonfilepickertypes = [ EXESCORM_TYPE_EXESCORMNET, + EXESCORM_TYPE_EMBEDDED, ]; // Reference. $mform->addElement('select', 'exescormtype', get_string('exescormtype', 'mod_exescorm'), $exescormtypes); @@ -89,7 +98,7 @@ public function definition() { $mform->addElement('text', 'packageurl', get_string('packageurl', 'mod_exescorm'), array('size' => 60)); $mform->setType('packageurl', PARAM_RAW); $mform->addHelpButton('packageurl', 'packageurl', 'exescorm'); - $mform->hideIf('packageurl', 'exescormtype', 'in', [EXESCORM_TYPE_LOCAL, EXESCORM_TYPE_EXESCORMNET]); + $mform->hideIf('packageurl', 'exescormtype', 'in', [EXESCORM_TYPE_LOCAL, EXESCORM_TYPE_EXESCORMNET, EXESCORM_TYPE_EMBEDDED]); // Workarround to hide static element. $group = []; $staticelement = $mform->createElement('static', 'onlinetypehelp', '', @@ -98,9 +107,17 @@ public function definition() { $group[] =& $staticelement; $mform->addGroup($group, 'typehelpgroup', '', ' ', false); $mform->hideIf('typehelpgroup', 'exescormtype', 'noteq', EXESCORM_TYPE_EXESCORMNET); + // Embedded editor help text. + $embeddedgroup = []; + $embeddedelement = $mform->createElement('static', 'embeddedtypehelp', '', + get_string('embeddedtypehelp', 'mod_exescorm')); + $embeddedelement->updateAttributes(['class' => 'font-weight-bold']); + $embeddedgroup[] =& $embeddedelement; + $mform->addGroup($embeddedgroup, 'embeddedtypehelpgroup', '', ' ', false); + $mform->hideIf('embeddedtypehelpgroup', 'exescormtype', 'noteq', EXESCORM_TYPE_EMBEDDED); // New local package upload. $filemanageroptions = array(); - $filemanageroptions['accepted_types'] = array('.zip', '.xml'); + $filemanageroptions['accepted_types'] = array('.zip', '.xml', '.elpx'); $filemanageroptions['maxbytes'] = 0; $filemanageroptions['maxfiles'] = 1; $filemanageroptions['subdirs'] = 0; @@ -204,6 +221,11 @@ public function definition() { $mform->setDefault('displayattemptstatus', $cfgexescorm->displayattemptstatus); $mform->setAdvanced('displayattemptstatus', $cfgexescorm->displayattemptstatus_adv); + // Teacher mode toggler visibility. + $mform->addElement('advcheckbox', 'teachermodevisible', get_string('teachermodevisible', 'mod_exescorm')); + $mform->addHelpButton('teachermodevisible', 'teachermodevisible', 'mod_exescorm'); + $mform->setDefault('teachermodevisible', 1); + // Availability. $mform->addElement('header', 'availability', get_string('availability')); @@ -304,6 +326,9 @@ public function definition() { $this->add_edit_online_buttons('editonlinearr'); $mform->hideIf('editonlinearr', 'exescormtype', 'noteq', EXESCORM_TYPE_EXESCORMNET); + + // Hide updatefreq for embedded type. + $mform->hideIf('updatefreq', 'exescormtype', 'eq', EXESCORM_TYPE_EMBEDDED); } /** @@ -459,6 +484,9 @@ public function validation($data, $files) { } } else if (strtolower(substr($file->get_filename(), -3)) == 'xml') { $errors['packagefile'] = get_string('invalidmanifestname', 'mod_exescorm'); + } else if (strtolower(pathinfo($file->get_filename(), PATHINFO_EXTENSION)) === 'elpx') { + // .elpx is an eXeLearning project file, not a SCORM package. + // It will be imported by the embedded editor and exported as SCORM on save. } else { // Validate this EXESCORM package. $errors = array_merge($errors, exescorm_validate_package($file)); @@ -469,6 +497,8 @@ public function validation($data, $files) { // Make sure updatefreq is not set if using normal local file, as exescormnet received file will be local. $errors['updatefreq'] = get_string('updatefreq_error', 'mod_exescorm'); } + } else if ($type === EXESCORM_TYPE_EMBEDDED) { + // Embedded editor handles everything via editor/save.php, no validation needed here. } else if ($type === EXESCORM_TYPE_EXTERNAL) { $reference = $data['packageurl']; // Syntax check. diff --git a/module.js b/module.js index 0be53e5..ab4389f 100644 --- a/module.js +++ b/module.js @@ -966,12 +966,36 @@ var exescorm_resize = function() { } }; +/** + * Injects CSS to hide the teacher mode toggler when configured. + * + * @param {Element} iFrame + */ +var exescorm_apply_teacher_mode_visibility = function(iFrame) { + if (!iFrame || !iFrame.contentWindow || !iFrame.contentWindow.document) { + return; + } + if (typeof exescormplayerdata === 'undefined' || Number(exescormplayerdata.teachermodevisible) === 1) { + return; + } + var doc = iFrame.contentWindow.document; + if (doc.getElementById('exescorm-teacher-mode-style')) { + return; + } + + var style = doc.createElement('style'); + style.id = 'exescorm-teacher-mode-style'; + style.textContent = '#teacher-mode-toggler-wrapper { visibility: hidden !important; }'; + (doc.head || doc.body || doc.documentElement).appendChild(style); +}; + /** * IFrame's onload handler. Used to keep iFrame's height dynamic, varying on iFrame's contents. * * @param {Element} iFrame */ var exescorm_iframe_onload = function(iFrame) { + exescorm_apply_teacher_mode_visibility(iFrame); exescorm_resize([], null); // Set a mutation observer, so we can adapt to changes from iFrame's javascript (such // as tab clicks o hide/show sections). diff --git a/player.php b/player.php index 804a48d..7e1ae1f 100644 --- a/player.php +++ b/player.php @@ -143,7 +143,13 @@ $result = exescorm_get_toc($USER, $exescorm, $cm->id, EXESCORM_TOCJSLINK, $currentorg, $scoid, $mode, $attempt, true, true); -$sco = $result->sco; +$sco = $result->sco ?? null; +if (empty($sco)) { + echo $OUTPUT->header(); + echo $OUTPUT->notification(get_string('packageempty', 'mod_exescorm'), 'warning'); + echo $OUTPUT->footer(); + exit; +} if ($exescorm->lastattemptlock == 1 && $result->attemptleft == 0) { echo $OUTPUT->header(); echo $OUTPUT->notification(get_string('exceededmaxattempts', 'mod_exescorm')); @@ -187,7 +193,8 @@ 'courseid' => $exescorm->course, 'cwidth' => $exescorm->width, 'cheight' => $exescorm->height, - 'popupoptions' => $exescorm->options), true); + 'popupoptions' => $exescorm->options, + 'teachermodevisible' => (int)($exescorm->teachermodevisible ?? 1)), true); $PAGE->requires->js('/mod/exescorm/request.js', true); $PAGE->requires->js('/lib/cookies.js', true); @@ -314,6 +321,7 @@ \core\session\manager::keepalive('networkdropped', 'mod_exescorm', 30, 10); $PAGE->requires->js_call_amd('mod_exescorm/fullscreen', 'init'); +$PAGE->requires->js_call_amd('mod_exescorm/editor_modal', 'init'); echo $OUTPUT->footer(); diff --git a/renderer.php b/renderer.php index fa990c0..650e9a7 100644 --- a/renderer.php +++ b/renderer.php @@ -118,13 +118,17 @@ public function generate_editexitbar(string $url, \stdClass $cm): string { $context['hasgrades'] = exescorm_get_user_grades($exescorm, 0); $capability = has_capability('moodle/course:update', context_course::instance($cm->course)); - if ($capability && get_config('exescorm', 'exeonlinebaseuri')) { - $returnto = new moodle_url("/mod/exescorm/view.php", ['id' => $cm->id, 'forceview' => 1]); - $exeonlineurl = get_config('exescorm', 'exeonlinebaseuri'); - if (empty($exeonlineurl)) { - $context['editaction'] = false; - } else { + if ($capability) { + if (exescorm_online_editor_available()) { + $returnto = new moodle_url("/mod/exescorm/view.php", ['id' => $cm->id, 'forceview' => 1]); $context['editaction'] = exescorm_redirector::get_redirection_url($cm->id, $returnto)->out(false); + } else if (exescorm_embedded_editor_available()) { + $context['editorurl'] = (new moodle_url('/mod/exescorm/editor/index.php', [ + 'id' => $cm->id, + 'sesskey' => sesskey(), + ]))->out(false); + $context['cmid'] = $cm->id; + $context['activityname'] = $exescorm ? format_string($exescorm->name) : ''; } } return $this->render_from_template('mod_exescorm/player_editexitbar', $context); diff --git a/settings.php b/settings.php index a15d144..3957d3c 100644 --- a/settings.php +++ b/settings.php @@ -21,9 +21,51 @@ $yesno = [0 => get_string('no'), 1 => get_string('yes')]; + // Embedded editor settings. + $settings->add(new admin_setting_heading('exescorm/embeddededitorsettings', + get_string('embeddededitorsettings', 'mod_exescorm'), '')); + + $editormodedesc = get_string('editormodedesc', 'mod_exescorm'); + + $editormodes = [ + 'online' => get_string('editormodeonline', 'mod_exescorm'), + 'embedded' => get_string('editormodeembedded', 'mod_exescorm'), + ]; + $settings->add(new admin_setting_configselect('exescorm/editormode', + get_string('editormode', 'mod_exescorm'), $editormodedesc, + 'online', $editormodes)); + + $settings->add(new \mod_exescorm\admin\admin_setting_embeddededitor( + get_string('embeddededitorstatus', 'mod_exescorm'), + '' + )); + + // JavaScript to toggle connection settings visibility based on editor mode. + $connectionsettingsdesc = ''; + // Connection settings. $settings->add(new admin_setting_heading('exescorm/connectionsettings', - get_string('exeonline:connectionsettings', 'mod_exescorm'), '')); + get_string('exeonline:connectionsettings', 'mod_exescorm'), $connectionsettingsdesc)); $settings->add(new admin_setting_configtext('exescorm/exeonlinebaseuri', get_string('exeonline:baseuri', 'mod_exescorm'), diff --git a/styles.css b/styles.css index 9e7e904..c7818ca 100644 --- a/styles.css +++ b/styles.css @@ -521,3 +521,101 @@ right: 145px; } +/* Embedded editor modal overlay */ +.exescorm-editor-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 9999; + background: #fff; + display: flex; + flex-direction: column; +} + +.exescorm-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: #f8f9fa; + border-bottom: 1px solid #dee2e6; + flex-shrink: 0; +} + +.exescorm-editor-title { + font-weight: bold; + font-size: 1.1em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.exescorm-editor-buttons { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.exescorm-editor-iframe { + flex: 1; + border: none; + width: 100%; +} + +/* Loading modal shown while saving */ +.exescorm-loading-modal { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease; +} + +.exescorm-loading-modal.is-visible { + opacity: 1; + visibility: visible; +} + +.exescorm-loading-modal__content { + background: #fff; + border-radius: 12px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + max-width: 400px; + width: 90%; +} + +.exescorm-loading-modal__spinner { + width: 48px; + height: 48px; + border: 4px solid #e9ecef; + border-top-color: #0f6cbf; + border-radius: 50%; + animation: exescorm-spin 0.8s linear infinite; + margin: 0 auto 1.5rem; +} + +@keyframes exescorm-spin { + to { transform: rotate(360deg); } +} + +.exescorm-loading-modal__title { + margin: 0 0 0.5rem; + font-size: 1.25rem; + color: #1d2125; +} + +.exescorm-loading-modal__message { + margin: 0; + color: #6c757d; + font-size: 0.9rem; +} + diff --git a/templates/admin_embedded_editor.mustache b/templates/admin_embedded_editor.mustache new file mode 100644 index 0000000..8aec00d --- /dev/null +++ b/templates/admin_embedded_editor.mustache @@ -0,0 +1,164 @@ +{{! + 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 . +}} +{{! + @template mod_exescorm/admin_embedded_editor + + Inline admin-settings widget for managing the embedded eXeLearning editor. + JavaScript (mod_exescorm/admin_embedded_editor) reads data-* attributes on the + container and uses AJAX external functions to perform install/update/uninstall. + + Context variables required for this template: + * sesskey - Session key for CSRF protection. + * active_source - String: 'moodledata', 'bundled', or 'none'. + * active_source_moodledata - Boolean. + * active_source_bundled - Boolean. + * active_source_none - Boolean. + * moodledata_available - Boolean. + * moodledata_version - Version string or empty. + * moodledata_installed_at - Datetime string or empty. + * bundled_available - Boolean. + * can_install - Boolean. + * can_update - Boolean (PHP always passes false; JS may set true). + * can_uninstall - Boolean. + + Example context (json): + { + "sesskey": "abc123", + "active_source": "moodledata", + "active_source_moodledata": true, + "active_source_bundled": false, + "active_source_none": false, + "moodledata_available": true, + "moodledata_version": "4.0.0", + "moodledata_installed_at": "2025-01-15 10:30:00", + "bundled_available": false, + "can_install": false, + "can_update": false, + "can_uninstall": true + } +}} + +
+ +
+
+ + {{! Status section }} +
+ + {{#active_source_moodledata}} + + {{#str}} editormoodledatasource, mod_exescorm {{/str}} + + {{#moodledata_version}} + v{{moodledata_version}} + {{/moodledata_version}} + {{#moodledata_installed_at}} + + {{#str}} editorinstalledat, mod_exescorm {{/str}} {{moodledata_installed_at}} + + {{/moodledata_installed_at}} + {{/active_source_moodledata}} + + {{#active_source_bundled}} + + {{#str}} editorbundledsource, mod_exescorm {{/str}} + + + {{#str}} editorbundleddesc, mod_exescorm {{/str}} + + {{/active_source_bundled}} + + {{#active_source_none}} + + {{#str}} noeditorinstalled, mod_exescorm {{/str}} + + + {{#str}} editornotinstalleddesc, mod_exescorm {{/str}} + + {{/active_source_none}} + + + {{! Latest version area — JS populates after actions }} + + + + +
+ + {{! Help text }} +

+ {{#str}} editormanagementhelp, mod_exescorm {{/str}} +

+ + {{! Action buttons }} +
+ + {{#can_install}} + + {{/can_install}} + + {{! Update button — hidden by default; JS shows when update is available }} + + + {{#can_uninstall}} + + {{/can_uninstall}} + +
+ + {{! Spinner — hidden until an action is running }} + + + {{! Result / error message area — hidden until set by JS }} + + +
+
+ +
diff --git a/templates/player_editexitbar.mustache b/templates/player_editexitbar.mustache index 1e20305..8fc861c 100644 --- a/templates/player_editexitbar.mustache +++ b/templates/player_editexitbar.mustache @@ -12,23 +12,37 @@ along with Moodle. If not, see . }} {{! - @template mod_exescorm/player_exitbar - Actions bar for the user reports page UI. + @template mod_exescorm/player_editexitbar + Actions bar for the player page UI. Classes required for JS: * none Data attributes required for JS: * none Context variables required for this template: - * action + * returnaction - URL for exit button + * editaction - URL for eXeLearning Online edit (optional) + * editorurl - URL for embedded editor (optional) + * cmid - Course module ID (for embedded editor) + * activityname - Activity name (for embedded editor) + * hasgrades - Whether activity has grades Example context (json): { - "action": "http://localhost/moodle/mod/exescorm/report.php?id=70&mode=interactions" + "returnaction": "http://localhost/moodle/course/view.php?id=2" } }}
{{#str}}exitactivity, mod_exescorm{{/str}} + {{#editorurl}} + + {{/editorurl}} {{#editaction}} {{#hasgrades}}