diff --git a/.distignore b/.distignore
new file mode 100644
index 0000000..3befb60
--- /dev/null
+++ b/.distignore
@@ -0,0 +1,21 @@
+.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
+AGENTS.md
+mod_exeweb-*.zip
diff --git a/.env.dist b/.env.dist
index 93647fa..e7ed48b 100644
--- a/.env.dist
+++ b/.env.dist
@@ -13,9 +13,17 @@ 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..f03031d
--- /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_exeweb-${{ 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..446b03f
--- /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_exeweb/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 Web > 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..9947284
--- /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_exeweb-${{ env.RELEASE_TAG }}
+ path: mod_exeweb-${{ env.RELEASE_TAG }}.zip
+
+ - name: Upload ZIP to release
+ if: github.event_name == 'release'
+ uses: softprops/action-gh-release@v2
+ with:
+ files: mod_exeweb-${{ env.RELEASE_TAG }}.zip
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 5567af6..768004c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,11 @@ phpmd-rules.xml
# Composer ignores
/vendor/
/composer.lock
-/composer.phar
\ No newline at end of file
+/composer.phar
+
+# Built static editor files
+dist/static/
+
+# Local editor checkout fetched during build
+exelearning/
+.omc/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..b4d64e0
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,121 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+Moodle activity module (`mod_exeweb`) for creating and editing web sites with eXeLearning. Teachers can author educational content via three modes: local upload, eXeLearning Online (remote editor), or an embedded static editor.
+
+**Component**: `mod_exeweb`
+**Moodle compatibility**: 4.2+
+**License**: GNU GPL v3+
+
+## Common Commands
+
+```bash
+# Development environment (Docker-based)
+make up # Start containers interactively
+make upd # Start containers in background
+make down # Stop containers
+make shell # Shell into Moodle container
+make clean # Stop containers + remove volumes
+
+# PHP dependencies
+make install-deps # composer install
+
+# Code quality
+make lint # PHP CodeSniffer (Moodle standard)
+make fix # Auto-fix CodeSniffer violations
+make phpmd # PHP Mess Detector
+
+# Testing
+make test # PHPUnit tests
+make behat # Behat BDD tests
+
+# Embedded editor
+make build-editor # Fetch exelearning source + build to dist/static/
+make clean-editor # Remove built editor artifacts
+
+# Packaging
+make package RELEASE=1.2.3 # Create distributable ZIP
+```
+
+Lint/test/phpmd/behat all delegate to `composer` scripts defined in `composer.json`. Run `make install-deps` first.
+
+## Architecture
+
+### Three Content Origins
+
+| Constant | Mode | How it works |
+|----------|------|-------------|
+| `EXEWEB_ORIGIN_LOCAL` | Upload | ZIP package uploaded via form |
+| `EXEWEB_ORIGIN_EXEONLINE` | Online | Redirects to remote eXeLearning; JWT-authenticated callbacks (`get_ode.php`/`set_ode.php`) sync the package |
+| `EXEWEB_ORIGIN_EMBEDDED` | Embedded | Static HTML5 editor in iframe from `dist/static/`; bridge script + postMessage for Moodle ↔ editor communication |
+
+### Key File Groups
+
+- **Core Moodle hooks**: `lib.php`, `locallib.php` (display/rendering), `mod_form.php` (activity form), `settings.php` (admin config)
+- **Editor integration**: `editor/index.php` (bootstrap), `editor/static.php` (asset server), `editor/save.php` (AJAX save)
+- **Online editor**: `get_ode.php`, `set_ode.php`, `classes/exeonline/` (redirector, JWT token manager)
+- **Frontend JS** (AMD): `amd/src/editor_modal.js` (fullscreen overlay), `amd/src/admin_embedded_editor.js` (settings page AJAX + progress), `amd/src/moodle_exe_bridge.js` (runs inside editor iframe, raw — not AMD)
+- **Package handling**: `classes/exeweb_package.php` (validation, extraction)
+- **Database**: `db/install.xml` (schema), `db/access.php` (capabilities), `db/upgrade.php` (migrations)
+- **Backup/restore**: `backup/moodle2/`
+
+### Embedded Editor: Hybrid Source Model
+
+The embedded editor uses a **hybrid source model** with two possible locations:
+
+1. **Bundled**: `dist/static/` inside the plugin (from release ZIP or `make build-editor`)
+2. **Admin-installed**: `$CFG->dataroot/mod_exeweb/embedded_editor/` (downloaded from GitHub Releases via the admin management page)
+
+**Source precedence**: moodledata (admin-installed) → bundled → not available
+
+Key classes:
+- `classes/local/embedded_editor_source_resolver.php` — single source of truth for which editor source is active
+- `classes/local/embedded_editor_installer.php` — download/install pipeline from GitHub Releases
+
+The resolver is used by `lib.php` helper functions (`exeweb_get_embedded_editor_local_static_dir()`, `exeweb_embedded_editor_uses_local_assets()`, `exeweb_get_embedded_editor_index_source()`), which in turn are used by `editor/static.php` (asset proxy) and `editor/index.php` (bootstrap). This makes the source transparent to the rest of the codebase.
+
+**Admin management (inline settings widget)**: Editor management is integrated directly into the plugin's admin settings page via a custom `admin_setting` subclass — no separate page. Actions (install/update/repair/uninstall) use AJAX external functions; a JS AMD module handles progress display and timeout resilience.
+
+Key files:
+- `classes/external/manage_embedded_editor.php` — AJAX external functions (`execute_action` + `get_status`), modern `\core_external\external_api` pattern (Moodle 4.2+, PSR-4 namespaced `\mod_exeweb\external\manage_embedded_editor`)
+- `classes/admin/admin_setting_embeddededitor.php` — Custom `admin_setting` subclass (PSR-4 namespaced `\mod_exeweb\admin\admin_setting_embeddededitor`), renders inline widget in settings page. `get_setting()`/`write_setting()` return empty string (display-only, no config stored).
+- `templates/admin_embedded_editor.mustache` — Inline widget template (status card, action buttons, progress bar, result area)
+- `amd/src/admin_embedded_editor.js` — AMD module: calls `get_status(checklatest=true)` on page load (async GitHub API check), handles action AJAX calls with 120s JS timeout, falls back to status polling every 10s using `CONFIG_INSTALLING` lock, polling capped at 5 minutes
+
+**Design decisions & rationale**:
+- **Why AJAX instead of synchronous POST**: Keeps user on settings page; the old `manage_embedded_editor.php` navigated away. POST with `NO_OUTPUT_BUFFERING` progress bar was considered but rejected because it still navigates away.
+- **Why indeterminate progress bar**: The installer doesn't expose byte-level progress. Operation typically takes 10-30s. Animated Bootstrap stripes are sufficient.
+- **Why 120s JS timeout + polling**: Reverse proxies (nginx default 60s, Apache 60-120s) may kill long AJAX requests for ~50MB ZIP downloads. The existing `CONFIG_INSTALLING` lock (300s TTL) serves as a "still running" signal. JS polls `get_status` after timeout to distinguish "still running" / "completed" / "failed". Stale lock (>300s) is detected and reported.
+- **Why GitHub API only from JS**: `discover_latest_version()` hits GitHub API (60 req/hr unauthenticated). Calling it on PHP render would fire on every settings page visit. JS calls it once async after page load.
+- **Why modern \core_external\external_api**: Plugin minimum is Moodle 4.2+ (`version.php`). Uses PSR-4 namespaced classes at `classes/external/`. The legacy `classes/external.php` (Frankenstyle) remains for existing external functions but new code uses the modern pattern.
+- **Both capabilities required**: `moodle/site:config` AND `mod/exeweb:manageembeddededitor`, matching the original `admin_externalpage` registration.
+
+### Embedded Editor Build (Development)
+
+`dist/static/` is **not committed**. It's built from the `exelearning/exelearning` repo:
+- `make build-editor` shallow-clones the source (configured via `.env` or env vars: `EXELEARNING_EDITOR_REPO_URL`, `EXELEARNING_EDITOR_REF`, `EXELEARNING_EDITOR_REF_TYPE`)
+- Builds with Bun (`bun install && bun run build:static`)
+- Copies output to `dist/static/`
+
+### File Storage
+
+Uses Moodle's file API — packages stored in `mod_exeweb/package` filearea, expanded content in `mod_exeweb/content`. Revision number in URLs for cache busting. Never serve files directly from disk.
+
+### Capabilities
+
+`mod/exeweb:view`, `mod/exeweb:addinstance`, `mod/exeweb:manageembeddededitor`
+
+## Code Standards
+
+- **PHP**: Moodle coding standard enforced via PHP CodeSniffer (`make lint`/`make fix`)
+- **Strings**: All UI strings in `lang/{ca,en,es,eu,gl}/exeweb.php` — use `get_string('key', 'mod_exeweb')`
+- **JS**: AMD modules in `amd/src/`, compiled to `amd/build/` (exception: `moodle_exe_bridge.js` loads raw in editor iframe)
+
+## Packaging & Release
+
+- `make package RELEASE=X.Y.Z` updates `version.php`, creates ZIP excluding files in `.distignore`, then restores dev values
+- GitHub Actions `release.yml` triggers on git tags: fetches editor, builds, packages, uploads to GitHub Release
+- `check-editor-releases.yml` runs daily to auto-release when new editor versions appear
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..3bc2163
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,3 @@
+# CLAUDE.md
+
+See [AGENTS.md](AGENTS.md) for detailed guidance on this codebase.
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
new file mode 100644
index 0000000..bd0d64c
--- /dev/null
+++ b/DEVELOPMENT.md
@@ -0,0 +1,122 @@
+# Development Guide
+
+This document covers the development setup for the eXeLearning web sites 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_exeweb/releases).
+
+## Development using Makefile
+
+To facilitate development, a `Makefile` is included to simplify Docker-based workflows.
+
+### Requirements
+
+- Docker
+- Docker Compose
+
+### Available Commands
+
+- **Pull the latest images**:
+ To pull the latest Docker images from the registry, use:
+
+ ```bash
+ make pull
+ ```
+ > **Note**: Docker need to be logged in ghcr.io *([more info...])(https://docs.github.com/es/packages/working-with-a-github-packages-registry/working-with-the-container-registry)*
+
+- **Start the development environment**:
+ To start the Docker containers in interactive mode, run:
+
+ ```bash
+ make up
+ ```
+
+ To start the containers in the background (daemon mode), run:
+
+ ```bash
+ make upd
+ ```
+
+- **Build the Docker containers**:
+ You can build the Docker containers using the following command. This will also check if the `EXELEARNING_WEB_SOURCECODE_PATH` is defined in the `.env` file:
+
+ ```bash
+ make build
+ ```
+
+ > **Note**: If `EXELEARNING_WEB_SOURCECODE_PATH` is not defined, the build will fail and display an error message.
+
+- **Access a shell in the Moodle container**:
+ To open a shell inside the running Moodle container, use:
+
+ ```bash
+ make shell
+ ```
+
+- **Stop and remove containers**:
+ To stop and remove the running Docker containers, run:
+
+ ```bash
+ make down
+ ```
+
+- **Clean up the environment**:
+ To stop and remove all Docker containers, volumes, and orphaned containers, run:
+
+ ```bash
+ make clean
+ ```
+
+### 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.
+
+### Example Workflow
+
+1. Ensure you have Docker running and properly configured.
+2. Define your environment variables in the `.env` file or copy from `.env.dist`.
+3. Pull the latest Docker images:
+
+ ```bash
+ make pull
+ ```
+
+4. Start the environment:
+
+ ```bash
+ make up
+ ```
+
+5. Build the environment if necessary:
+
+ ```bash
+ make build
+ ```
+
+6. Once development is complete, stop and clean up the environment:
+
+ ```bash
+ make down
+ make clean
+ ```
diff --git a/Makefile b/Makefile
index 26b3576..9a8f0fc 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,11 @@
-# Makefile to facilitate the use of Docker in the exelearning-web project
+# Makefile for mod_exeweb 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)
@@ -42,12 +49,12 @@ 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
+up: check-docker check-env build-editor
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
+upd: check-docker check-env build-editor
docker compose up -d
# Stop and remove Docker containers
@@ -102,24 +109,131 @@ 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_exeweb
+
+# 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/exeweb-package
+ mkdir -p /tmp/exeweb-package/exeweb
+ rsync -av --exclude-from=.distignore ./ /tmp/exeweb-package/exeweb/
+ cd /tmp/exeweb-package && zip -qr "$(CURDIR)/$(PLUGIN_NAME)-$(RELEASE).zip" exeweb
+ rm -rf /tmp/exeweb-package
+ @echo "Restoring 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 " help - Display this help with 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
diff --git a/README.md b/README.md
index 5fc3dc0..75429d9 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# eXeLearning web sites for Moodle
+[](https://ateeducacion.github.io/moodle-playground/?blueprint-url=https://raw.githubusercontent.com/exelearning/mod_exeweb/refs/heads/main/blueprint.json)
+
Activity-type module to create and edit web sites 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_exeweb/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 _Manage embedded editor_ page 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_exeweb/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/exeweb
+### 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_exeweb/releases).
+2. Place the extracted contents in `{your/moodle/dirroot}/mod/exeweb`.
+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,108 +66,29 @@ 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: *exeweb | 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
-## Development using Makefile
-
-To facilitate development, a `Makefile` is included to simplify Docker-based workflows.
-
-### Requirements
-
-- Docker
-- Docker Compose
-
-### Available Commands
-
-- **Pull the latest images**:
- To pull the latest Docker images from the registry, use:
-
- ```bash
- make pull
- ```
- > **Note**: Docker need to be logged in ghcr.io *([more info...])(https://docs.github.com/es/packages/working-with-a-github-packages-registry/working-with-the-container-registry)*
-
-- **Start the development environment**:
- To start the Docker containers in interactive mode, run:
-
- ```bash
- make up
- ```
-
- To start the containers in the background (daemon mode), run:
-
- ```bash
- make upd
- ```
-
-- **Build the Docker containers**:
- You can build the Docker containers using the following command. This will also check if the `EXELEARNING_WEB_SOURCECODE_PATH` is defined in the `.env` file:
-
- ```bash
- make build
- ```
-
- > **Note**: If `EXELEARNING_WEB_SOURCECODE_PATH` is not defined, the build will fail and display an error message.
-
-- **Access a shell in the Moodle container**:
- To open a shell inside the running Moodle container, use:
-
- ```bash
- make shell
- ```
-
-- **Stop and remove containers**:
- To stop and remove the running Docker containers, run:
-
- ```bash
- make down
- ```
-
-- **Clean up the environment**:
- To stop and remove all Docker containers, volumes, and orphaned containers, run:
-
- ```bash
- make clean
- ```
-
-### 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.
-
-### Example Workflow
-
-1. Ensure you have Docker running and properly configured.
-2. Define your environment variables in the `.env` file or copy from `.env.dist`.
-3. Pull the latest Docker images:
+The plugin supports two editor sources with the following precedence:
- ```bash
- make pull
- ```
+1. **Admin-installed** (moodledata): Downloaded from GitHub Releases via the admin management page. Stored under `moodledata/mod_exeweb/embedded_editor/`.
+2. **Bundled** (plugin): Included in the plugin release ZIP at `dist/static/`.
-4. Start the environment:
+An admin-installed version always takes precedence over the bundled version. If neither source is available, the embedded editor cannot be used.
- ```bash
- make up
- ```
+### Managing the editor
-5. Build the environment if necessary:
+1. Go to _Site administration > Plugins > Activity modules > eXeLearning (website)_.
+2. The settings page shows the current editor status and active source.
+3. Click _Manage embedded editor_ to access the management page.
+4. From there you can install, update, repair, or remove the editor.
- ```bash
- make build
- ```
+The management page requires the `moodle/site:config` and `mod/exeweb:manageembeddededitor` capabilities.
-6. Once development is complete, stop and clean up the environment:
+## Development
- ```bash
- make down
- make clean
- ```
+For development setup, build instructions, and contributing guidelines, see [DEVELOPMENT.md](DEVELOPMENT.md).
## About
diff --git a/amd/build/admin_embedded_editor.min.js b/amd/build/admin_embedded_editor.min.js
new file mode 100644
index 0000000..51ba9a1
--- /dev/null
+++ b/amd/build/admin_embedded_editor.min.js
@@ -0,0 +1,559 @@
+// 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_exeweb/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_exeweb-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_exeweb-latest-version-spinner'),
+ textEl: container.find('.mod_exeweb-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_exeweb-btn-install').prop('disabled', true).hide();
+ container.find('.mod_exeweb-btn-update').prop('disabled', true).hide();
+ container.find('.mod_exeweb-btn-uninstall').prop('disabled', true).hide();
+
+ if (statusData.can_install) {
+ container.find('.mod_exeweb-btn-install').prop('disabled', false).show();
+ }
+ if (statusData.can_update) {
+ container.find('.mod_exeweb-btn-update').prop('disabled', false).show();
+ }
+ if (statusData.can_uninstall) {
+ container.find('.mod_exeweb-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_exeweb-progress-container').show();
+ container.find('.mod_exeweb-result-area').hide();
+ };
+
+ /**
+ * Hide the progress bar area.
+ *
+ * @param {jQuery} container The widget container.
+ */
+ var hideProgress = function(container) {
+ container.find('.mod_exeweb-progress-container').hide();
+ };
+
+ /**
+ * Set the progress bar visual state.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {string} state One of active, success, or error.
+ * @param {string} message Optional message to display below the bar.
+ */
+ var setProgressState = function(container, state, message) {
+ var bar = container.find('.mod_exeweb-progress-container .progress-bar');
+ var msgEl = container.find('.mod_exeweb-progress-message');
+
+ bar.removeClass('bg-success bg-danger progress-bar-striped progress-bar-animated');
+ bar.css('width', '100%');
+ bar.attr('aria-valuenow', 100);
+
+ if (state === 'active') {
+ bar.addClass('progress-bar-striped progress-bar-animated');
+ } else if (state === 'success') {
+ bar.addClass('bg-success');
+ } else if (state === 'error') {
+ bar.addClass('bg-danger');
+ }
+
+ 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_exeweb-status-primary');
+ if (!summaryEl.length) {
+ return;
+ }
+
+ Str.get_strings([
+ {key: 'editormoodledatasource', component: 'mod_exeweb'},
+ {key: 'editorinstalledat', component: 'mod_exeweb'},
+ {key: 'editorbundledsource', component: 'mod_exeweb'},
+ {key: 'editorbundleddesc', component: 'mod_exeweb'},
+ {key: 'noeditorinstalled', component: 'mod_exeweb'},
+ {key: 'editornotinstalleddesc', component: 'mod_exeweb'},
+ ]).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_exeweb')
+ .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_exeweb')
+ .catch(function() {
+ return Str.get_string('editorupdateavailable', 'mod_exeweb', 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_exeweb-result-area');
+ var msgEl = container.find('.mod_exeweb-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_exeweb_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_exeweb_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_exeweb').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_exeweb').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_exeweb').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_exeweb').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) {
+ disableButtons(container);
+ showProgress(container);
+ setProgressState(container, 'active');
+
+ 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);
+
+ setProgressState(container, 'success');
+ var message = result.message || 'Done.';
+ showResultMessage(container, message, 'success');
+ Notification.addNotification({message: message, type: 'success'});
+
+ refreshStatus(container, true, runtimeConfig).then(function() {
+ hideProgress(container);
+ }).catch(function() {
+ hideProgress(container);
+ });
+
+ return result;
+ }).catch(function(err) {
+ if (timedOut) {
+ return;
+ }
+ window.clearTimeout(timeoutHandle);
+
+ setProgressState(container, 'error');
+ 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_exeweb'},
+ {key: 'confirmuninstall', component: 'mod_exeweb'},
+ {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/editor_modal.min.js b/amd/build/editor_modal.min.js
new file mode 100644
index 0000000..80635bb
--- /dev/null
+++ b/amd/build/editor_modal.min.js
@@ -0,0 +1,579 @@
+define(["exports", "core/str", "core/log"], function(exports, str, log) {
+ "use strict";
+
+ Object.defineProperty(exports, "__esModule", {value: true});
+
+ var Logger = log && log.default ? log.default : log;
+
+ var overlay = null;
+ var iframe = null;
+ var saveBtn = null;
+ var loadingModal = null;
+ var editorOrigin = "*";
+ var openRequestSent = false;
+ var openRequestId = null;
+ var exportRequestId = null;
+ var isSaving = false;
+ var hasUnsavedChanges = false;
+ var session = null;
+ var requestCounter = 0;
+ var openAttemptCount = 0;
+ var openResponseTimer = null;
+
+ var MAX_OPEN_ATTEMPTS = 3;
+ var OPEN_RESPONSE_TIMEOUT_MS = 3000;
+ var FIXED_EXPORT_FORMAT = "elpx";
+
+ function getOrigin(url) {
+ try {
+ return new URL(url, window.location.href).origin;
+ } catch (e) {
+ return "*";
+ }
+ }
+
+ function nextRequestId(prefix) {
+ requestCounter += 1;
+ return prefix + "-" + Date.now() + "-" + requestCounter;
+ }
+
+ function updatePackageUrlRevision(url, revision) {
+ if (!url || !revision) {
+ return url;
+ }
+ var normalizedRevision = String(revision).replace(/[^0-9]/g, "");
+ if (!normalizedRevision) {
+ return url;
+ }
+ var updated = url.replace(/(\/mod_exeweb\/package\/)(\d+)(\/)/, "$1" + normalizedRevision + "$3");
+ if (updated === url) {
+ return url;
+ }
+ var separator = updated.indexOf("?") !== -1 ? "&" : "?";
+ return updated + separator + "v=" + normalizedRevision + "-" + Date.now();
+ }
+
+ function persistUpdatedPackageUrl(newUrl) {
+ if (!newUrl || !session) {
+ return;
+ }
+ session.packageUrl = newUrl;
+ var selector = '[data-action="mod_exeweb/editor-open"][data-cmid="' + String(session.cmid) + '"]';
+ var openButton = document.querySelector(selector);
+ if (openButton && openButton.dataset) {
+ openButton.dataset.packageurl = newUrl;
+ }
+ }
+
+ function updateContentUrlRevision(url, revision) {
+ if (!url || !revision) {
+ return url;
+ }
+ var normalizedRevision = String(revision).replace(/[^0-9]/g, "");
+ if (!normalizedRevision) {
+ return url;
+ }
+ var updated = url.replace(/(\/mod_exeweb\/content\/)(\d+)(\/)/, "$1" + normalizedRevision + "$3");
+ if (updated === url) {
+ return url;
+ }
+ var separator = updated.indexOf("?") !== -1 ? "&" : "?";
+ return updated + separator + "v=" + normalizedRevision + "-" + Date.now();
+ }
+
+ function refreshActivityIframe(revision) {
+ var activityIframe = document.getElementById("exewebobject");
+ if (!activityIframe || !activityIframe.src) {
+ return;
+ }
+ var refreshedSrc = updateContentUrlRevision(activityIframe.src, revision);
+ if (refreshedSrc && refreshedSrc !== activityIframe.src) {
+ activityIframe.src = refreshedSrc;
+ }
+ }
+
+ function setSaveLabel(key, fallback) {
+ if (!saveBtn) {
+ return Promise.resolve();
+ }
+
+ var component = key === "closebuttontitle" ? "core" : "mod_exeweb";
+ return str.get_string(key, component).then(function(label) {
+ saveBtn.innerHTML = ' ' + label;
+ }).catch(function() {
+ saveBtn.innerHTML = ' ' + fallback;
+ });
+ }
+
+ function createLoadingModal() {
+ var modal = document.createElement("div");
+ modal.className = "exeweb-loading-modal";
+ modal.id = "exeweb-loading-modal";
+
+ return str.get_string("saving", "mod_exeweb").then(function(savingText) {
+ return str.get_string("savingwait", "mod_exeweb").then(function(waitText) {
+ return {saving: savingText, wait: waitText};
+ });
+ }).catch(function() {
+ return {saving: "Saving...", wait: "Please wait while the file is being saved."};
+ }).then(function(texts) {
+ modal.innerHTML =
+ '' +
+ '
' +
+ '
' + texts.saving + ' ' +
+ '
' + texts.wait + '
' +
+ '
';
+ document.body.appendChild(modal);
+ return modal;
+ });
+ }
+
+ function showLoadingModal() {
+ if (loadingModal) {
+ loadingModal.classList.add("is-visible");
+ return Promise.resolve();
+ }
+ return createLoadingModal().then(function(modal) {
+ loadingModal = modal;
+ loadingModal.classList.add("is-visible");
+ });
+ }
+
+ function hideLoadingModal() {
+ if (loadingModal) {
+ loadingModal.classList.remove("is-visible");
+ }
+ }
+
+ function removeLoadingModal() {
+ if (loadingModal) {
+ loadingModal.remove();
+ loadingModal = null;
+ }
+ }
+
+ function checkUnsavedChanges() {
+ if (!hasUnsavedChanges) {
+ return Promise.resolve(false);
+ }
+ return str.get_string("unsavedchanges", "mod_exeweb").catch(function() {
+ return "You have unsaved changes. Are you sure you want to close?";
+ }).then(function(message) {
+ return !window.confirm(message);
+ });
+ }
+
+ function postToEditor(message, transfer) {
+ if (!iframe || !iframe.contentWindow) {
+ return;
+ }
+
+ if (transfer && transfer.length) {
+ iframe.contentWindow.postMessage(message, editorOrigin, transfer);
+ } else {
+ iframe.contentWindow.postMessage(message, editorOrigin);
+ }
+ }
+
+ function clearOpenResponseTimer() {
+ if (openResponseTimer) {
+ clearTimeout(openResponseTimer);
+ openResponseTimer = null;
+ }
+ }
+
+ function scheduleOpenRetry() {
+ if (openAttemptCount >= MAX_OPEN_ATTEMPTS) {
+ return;
+ }
+
+ setTimeout(function() {
+ openInitialPackage();
+ }, 300 * openAttemptCount);
+ }
+
+ function armOpenResponseTimer() {
+ clearOpenResponseTimer();
+ openResponseTimer = setTimeout(function() {
+ if (!openRequestSent) {
+ return;
+ }
+
+ Logger.error("[editor_modal] OPEN_FILE timeout waiting for response");
+ openRequestSent = false;
+ scheduleOpenRetry();
+ }, OPEN_RESPONSE_TIMEOUT_MS);
+ }
+
+ function setSavingState(saving) {
+ isSaving = saving;
+ if (!saveBtn) {
+ return Promise.resolve();
+ }
+
+ saveBtn.disabled = saving;
+ if (saving) {
+ return setSaveLabel("saving", "Saving...").then(function() {
+ return showLoadingModal();
+ });
+ }
+ hideLoadingModal();
+ return setSaveLabel("savetomoodle", "Save to Moodle");
+ }
+
+ function openInitialPackage() {
+ if (session && session.skipOpenFileOnInit) {
+ return Promise.resolve();
+ }
+
+ if (openRequestSent || !session || !session.packageUrl) {
+ return Promise.resolve();
+ }
+
+ openRequestSent = true;
+ openAttemptCount += 1;
+
+ return fetch(session.packageUrl, {credentials: "include"}).then(function(response) {
+ if (!response.ok) {
+ throw new Error("HTTP " + response.status + ": " + response.statusText);
+ }
+ return response.arrayBuffer();
+ }).then(function(bytes) {
+ openRequestId = nextRequestId("open");
+ postToEditor({
+ type: "OPEN_FILE",
+ requestId: openRequestId,
+ data: {
+ bytes: bytes,
+ filename: "package.elpx"
+ }
+ });
+
+ armOpenResponseTimer();
+ }).catch(function(error) {
+ Logger.error("[editor_modal] Failed to open package:", error);
+ openRequestSent = false;
+ scheduleOpenRetry();
+ });
+ }
+
+ function uploadExportedFile(payload) {
+ var bytes = payload && payload.bytes;
+ if (!bytes) {
+ return Promise.reject(new Error("Missing export bytes"));
+ }
+
+ var filename = payload.filename || "package.elpx";
+ var mimeType = payload.mimeType || "application/zip";
+
+ var blob = new Blob([bytes], {type: mimeType});
+ var formData = new FormData();
+ formData.append("package", blob, filename);
+ formData.append("format", FIXED_EXPORT_FORMAT);
+ formData.append("cmid", String(session.cmid));
+ formData.append("sesskey", session.sesskey);
+
+ return fetch(session.saveUrl, {
+ method: "POST",
+ body: formData,
+ credentials: "include"
+ }).then(function(response) {
+ return response.json().then(function(result) {
+ if (!response.ok || !result || !result.success) {
+ throw new Error((result && result.error) || ("Save failed (" + response.status + ")"));
+ }
+ return result;
+ });
+ }).then(function(result) {
+ var updatedPackageUrl = updatePackageUrlRevision(session.packageUrl, result.revision);
+ persistUpdatedPackageUrl(updatedPackageUrl);
+ refreshActivityIframe(result.revision);
+ hasUnsavedChanges = false;
+ return setSaveLabel("savedsuccess", "Saved successfully");
+ }).then(function() {
+ close(true);
+ });
+ }
+
+ function requestExport() {
+ if (isSaving || !iframe || !iframe.contentWindow) {
+ return Promise.resolve();
+ }
+
+ return setSavingState(true).then(function() {
+ exportRequestId = nextRequestId("export");
+ postToEditor({
+ type: "REQUEST_EXPORT",
+ requestId: exportRequestId,
+ data: {
+ format: FIXED_EXPORT_FORMAT,
+ filename: "package.elpx"
+ }
+ });
+ });
+ }
+
+ function handleBridgeMessage(event) {
+ if (!iframe || !iframe.contentWindow || event.source !== iframe.contentWindow || !event.data) {
+ return Promise.resolve();
+ }
+ if (editorOrigin !== "*" && event.origin !== editorOrigin) {
+ return Promise.resolve();
+ }
+
+ var data = event.data;
+
+ if (data.type === "EXELEARNING_READY") {
+ postToEditor({
+ type: "CONFIGURE",
+ requestId: nextRequestId("configure"),
+ data: {
+ hideUI: {
+ fileMenu: true,
+ saveButton: true,
+ userMenu: true
+ }
+ }
+ });
+ return openInitialPackage();
+ }
+
+ if (data.type === "DOCUMENT_LOADED") {
+ if (saveBtn && !isSaving) {
+ saveBtn.disabled = false;
+ }
+ return Promise.resolve();
+ }
+
+ if (data.type === "DOCUMENT_CHANGED") {
+ hasUnsavedChanges = true;
+ return Promise.resolve();
+ }
+
+ if (data.type === "OPEN_FILE_SUCCESS") {
+ if (data.requestId === openRequestId && saveBtn && !isSaving) {
+ saveBtn.disabled = false;
+ openRequestSent = false;
+ openAttemptCount = 0;
+ clearOpenResponseTimer();
+ }
+ return Promise.resolve();
+ }
+
+ if (data.type === "OPEN_FILE_ERROR") {
+ if (data.requestId === openRequestId) {
+ Logger.error("[editor_modal] OPEN_FILE_ERROR:", data.error);
+ openRequestSent = false;
+ clearOpenResponseTimer();
+ scheduleOpenRetry();
+ }
+ return Promise.resolve();
+ }
+
+ if (data.type === "EXPORT_FILE") {
+ if (data.requestId === exportRequestId) {
+ return uploadExportedFile(data).catch(function(error) {
+ Logger.error("[editor_modal] Upload failed:", error);
+ return setSavingState(false);
+ });
+ }
+ return Promise.resolve();
+ }
+
+ if (data.type === "REQUEST_EXPORT_ERROR") {
+ if (data.requestId === exportRequestId) {
+ Logger.error("[editor_modal] REQUEST_EXPORT_ERROR:", data.error);
+ return setSavingState(false);
+ }
+ return Promise.resolve();
+ }
+
+ return Promise.resolve();
+ }
+
+ function handleLegacyBridgeMessage(event) {
+ var data = event.data;
+ if (!data || data.source !== "exeweb-editor") {
+ return Promise.resolve();
+ }
+
+ if (data.type === "editor-ready" && data.data && data.data.packageUrl) {
+ var incomingUrl = data.data.packageUrl;
+ if (!session || incomingUrl !== session.packageUrl) {
+ if (session) {
+ session.packageUrl = incomingUrl;
+ }
+ openRequestSent = false;
+ openAttemptCount = 0;
+ }
+ return openInitialPackage();
+ }
+
+ if (data.type === "request-save") {
+ return requestExport();
+ }
+
+ return Promise.resolve();
+ }
+
+ function handleMessage(event) {
+ return handleBridgeMessage(event).then(function() {
+ return handleLegacyBridgeMessage(event);
+ });
+ }
+
+ function handleKeydown(event) {
+ if (event.key === "Escape") {
+ close(false);
+ }
+ }
+
+ function close(skipConfirm) {
+ if (!overlay) {
+ return Promise.resolve();
+ }
+
+ var doClose = function() {
+ var wasShowingLoader = isSaving || (skipConfirm === true);
+
+ overlay.remove();
+ overlay = null;
+ iframe = null;
+ saveBtn = null;
+ session = null;
+ openRequestSent = false;
+ openRequestId = null;
+ exportRequestId = null;
+ isSaving = false;
+ hasUnsavedChanges = false;
+ openAttemptCount = 0;
+ clearOpenResponseTimer();
+
+ 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();
+ }
+
+ function open(cmid, editorUrl, activityName, packageUrl, saveUrl, sesskey) {
+ Logger.debug("[editor_modal] Opening editor for cmid:", cmid);
+
+ if (overlay) {
+ return;
+ }
+
+ editorOrigin = getOrigin(editorUrl);
+ openAttemptCount = 0;
+ session = {
+ cmid: cmid,
+ editorUrl: editorUrl,
+ packageUrl: packageUrl || "",
+ skipOpenFileOnInit: !!packageUrl,
+ saveUrl: saveUrl,
+ sesskey: sesskey
+ };
+
+ overlay = document.createElement("div");
+ overlay.id = "exeweb-editor-overlay";
+ overlay.className = "exeweb-editor-overlay";
+
+ var header = document.createElement("div");
+ header.className = "exeweb-editor-header";
+
+ var title = document.createElement("span");
+ title.className = "exeweb-editor-title";
+ title.textContent = activityName || "";
+ header.appendChild(title);
+
+ var buttonGroup = document.createElement("div");
+ buttonGroup.className = "exeweb-editor-buttons";
+
+ saveBtn = document.createElement("button");
+ saveBtn.className = "btn btn-primary mr-2";
+ saveBtn.id = "exeweb-editor-save";
+ saveBtn.disabled = true;
+ setSaveLabel("savetomoodle", "Save to Moodle");
+ saveBtn.addEventListener("click", requestExport);
+
+ var closeBtn = document.createElement("button");
+ closeBtn.className = "btn btn-secondary";
+ closeBtn.id = "exeweb-editor-close";
+
+ str.get_string("closebuttontitle", "core").then(function(label) {
+ closeBtn.textContent = label;
+ }).catch(function() {
+ closeBtn.textContent = "Close";
+ });
+
+ closeBtn.addEventListener("click", function() { close(false); });
+
+ buttonGroup.appendChild(saveBtn);
+ buttonGroup.appendChild(closeBtn);
+ header.appendChild(buttonGroup);
+ overlay.appendChild(header);
+
+ iframe = document.createElement("iframe");
+ iframe.className = "exeweb-editor-iframe";
+ iframe.src = editorUrl;
+ iframe.setAttribute("allow", "fullscreen");
+ iframe.setAttribute("frameborder", "0");
+ iframe.addEventListener("load", function() {
+ if (!openRequestSent && session && session.packageUrl) {
+ openInitialPackage();
+ }
+ });
+ overlay.appendChild(iframe);
+
+ document.body.appendChild(overlay);
+ document.body.style.overflow = "hidden";
+
+ window.addEventListener("message", handleMessage);
+ document.addEventListener("keydown", handleKeydown);
+ }
+
+ function init() {
+ document.addEventListener("click", function(event) {
+ var button = event.target.closest('[data-action="mod_exeweb/editor-open"]');
+ if (!button) {
+ return;
+ }
+
+ event.preventDefault();
+ open(
+ button.dataset.cmid,
+ button.dataset.editorurl,
+ button.dataset.activityname,
+ button.dataset.packageurl,
+ button.dataset.saveurl,
+ button.dataset.sesskey
+ );
+ });
+ }
+
+ exports.open = open;
+ exports.close = close;
+ exports.init = init;
+});
+
+//# sourceMappingURL=editor_modal.min.js.map
diff --git a/amd/build/editor_modal.min.js.map b/amd/build/editor_modal.min.js.map
new file mode 100644
index 0000000..9535559
--- /dev/null
+++ b/amd/build/editor_modal.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"editor_modal.min.js","sources":["../src/editor_modal.js"],"sourcesContent":[""],"names":[],"mappings":""}
diff --git a/amd/build/fullscreen.min.js b/amd/build/fullscreen.min.js
index 3f2f3c0..07cd0cc 100644
--- a/amd/build/fullscreen.min.js
+++ b/amd/build/fullscreen.min.js
@@ -1,8 +1,104 @@
+// 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 .
+
/**
*
* @author 2021 3iPunt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-define("mod_exeweb/fullscreen",["jquery","core/str","core/ajax","core/templates"],(function($){function Fullscreen(){$("#toggleFullscreen").on("click",this.toggleFullScreen.bind(this)),document.addEventListener("fullscreenchange",this.changeFullScreen,!1),document.addEventListener("mozfullscreenchange",this.changeFullScreen,!1),document.addEventListener("MSFullscreenChange",this.changeFullScreen,!1),document.addEventListener("webkitfullscreenchange",this.changeFullScreen,!1)}return Fullscreen.prototype.changeFullScreen=function(e){let btnToggle=document.getElementById("toggleFullscreen"),page=document.getElementById("exewebpage"),iframe=document.getElementById("exewebobject"),btnEdit=document.getElementById("editonexe");page.classList.contains("fullscreen")?(btnToggle.classList.remove("actived"),page.classList.remove("fullscreen"),iframe.classList.remove("fullscreen"),btnEdit.classList.remove("hidden")):(btnToggle.classList.add("actived"),page.classList.add("fullscreen"),iframe.classList.add("fullscreen"),btnEdit.classList.add("hidden"))},Fullscreen.prototype.toggleFullScreen=function(e){if(document.fullscreenElement||document.webkitFullscreenElement||document.mozFullScreenElement||document.msFullscreenElement)document.exitFullscreen?document.exitFullscreen():document.mozCancelFullScreen?document.mozCancelFullScreen():document.webkitExitFullscreen?document.webkitExitFullscreen():document.msExitFullscreen&&document.msExitFullscreen();else{let element=document.querySelector("#exewebpage");element.requestFullscreen?element.requestFullscreen():element.mozRequestFullScreen?element.mozRequestFullScreen():element.webkitRequestFullscreen?element.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT):element.msRequestFullscreen&&element.msRequestFullscreen()}},Fullscreen.prototype.node=null,{init:function(){return new Fullscreen}}}));
-//# sourceMappingURL=fullscreen.min.js.map
\ No newline at end of file
+/* eslint-disable no-unused-vars */
+/* eslint-disable no-console */
+
+define([
+ 'jquery',
+ 'core/str',
+ 'core/ajax',
+ 'core/templates'
+ ], function($) {
+ "use strict";
+ /**
+ * @constructor
+ */
+ function Fullscreen() {
+ $('#toggleFullscreen').on('click', this.toggleFullScreen.bind(this));
+ document.addEventListener('fullscreenchange', this.changeFullScreen, false);
+ document.addEventListener('mozfullscreenchange', this.changeFullScreen, false);
+ document.addEventListener('MSFullscreenChange', this.changeFullScreen, false);
+ document.addEventListener('webkitfullscreenchange', this.changeFullScreen, false);
+ }
+
+ Fullscreen.prototype.changeFullScreen = function(e) {
+ let btnToggle = document.getElementById('toggleFullscreen');
+ let page = document.getElementById('exewebpage');
+ let iframe = document.getElementById('exewebobject');
+ let btnEdit = document.getElementById('editonexe');
+
+ if (page.classList.contains('fullscreen')) {
+ btnToggle.classList.remove('actived');
+ page.classList.remove('fullscreen');
+ iframe.classList.remove('fullscreen');
+ btnEdit.classList.remove('hidden');
+ } else {
+ btnToggle.classList.add('actived');
+ page.classList.add('fullscreen');
+ iframe.classList.add('fullscreen');
+ btnEdit.classList.add('hidden');
+ }
+ };
+
+ Fullscreen.prototype.toggleFullScreen = function(e) {
+
+ if (document.fullscreenElement ||
+ document.webkitFullscreenElement ||
+ document.mozFullScreenElement ||
+ document.msFullscreenElement) {
+ if (document.exitFullscreen) {
+ document.exitFullscreen();
+ } else if (document.mozCancelFullScreen) {
+ document.mozCancelFullScreen();
+ } else if (document.webkitExitFullscreen) {
+ document.webkitExitFullscreen();
+ } else if (document.msExitFullscreen) {
+ document.msExitFullscreen();
+ }
+ } else {
+ let element = document.querySelector('#exewebpage');
+ if (element.requestFullscreen) {
+ element.requestFullscreen();
+ } else if (element.mozRequestFullScreen) {
+ element.mozRequestFullScreen();
+ } else if (element.webkitRequestFullscreen) {
+ element.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT);
+ } else if (element.msRequestFullscreen) {
+ element.msRequestFullscreen();
+ }
+ }
+
+ };
+
+ /** @type {jQuery} The jQuery node for the region. */
+ Fullscreen.prototype.node = null;
+
+ return {
+ /**
+ * @return {Fullscreen}
+ */
+ init: function() {
+ return new Fullscreen();
+ }
+ };
+ }
+);
diff --git a/amd/build/fullscreen.min.js.map b/amd/build/fullscreen.min.js.map
deleted file mode 100644
index 8b93b54..0000000
--- a/amd/build/fullscreen.min.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"fullscreen.min.js","sources":["../src/fullscreen.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n *\n * @author 2021 3iPunt \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n/* eslint-disable no-unused-vars */\n/* eslint-disable no-console */\n\ndefine([\n 'jquery',\n 'core/str',\n 'core/ajax',\n 'core/templates'\n ], function($) {\n \"use strict\";\n /**\n * @constructor\n */\n function Fullscreen() {\n $('#toggleFullscreen').on('click', this.toggleFullScreen.bind(this));\n document.addEventListener('fullscreenchange', this.changeFullScreen, false);\n document.addEventListener('mozfullscreenchange', this.changeFullScreen, false);\n document.addEventListener('MSFullscreenChange', this.changeFullScreen, false);\n document.addEventListener('webkitfullscreenchange', this.changeFullScreen, false);\n }\n\n Fullscreen.prototype.changeFullScreen = function(e) {\n let btnToggle = document.getElementById('toggleFullscreen');\n let page = document.getElementById('exewebpage');\n let iframe = document.getElementById('exewebobject');\n let btnEdit = document.getElementById('editonexe');\n\n if (page.classList.contains('fullscreen')) {\n btnToggle.classList.remove('actived');\n page.classList.remove('fullscreen');\n iframe.classList.remove('fullscreen');\n btnEdit.classList.remove('hidden');\n } else {\n btnToggle.classList.add('actived');\n page.classList.add('fullscreen');\n iframe.classList.add('fullscreen');\n btnEdit.classList.add('hidden');\n }\n };\n\n Fullscreen.prototype.toggleFullScreen = function(e) {\n\n if (document.fullscreenElement ||\n document.webkitFullscreenElement ||\n document.mozFullScreenElement ||\n document.msFullscreenElement) {\n if (document.exitFullscreen) {\n document.exitFullscreen();\n } else if (document.mozCancelFullScreen) {\n document.mozCancelFullScreen();\n } else if (document.webkitExitFullscreen) {\n document.webkitExitFullscreen();\n } else if (document.msExitFullscreen) {\n document.msExitFullscreen();\n }\n } else {\n let element = document.querySelector('#exewebpage');\n if (element.requestFullscreen) {\n element.requestFullscreen();\n } else if (element.mozRequestFullScreen) {\n element.mozRequestFullScreen();\n } else if (element.webkitRequestFullscreen) {\n element.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT);\n } else if (element.msRequestFullscreen) {\n element.msRequestFullscreen();\n }\n }\n\n };\n\n /** @type {jQuery} The jQuery node for the region. */\n Fullscreen.prototype.node = null;\n\n return {\n /**\n * @return {Fullscreen}\n */\n init: function() {\n return new Fullscreen();\n }\n };\n }\n);\n"],"names":["define","$","Fullscreen","on","this","toggleFullScreen","bind","document","addEventListener","changeFullScreen","prototype","e","btnToggle","getElementById","page","iframe","btnEdit","classList","contains","remove","add","fullscreenElement","webkitFullscreenElement","mozFullScreenElement","msFullscreenElement","exitFullscreen","mozCancelFullScreen","webkitExitFullscreen","msExitFullscreen","element","querySelector","requestFullscreen","mozRequestFullScreen","webkitRequestFullscreen","Element","ALLOW_KEYBOARD_INPUT","msRequestFullscreen","node","init"],"mappings":";;;;;AAwBAA,+BAAO,CACC,SACA,WACA,YACA,mBACD,SAASC,YAKCC,aACJD,EAAE,qBAAqBE,GAAG,QAASC,KAAKC,iBAAiBC,KAAKF,OAC9DG,SAASC,iBAAiB,mBAAoBJ,KAAKK,kBAAkB,GACrEF,SAASC,iBAAiB,sBAAuBJ,KAAKK,kBAAkB,GACxEF,SAASC,iBAAiB,qBAAsBJ,KAAKK,kBAAkB,GACvEF,SAASC,iBAAiB,yBAA0BJ,KAAKK,kBAAkB,UAGhFP,WAAWQ,UAAUD,iBAAmB,SAASE,OACzCC,UAAYL,SAASM,eAAe,oBACpCC,KAAOP,SAASM,eAAe,cAC/BE,OAASR,SAASM,eAAe,gBACjCG,QAAUT,SAASM,eAAe,aAElCC,KAAKG,UAAUC,SAAS,eACxBN,UAAUK,UAAUE,OAAO,WAC3BL,KAAKG,UAAUE,OAAO,cACtBJ,OAAOE,UAAUE,OAAO,cACxBH,QAAQC,UAAUE,OAAO,YAEzBP,UAAUK,UAAUG,IAAI,WACxBN,KAAKG,UAAUG,IAAI,cACnBL,OAAOE,UAAUG,IAAI,cACrBJ,QAAQC,UAAUG,IAAI,YAI9BlB,WAAWQ,UAAUL,iBAAmB,SAASM,MAEzCJ,SAASc,mBACTd,SAASe,yBACTf,SAASgB,sBACThB,SAASiB,oBACLjB,SAASkB,eACTlB,SAASkB,iBACFlB,SAASmB,oBAChBnB,SAASmB,sBACFnB,SAASoB,qBAChBpB,SAASoB,uBACFpB,SAASqB,kBAChBrB,SAASqB,uBAEV,KACCC,QAAUtB,SAASuB,cAAc,eACjCD,QAAQE,kBACRF,QAAQE,oBACDF,QAAQG,qBACfH,QAAQG,uBACDH,QAAQI,wBACfJ,QAAQI,wBAAwBC,QAAQC,sBACjCN,QAAQO,qBACfP,QAAQO,wBAOpBlC,WAAWQ,UAAU2B,KAAO,KAErB,CAIHC,KAAM,kBACK,IAAIpC"}
\ No newline at end of file
diff --git a/amd/build/modform.min.js b/amd/build/modform.min.js
index f1570c2..7e2a84f 100644
--- a/amd/build/modform.min.js
+++ b/amd/build/modform.min.js
@@ -1,11 +1,11 @@
define("mod_exeweb/modform",["exports","core/log","core/str","core/prefetch"],(function(_exports,_log,_str,_prefetch){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
/**
- * Javascript helper function for mod_exeweb module.
+ * Javascript helper function for mod_exeweb module form.
*
- * @module mod_exeweb/resize
+ * @module mod_exeweb/modform
* @copyright 2023 3&Punt
* @author Juan Carrera
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_log=_interopRequireDefault(_log),_prefetch=_interopRequireDefault(_prefetch);const SELECTORS_ORIGIN="#id_exeorigin",SELECTORS_SUBMITBUTTON="#id_submitbutton",SELECTORS_SUBMITBUTTON2="#id_submitbutton2";_exports.init=()=>{_log.default.debug("we have been started!"),_prefetch.default.prefetchStrings("mod_exeweb",["exeweb:editonlineanddisplay","exeweb:editonlineandreturntocourse"]),_prefetch.default.prefetchStrings("core",["savechangesanddisplay","savechangesandreturntocourse"]),document.querySelector(SELECTORS_ORIGIN).addEventListener("change",(e=>{const buttonDisplay=document.querySelector(SELECTORS_SUBMITBUTTON),buttonCourse=document.querySelector(SELECTORS_SUBMITBUTTON2);_log.default.debug(buttonCourse),"exeonline"==e.target.value?((0,_str.get_string)("exeweb:editonlineanddisplay","mod_exeweb").then((label=>{buttonDisplay.value=label})).catch(),(0,_str.get_string)("exeweb:editonlineandreturntocourse","mod_exeweb").then((label=>{_log.default.debug("Label buttton course: ",label),buttonCourse.value=label})).catch()):((0,_str.get_string)("savechangesanddisplay","core").then((label=>{buttonDisplay.value=label})).catch(),(0,_str.get_string)("savechangesandreturntocourse","core").then((label=>{buttonCourse.value=label})).catch())}))}}));
+ */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_log=_interopRequireDefault(_log),_prefetch=_interopRequireDefault(_prefetch);const SELECTORS_ORIGIN="#id_exeorigin",SELECTORS_SUBMITBUTTON="#id_submitbutton",SELECTORS_SUBMITBUTTON2="#id_submitbutton2";_exports.init=()=>{_log.default.debug("we have been started!"),_prefetch.default.prefetchStrings("mod_exeweb",["exeweb:editonlineanddisplay","exeweb:editonlineandreturntocourse"]),_prefetch.default.prefetchStrings("core",["savechangesanddisplay","savechangesandreturntocourse"]),document.querySelector(SELECTORS_ORIGIN).addEventListener("change",(e=>{const buttonDisplay=document.querySelector(SELECTORS_SUBMITBUTTON),buttonCourse=document.querySelector(SELECTORS_SUBMITBUTTON2);_log.default.debug(buttonCourse),"exeonline"==e.target.value?((0,_str.get_string)("exeweb:editonlineanddisplay","mod_exeweb").then((label=>{buttonDisplay.value=label})).catch(),(0,_str.get_string)("exeweb:editonlineandreturntocourse","mod_exeweb").then((label=>{_log.default.debug("Label buttton course: ",label),buttonCourse.value=label})).catch()):"embedded"==e.target.value?((0,_str.get_string)("savechangesanddisplay","core").then((label=>{buttonDisplay.value=label})).catch(),(0,_str.get_string)("savechangesandreturntocourse","core").then((label=>{buttonCourse.value=label})).catch()):((0,_str.get_string)("savechangesanddisplay","core").then((label=>{buttonDisplay.value=label})).catch(),(0,_str.get_string)("savechangesandreturntocourse","core").then((label=>{buttonCourse.value=label})).catch())}))}}));
-//# sourceMappingURL=modform.min.js.map
\ No newline at end of file
+//# sourceMappingURL=modform.min.js.map
diff --git a/amd/build/modform.min.js.map b/amd/build/modform.min.js.map
index 915b7dc..0145f82 100644
--- a/amd/build/modform.min.js.map
+++ b/amd/build/modform.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"modform.min.js","sources":["../src/modform.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Javascript helper function for mod_exeweb module.\n *\n * @module mod_exeweb/resize\n * @copyright 2023 3&Punt\n * @author Juan Carrera \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n/* eslint-disable no-console */\n\nimport Log from 'core/log';\nimport {get_string as getString} from 'core/str';\nimport Prefetch from 'core/prefetch';\n\nconst SELECTORS = {\n ORIGIN: '#id_exeorigin',\n SUBMITBUTTON: '#id_submitbutton',\n SUBMITBUTTON2: '#id_submitbutton2'\n};\n\n\nconst initialize = () => {\n Prefetch.prefetchStrings('mod_exeweb', ['exeweb:editonlineanddisplay', 'exeweb:editonlineandreturntocourse']);\n Prefetch.prefetchStrings('core', ['savechangesanddisplay', 'savechangesandreturntocourse']);\n\n\n // Add listener to the click event that will load the form.\n document.querySelector(SELECTORS.ORIGIN).addEventListener('change', (e) => {\n const buttonDisplay = document.querySelector(SELECTORS.SUBMITBUTTON);\n const buttonCourse = document.querySelector(SELECTORS.SUBMITBUTTON2);\n Log.debug(buttonCourse);\n if (e.target.value == 'exeonline') {\n getString('exeweb:editonlineanddisplay', 'mod_exeweb')\n .then((label) => {\n buttonDisplay.value = label;\n return;\n })\n .catch();\n getString('exeweb:editonlineandreturntocourse', 'mod_exeweb')\n .then((label) => {\n Log.debug('Label buttton course: ', label);\n buttonCourse.value = label;\n return;\n })\n .catch();\n\n } else {\n getString('savechangesanddisplay', 'core')\n .then((label) => {\n buttonDisplay.value = label;\n return;\n })\n .catch();\n\n getString('savechangesandreturntocourse', 'core')\n .then((label) => {\n buttonCourse.value = label;\n return;\n })\n .catch();\n\n }\n });\n};\n\n\nexport const init = () => {\n Log.debug('we have been started!');\n initialize();\n};\n"],"names":["SELECTORS","debug","prefetchStrings","document","querySelector","addEventListener","e","buttonDisplay","buttonCourse","target","value","then","label","catch"],"mappings":";;;;;;;;sKA8BMA,iBACM,gBADNA,uBAEY,mBAFZA,wBAGa,kCAiDC,kBACZC,MAAM,2CA7CDC,gBAAgB,aAAc,CAAC,8BAA+B,yDAC9DA,gBAAgB,OAAQ,CAAC,wBAAyB,iCAI3DC,SAASC,cAAcJ,kBAAkBK,iBAAiB,UAAWC,UAC3DC,cAAgBJ,SAASC,cAAcJ,wBACvCQ,aAAeL,SAASC,cAAcJ,sCACxCC,MAAMO,cACY,aAAlBF,EAAEG,OAAOC,2BACC,8BAA+B,cACpCC,MAAMC,QACHL,cAAcG,MAAQE,SAGzBC,4BACK,qCAAsC,cAC3CF,MAAMC,qBACCX,MAAM,yBAA0BW,OACpCJ,aAAaE,MAAQE,SAGxBC,8BAGK,wBAAyB,QAC9BF,MAAMC,QACHL,cAAcG,MAAQE,SAGzBC,4BAEK,+BAAgC,QACrCF,MAAMC,QACHJ,aAAaE,MAAQE,SAGxBC"}
\ No newline at end of file
+{"version":3,"file":"modform.min.js","sources":["../src/modform.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Javascript helper function for mod_exeweb module.\n *\n * @module mod_exeweb/resize\n * @copyright 2023 3&Punt\n * @author Juan Carrera \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n/* eslint-disable no-console */\n\nimport Log from 'core/log';\nimport {get_string as getString} from 'core/str';\nimport Prefetch from 'core/prefetch';\n\nconst SELECTORS = {\n ORIGIN: '#id_exeorigin',\n SUBMITBUTTON: '#id_submitbutton',\n SUBMITBUTTON2: '#id_submitbutton2'\n};\n\n\nconst initialize = () => {\n Prefetch.prefetchStrings('mod_exeweb', ['exeweb:editonlineanddisplay', 'exeweb:editonlineandreturntocourse']);\n Prefetch.prefetchStrings('core', ['savechangesanddisplay', 'savechangesandreturntocourse']);\n\n\n // Add listener to the click event that will load the form.\n document.querySelector(SELECTORS.ORIGIN).addEventListener('change', (e) => {\n const buttonDisplay = document.querySelector(SELECTORS.SUBMITBUTTON);\n const buttonCourse = document.querySelector(SELECTORS.SUBMITBUTTON2);\n Log.debug(buttonCourse);\n if (e.target.value == 'exeonline') {\n getString('exeweb:editonlineanddisplay', 'mod_exeweb')\n .then((label) => {\n buttonDisplay.value = label;\n return;\n })\n .catch();\n getString('exeweb:editonlineandreturntocourse', 'mod_exeweb')\n .then((label) => {\n Log.debug('Label buttton course: ', label);\n buttonCourse.value = label;\n return;\n })\n .catch();\n\n } else {\n getString('savechangesanddisplay', 'core')\n .then((label) => {\n buttonDisplay.value = label;\n return;\n })\n .catch();\n\n getString('savechangesandreturntocourse', 'core')\n .then((label) => {\n buttonCourse.value = label;\n return;\n })\n .catch();\n\n }\n });\n};\n\n\nexport const init = () => {\n Log.debug('we have been started!');\n initialize();\n};\n"],"names":["SELECTORS","debug","prefetchStrings","document","querySelector","addEventListener","e","buttonDisplay","buttonCourse","target","value","then","label","catch"],"mappings":";;;;;;;;sKA8BMA,iBACM,gBADNA,uBAEY,mBAFZA,wBAGa,kCAiDC,kBACZC,MAAM,2CA7CDC,gBAAgB,aAAc,CAAC,8BAA+B,yDAC9DA,gBAAgB,OAAQ,CAAC,wBAAyB,iCAI3DC,SAASC,cAAcJ,kBAAkBK,iBAAiB,UAAWC,UAC3DC,cAAgBJ,SAASC,cAAcJ,wBACvCQ,aAAeL,SAASC,cAAcJ,sCACxCC,MAAMO,cACY,aAAlBF,EAAEG,OAAOC,2BACC,8BAA+B,cACpCC,MAAMC,QACHL,cAAcG,MAAQE,SAGzBC,4BACK,qCAAsC,cAC3CF,MAAMC,qBACCX,MAAM,yBAA0BW,OACpCJ,aAAaE,MAAQE,SAGxBC,8BAGK,wBAAyB,QAC9BF,MAAMC,QACHL,cAAcG,MAAQE,SAGzBC,4BAEK,+BAAgC,QACrCF,MAAMC,QACHJ,aAAaE,MAAQE,SAGxBC"}
diff --git a/amd/build/moodle_exe_bridge.min.js b/amd/build/moodle_exe_bridge.min.js
new file mode 100644
index 0000000..570c4ae
--- /dev/null
+++ b/amd/build/moodle_exe_bridge.min.js
@@ -0,0 +1,113 @@
+/**
+ * Lightweight bridge between embedded eXeLearning editor and Moodle modal.
+ *
+ * @module mod_exeweb/moodle_exe_bridge
+ */
+
+/* eslint-disable no-console */
+
+(function() {
+ 'use strict';
+
+ var config = window.__MOODLE_EXE_CONFIG__ || {};
+ var targetOrigin = window.__EXE_EMBEDDING_CONFIG__?.parentOrigin || '*';
+
+ function notifyParent(type, data) {
+ if (window.parent && window.parent !== window) {
+ window.parent.postMessage({
+ source: 'exeweb-editor',
+ type: type,
+ data: data || {},
+ }, targetOrigin);
+ }
+ }
+
+ function postProtocolMessage(message) {
+ if (window.parent && window.parent !== window) {
+ window.parent.postMessage(message, targetOrigin);
+ }
+ }
+
+ var monitoredYdoc = null;
+ var changeNotified = false;
+
+ function monitorDocumentChanges() {
+ try {
+ var app = window.eXeLearning?.app;
+ var ydoc = 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?.app;
+ var manager = 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() {
+ // After any parent message, re-check ydoc on next tick (it may have been replaced by OPEN_FILE).
+ setTimeout(monitorDocumentChanges, 500);
+ });
+
+ async function init() {
+ try {
+ if (window.eXeLearning?.ready) {
+ await window.eXeLearning.ready;
+ }
+
+ notifyParent('editor-ready', {
+ cmid: config.cmid || null,
+ packageUrl: config.packageUrl || '',
+ });
+
+ notifyWhenDocumentLoaded();
+
+ document.addEventListener('keydown', function(event) {
+ if ((event.ctrlKey || event.metaKey) && event.key === 's') {
+ event.preventDefault();
+ notifyParent('request-save');
+ }
+ });
+ } catch (error) {
+ console.error('[moodle-exe-bridge] Initialization failed:', error);
+ }
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
diff --git a/amd/build/resize.min.js b/amd/build/resize.min.js
index 314d6e0..5317ab1 100644
--- a/amd/build/resize.min.js
+++ b/amd/build/resize.min.js
@@ -8,4 +8,4 @@ define("mod_exeweb/resize",["exports","core/log"],(function(_exports,_log){var o
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=_exports.exewebResize=_exports.exewebIframeOnload=void 0,_log=(obj=_log)&&obj.__esModule?obj:{default:obj};const exewebResize=function(){let iFrame=document.querySelector("#exewebobject");if(iFrame.contentDocument.body){iFrame.style.width="100%";let iFrameHeight=iFrame.contentDocument.body.scrollHeight;iFrame.style.height=iFrameHeight+50+"px",_log.default.debug("iFrame height: "+(iFrameHeight+50)+"px")}};_exports.exewebResize=exewebResize;const exewebIframeOnload=function(event){exewebResize();let iFrame=event.target;new MutationObserver(window.exewebResize).observe(iFrame.contentDocument.body,{attributes:!0,childList:!0,subtree:!0})};_exports.exewebIframeOnload=exewebIframeOnload;_exports.init=()=>{window.exewebResize=exewebResize,window.exewebIframeOnload=exewebIframeOnload;let page=document.getElementById("exewebpage"),iframe=document.getElementById("exewebobject");_log.default.debug("Setting iFframe load event listener"),iframe.addEventListener("load",exewebIframeOnload),"complete"===iframe.contentDocument.readyState&&exewebResize();new ResizeObserver(exewebResize).observe(page)}}));
-//# sourceMappingURL=resize.min.js.map
\ No newline at end of file
+//# sourceMappingURL=resize.min.js.map
diff --git a/amd/build/resize.min.js.map b/amd/build/resize.min.js.map
index 240dca7..004cf80 100644
--- a/amd/build/resize.min.js.map
+++ b/amd/build/resize.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"resize.min.js","sources":["../src/resize.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Javascript helper function for mod_exeweb module.\n *\n * @module mod_exeweb/resize\n * @copyright 2023 3&Punt\n * @author Juan Carrera \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n/* eslint-disable no-console */\n\nimport Log from 'core/log';\n\n/**\n * Resizes iFrame and container height to iframes body size.\n * This function is to be declared on window namespace so iframe onload event can find it.\n * Used as mutation observer callback.\n *\n */\nexport const exewebResize = function() {\n let iFrame = document.querySelector('#exewebobject');\n if (iFrame.contentDocument.body) {\n iFrame.style.width = '100%';\n let iFrameHeight = iFrame.contentDocument.body.scrollHeight;\n iFrame.style.height = (iFrameHeight + 50) + 'px';\n Log.debug('iFrame height: ' + (iFrameHeight + 50) + 'px');\n }\n};\n\n/**\n * IFrame's onload handler. Used to keep iFrame's height dynamic, varying on iFrame's contents.\n *\n * @param {event} event\n */\nexport const exewebIframeOnload = function(event) {\n exewebResize();\n // Set a mutation observer, so we can adapt to changes from iFrame's javascript (such\n // as tab clicks o hide/show sections).\n let iFrame = event.target;\n const config = {attributes: true, childList: true, subtree: true};\n const observer = new MutationObserver(window.exewebResize);\n observer.observe(iFrame.contentDocument.body, config);\n};\n\nexport const init = () => {\n // Declare on window namespace so iframe onload event can find it.\n window.exewebResize = exewebResize;\n window.exewebIframeOnload = exewebIframeOnload;\n\n let page = document.getElementById('exewebpage');\n let iframe = document.getElementById('exewebobject');\n Log.debug('Setting iFframe load event listener');\n iframe.addEventListener('load', exewebIframeOnload);\n\n if (iframe.contentDocument.readyState === 'complete') {\n exewebResize();\n }\n\n // Watch for page changes.\n const pageObserver = new ResizeObserver(exewebResize);\n pageObserver.observe(page);\n};\n"],"names":["exewebResize","iFrame","document","querySelector","contentDocument","body","style","width","iFrameHeight","scrollHeight","height","debug","exewebIframeOnload","event","target","MutationObserver","window","observe","attributes","childList","subtree","page","getElementById","iframe","addEventListener","readyState","ResizeObserver"],"mappings":";;;;;;;;4LAkCaA,aAAe,eACpBC,OAASC,SAASC,cAAc,oBAChCF,OAAOG,gBAAgBC,KAAM,CAC7BJ,OAAOK,MAAMC,MAAQ,WACjBC,aAAeP,OAAOG,gBAAgBC,KAAKI,aAC/CR,OAAOK,MAAMI,OAAUF,aAAe,GAAM,kBACxCG,MAAM,mBAAqBH,aAAe,IAAM,iDAS/CI,mBAAqB,SAASC,OACvCb,mBAGIC,OAASY,MAAMC,OAEF,IAAIC,iBAAiBC,OAAOhB,cACpCiB,QAAQhB,OAAOG,gBAAgBC,KAFzB,CAACa,YAAY,EAAMC,WAAW,EAAMC,SAAS,kEAK5C,KAEhBJ,OAAOhB,aAAeA,aACtBgB,OAAOJ,mBAAqBA,uBAExBS,KAAOnB,SAASoB,eAAe,cAC/BC,OAASrB,SAASoB,eAAe,6BACjCX,MAAM,uCACVY,OAAOC,iBAAiB,OAAQZ,oBAEU,aAAtCW,OAAOnB,gBAAgBqB,YACvBzB,eAIiB,IAAI0B,eAAe1B,cAC3BiB,QAAQI"}
\ No newline at end of file
+{"version":3,"file":"resize.min.js","sources":["../src/resize.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Javascript helper function for mod_exeweb module.\n *\n * @module mod_exeweb/resize\n * @copyright 2023 3&Punt\n * @author Juan Carrera \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n/* eslint-disable no-console */\n\nimport Log from 'core/log';\n\n/**\n * Resizes iFrame and container height to iframes body size.\n * This function is to be declared on window namespace so iframe onload event can find it.\n * Used as mutation observer callback.\n *\n */\nexport const exewebResize = function() {\n let iFrame = document.querySelector('#exewebobject');\n if (iFrame.contentDocument.body) {\n iFrame.style.width = '100%';\n let iFrameHeight = iFrame.contentDocument.body.scrollHeight;\n iFrame.style.height = (iFrameHeight + 50) + 'px';\n Log.debug('iFrame height: ' + (iFrameHeight + 50) + 'px');\n }\n};\n\n/**\n * IFrame's onload handler. Used to keep iFrame's height dynamic, varying on iFrame's contents.\n *\n * @param {event} event\n */\nexport const exewebIframeOnload = function(event) {\n exewebResize();\n // Set a mutation observer, so we can adapt to changes from iFrame's javascript (such\n // as tab clicks o hide/show sections).\n let iFrame = event.target;\n const config = {attributes: true, childList: true, subtree: true};\n const observer = new MutationObserver(window.exewebResize);\n observer.observe(iFrame.contentDocument.body, config);\n};\n\nexport const init = () => {\n // Declare on window namespace so iframe onload event can find it.\n window.exewebResize = exewebResize;\n window.exewebIframeOnload = exewebIframeOnload;\n\n let page = document.getElementById('exewebpage');\n let iframe = document.getElementById('exewebobject');\n Log.debug('Setting iFframe load event listener');\n iframe.addEventListener('load', exewebIframeOnload);\n\n if (iframe.contentDocument.readyState === 'complete') {\n exewebResize();\n }\n\n // Watch for page changes.\n const pageObserver = new ResizeObserver(exewebResize);\n pageObserver.observe(page);\n};\n"],"names":["exewebResize","iFrame","document","querySelector","contentDocument","body","style","width","iFrameHeight","scrollHeight","height","debug","exewebIframeOnload","event","target","MutationObserver","window","observe","attributes","childList","subtree","page","getElementById","iframe","addEventListener","readyState","ResizeObserver"],"mappings":";;;;;;;;4LAkCaA,aAAe,eACpBC,OAASC,SAASC,cAAc,oBAChCF,OAAOG,gBAAgBC,KAAM,CAC7BJ,OAAOK,MAAMC,MAAQ,WACjBC,aAAeP,OAAOG,gBAAgBC,KAAKI,aAC/CR,OAAOK,MAAMI,OAAUF,aAAe,GAAM,kBACxCG,MAAM,mBAAqBH,aAAe,IAAM,iDAS/CI,mBAAqB,SAASC,OACvCb,mBAGIC,OAASY,MAAMC,OAEF,IAAIC,iBAAiBC,OAAOhB,cACpCiB,QAAQhB,OAAOG,gBAAgBC,KAFzB,CAACa,YAAY,EAAMC,WAAW,EAAMC,SAAS,kEAK5C,KAEhBJ,OAAOhB,aAAeA,aACtBgB,OAAOJ,mBAAqBA,uBAExBS,KAAOnB,SAASoB,eAAe,cAC/BC,OAASrB,SAASoB,eAAe,6BACjCX,MAAM,uCACVY,OAAOC,iBAAiB,OAAQZ,oBAEU,aAAtCW,OAAOnB,gBAAgBqB,YACvBzB,eAIiB,IAAI0B,eAAe1B,cAC3BiB,QAAQI"}
diff --git a/amd/src/admin_embedded_editor.js b/amd/src/admin_embedded_editor.js
new file mode 100644
index 0000000..666ca51
--- /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_exeweb/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_exeweb-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_exeweb-latest-version-spinner'),
+ textEl: container.find('.mod_exeweb-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_exeweb-btn-install').prop('disabled', true).hide();
+ container.find('.mod_exeweb-btn-update').prop('disabled', true).hide();
+ container.find('.mod_exeweb-btn-uninstall').prop('disabled', true).hide();
+
+ if (statusData.can_install) {
+ container.find('.mod_exeweb-btn-install').prop('disabled', false).show();
+ }
+ if (statusData.can_update) {
+ container.find('.mod_exeweb-btn-update').prop('disabled', false).show();
+ }
+ if (statusData.can_uninstall) {
+ container.find('.mod_exeweb-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_exeweb-progress-container').show();
+ container.find('.mod_exeweb-result-area').hide();
+ };
+
+ /**
+ * Hide the progress bar area.
+ *
+ * @param {jQuery} container The widget container.
+ */
+ var hideProgress = function(container) {
+ container.find('.mod_exeweb-progress-container').hide();
+ };
+
+ /**
+ * Set the progress bar visual state.
+ *
+ * @param {jQuery} container The widget container.
+ * @param {string} state One of active, success, or error.
+ * @param {string} message Optional message to display below the bar.
+ */
+ var setProgressState = function(container, state, message) {
+ var msgEl = container.find('.mod_exeweb-progress-message');
+ var spinnerEl = container.find('.mod_exeweb-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_exeweb-status-primary');
+ if (!summaryEl.length) {
+ return;
+ }
+
+ Str.get_strings([
+ {key: 'editormoodledatasource', component: 'mod_exeweb'},
+ {key: 'editorinstalledat', component: 'mod_exeweb'},
+ {key: 'editorbundledsource', component: 'mod_exeweb'},
+ {key: 'editorbundleddesc', component: 'mod_exeweb'},
+ {key: 'noeditorinstalled', component: 'mod_exeweb'},
+ {key: 'editornotinstalleddesc', component: 'mod_exeweb'},
+ ]).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_exeweb')
+ .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_exeweb')
+ .catch(function() {
+ return Str.get_string('editorupdateavailable', 'mod_exeweb', 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_exeweb-result-area');
+ var msgEl = container.find('.mod_exeweb-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_exeweb_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_exeweb_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_exeweb').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_exeweb').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_exeweb').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_exeweb').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_exeweb'},
+ {key: msgStringKey, component: 'mod_exeweb'},
+ ]).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_exeweb'},
+ {key: 'confirmuninstall', component: 'mod_exeweb'},
+ {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/editor_modal.js b/amd/src/editor_modal.js
new file mode 100644
index 0000000..5d9fdc9
--- /dev/null
+++ b/amd/src/editor_modal.js
@@ -0,0 +1,554 @@
+// This file is part of Moodle - http://moodle.org/
+
+/* eslint-disable no-console */
+
+import {get_string as getString} from 'core/str';
+import Log from 'core/log';
+
+let overlay = null;
+let iframe = null;
+let saveBtn = null;
+let loadingModal = null;
+let editorOrigin = '*';
+let openRequestSent = false;
+let openRequestId = null;
+let exportRequestId = null;
+let isSaving = false;
+let hasUnsavedChanges = false;
+let session = null;
+let requestCounter = 0;
+let openAttemptCount = 0;
+let openResponseTimer = null;
+
+const MAX_OPEN_ATTEMPTS = 3;
+const OPEN_RESPONSE_TIMEOUT_MS = 3000;
+const FIXED_EXPORT_FORMAT = 'elpx';
+
+const getOrigin = (url) => {
+ try {
+ return new URL(url, window.location.href).origin;
+ } catch {
+ return '*';
+ }
+};
+
+const nextRequestId = (prefix) => {
+ requestCounter += 1;
+ return `${prefix}-${Date.now()}-${requestCounter}`;
+};
+
+const updatePackageUrlRevision = (url, revision) => {
+ if (!url || !revision) {
+ return url;
+ }
+ const normalizedRevision = String(revision).replace(/[^0-9]/g, '');
+ if (!normalizedRevision) {
+ return url;
+ }
+ const updated = url.replace(/(\/mod_exeweb\/package\/)(\d+)(\/)/, `$1${normalizedRevision}$3`);
+ if (updated === url) {
+ return url;
+ }
+ const separator = updated.includes('?') ? '&' : '?';
+ return `${updated}${separator}v=${normalizedRevision}-${Date.now()}`;
+};
+
+const persistUpdatedPackageUrl = (newUrl) => {
+ if (!newUrl || !session) {
+ return;
+ }
+ session.packageUrl = newUrl;
+ const selector = `[data-action="mod_exeweb/editor-open"][data-cmid="${String(session.cmid)}"]`;
+ const openButton = document.querySelector(selector);
+ if (openButton?.dataset) {
+ openButton.dataset.packageurl = newUrl;
+ }
+};
+
+const updateContentUrlRevision = (url, revision) => {
+ if (!url || !revision) {
+ return url;
+ }
+ const normalizedRevision = String(revision).replace(/[^0-9]/g, '');
+ if (!normalizedRevision) {
+ return url;
+ }
+ const updated = url.replace(/(\/mod_exeweb\/content\/)(\d+)(\/)/, `$1${normalizedRevision}$3`);
+ if (updated === url) {
+ return url;
+ }
+ const separator = updated.includes('?') ? '&' : '?';
+ return `${updated}${separator}v=${normalizedRevision}-${Date.now()}`;
+};
+
+const refreshActivityIframe = (revision) => {
+ const activityIframe = document.getElementById('exewebobject');
+ if (!activityIframe || !activityIframe.src) {
+ return;
+ }
+ const refreshedSrc = updateContentUrlRevision(activityIframe.src, revision);
+ if (refreshedSrc && refreshedSrc !== activityIframe.src) {
+ activityIframe.src = refreshedSrc;
+ }
+};
+
+const setSaveLabel = async(key, fallback) => {
+ if (!saveBtn) {
+ return;
+ }
+ try {
+ const text = await getString(key, key === 'closebuttontitle' ? 'core' : 'mod_exeweb');
+ saveBtn.innerHTML = ' ' + text;
+ } catch {
+ saveBtn.innerHTML = ' ' + fallback;
+ }
+};
+
+const createLoadingModal = async() => {
+ const modal = document.createElement('div');
+ modal.className = 'exeweb-loading-modal';
+ modal.id = 'exeweb-loading-modal';
+
+ let savingText = 'Saving...';
+ let waitText = 'Please wait while the file is being saved.';
+ try {
+ savingText = await getString('saving', 'mod_exeweb');
+ waitText = await getString('savingwait', 'mod_exeweb');
+ } catch {
+ // Use defaults.
+ }
+
+ modal.innerHTML = `
+
+
+
${savingText}
+
${waitText}
+
+ `;
+
+ document.body.appendChild(modal);
+ return modal;
+};
+
+const showLoadingModal = async() => {
+ if (!loadingModal) {
+ loadingModal = await createLoadingModal();
+ }
+ loadingModal.classList.add('is-visible');
+};
+
+const hideLoadingModal = () => {
+ if (loadingModal) {
+ loadingModal.classList.remove('is-visible');
+ }
+};
+
+const removeLoadingModal = () => {
+ if (loadingModal) {
+ loadingModal.remove();
+ loadingModal = null;
+ }
+};
+
+const checkUnsavedChanges = async() => {
+ if (!hasUnsavedChanges) {
+ return false;
+ }
+ let message = 'You have unsaved changes. Are you sure you want to close?';
+ try {
+ message = await getString('unsavedchanges', 'mod_exeweb');
+ } catch {
+ // Use default.
+ }
+ return !window.confirm(message);
+};
+
+const postToEditor = (message, transfer) => {
+ if (!iframe?.contentWindow) {
+ return;
+ }
+ if (transfer && transfer.length) {
+ iframe.contentWindow.postMessage(message, editorOrigin, transfer);
+ } else {
+ iframe.contentWindow.postMessage(message, editorOrigin);
+ }
+};
+
+const clearOpenResponseTimer = () => {
+ if (openResponseTimer) {
+ clearTimeout(openResponseTimer);
+ openResponseTimer = null;
+ }
+};
+
+const scheduleOpenRetry = () => {
+ if (openAttemptCount >= MAX_OPEN_ATTEMPTS) {
+ return;
+ }
+
+ setTimeout(() => {
+ openInitialPackage();
+ }, 300 * openAttemptCount);
+};
+
+const armOpenResponseTimer = () => {
+ clearOpenResponseTimer();
+ openResponseTimer = setTimeout(() => {
+ if (!openRequestSent) {
+ return;
+ }
+
+ Log.error('[editor_modal] OPEN_FILE timeout waiting for response');
+ openRequestSent = false;
+ scheduleOpenRetry();
+ }, OPEN_RESPONSE_TIMEOUT_MS);
+};
+
+const setSavingState = async(saving) => {
+ isSaving = saving;
+ if (!saveBtn) {
+ return;
+ }
+ saveBtn.disabled = saving;
+ if (saving) {
+ await setSaveLabel('saving', 'Saving...');
+ await showLoadingModal();
+ } else {
+ await setSaveLabel('savetomoodle', 'Save to Moodle');
+ hideLoadingModal();
+ }
+};
+
+const openInitialPackage = async() => {
+ if (session?.skipOpenFileOnInit) {
+ return;
+ }
+ if (openRequestSent || !session?.packageUrl) {
+ return;
+ }
+
+ openRequestSent = true;
+ openAttemptCount += 1;
+
+ try {
+ const response = await fetch(session.packageUrl, {credentials: 'include'});
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const bytes = await response.arrayBuffer();
+ openRequestId = nextRequestId('open');
+
+ postToEditor({
+ type: 'OPEN_FILE',
+ requestId: openRequestId,
+ data: {
+ bytes,
+ filename: 'package.elpx',
+ },
+ });
+
+ armOpenResponseTimer();
+ } catch (error) {
+ Log.error('[editor_modal] Failed to open package:', error);
+ openRequestSent = false;
+ scheduleOpenRetry();
+ }
+};
+
+const uploadExportedFile = async(payload) => {
+ const bytes = payload?.bytes;
+ if (!bytes) {
+ throw new Error('Missing export bytes');
+ }
+
+ const filename = payload.filename || 'package.elpx';
+ const mimeType = payload.mimeType || 'application/zip';
+
+ const blob = new Blob([bytes], {type: mimeType});
+ const formData = new FormData();
+ formData.append('package', blob, filename);
+ formData.append('format', FIXED_EXPORT_FORMAT);
+ formData.append('cmid', String(session.cmid));
+ formData.append('sesskey', session.sesskey);
+
+ const response = await fetch(session.saveUrl, {
+ method: 'POST',
+ body: formData,
+ credentials: 'include',
+ });
+
+ const result = await response.json();
+ if (!response.ok || !result?.success) {
+ throw new Error(result?.error || `Save failed (${response.status})`);
+ }
+
+ const updatedPackageUrl = updatePackageUrlRevision(session.packageUrl, result.revision);
+ persistUpdatedPackageUrl(updatedPackageUrl);
+ refreshActivityIframe(result.revision);
+
+ hasUnsavedChanges = false;
+ await setSaveLabel('savedsuccess', 'Saved successfully');
+ close(true);
+};
+
+const requestExport = async() => {
+ if (isSaving || !iframe?.contentWindow) {
+ return;
+ }
+
+ await setSavingState(true);
+ exportRequestId = nextRequestId('export');
+
+ postToEditor({
+ type: 'REQUEST_EXPORT',
+ requestId: exportRequestId,
+ data: {
+ format: FIXED_EXPORT_FORMAT,
+ filename: 'package.elpx',
+ },
+ });
+};
+
+const handleBridgeMessage = async(event) => {
+ if (!iframe?.contentWindow || event.source !== iframe.contentWindow || !event.data) {
+ return;
+ }
+ if (editorOrigin !== '*' && event.origin !== editorOrigin) {
+ return;
+ }
+
+ const data = event.data;
+
+ switch (data.type) {
+ case 'EXELEARNING_READY':
+ postToEditor({
+ type: 'CONFIGURE',
+ requestId: nextRequestId('configure'),
+ data: {
+ hideUI: {
+ fileMenu: true,
+ saveButton: true,
+ userMenu: true,
+ },
+ },
+ });
+ openInitialPackage();
+ break;
+
+ case 'DOCUMENT_LOADED':
+ if (saveBtn && !isSaving) {
+ saveBtn.disabled = false;
+ }
+ break;
+
+ case 'DOCUMENT_CHANGED':
+ hasUnsavedChanges = true;
+ break;
+
+ case 'OPEN_FILE_SUCCESS':
+ if (data.requestId === openRequestId && saveBtn && !isSaving) {
+ saveBtn.disabled = false;
+ openRequestSent = false;
+ openAttemptCount = 0;
+ clearOpenResponseTimer();
+ }
+ break;
+
+ case 'OPEN_FILE_ERROR':
+ if (data.requestId === openRequestId) {
+ Log.error('[editor_modal] OPEN_FILE_ERROR:', data.error);
+ openRequestSent = false;
+ clearOpenResponseTimer();
+ scheduleOpenRetry();
+ }
+ break;
+
+ case 'EXPORT_FILE':
+ if (data.requestId === exportRequestId) {
+ try {
+ await uploadExportedFile(data);
+ } catch (error) {
+ Log.error('[editor_modal] Upload failed:', error);
+ await setSavingState(false);
+ }
+ }
+ break;
+
+ case 'REQUEST_EXPORT_ERROR':
+ if (data.requestId === exportRequestId) {
+ Log.error('[editor_modal] REQUEST_EXPORT_ERROR:', data.error);
+ await setSavingState(false);
+ }
+ break;
+
+ default:
+ break;
+ }
+};
+
+const handleLegacyBridgeMessage = async(event) => {
+ const data = event.data;
+ if (!data || data.source !== 'exeweb-editor') {
+ return;
+ }
+
+ if (data.type === 'editor-ready' && data.data?.packageUrl) {
+ const incomingUrl = data.data.packageUrl;
+ if (incomingUrl !== session?.packageUrl) {
+ session.packageUrl = incomingUrl;
+ openRequestSent = false;
+ openAttemptCount = 0;
+ }
+ openInitialPackage();
+ }
+
+ if (data.type === 'request-save') {
+ await requestExport();
+ }
+};
+
+const handleMessage = async(event) => {
+ await handleBridgeMessage(event);
+ await handleLegacyBridgeMessage(event);
+};
+
+const handleKeydown = (event) => {
+ if (event.key === 'Escape') {
+ close(false);
+ }
+};
+
+export const close = async(skipConfirm) => {
+ if (!overlay) {
+ return;
+ }
+ if (!skipConfirm) {
+ const shouldCancel = await checkUnsavedChanges();
+ if (shouldCancel) {
+ return;
+ }
+ }
+
+ const wasShowingLoader = isSaving || (skipConfirm === true);
+
+ overlay.remove();
+ overlay = null;
+ iframe = null;
+ saveBtn = null;
+ session = null;
+ openRequestSent = false;
+ openRequestId = null;
+ exportRequestId = null;
+ isSaving = false;
+ hasUnsavedChanges = false;
+ openAttemptCount = 0;
+ clearOpenResponseTimer();
+
+ document.body.style.overflow = '';
+ window.removeEventListener('message', handleMessage);
+ document.removeEventListener('keydown', handleKeydown);
+
+ if (wasShowingLoader) {
+ setTimeout(() => {
+ hideLoadingModal();
+ removeLoadingModal();
+ }, 1500);
+ } else {
+ hideLoadingModal();
+ removeLoadingModal();
+ }
+};
+
+export const open = async(cmid, editorUrl, activityName, packageUrl, saveUrl, sesskey) => {
+ Log.debug('[editor_modal] Opening editor for cmid:', cmid);
+
+ if (overlay) {
+ return;
+ }
+
+ editorOrigin = getOrigin(editorUrl);
+ openAttemptCount = 0;
+ session = {
+ cmid,
+ editorUrl,
+ packageUrl: packageUrl || '',
+ skipOpenFileOnInit: !!packageUrl,
+ saveUrl,
+ sesskey,
+ };
+
+ overlay = document.createElement('div');
+ overlay.id = 'exeweb-editor-overlay';
+ overlay.className = 'exeweb-editor-overlay';
+
+ const header = document.createElement('div');
+ header.className = 'exeweb-editor-header';
+
+ const title = document.createElement('span');
+ title.className = 'exeweb-editor-title';
+ title.textContent = activityName || '';
+ header.appendChild(title);
+
+ const buttonGroup = document.createElement('div');
+ buttonGroup.className = 'exeweb-editor-buttons';
+
+ saveBtn = document.createElement('button');
+ saveBtn.className = 'btn btn-primary mr-2';
+ saveBtn.id = 'exeweb-editor-save';
+ saveBtn.disabled = true;
+ await setSaveLabel('savetomoodle', 'Save to Moodle');
+ saveBtn.addEventListener('click', requestExport);
+
+ const closeBtn = document.createElement('button');
+ closeBtn.className = 'btn btn-secondary';
+ closeBtn.id = 'exeweb-editor-close';
+ try {
+ closeBtn.textContent = await getString('closebuttontitle', 'core');
+ } catch {
+ closeBtn.textContent = 'Close';
+ }
+ closeBtn.addEventListener('click', () => close(false));
+
+ buttonGroup.appendChild(saveBtn);
+ buttonGroup.appendChild(closeBtn);
+ header.appendChild(buttonGroup);
+ overlay.appendChild(header);
+
+ iframe = document.createElement('iframe');
+ iframe.className = 'exeweb-editor-iframe';
+ iframe.src = editorUrl;
+ iframe.setAttribute('allow', 'fullscreen');
+ iframe.setAttribute('frameborder', '0');
+ iframe.addEventListener('load', () => {
+ if (!openRequestSent && session?.packageUrl) {
+ openInitialPackage();
+ }
+ });
+ overlay.appendChild(iframe);
+
+ document.body.appendChild(overlay);
+ document.body.style.overflow = 'hidden';
+
+ window.addEventListener('message', handleMessage);
+ document.addEventListener('keydown', handleKeydown);
+};
+
+export const init = () => {
+ document.addEventListener('click', (event) => {
+ const button = event.target.closest('[data-action="mod_exeweb/editor-open"]');
+ if (!button) {
+ return;
+ }
+
+ event.preventDefault();
+ open(
+ button.dataset.cmid,
+ button.dataset.editorurl,
+ button.dataset.activityname,
+ button.dataset.packageurl,
+ button.dataset.saveurl,
+ button.dataset.sesskey
+ );
+ });
+};
diff --git a/amd/src/modform.js b/amd/src/modform.js
index 6a49eed..770a242 100644
--- a/amd/src/modform.js
+++ b/amd/src/modform.js
@@ -14,9 +14,9 @@
// along with Moodle. If not, see .
/**
- * Javascript helper function for mod_exeweb module.
+ * Javascript helper function for mod_exeweb module form.
*
- * @module mod_exeweb/resize
+ * @module mod_exeweb/modform
* @copyright 2023 3&Punt
* @author Juan Carrera
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -60,6 +60,21 @@ const initialize = () => {
})
.catch();
+ } else if (e.target.value == 'embedded') {
+ getString('savechangesanddisplay', 'core')
+ .then((label) => {
+ buttonDisplay.value = label;
+ return;
+ })
+ .catch();
+
+ getString('savechangesandreturntocourse', 'core')
+ .then((label) => {
+ buttonCourse.value = label;
+ return;
+ })
+ .catch();
+
} else {
getString('savechangesanddisplay', 'core')
.then((label) => {
diff --git a/amd/src/moodle_exe_bridge.js b/amd/src/moodle_exe_bridge.js
new file mode 100644
index 0000000..570c4ae
--- /dev/null
+++ b/amd/src/moodle_exe_bridge.js
@@ -0,0 +1,113 @@
+/**
+ * Lightweight bridge between embedded eXeLearning editor and Moodle modal.
+ *
+ * @module mod_exeweb/moodle_exe_bridge
+ */
+
+/* eslint-disable no-console */
+
+(function() {
+ 'use strict';
+
+ var config = window.__MOODLE_EXE_CONFIG__ || {};
+ var targetOrigin = window.__EXE_EMBEDDING_CONFIG__?.parentOrigin || '*';
+
+ function notifyParent(type, data) {
+ if (window.parent && window.parent !== window) {
+ window.parent.postMessage({
+ source: 'exeweb-editor',
+ type: type,
+ data: data || {},
+ }, targetOrigin);
+ }
+ }
+
+ function postProtocolMessage(message) {
+ if (window.parent && window.parent !== window) {
+ window.parent.postMessage(message, targetOrigin);
+ }
+ }
+
+ var monitoredYdoc = null;
+ var changeNotified = false;
+
+ function monitorDocumentChanges() {
+ try {
+ var app = window.eXeLearning?.app;
+ var ydoc = 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?.app;
+ var manager = 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() {
+ // After any parent message, re-check ydoc on next tick (it may have been replaced by OPEN_FILE).
+ setTimeout(monitorDocumentChanges, 500);
+ });
+
+ async function init() {
+ try {
+ if (window.eXeLearning?.ready) {
+ await window.eXeLearning.ready;
+ }
+
+ notifyParent('editor-ready', {
+ cmid: config.cmid || null,
+ packageUrl: config.packageUrl || '',
+ });
+
+ notifyWhenDocumentLoaded();
+
+ document.addEventListener('keydown', function(event) {
+ if ((event.ctrlKey || event.metaKey) && event.key === 's') {
+ event.preventDefault();
+ notifyParent('request-save');
+ }
+ });
+ } catch (error) {
+ console.error('[moodle-exe-bridge] Initialization failed:', error);
+ }
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
diff --git a/blueprint.json b/blueprint.json
new file mode 100644
index 0000000..00319d0
--- /dev/null
+++ b/blueprint.json
@@ -0,0 +1,119 @@
+{
+ "$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 Web Demo",
+ "locale": "es",
+ "timezone": "Europe/Madrid"
+ }
+ },
+ { "step": "login", "username": "{{ADMIN_USER}}" },
+ {
+ "step": "installMoodlePlugin",
+ "pluginType": "mod",
+ "pluginName": "exeweb",
+ "url": "https://github.com/exelearning/mod_exeweb/archive/refs/heads/main.zip"
+ },
+ {
+ "step": "setConfigs",
+ "configs": [
+ { "name": "editormode", "value": "embedded", "plugin": "exeweb" },
+ { "name": "displayoptions", "value": "1,5,6", "plugin": "exeweb" },
+ { "name": "display", "value": "1", "plugin": "exeweb" },
+ { "name": "framesize", "value": "130", "plugin": "exeweb" },
+ { "name": "printintro", "value": "1", "plugin": "exeweb" },
+ { "name": "showdate", "value": "0", "plugin": "exeweb" },
+ { "name": "popupwidth", "value": "620", "plugin": "exeweb" },
+ { "name": "popupheight", "value": "450", "plugin": "exeweb" }
+ ]
+ },
+ { "step": "createCategory", "name": "Test Courses" },
+ {
+ "step": "createCourse",
+ "fullname": "eXeLearning Web Test Course",
+ "shortname": "EXEWEB01",
+ "category": "Test Courses",
+ "summary": "Test course for the eXeLearning Web plugin."
+ },
+ {
+ "step": "createUser",
+ "username": "student",
+ "password": "password",
+ "email": "student@example.com",
+ "firstname": "Demo",
+ "lastname": "Student"
+ },
+ {
+ "step": "enrolUser",
+ "username": "{{ADMIN_USER}}",
+ "course": "EXEWEB01",
+ "role": "editingteacher"
+ },
+ {
+ "step": "enrolUser",
+ "username": "student",
+ "course": "EXEWEB01",
+ "role": "student"
+ },
+ {
+ "step": "createSection",
+ "course": "EXEWEB01",
+ "name": "Example Web Activities"
+ },
+ {
+ "step": "addModule",
+ "module": "exeweb",
+ "course": "EXEWEB01",
+ "section": 1,
+ "name": "Test Content",
+ "intro": "Sample eXeLearning web content for testing.",
+ "exeorigin": "local",
+ "revision": 1,
+ "display": 1,
+ "files": [
+ {
+ "filearea": "package",
+ "itemid": 1,
+ "filename": "test-content.elpx",
+ "data": {
+ "url": "https://raw.githubusercontent.com/exelearning/wp-exelearning/refs/heads/main/tests/fixtures/test-content.elpx"
+ }
+ }
+ ]
+ },
+ {
+ "step": "addModule",
+ "module": "exeweb",
+ "course": "EXEWEB01",
+ "section": 1,
+ "name": "Propiedades",
+ "intro": "Example content demonstrating eXeLearning properties.",
+ "exeorigin": "local",
+ "revision": 1,
+ "display": 1,
+ "files": [
+ {
+ "filearea": "package",
+ "itemid": 1,
+ "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..b78d189
--- /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_exeweb
+ * @copyright 2025 eXeLearning
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_exeweb\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('exeweb/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_exeweb/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_exeweb\local\embedded_editor_source_resolver::get_status();
+
+ // Determine source flags for the template.
+ $sourcemoodledata = ($status->active_source ===
+ \mod_exeweb\local\embedded_editor_source_resolver::SOURCE_MOODLEDATA);
+ $sourcebundled = ($status->active_source ===
+ \mod_exeweb\local\embedded_editor_source_resolver::SOURCE_BUNDLED);
+ $sourcenone = ($status->active_source ===
+ \mod_exeweb\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_exeweb/admin_embedded_editor', 'init', [$jscontext]);
+
+ $widgethtml = $OUTPUT->render_from_template('mod_exeweb/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_exeweb-admin-embedded-editor-setting');
+ }
+}
diff --git a/classes/exeweb_package.php b/classes/exeweb_package.php
index b1003ed..329bf82 100644
--- a/classes/exeweb_package.php
+++ b/classes/exeweb_package.php
@@ -29,16 +29,15 @@
class exeweb_package {
/**
- * Check that a Zip file contains a valid exeweb package
+ * Check that a Zip/ELPX file contains a valid exeweb package.
*
- * @param \stored_file $file A Zip file.
+ * @param \stored_file $file A Zip or ELPX file.
* @return array empty if no issue is found. Array of error message otherwise
*/
public static function validate_package(\stored_file $file) {
$errors = [];
- $mimetype = $file->get_mimetype();
- if ($mimetype !== 'application/zip') {
+ if (!self::is_valid_package_file($file)) {
$errors['packagefile'] = get_string('badexelearningpackage', 'mod_exeweb');
return $errors;
}
@@ -59,6 +58,33 @@ public static function validate_package(\stored_file $file) {
return $errors;
}
+ /**
+ * 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;
+ }
+
/**
* Validate against mandatory and forbidden files config setting.
@@ -163,7 +189,7 @@ public static function save_draft_file(object $data) {
* @param integer $contextid
* @return \stored_file|boolean
*/
- public static function get_mainfile(array $contentlist, int $contextid) {
+ public static function get_mainfile(array $contentlist, int $contextid, int $itemid = 0) {
if (empty($contentlist)) {
return false;
}
@@ -176,7 +202,7 @@ public static function get_mainfile(array $contentlist, int $contextid) {
}
// Find main file and set it.
foreach ($mainfilenames as $item) {
- $mainfile = $fs->get_file($contextid, 'mod_exeweb', 'content', 0, $filepath, $item);
+ $mainfile = $fs->get_file($contextid, 'mod_exeweb', 'content', $itemid, $filepath, $item);
if ($mainfile !== false) {
return $mainfile;
}
diff --git a/classes/external/manage_embedded_editor.php b/classes/external/manage_embedded_editor.php
new file mode 100644
index 0000000..ed4fb14
--- /dev/null
+++ b/classes/external/manage_embedded_editor.php
@@ -0,0 +1,280 @@
+.
+
+/**
+ * External functions for managing the embedded editor in mod_exeweb.
+ *
+ * @package mod_exeweb
+ * @category external
+ * @copyright 2025 eXeLearning
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_exeweb\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_exeweb\local\embedded_editor_installer;
+use mod_exeweb\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/exeweb: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_exeweb', $action)
+ );
+ }
+
+ $context = \context_system::instance();
+ self::validate_context($context);
+ require_capability('moodle/site:config', $context);
+ require_capability('mod/exeweb: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_exeweb');
+ }
+
+ /**
+ * 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/exeweb:manageembeddededitor', $context);
+
+ // Resolve source state.
+ $localstatus = embedded_editor_source_resolver::get_status();
+
+ // Install lock state.
+ $locktime = get_config('exeweb', 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/exeweb: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..561bf98
--- /dev/null
+++ b/classes/local/embedded_editor_installer.php
@@ -0,0 +1,711 @@
+.
+
+/**
+ * Embedded editor installer for mod_exeweb.
+ *
+ * Downloads, validates, and installs the static eXeLearning editor from
+ * GitHub Releases into the moodledata directory.
+ *
+ * @package mod_exeweb
+ * @copyright 2025 eXeLearning
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_exeweb\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_exeweb');
+ }
+
+ $entrybody = $this->extract_first_feed_entry($feedbody);
+ $candidate = $this->extract_version_candidate_from_entry($entrybody);
+ if ($candidate === null) {
+ throw new \moodle_exception('editorgithubparseerror', 'mod_exeweb');
+ }
+
+ $version = $this->normalize_version_candidate($candidate);
+ if ($version === null) {
+ throw new \moodle_exception('editorgithubparseerror', 'mod_exeweb', '', $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_exeweb',
+ ],
+ ]);
+
+ $response = $curl->get($this->get_releases_feed_url());
+ if ($curl->get_errno()) {
+ throw new \moodle_exception('editorgithubconnecterror', 'mod_exeweb', '', $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_exeweb', '', $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_exeweb');
+ }
+
+ 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_exeweb');
+ $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_exeweb', '', $curl->error);
+ }
+
+ if (!is_file($tmpfile) || filesize($tmpfile) === 0) {
+ $this->cleanup_temp_file($tmpfile);
+ throw new \moodle_exception('editordownloaderror', 'mod_exeweb', '',
+ get_string('editordownloademptyfile', 'mod_exeweb'));
+ }
+
+ 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_exeweb');
+ }
+ $header = fread($handle, 4);
+ fclose($handle);
+
+ if ($header !== "PK\x03\x04") {
+ throw new \moodle_exception('editorinvalidzip', 'mod_exeweb');
+ }
+ }
+
+
+ /**
+ * 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_exeweb');
+ }
+
+ $tmpdir = make_temp_directory('mod_exeweb/extract-' . random_string(12));
+
+ $zip = new \ZipArchive();
+ $result = $zip->open($zippath);
+ if ($result !== true) {
+ throw new \moodle_exception('editorextractfailed', 'mod_exeweb', '', $result);
+ }
+
+ if (!$zip->extractTo($tmpdir)) {
+ $zip->close();
+ $this->cleanup_temp_dir($tmpdir);
+ throw new \moodle_exception('editorextractfailed', 'mod_exeweb', '',
+ get_string('editorextractwriteerror', 'mod_exeweb'));
+ }
+
+ $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_exeweb');
+ }
+
+ /**
+ * 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_exeweb');
+ }
+ }
+
+ /**
+ * 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_exeweb', '',
+ get_string('editormkdirerror', 'mod_exeweb', $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_exeweb', '',
+ get_string('editorbackuperror', 'mod_exeweb'));
+ }
+ }
+
+ // 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_exeweb', '',
+ get_string('editorcopyfailed', 'mod_exeweb'));
+ }
+
+ // 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, 'exeweb');
+ set_config(self::CONFIG_INSTALLED_AT, date('Y-m-d H:i:s'), 'exeweb');
+ }
+
+ /**
+ * Clear installation metadata from plugin config.
+ */
+ public function clear_metadata(): void {
+ unset_config(self::CONFIG_VERSION, 'exeweb');
+ unset_config(self::CONFIG_INSTALLED_AT, 'exeweb');
+ }
+
+ /**
+ * 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('exeweb', self::CONFIG_INSTALLING);
+ if ($locktime !== false && (time() - (int)$locktime) < self::INSTALL_LOCK_TIMEOUT) {
+ throw new \moodle_exception('editorinstallconcurrent', 'mod_exeweb');
+ }
+ set_config(self::CONFIG_INSTALLING, time(), 'exeweb');
+ }
+
+ /**
+ * Release the installation lock.
+ */
+ private function release_lock(): void {
+ unset_config(self::CONFIG_INSTALLING, 'exeweb');
+ }
+
+ /**
+ * 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..c304f1a
--- /dev/null
+++ b/classes/local/embedded_editor_source_resolver.php
@@ -0,0 +1,215 @@
+.
+
+/**
+ * Embedded editor source resolver for mod_exeweb.
+ *
+ * Single source of truth for determining which embedded editor source is active.
+ * Implements a precedence policy: moodledata → bundled → none.
+ *
+ * @package mod_exeweb
+ * @copyright 2025 eXeLearning
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_exeweb\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_exeweb/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/exeweb/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('exeweb', '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('exeweb', '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/classes/output/renderer.php b/classes/output/renderer.php
index 945fc0c..9d01566 100644
--- a/classes/output/renderer.php
+++ b/classes/output/renderer.php
@@ -26,6 +26,7 @@
namespace mod_exeweb\output;
use context_course;
+use context_module;
use mod_exeweb\exeonline\exeonline_redirector;
use moodle_url;
@@ -38,7 +39,7 @@
class renderer extends \plugin_renderer_base {
/**
- * Generate the exeweb's "Exit activity" button
+ * Generate the exeweb's action bar
*
* @param \stdClass $cm The course module viewed.
* @return string
@@ -46,16 +47,22 @@ class renderer extends \plugin_renderer_base {
public function generate_action_bar(\stdClass $cm): string {
$context = [];
$hascapability = has_capability('moodle/course:update', context_course::instance($cm->course));
- if ($hascapability && get_config('exeweb', 'exeonlinebaseuri')) {
- $returnto = new moodle_url("/mod/exeweb/view.php", ['id' => $cm->id, 'forceview' => 1]);
- $context['editaction'] = exeonline_redirector::get_redirection_url($cm->id, $returnto)->out(false);
+
+ if ($hascapability) {
+ if (exeweb_online_editor_available()) {
+ $returnto = new moodle_url('/mod/exeweb/view.php', ['id' => $cm->id, 'forceview' => 1]);
+ $context['editaction'] = exeonline_redirector::get_redirection_url($cm->id, $returnto)->out(false);
+ } else if (exeweb_embedded_editor_available()) {
+ $context = array_merge($context, $this->get_embedded_editor_context($cm));
+ }
}
+
return $this->render_from_template('mod_exeweb/action_bar', $context);
}
-
/**
* Returns file embedding html.
+ *
* @param \stdClass $cm
* @param \moodleurl|string $fullurl
* @param string $title
@@ -63,16 +70,48 @@ public function generate_action_bar(\stdClass $cm): string {
* @return string html
*/
public function generate_embed_general(\stdClass $cm, $fullurl, $title, $clicktoopen): string {
-
$context = [];
$context['fullurl'] = ($fullurl instanceof moodle_url) ? $fullurl->out() : $fullurl;
$context['title'] = s($title);
$context['clicktoopen'] = $clicktoopen;
- if (has_capability('moodle/course:update', context_course::instance($cm->course))) {
- $returnto = new moodle_url("/mod/exeweb/view.php", ['id' => $cm->id, 'forceview' => 1]);
- $context['editaction'] = exeonline_redirector::get_redirection_url($cm->id, $returnto)->out(false);
+
+ $hascapability = has_capability('moodle/course:update', context_course::instance($cm->course));
+ if ($hascapability) {
+ if (exeweb_online_editor_available()) {
+ $returnto = new moodle_url('/mod/exeweb/view.php', ['id' => $cm->id, 'forceview' => 1]);
+ $context['editaction'] = exeonline_redirector::get_redirection_url($cm->id, $returnto)->out(false);
+ } else if (exeweb_embedded_editor_available()) {
+ $context = array_merge($context, $this->get_embedded_editor_context($cm, s($title)));
+ }
}
return $this->render_from_template('mod_exeweb/embed_general', $context);
}
+
+ /**
+ * Build context needed by embedded editor modal.
+ *
+ * @param \stdClass $cm
+ * @param string|null $activityname
+ * @return array
+ */
+ private function get_embedded_editor_context(\stdClass $cm, ?string $activityname = null): array {
+ global $DB;
+
+ $modulecontext = context_module::instance($cm->id);
+ $exeweb = $DB->get_record('exeweb', ['id' => $cm->instance], '*', MUST_EXIST);
+ $packageurl = \exeweb_get_package_url($exeweb, $modulecontext);
+
+ return [
+ 'editorurl' => (new moodle_url('/mod/exeweb/editor/index.php', [
+ 'id' => $cm->id,
+ 'sesskey' => sesskey(),
+ ]))->out(false),
+ 'saveurl' => (new moodle_url('/mod/exeweb/editor/save.php'))->out(false),
+ 'packageurl' => $packageurl ? $packageurl->out(false) : '',
+ 'sesskey' => sesskey(),
+ 'cmid' => $cm->id,
+ 'activityname' => $activityname ?? format_string($exeweb->name),
+ ];
+ }
}
diff --git a/db/access.php b/db/access.php
index 643a688..28cdaf6 100644
--- a/db/access.php
+++ b/db/access.php
@@ -45,4 +45,14 @@
],
'clonepermissionsfrom' => 'moodle/course:manageactivities'
],
+
+ 'mod/exeweb:manageembeddededitor' => [
+ 'riskbitmask' => RISK_CONFIG | RISK_DATALOSS,
+
+ 'captype' => 'write',
+ 'contextlevel' => CONTEXT_SYSTEM,
+ 'archetypes' => [
+ 'manager' => CAP_ALLOW,
+ ],
+ ],
];
diff --git a/db/services.php b/db/services.php
index ecaaaeb..8f11e93 100644
--- a/db/services.php
+++ b/db/services.php
@@ -28,6 +28,23 @@
$functions = [
+ 'mod_exeweb_manage_embedded_editor_action' => [
+ 'classname' => 'mod_exeweb\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/exeweb:manageembeddededitor',
+ ],
+ 'mod_exeweb_manage_embedded_editor_status' => [
+ 'classname' => 'mod_exeweb\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/exeweb:manageembeddededitor',
+ ],
+
'mod_exeweb_view_exeweb' => [
'classname' => 'mod_exeweb_external',
'methodname' => 'view_exeweb',
diff --git a/docker-compose.yml b/docker-compose.yml
index cfb2c73..141c5f0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,28 +1,27 @@
---
-version: "3"
services:
- exelearning-web:
- image: ghcr.io/exelearning/exelearning-web:${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
+ # 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
+ image: erseco/alpine-moodle:${MOODLE_VERSION:-v5.0.5}
restart: unless-stopped
environment:
LANG: es_ES.UTF-8
@@ -59,25 +58,28 @@ services:
- db
db:
- image: mariadb:10.6.7
+ 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
+ # 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:
diff --git a/editor/index.php b/editor/index.php
new file mode 100644
index 0000000..11fbb8a
--- /dev/null
+++ b/editor/index.php
@@ -0,0 +1,173 @@
+.
+
+/**
+ * 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_exeweb
+ * @copyright 2025 eXeLearning
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require('../../../config.php');
+require_once($CFG->dirroot . '/mod/exeweb/lib.php');
+
+/**
+ * Output a visible error page inside the editor iframe.
+ *
+ * @param string $message The error message to display.
+ */
+function exeweb_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('exeweb', $id, 0, false, MUST_EXIST);
+$course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST);
+$exeweb = $DB->get_record('exeweb', ['id' => $cm->instance], '*', MUST_EXIST);
+
+require_login($course, true, $cm);
+$context = context_module::instance($cm->id);
+require_capability('moodle/course:manageactivities', $context);
+require_sesskey();
+
+$pageurl = new moodle_url('/mod/exeweb/editor/index.php', ['id' => $id, 'sesskey' => sesskey()]);
+$PAGE->set_url($pageurl);
+$PAGE->set_title(format_string($exeweb->name));
+$PAGE->set_heading($course->fullname);
+
+// Build the package URL for the editor to import.
+$packageurl = exeweb_get_package_url($exeweb, $context);
+
+// Build the save endpoint URL.
+$saveurl = new moodle_url('/mod/exeweb/editor/save.php');
+
+// Serve editor resources through static.php (slash arguments) to ensure
+// files are always accessible regardless of web server configuration.
+// Direct access to dist/static/ can fail on servers that block .zip, .md, etc.
+$editorbaseurl = $CFG->wwwroot . '/mod/exeweb/editor/static.php/' . $cm->id;
+
+// Read the editor template from the local installation (moodledata or bundled).
+$editorindexsource = exeweb_get_embedded_editor_index_source();
+if ($editorindexsource === null) {
+ if (is_siteadmin()) {
+ exeweb_editor_error_page(get_string('embeddednotinstalledadmin', 'mod_exeweb'));
+ } else {
+ exeweb_editor_error_page(get_string('embeddednotinstalledcontactadmin', 'mod_exeweb'));
+ }
+}
+$html = @file_get_contents($editorindexsource);
+if ($html === false || empty($html)) {
+ exeweb_editor_error_page(get_string('editorreaderror', 'mod_exeweb'));
+}
+
+// Inject tag pointing directly to the static directory.
+$basetag = ' ';
+$html = preg_replace('/(]*>)/i', '$1' . $basetag, $html);
+
+// Fix explicit "./" relative paths in attributes (same pattern used by WP and Omeka-S).
+$html = preg_replace(
+ '/(?<=["\'])\.\//',
+ htmlspecialchars($editorbaseurl, ENT_QUOTES, 'UTF-8') . '/',
+ $html
+);
+
+// Build Moodle configuration for the bridge script.
+$moodleconfig = json_encode([
+ 'cmid' => $cm->id,
+ 'contextid' => $context->id,
+ 'sesskey' => sesskey(),
+ 'packageUrl' => $packageurl ? $packageurl->out(false) : '',
+ 'saveUrl' => $saveurl->out(false),
+ 'activityName' => format_string($exeweb->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_exeweb', '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('