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 +[![Preview in Moodle Playground](https://raw.githubusercontent.com/ateeducacion/action-moodle-playground-pr-preview/refs/heads/main/assets/playground-preview-button.svg)](https://ateeducacion.github.io/moodle-playground/?blueprint-url=https://raw.githubusercontent.com/exelearning/mod_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('', $bridgescript . "\n" . '', $html); + +// Output the processed HTML. +header('Content-Type: text/html; charset=utf-8'); +header('X-Frame-Options: SAMEORIGIN'); +echo $html; diff --git a/editor/save.php b/editor/save.php new file mode 100644 index 0000000..76196be --- /dev/null +++ b/editor/save.php @@ -0,0 +1,137 @@ +. + +/** + * AJAX endpoint for saving packages from the embedded eXeLearning editor. + * + * Receives an exported package, stores it in filearea "package", then updates + * module metadata. Old package files are deleted only after the new one is + * successfully stored and processed. + * + * @package mod_exeweb + * @copyright 2025 eXeLearning + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +define('AJAX_SCRIPT', true); + +require('../../../config.php'); +require_once($CFG->dirroot . '/mod/exeweb/lib.php'); +require_once($CFG->dirroot . '/mod/exeweb/locallib.php'); + +use mod_exeweb\exeweb_package; + +$cmid = required_param('cmid', PARAM_INT); +$format = 'elpx'; + +$cm = get_coursemodule_from_id('exeweb', $cmid, 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); +require_sesskey(); +$context = context_module::instance($cm->id); +require_capability('moodle/course:manageactivities', $context); + +header('Content-Type: application/json; charset=utf-8'); + +$newpackage = null; +$newrevision = (int)$exeweb->revision + 1; + +try { + if (empty($_FILES['package'])) { + throw new moodle_exception('nofile', 'error'); + } + + $uploadedfile = $_FILES['package']; + if ((int)$uploadedfile['error'] !== UPLOAD_ERR_OK) { + throw new moodle_exception('uploadproblem', 'error'); + } + $fs = get_file_storage(); + $defaultname = 'package.elpx'; + + $filename = clean_filename($uploadedfile['name']); + if (empty($filename)) { + $filename = $defaultname; + } + + $fileinfo = [ + 'contextid' => $context->id, + 'component' => 'mod_exeweb', + 'filearea' => 'package', + 'itemid' => $newrevision, + 'filepath' => '/', + 'filename' => $filename, + 'userid' => $USER->id, + 'source' => $filename, + 'author' => fullname($USER), + 'license' => 'unknown', + ]; + + $newpackage = $fs->create_file_from_pathname($fileinfo, $uploadedfile['tmp_name']); + + // Keep backwards-compatible preview/index extraction when package contains website structure. + $mainfile = false; + try { + $contentslist = exeweb_package::expand_package($newpackage); + $mainfile = exeweb_package::get_mainfile($contentslist, $newpackage->get_contextid(), $newpackage->get_itemid()); + } catch (Throwable $e) { + /* ELPX may not include a web entrypoint; ignore content extraction errors. */ + } + + if ($mainfile !== false) { + file_set_sortorder( + $context->id, + 'mod_exeweb', + 'content', + $newpackage->get_itemid(), + $mainfile->get_filepath(), + $mainfile->get_filename(), + 1 + ); + $exeweb->entrypath = $mainfile->get_filepath(); + $exeweb->entryname = $mainfile->get_filename(); + } + + $exeweb->revision = $newrevision; + $exeweb->timemodified = time(); + $exeweb->usermodified = $USER->id; + $DB->update_record('exeweb', $exeweb); + + // Delete old package revisions only after successful save. + $packagefiles = $fs->get_area_files($context->id, 'mod_exeweb', 'package', false, 'itemid, filepath, filename', false); + foreach ($packagefiles as $storedfile) { + if ((int)$storedfile->get_itemid() !== $newrevision) { + $storedfile->delete(); + } + } + + echo json_encode([ + 'success' => true, + 'revision' => $exeweb->revision, + 'format' => $format, + ]); +} catch (Throwable $e) { + if ($newpackage) { + $newpackage->delete(); + } + debugging('mod_exeweb editor save failed: ' . $e->getMessage(), DEBUG_DEVELOPER); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => get_string('error'), + ]); +} diff --git a/editor/static.php b/editor/static.php new file mode 100644 index 0000000..f6f34c3 --- /dev/null +++ b/editor/static.php @@ -0,0 +1,147 @@ +. + +/** + * Serve static files from the embedded eXeLearning editor (dist/static/). + * + * @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'); + +// Support both slash arguments (PATH_INFO) and query params. +// Slash arguments: /static.php/{cmid}/{filepath} (used as href for editor). +// Query params: /static.php?id={cmid}&file={filepath} (legacy fallback). +$pathinfo = !empty($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] + : (!empty($_SERVER['ORIG_PATH_INFO']) ? $_SERVER['ORIG_PATH_INFO'] : ''); + +// Fallback: parse from REQUEST_URI when PATH_INFO is not available. +// Some server configurations (PHP-FPM, certain Apache/nginx setups) don't populate PATH_INFO. +if (empty($pathinfo) && !empty($_SERVER['REQUEST_URI'])) { + $requesturi = $_SERVER['REQUEST_URI']; + // Remove query string. + $qpos = strpos($requesturi, '?'); + if ($qpos !== false) { + $requesturi = substr($requesturi, 0, $qpos); + } + // Extract path after 'static.php/'. + $marker = 'static.php/'; + $mpos = strpos($requesturi, $marker); + if ($mpos !== false) { + $pathinfo = '/' . substr($requesturi, $mpos + strlen($marker)); + } +} + +if (!empty($pathinfo)) { + $parts = explode('/', ltrim($pathinfo, '/'), 2); + if (count($parts) < 2 || !is_numeric($parts[0]) || empty($parts[1])) { + send_header_404(); + die('Invalid path'); + } + $id = (int)$parts[0]; + $file = $parts[1]; +} else { + $file = required_param('file', PARAM_PATH); + $id = required_param('id', PARAM_INT); +} + +$cm = get_coursemodule_from_id('exeweb', $id, 0, false, MUST_EXIST); +$course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST); + +require_login($course, true, $cm); +$context = context_module::instance($cm->id); +require_capability('moodle/course:manageactivities', $context); + +// Sanitize the file path to prevent directory traversal. +$file = clean_param($file, PARAM_PATH); +$file = ltrim($file, '/'); + +// Prevent directory traversal. +if (strpos($file, '..') !== false) { + send_header_404(); + die('File not found'); +} + +// Determine content type. +$mimetypes = [ + 'html' => 'text/html', + 'htm' => 'text/html', + 'css' => 'text/css', + 'js' => 'application/javascript', + 'mjs' => 'application/javascript', + 'json' => 'application/json', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'svg' => 'image/svg+xml', + 'ico' => 'image/x-icon', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'eot' => 'application/vnd.ms-fontobject', + 'webp' => 'image/webp', + 'mp3' => 'audio/mpeg', + 'mp4' => 'video/mp4', + 'webm' => 'video/webm', + 'ogg' => 'audio/ogg', + 'wav' => 'audio/wav', + 'pdf' => 'application/pdf', + 'xml' => 'application/xml', + 'wasm' => 'application/wasm', + 'zip' => 'application/zip', + 'md' => 'text/plain', +]; + +$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); +$contenttype = isset($mimetypes[$ext]) ? $mimetypes[$ext] : 'application/octet-stream'; + +// Release session lock early so parallel requests are not blocked. +\core\session\manager::write_close(); + +if (!exeweb_embedded_editor_uses_local_assets()) { + send_header_404(); + die('Editor assets not installed'); +} + +$staticdir = exeweb_get_embedded_editor_local_static_dir(); +$filepath = realpath($staticdir . '/' . $file); +$staticroot = realpath($staticdir); + +// Ensure the resolved path is within the static directory. +if ($filepath === false || $staticroot === false || strpos($filepath, $staticroot) !== 0) { + send_header_404(); + die('File not found'); +} + +if (!is_file($filepath)) { + send_header_404(); + die('File not found'); +} + +header('Content-Type: ' . $contenttype); +header('Content-Length: ' . filesize($filepath)); +header('Cache-Control: public, max-age=604800'); // Cache for 1 week. +header('X-Frame-Options: SAMEORIGIN'); + +if (basename($file) === 'preview-sw.js') { + header('Service-Worker-Allowed: /'); +} + +readfile($filepath); diff --git a/lang/ca/exeweb.php b/lang/ca/exeweb.php index d782622..3479d8c 100644 --- a/lang/ca/exeweb.php +++ b/lang/ca/exeweb.php @@ -75,7 +75,7 @@ $string['exeweb:sendtemplate'] = 'Enviar plantilla'; $string['exeweb:sendtemplate_desc'] = 'Envía la plantilla predeterminada a eXeLearning al crear un nuevo contenido.'; $string['exeweb:template'] = 'Nueva plantilla de paquete.'; -$string['exeweb:template_desc'] = 'El elp subido aquí se utilizará como paquete por defecto para los nuevos contenidos. Se mostrará hasta que sea sustituido por el enviado por eXeLearning. NO descomprima el zip.'; +$string['exeweb:template_desc'] = 'El paquet (.zip o .elpx) pujat aquí es farà servir com a paquet per defecte per a les noves activitats. Es mostrarà fins que sigui substituït pel enviat per eXeLearning. NO descomprimiu el paquet.'; $string['exeweb:editonlineanddisplay'] = 'Ir a eXeLearning y mostrar'; $string['exeweb:editonlineandreturntocourse'] = 'Ir a eXeLearning y volver al curso'; $string['filenotfound'] = 'Lo sentimos, el archivo no se ha encontrado.'; @@ -151,6 +151,99 @@ Si el tipo de archivo es desconocido para el sistema, no se muestra.'; $string['uploadeddate'] = 'Subido {$a}'; +$string['embeddededitorsettings'] = 'Tipus d\'editor'; +$string['embeddededitorstatus'] = 'Editor incrustat'; +$string['editorlatestversionongithub'] = 'Última versió a GitHub:'; +$string['manageembeddededitor'] = 'Gestiona l\'editor incrustat'; +$string['manageembeddededitor_desc'] = 'Instal·la, actualitza o repara l\'editor incrustat d\'eXeLearning.'; +$string['editorsource_moodledata'] = 'Instal·lat (gestionat per administració)'; +$string['editorsource_bundled'] = 'Inclòs amb el connector'; +$string['editorsource_none'] = 'No instal·lat'; +$string['editorinstall'] = 'Instal·la l\'última versió'; +$string['editorupdate'] = 'Actualitza l\'editor'; +$string['editoruninstall'] = 'Elimina'; +$string['editorinstallsuccess'] = 'L\'editor eXeLearning v{$a} s\'ha instal·lat correctament.'; +$string['editoruninstallsuccess'] = 'S\'ha eliminat la instal·lació de l\'editor incrustat.'; +$string['editorversion'] = 'Versió'; +$string['editorinstalledat'] = 'Instal·lat el'; +$string['editorsource'] = 'Origen'; +$string['editoractivesource'] = 'Origen actiu'; +$string['editormoodledatadir'] = 'Directori de dades'; +$string['editorbundleddir'] = 'Directori inclòs'; +$string['editorlatestversion'] = 'Última versió disponible'; +$string['editorstatusinfo'] = 'L\'editor incrustat serveix recursos estàtics per a l\'editor integrat d\'eXeLearning. Els orígens es comproven en aquest ordre: instal·lat per administració (moodledata) i després inclòs amb el connector (dist/).'; +$string['editorgithubconnecterror'] = 'No s\'ha pogut connectar amb GitHub: {$a}'; +$string['editorgithubapierror'] = 'GitHub ha retornat l\'estat HTTP {$a}. Torneu-ho a provar més tard.'; +$string['editorgithubparseerror'] = 'No s\'ha pogut interpretar la informació de l\'última versió publicada a GitHub.'; +$string['editordownloaderror'] = 'No s\'ha pogut descarregar el paquet de l\'editor: {$a}'; +$string['editordownloademptyfile'] = 'El fitxer descarregat és buit.'; +$string['editorinvalidzip'] = 'El fitxer descarregat no és un ZIP vàlid.'; +$string['editorzipextensionmissing'] = 'L\'extensió PHP ZipArchive no està disponible. Demaneu a l\'administrador del servidor que l\'habiliti.'; +$string['editorextractfailed'] = 'No s\'ha pogut extreure el paquet de l\'editor: {$a}'; +$string['editorextractwriteerror'] = 'No s\'han pogut escriure els fitxers extrets al directori temporal.'; +$string['editorinvalidlayout'] = 'El paquet no conté els fitxers esperats de l\'editor (index.html i directoris de recursos).'; +$string['editorinstallfailed'] = 'No s\'ha pogut instal·lar l\'editor: {$a}'; +$string['editormkdirerror'] = 'No s\'ha pogut crear el directori: {$a}'; +$string['editorbackuperror'] = 'No s\'ha pogut fer una còpia de seguretat de la instal·lació existent de l\'editor.'; +$string['editorcopyfailed'] = 'No s\'han pogut copiar els fitxers de l\'editor al directori de destinació.'; +$string['editorinstallconcurrent'] = 'Ja hi ha una instal·lació en curs. Espereu uns minuts i torneu-ho a provar.'; +$string['editorconfirmuninstall'] = 'Segur que voleu eliminar l\'editor instal·lat per administració? S\'utilitzarà la versió inclosa o remota.'; +$string['editorupdateavailable'] = 'Actualització disponible: v{$a}'; +$string['editorcurrentversion'] = 'Versió actual: v{$a}'; +$string['editornotyetinstalled'] = 'No s\'ha trobat cap editor instal·lat per administració.'; +$string['editormoodledatasource'] = 'Instal·lat per l\'administrador (moodledata)'; +$string['editorbundledsource'] = 'Inclòs amb el connector'; +$string['editoravailable'] = 'Disponible'; +$string['editornotavailable'] = 'No disponible'; +$string['editormanagelink'] = 'Gestiona l\'editor incrustat'; +$string['editorsourceprecedence'] = 'Prioritat d\'origen: instal·lat per administració > inclòs.'; +$string['exeweb:manageembeddededitor'] = 'Gestiona la instal·lació de l\'editor incrustat d\'eXeLearning'; +$string['editorcheckingerror'] = 'No s\'han pogut comprovar les actualitzacions. És possible que GitHub no estigui disponible temporalment.'; +$string['editorinstallconfirm'] = 'Això descarregarà i instal·larà l\'última versió de l\'editor eXeLearning (v{$a}) des de GitHub. Voleu continuar?'; +$string['editoradminrequired'] = 'L\'editor incrustat d\'eXeLearning no està instal·lat. Poseu-vos en contacte amb l\'administrador del lloc.'; +$string['editormanagementhelp'] = 'Descarregueu i instal·leu des de GitHub l\'última versió de l\'editor d\'eXeLearning. La versió instal·lada per l\'administrador té prioritat sobre la inclosa amb el connector.'; +$string['editorbundleddesc'] = 'El connector inclou una versió. Podeu instal·lar l\'última versió publicada a GitHub.'; +$string['editornotinstalleddesc'] = 'Instal·leu l\'editor des de GitHub per habilitar el mode d\'edició incrustada.'; +$string['invalidaction'] = 'Acció no vàlida: {$a}'; +$string['installing'] = 'Instal·lant...'; +$string['checkingforupdates'] = 'Comprovant actualitzacions...'; +$string['operationtakinglong'] = 'L\'operació està tardant més del que s\'esperava. S\'està comprovant l\'estat...'; +$string['checkingstatus'] = 'Comprovant l\'estat...'; +$string['stillworking'] = 'Encara s\'està processant...'; +$string['editorinstalling'] = 'Instal·lant...'; +$string['editordownloadingmessage'] = 'Descarregant i instal·lant l\'editor. Això pot trigar un minut...'; +$string['editoruninstalling'] = 'Eliminant...'; +$string['editoruninstallingmessage'] = 'Eliminant la instal·lació de l\'editor...'; +$string['operationtimedout'] = 'L\'operació ha superat el temps d\'espera. Comproveu l\'estat de l\'editor i torneu-ho a provar.'; +$string['latestversionchecking'] = 'Comprovant...'; +$string['latestversionerror'] = 'No s\'han pogut comprovar les actualitzacions'; +$string['updateavailable'] = 'Actualització disponible'; +$string['installstale'] = 'La instal·lació pot haver fallat. Torneu-ho a provar.'; +$string['noeditorinstalled'] = 'No hi ha cap editor instal·lat'; +$string['confirmuninstall'] = 'Segur que voleu desinstal·lar l\'editor incrustat? Això eliminarà la còpia instal·lada per administració de moodledata.'; +$string['confirmuninstalltitle'] = 'Confirma la desinstal·lació'; +$string['editorinstalledsuccess'] = 'Editor instal·lat correctament'; +$string['editoruninstalledsuccess'] = 'Editor desinstal·lat correctament'; +$string['editorupdatedsuccess'] = 'Editor actualitzat correctament'; +$string['editorrepairsuccess'] = 'Editor reparat correctament'; +$string['editormode'] = 'Mode d\'editor'; +$string['editormodedesc'] = 'Seleccioneu quin editor voleu utilitzar per crear i editar contingut eXeLearning. La configuració de connexió online només s\'aplica quan es selecciona el mode "eXeLearning Online".'; +$string['editormodeonline'] = 'eXeLearning Online (servidor remot)'; +$string['editormodeembedded'] = 'Editor integrat (incrustat)'; +$string['embeddednotinstalledcontactadmin'] = 'Els fitxers de l\'editor integrat no estan instal·lats. Contacteu amb l\'administrador del lloc per instal·lar-lo.'; +$string['embeddednotinstalledadmin'] = 'Els fitxers de l\'editor integrat no estan instal·lats. Podeu instal·lar-lo des de la configuració del connector.'; +$string['editembedded'] = 'Editar amb eXeLearning'; +$string['editembedded_integrated'] = 'Integrat'; +$string['editembedded_help'] = 'Obre l\'editor eXeLearning integrat per editar el contingut directament dins de Moodle.'; +$string['editormissing'] = 'L\'editor integrat eXeLearning no està instal·lat. Contacteu amb l\'administrador.'; +$string['editorreaderror'] = 'No s\'han pogut llegir els fitxers de l\'editor integrat eXeLearning. Comproveu els permisos dels fitxers i contacteu amb l\'administrador.'; +$string['embeddedtypehelp'] = 'Es crearà l\'activitat i podreu editar-la amb l\'editor eXeLearning integrat des de la pàgina de visualització de l\'activitat.'; +$string['saving'] = 'Desant...'; +$string['savedsuccess'] = 'Canvis desats correctament'; +$string['savetomoodle'] = 'Desar a Moodle'; +$string['savingwait'] = 'Si us plau, espereu mentre es desa l\'arxiu.'; +$string['unsavedchanges'] = 'Teniu canvis sense desar. Esteu segurs que voleu tancar?'; +$string['typeembedded'] = 'Crear amb eXeLearning (editor integrat)'; $string['typeexewebcreate'] = 'Crear con eXeLearning'; $string['typeexewebedit'] = 'Editar con eXeLearning'; $string['typelocal'] = 'Paquete subido'; diff --git a/lang/en/exeweb.php b/lang/en/exeweb.php index 84a05ed..f302f6a 100644 --- a/lang/en/exeweb.php +++ b/lang/en/exeweb.php @@ -75,7 +75,7 @@ $string['exeweb:sendtemplate'] = 'Send template'; $string['exeweb:sendtemplate_desc'] = 'Sends default template to eXeLearning when creating a new activity.'; $string['exeweb:template'] = 'New package template.'; -$string['exeweb:template_desc'] = 'Package ulpoaded here will be used as default package for new activities. It will be shown until replaced by the one sent by eXeLearning. Do NOT unzip the package.'; +$string['exeweb:template_desc'] = 'Package (.zip or .elpx) uploaded here will be used as default package for new activities. It will be shown until replaced by the one sent by eXeLearning. Do NOT unzip the package.'; $string['exeweb:editonlineanddisplay'] = 'Edit on eXeLearning and display'; $string['exeweb:editonlineandreturntocourse'] = 'Edit on eXeLearning and return to course'; $string['filenotfound'] = 'File not found, sorry.'; @@ -129,10 +129,11 @@ $string['exewebdetails_typedate'] = '{$a->type} {$a->date}'; $string['exewebdetails_sizetypedate'] = '{$a->size} {$a->type} {$a->date}'; $string['exeorigin'] = 'Type'; -$string['exeorigin_help'] = 'This setting determines how the package is included in the course. There are two options: +$string['exeorigin_help'] = 'This setting determines how the package is included in the course. Options may include: * Uploaded package - Enables a zipped eXeLearning website to be chosen via the file picker. -* Create/Edit with eXeLearning - Creates the activity and takes you to eXeLearning to edit the package. When done, eXeLearning will send the newly created package back to Moodle.'; +* Create with eXeLearning (embedded editor) - Creates the activity using the embedded editor. You can then edit it directly from the activity view page. +* Create/Edit with eXeLearning (Online) - Creates the activity and takes you to eXeLearning Online to edit the package. When done, eXeLearning will send the newly created package back to Moodle.'; $string['exeweb:exportexeweb'] = 'Export'; $string['exeweb:view'] = 'View'; $string['search:activity'] = 'File'; @@ -151,6 +152,107 @@ If the file type is not known, it will not be displayed.'; $string['uploadeddate'] = 'Uploaded {$a}'; +$string['embeddededitorsettings'] = 'Editor type'; +$string['editormode'] = 'Editor mode'; +$string['editormodedesc'] = 'Select which editor to use for creating and editing eXeLearning content. Online connection settings only apply when "eXeLearning Online" mode is selected.'; +$string['editormodeonline'] = 'eXeLearning Online (remote server)'; +$string['editormodeembedded'] = 'Integrated editor (embedded)'; +$string['embeddednotinstalledcontactadmin'] = 'The embedded editor files are not installed. Please contact your site administrator to install it.'; +$string['embeddednotinstalledadmin'] = 'The embedded editor files are not installed. You can install it from the plugin settings.'; +$string['editembedded'] = 'Edit with eXeLearning'; +$string['editembedded_integrated'] = 'Integrated'; +$string['editembedded_help'] = 'Open the embedded eXeLearning editor to edit the content directly within Moodle.'; +$string['editormissing'] = 'The eXeLearning embedded editor is not installed. Please contact your administrator.'; +$string['editorreaderror'] = 'Could not read the eXeLearning embedded editor files. Please check file permissions and contact your administrator.'; +$string['embeddedtypehelp'] = 'The activity will be created and you can then edit it using the embedded eXeLearning editor from the activity view page.'; +$string['saving'] = 'Saving...'; +$string['savedsuccess'] = 'Changes saved successfully'; +$string['savetomoodle'] = 'Save to Moodle'; +$string['savingwait'] = 'Please wait while the file is being saved.'; +$string['unsavedchanges'] = 'You have unsaved changes. Are you sure you want to close?'; +$string['typeembedded'] = 'Create with eXeLearning (embedded editor)'; $string['typeexewebcreate'] = 'Create with eXeLearning'; $string['typeexewebedit'] = 'Edit with eXeLearning'; $string['typelocal'] = 'Uploaded package'; + +$string['teachermodevisible'] = 'Show teacher layer selector'; +$string['teachermodevisible_help'] = 'If disabled, the teacher layer selector inside the embedded resource is hidden.'; + +// Embedded editor management. +$string['manageembeddededitor'] = 'Manage embedded editor'; +$string['manageembeddededitor_desc'] = 'Install, update, or repair the embedded eXeLearning editor.'; +$string['embeddededitorstatus'] = 'Embedded editor'; +$string['editorsource_moodledata'] = 'Installed (admin-managed)'; +$string['editorsource_bundled'] = 'Bundled with plugin'; +$string['editorsource_none'] = 'Not installed'; +$string['editorinstall'] = 'Install latest version'; +$string['editorupdate'] = 'Update editor'; +$string['editoruninstall'] = 'Remove'; +$string['editorinstallsuccess'] = 'eXeLearning editor v{$a} installed successfully.'; +$string['editoruninstallsuccess'] = 'Embedded editor installation removed.'; +$string['editorversion'] = 'Version'; +$string['editorinstalledat'] = 'Installed at'; +$string['editorsource'] = 'Source'; +$string['editoractivesource'] = 'Active source'; +$string['editormoodledatadir'] = 'Data directory'; +$string['editorbundleddir'] = 'Bundled directory'; +$string['editorlatestversion'] = 'Latest available version'; +$string['editorlatestversionongithub'] = 'Latest version on GitHub:'; +$string['editorstatusinfo'] = 'The embedded editor serves static assets for the integrated eXeLearning editor. Sources are checked in order: admin-installed (moodledata), then bundled (plugin dist/).'; +$string['editorgithubconnecterror'] = 'Could not connect to GitHub: {$a}'; +$string['editorgithubapierror'] = 'GitHub returned HTTP status {$a}. Please try again later.'; +$string['editorgithubparseerror'] = 'Could not parse the latest release information from GitHub.'; +$string['editordownloaderror'] = 'Failed to download the editor package: {$a}'; +$string['editordownloademptyfile'] = 'The downloaded file is empty.'; +$string['editorinvalidzip'] = 'The downloaded file is not a valid ZIP archive.'; +$string['editorzipextensionmissing'] = 'The PHP ZipArchive extension is not available. Please ask your server administrator to enable it.'; +$string['editorextractfailed'] = 'Failed to extract the editor package: {$a}'; +$string['editorextractwriteerror'] = 'Could not write extracted files to the temporary directory.'; +$string['editorinvalidlayout'] = 'The package does not contain the expected editor files (index.html and asset directories).'; +$string['editorinstallfailed'] = 'Failed to install the editor: {$a}'; +$string['editormkdirerror'] = 'Could not create directory: {$a}'; +$string['editorbackuperror'] = 'Could not back up the existing editor installation.'; +$string['editorcopyfailed'] = 'Could not copy editor files to the target directory.'; +$string['editorinstallconcurrent'] = 'An installation is already in progress. Please wait a few minutes and try again.'; +$string['editorconfirmuninstall'] = 'Are you sure you want to remove the admin-installed editor? The bundled or remote version will be used instead.'; +$string['editorupdateavailable'] = 'Update available: v{$a}'; +$string['editorcurrentversion'] = 'Current version: v{$a}'; +$string['editornotyetinstalled'] = 'No admin-installed editor found.'; +$string['editormoodledatasource'] = 'Admin-installed (moodledata)'; +$string['editorbundledsource'] = 'Bundled with plugin'; +$string['editoravailable'] = 'Available'; +$string['editornotavailable'] = 'Not available'; +$string['editormanagelink'] = 'Manage embedded editor'; +$string['editorsourceprecedence'] = 'Source precedence: admin-installed > bundled.'; +$string['exeweb:manageembeddededitor'] = 'Manage the embedded eXeLearning editor installation'; +$string['editorcheckingerror'] = 'Could not check for updates. GitHub may be temporarily unreachable.'; +$string['editorinstallconfirm'] = 'This will download and install the latest eXeLearning editor (v{$a}) from GitHub. Continue?'; +$string['editoradminrequired'] = 'The embedded eXeLearning editor is not installed. Please contact your site administrator.'; +$string['editormanagementhelp'] = 'Download and install the latest eXeLearning editor from GitHub. The version installed by the administrator takes priority over the bundled one.'; +$string['editorbundleddesc'] = 'A version is included with the plugin. You can install the latest version published on GitHub.'; +$string['editornotinstalleddesc'] = 'Install the editor from GitHub to enable the embedded editing mode.'; +$string['invalidaction'] = 'Invalid action: {$a}'; +$string['installing'] = 'Installing...'; +$string['checkingforupdates'] = 'Checking for updates...'; +$string['operationtakinglong'] = 'Operation is taking longer than expected. Checking status...'; +$string['checkingstatus'] = 'Checking status...'; +$string['stillworking'] = 'Still working...'; +$string['editorinstalling'] = 'Installing...'; +$string['editordownloadingmessage'] = 'Downloading and installing the editor. This may take a minute...'; +$string['editoruninstalling'] = 'Removing...'; +$string['editoruninstallingmessage'] = 'Removing the editor installation...'; +$string['operationtimedout'] = 'Operation timed out. Please check the editor status and try again.'; +$string['latestversionchecking'] = 'Checking...'; +$string['latestversionerror'] = 'Could not check for updates'; +$string['updateavailable'] = 'Update available'; +$string['installstale'] = 'Installation may have failed. Please try again.'; +$string['noeditorinstalled'] = 'No editor installed'; +$string['confirmuninstall'] = 'Are you sure you want to uninstall the embedded editor? This will remove the admin-installed copy from moodledata.'; +$string['confirmuninstalltitle'] = 'Confirm uninstall'; +$string['editorinstalledsuccess'] = 'Editor installed successfully'; +$string['editoruninstalledsuccess'] = 'Editor uninstalled successfully'; +$string['editorupdatedsuccess'] = 'Editor updated successfully'; +$string['editorrepairsuccess'] = 'Editor repaired successfully'; + +$string['editoruploadmissingfile'] = 'No editor ZIP file was uploaded.'; +$string['editoruploadfailed'] = 'Failed to upload the editor package: {$a}'; diff --git a/lang/es/exeweb.php b/lang/es/exeweb.php index 27d3466..e128a10 100644 --- a/lang/es/exeweb.php +++ b/lang/es/exeweb.php @@ -75,7 +75,7 @@ $string['exeweb:sendtemplate'] = 'Enviar plantilla'; $string['exeweb:sendtemplate_desc'] = 'Envía la plantilla predeterminada a eXeLearning al crear un nuevo contenido.'; $string['exeweb:template'] = 'Nueva plantilla de paquete.'; -$string['exeweb:template_desc'] = 'El elp subido aquí se utilizará como paquete por defecto para los nuevos contenidos. Se mostrará hasta que sea sustituido por el enviado por eXeLearning. NO descomprima el zip.'; +$string['exeweb:template_desc'] = 'El paquete (.zip o .elpx) subido aquí se utilizará como paquete por defecto para los nuevos contenidos. Se mostrará hasta que sea sustituido por el enviado por eXeLearning. NO descomprima el paquete.'; $string['exeweb:editonlineanddisplay'] = 'Ir a eXeLearning y mostrar'; $string['exeweb:editonlineandreturntocourse'] = 'Ir a eXeLearning y volver al curso'; $string['filenotfound'] = 'Lo sentimos, el archivo no se ha encontrado.'; @@ -129,10 +129,11 @@ $string['exewebdetails_typedate'] = '{$a->type} {$a->date}'; $string['exewebdetails_sizetypedate'] = '{$a->size} {$a->type} {$a->date}'; $string['exeorigin'] = 'Tipo'; -$string['exeorigin_help'] = 'Este ajuste determina cómo se incluye el paquete en el curso. Hay dos opciones: +$string['exeorigin_help'] = 'Este ajuste determina cómo se incluye el paquete en el curso. Las opciones pueden incluir: * Paquete subido - Permite elegir el zip creado con eXeLearning por medio del selector de archivos. -* Crear/Editar con eXeLearning - Crea la actividad y te lleva a eXeLearning para editar el contenido. Al terminar, eXeLearning lo enviará de vuelta a Moodle.'; +* Crear con eXeLearning (editor integrado) - Crea la actividad usando el editor integrado. Podrá editarla directamente desde la página de visualización de la actividad. +* Crear/Editar con eXeLearning (Online) - Crea la actividad y te lleva a eXeLearning Online para editar el contenido. Al terminar, eXeLearning lo enviará de vuelta a Moodle.'; $string['exeweb:exportexeweb'] = 'Exportar recurso'; $string['exeweb:view'] = 'Ver recurso'; $string['search:activity'] = 'Fichero'; @@ -151,6 +152,105 @@ Si el tipo de archivo es desconocido para el sistema, no se muestra.'; $string['uploadeddate'] = 'Subido {$a}'; +$string['embeddededitorsettings'] = 'Tipo de editor'; +$string['embeddededitorstatus'] = 'Editor embebido'; +$string['editorlatestversionongithub'] = 'Última versión en GitHub:'; +$string['manageembeddededitor'] = 'Gestionar editor embebido'; +$string['manageembeddededitor_desc'] = 'Instalar, actualizar o reparar el editor embebido de eXeLearning.'; +$string['editorsource_moodledata'] = 'Instalado (gestionado por administración)'; +$string['editorsource_bundled'] = 'Incluido con el plugin'; +$string['editorsource_none'] = 'No instalado'; +$string['editorinstall'] = 'Instalar última versión'; +$string['editorupdate'] = 'Actualizar editor'; +$string['editoruninstall'] = 'Eliminar'; +$string['editorinstallsuccess'] = 'El editor eXeLearning v{$a} se ha instalado correctamente.'; +$string['editoruninstallsuccess'] = 'Se ha eliminado la instalación del editor embebido.'; +$string['editorversion'] = 'Versión'; +$string['editorinstalledat'] = 'Instalado el'; +$string['editorsource'] = 'Origen'; +$string['editoractivesource'] = 'Origen activo'; +$string['editormoodledatadir'] = 'Directorio de datos'; +$string['editorbundleddir'] = 'Directorio incluido'; +$string['editorlatestversion'] = 'Última versión disponible'; +$string['editorstatusinfo'] = 'El editor embebido sirve recursos estáticos para el editor integrado de eXeLearning. Los orígenes se comprueban en este orden: instalado por administración (moodledata) y después incluido en el plugin (dist/).'; +$string['editorgithubconnecterror'] = 'No se pudo conectar con GitHub: {$a}'; +$string['editorgithubapierror'] = 'GitHub devolvió el estado HTTP {$a}. Inténtelo de nuevo más tarde.'; +$string['editorgithubparseerror'] = 'No se pudo interpretar la información de la última versión publicada en GitHub.'; +$string['editordownloaderror'] = 'Error al descargar el paquete del editor: {$a}'; +$string['editordownloademptyfile'] = 'El archivo descargado está vacío.'; +$string['editorinvalidzip'] = 'El archivo descargado no es un ZIP válido.'; +$string['editorzipextensionmissing'] = 'La extensión PHP ZipArchive no está disponible. Pida al administrador del servidor que la habilite.'; +$string['editorextractfailed'] = 'Error al extraer el paquete del editor: {$a}'; +$string['editorextractwriteerror'] = 'No se pudieron escribir los archivos extraídos en el directorio temporal.'; +$string['editorinvalidlayout'] = 'El paquete no contiene los archivos esperados del editor (index.html y directorios de recursos).'; +$string['editorinstallfailed'] = 'Error al instalar el editor: {$a}'; +$string['editormkdirerror'] = 'No se pudo crear el directorio: {$a}'; +$string['editorbackuperror'] = 'No se pudo crear una copia de seguridad de la instalación existente del editor.'; +$string['editorcopyfailed'] = 'No se pudieron copiar los archivos del editor al directorio de destino.'; +$string['editorinstallconcurrent'] = 'Ya hay una instalación en curso. Espere unos minutos e inténtelo de nuevo.'; +$string['editorconfirmuninstall'] = '¿Está seguro de que desea eliminar el editor instalado por administración? Se utilizará la versión incluida o remota.'; +$string['editorupdateavailable'] = 'Actualización disponible: v{$a}'; +$string['editorcurrentversion'] = 'Versión actual: v{$a}'; +$string['editornotyetinstalled'] = 'No se ha encontrado ningún editor instalado por administración.'; +$string['editormoodledatasource'] = 'Instalado por el administrador (moodledata)'; +$string['editorbundledsource'] = 'Incluido con el plugin'; +$string['editoravailable'] = 'Disponible'; +$string['editornotavailable'] = 'No disponible'; +$string['editormanagelink'] = 'Gestionar editor embebido'; +$string['editorsourceprecedence'] = 'Prioridad de origen: instalado por administración > incluido.'; +$string['exeweb:manageembeddededitor'] = 'Gestionar la instalación del editor embebido de eXeLearning'; +$string['editorcheckingerror'] = 'No se pudieron comprobar las actualizaciones. Es posible que GitHub no esté disponible temporalmente.'; +$string['editorinstallconfirm'] = 'Esto descargará e instalará la última versión del editor eXeLearning (v{$a}) desde GitHub. ¿Desea continuar?'; +$string['editoradminrequired'] = 'El editor embebido de eXeLearning no está instalado. Contacte con el administrador del sitio.'; +$string['editormanagementhelp'] = 'Descargue e instale desde GitHub la última versión del editor de eXeLearning. La versión instalada por el administrador tiene prioridad sobre la incluida con el plugin.'; +$string['editorbundleddesc'] = 'El plugin incluye una versión. Puede instalar la última versión publicada en GitHub.'; +$string['editornotinstalleddesc'] = 'Instale el editor desde GitHub para habilitar el modo de edición embebido.'; +$string['invalidaction'] = 'Acción no válida: {$a}'; +$string['installing'] = 'Instalando...'; +$string['checkingforupdates'] = 'Comprobando actualizaciones...'; +$string['operationtakinglong'] = 'La operación está tardando más de lo esperado. Comprobando estado...'; +$string['checkingstatus'] = 'Comprobando estado...'; +$string['stillworking'] = 'Sigue en proceso...'; +$string['editorinstalling'] = 'Instalando...'; +$string['editordownloadingmessage'] = 'Descargando e instalando el editor. Esto puede tardar un minuto...'; +$string['editoruninstalling'] = 'Eliminando...'; +$string['editoruninstallingmessage'] = 'Eliminando la instalación del editor...'; +$string['operationtimedout'] = 'La operación ha superado el tiempo de espera. Compruebe el estado del editor e inténtelo de nuevo.'; +$string['latestversionchecking'] = 'Comprobando...'; +$string['latestversionerror'] = 'No se pudieron comprobar las actualizaciones'; +$string['updateavailable'] = 'Actualización disponible'; +$string['installstale'] = 'La instalación puede haber fallado. Inténtelo de nuevo.'; +$string['noeditorinstalled'] = 'No hay ningún editor instalado'; +$string['confirmuninstall'] = '¿Está seguro de que desea desinstalar el editor embebido? Esto eliminará la copia instalada por administración de moodledata.'; +$string['confirmuninstalltitle'] = 'Confirmar desinstalación'; +$string['editorinstalledsuccess'] = 'Editor instalado correctamente'; +$string['editoruninstalledsuccess'] = 'Editor desinstalado correctamente'; +$string['editorupdatedsuccess'] = 'Editor actualizado correctamente'; +$string['editorrepairsuccess'] = 'Editor reparado correctamente'; +$string['editormode'] = 'Modo de editor'; +$string['editormodedesc'] = 'Seleccione qué editor usar para crear y editar contenido eXeLearning. La configuración de conexión online solo aplica cuando se selecciona el modo "eXeLearning Online".'; +$string['editormodeonline'] = 'eXeLearning Online (servidor remoto)'; +$string['editormodeembedded'] = 'Editor integrado (embebido)'; +$string['embeddednotinstalledcontactadmin'] = 'Los archivos del editor integrado no están instalados. Contacte con el administrador del sitio para instalarlo.'; +$string['embeddednotinstalledadmin'] = 'Los archivos del editor integrado no están instalados. Puede instalarlo desde la configuración del plugin.'; +$string['editembedded'] = 'Editar con eXeLearning'; +$string['editembedded_integrated'] = 'Integrado'; +$string['editembedded_help'] = 'Abre el editor eXeLearning integrado para editar el contenido directamente dentro de Moodle.'; +$string['editormissing'] = 'El editor integrado eXeLearning no está instalado. Contacte con el administrador.'; +$string['editorreaderror'] = 'No se pudieron leer los archivos del editor integrado eXeLearning. Compruebe los permisos de los archivos y contacte con el administrador.'; +$string['embeddedtypehelp'] = 'Se creará la actividad y podrá editarla usando el editor eXeLearning integrado desde la página de visualización de la actividad.'; +$string['saving'] = 'Guardando...'; +$string['savedsuccess'] = 'Cambios guardados correctamente'; +$string['savetomoodle'] = 'Guardar en Moodle'; +$string['savingwait'] = 'Por favor, espere mientras se guarda el archivo.'; +$string['unsavedchanges'] = 'Tiene cambios sin guardar. ¿Está seguro de que desea cerrar?'; +$string['typeembedded'] = 'Crear con eXeLearning (editor integrado)'; $string['typeexewebcreate'] = 'Crear con eXeLearning'; $string['typeexewebedit'] = 'Editar con eXeLearning'; $string['typelocal'] = 'Paquete subido'; + +$string['teachermodevisible'] = 'Mostrar el selector de capa docente'; +$string['teachermodevisible_help'] = 'Si se desactiva, se ocultará el selector de capa docente dentro del recurso incrustado.'; + +$string['editoruploadmissingfile'] = 'No se ha subido ningún archivo ZIP del editor.'; +$string['editoruploadfailed'] = 'No se pudo subir el paquete del editor: {$a}'; diff --git a/lang/eu/exeweb.php b/lang/eu/exeweb.php index c04eece..425d035 100644 --- a/lang/eu/exeweb.php +++ b/lang/eu/exeweb.php @@ -75,7 +75,7 @@ $string['exeweb:sendtemplate'] = 'Enviar plantilla'; $string['exeweb:sendtemplate_desc'] = 'Envía la plantilla predeterminada a eXeLearning al crear un nuevo contenido.'; $string['exeweb:template'] = 'Nueva plantilla de paquete.'; -$string['exeweb:template_desc'] = 'El elp subido aquí se utilizará como paquete por defecto para los nuevos contenidos. Se mostrará hasta que sea sustituido por el enviado por eXeLearning. NO descomprima el zip.'; +$string['exeweb:template_desc'] = 'El paquete (.zip o .elpx) subido aquí se utilizará como paquete por defecto para los nuevos contenidos. Se mostrará hasta que sea sustituido por el enviado por eXeLearning. NO descomprima el paquete.'; $string['exeweb:editonlineanddisplay'] = 'Ir a eXeLearning y mostrar'; $string['exeweb:editonlineandreturntocourse'] = 'Ir a eXeLearning y volver al curso'; $string['filenotfound'] = 'Lo sentimos, el archivo no se ha encontrado.'; @@ -151,6 +151,99 @@ Si el tipo de archivo es desconocido para el sistema, no se muestra.'; $string['uploadeddate'] = 'Subido {$a}'; +$string['embeddededitorsettings'] = 'Editore mota'; +$string['embeddededitorstatus'] = 'Editore txertatua'; +$string['editorlatestversionongithub'] = 'GitHub-eko azken bertsioa:'; +$string['manageembeddededitor'] = 'Kudeatu editore txertatua'; +$string['manageembeddededitor_desc'] = 'Instalatu, eguneratu edo konpondu eXeLearning editore txertatua.'; +$string['editorsource_moodledata'] = 'Instalatua (administrazioak kudeatua)'; +$string['editorsource_bundled'] = 'Pluginarekin batera dator'; +$string['editorsource_none'] = 'Instalatu gabe'; +$string['editorinstall'] = 'Instalatu azken bertsioa'; +$string['editorupdate'] = 'Eguneratu editorea'; +$string['editoruninstall'] = 'Ezabatu'; +$string['editorinstallsuccess'] = 'eXeLearning editorea ondo instalatu da: v{$a}.'; +$string['editoruninstallsuccess'] = 'Editore txertatuaren instalazioa ezabatu da.'; +$string['editorversion'] = 'Bertsioa'; +$string['editorinstalledat'] = 'Instalazio-data'; +$string['editorsource'] = 'Jatorria'; +$string['editoractivesource'] = 'Jatorri aktiboa'; +$string['editormoodledatadir'] = 'Datuen direktorioa'; +$string['editorbundleddir'] = 'Barneko direktorioa'; +$string['editorlatestversion'] = 'Eskuragarri dagoen azken bertsioa'; +$string['editorstatusinfo'] = 'Editore txertatuak baliabide estatikoak zerbitzatzen ditu eXeLearning editore integraturako. Jatorriak ordena honetan egiaztatzen dira: administrazioak instalatutakoa (moodledata) eta, ondoren, pluginarekin datorrena (dist/).'; +$string['editorgithubconnecterror'] = 'Ezin izan da GitHub-era konektatu: {$a}'; +$string['editorgithubapierror'] = 'GitHub-ek HTTP egoera hau itzuli du: {$a}. Saiatu berriro geroago.'; +$string['editorgithubparseerror'] = 'Ezin izan da GitHub-eko azken argitalpenaren informazioa interpretatu.'; +$string['editordownloaderror'] = 'Errorea editorearen paketea deskargatzean: {$a}'; +$string['editordownloademptyfile'] = 'Deskargatutako fitxategia hutsik dago.'; +$string['editorinvalidzip'] = 'Deskargatutako fitxategia ez da ZIP balioduna.'; +$string['editorzipextensionmissing'] = 'PHP ZipArchive hedapena ez dago erabilgarri. Eskatu zerbitzariaren administratzaileari gaitzeko.'; +$string['editorextractfailed'] = 'Errorea editorearen paketea erauztean: {$a}'; +$string['editorextractwriteerror'] = 'Ezin izan dira erauzitako fitxategiak aldi baterako direktorioan idatzi.'; +$string['editorinvalidlayout'] = 'Paketeak ez ditu espero diren editore-fitxategiak (index.html eta baliabideen direktorioak).'; +$string['editorinstallfailed'] = 'Errorea editorea instalatzean: {$a}'; +$string['editormkdirerror'] = 'Ezin izan da direktorioa sortu: {$a}'; +$string['editorbackuperror'] = 'Ezin izan da lehendik zegoen editore-instalazioaren babeskopia sortu.'; +$string['editorcopyfailed'] = 'Ezin izan dira editore-fitxategiak helmugako direktoriora kopiatu.'; +$string['editorinstallconcurrent'] = 'Badago beste instalazio bat martxan. Itxaron minutu batzuk eta saiatu berriro.'; +$string['editorconfirmuninstall'] = 'Ziur zaude administrazioak instalatutako editorea ezabatu nahi duzula? Barneko edo urruneko bertsioa erabiliko da.'; +$string['editorupdateavailable'] = 'Eguneraketa eskuragarri: v{$a}'; +$string['editorcurrentversion'] = 'Uneko bertsioa: v{$a}'; +$string['editornotyetinstalled'] = 'Ez da administrazioak instalatutako editorerik aurkitu.'; +$string['editormoodledatasource'] = 'Administratzaileak instalatua (moodledata)'; +$string['editorbundledsource'] = 'Pluginarekin batera dator'; +$string['editoravailable'] = 'Eskuragarri'; +$string['editornotavailable'] = 'Ez dago erabilgarri'; +$string['editormanagelink'] = 'Kudeatu editore txertatua'; +$string['editorsourceprecedence'] = 'Jatorri-lehentasuna: administrazioak instalatua > barnekoa.'; +$string['exeweb:manageembeddededitor'] = 'Kudeatu eXeLearning editore txertatuaren instalazioa'; +$string['editorcheckingerror'] = 'Ezin izan dira eguneraketak egiaztatu. Baliteke GitHub aldi baterako erabilgarri ez egotea.'; +$string['editorinstallconfirm'] = 'Honek GitHub-etik eXeLearning editorearen azken bertsioa (v{$a}) deskargatu eta instalatuko du. Jarraitu?'; +$string['editoradminrequired'] = 'eXeLearning editore txertatua ez dago instalatuta. Jarri harremanetan guneko administratzailearekin.'; +$string['editormanagementhelp'] = 'Deskargatu eta instalatu GitHub-etik eXeLearning editorearen azken bertsioa. Administratzaileak instalatutako bertsioak lehentasuna du pluginarekin datorrenaren aurrean.'; +$string['editorbundleddesc'] = 'Pluginak bertsio bat dakar. GitHub-en argitaratutako azken bertsioa instala dezakezu.'; +$string['editornotinstalleddesc'] = 'Instalatu editorea GitHub-etik edizio txertatuko modua gaitzeko.'; +$string['invalidaction'] = 'Ekintza baliogabea: {$a}'; +$string['installing'] = 'Instalatzen...'; +$string['checkingforupdates'] = 'Eguneraketak egiaztatzen...'; +$string['operationtakinglong'] = 'Eragiketa espero baino gehiago ari da irauten. Egoera egiaztatzen...'; +$string['checkingstatus'] = 'Egoera egiaztatzen...'; +$string['stillworking'] = 'Oraindik lanean...'; +$string['editorinstalling'] = 'Instalatzen...'; +$string['editordownloadingmessage'] = 'Editorea deskargatzen eta instalatzen. Minutu bat behar izan dezake...'; +$string['editoruninstalling'] = 'Ezabatzen...'; +$string['editoruninstallingmessage'] = 'Editorearen instalazioa ezabatzen...'; +$string['operationtimedout'] = 'Eragiketak denbora-muga gainditu du. Egiaztatu editorearen egoera eta saiatu berriro.'; +$string['latestversionchecking'] = 'Egiaztatzen...'; +$string['latestversionerror'] = 'Ezin izan dira eguneraketak egiaztatu'; +$string['updateavailable'] = 'Eguneraketa eskuragarri'; +$string['installstale'] = 'Baliteke instalazioak huts egin izana. Saiatu berriro.'; +$string['noeditorinstalled'] = 'Ez dago editorerik instalatuta'; +$string['confirmuninstall'] = 'Ziur zaude editore txertatua desinstalatu nahi duzula? Honek moodledata-ko administrazioak instalatutako kopia ezabatuko du.'; +$string['confirmuninstalltitle'] = 'Berretsi desinstalazioa'; +$string['editorinstalledsuccess'] = 'Editorea ondo instalatu da'; +$string['editoruninstalledsuccess'] = 'Editorea ondo desinstalatu da'; +$string['editorupdatedsuccess'] = 'Editorea ondo eguneratu da'; +$string['editorrepairsuccess'] = 'Editorea ondo konpondu da'; +$string['editormode'] = 'Editore modua'; +$string['editormodedesc'] = 'Aukeratu zein editore erabili eXeLearning edukia sortu eta editatzeko. Online konexio-ezarpenak soilik aplikatzen dira "eXeLearning Online" modua hautatzen denean.'; +$string['editormodeonline'] = 'eXeLearning Online (urruneko zerbitzaria)'; +$string['editormodeembedded'] = 'Editore txertatua (integratua)'; +$string['embeddednotinstalledcontactadmin'] = 'Editore txertatuaren fitxategiak ez daude instalatuta. Jarri harremanetan guneko administratzailearekin instalatzeko.'; +$string['embeddednotinstalledadmin'] = 'Editore txertatuaren fitxategiak ez daude instalatuta. Pluginaren ezarpenetan instala dezakezu.'; +$string['editembedded'] = 'Editatu eXeLearning-ekin'; +$string['editembedded_integrated'] = 'Integratua'; +$string['editembedded_help'] = 'Ireki eXeLearning editore txertatua edukia zuzenean Moodle-n editatzeko.'; +$string['editormissing'] = 'eXeLearning editore txertatua ez dago instalatuta. Jarri harremanetan administratzailearekin.'; +$string['editorreaderror'] = 'Ezin izan dira eXeLearning editore txertatuaren fitxategiak irakurri. Egiaztatu fitxategien baimenak eta jarri harremanetan administratzailearekin.'; +$string['embeddedtypehelp'] = 'Jarduera sortuko da eta eXeLearning editore txertatuarekin editatu ahal izango duzu jardueraren ikuspegi-orritik.'; +$string['saving'] = 'Gordetzen...'; +$string['savedsuccess'] = 'Aldaketak ondo gorde dira'; +$string['savetomoodle'] = 'Moodle-n gorde'; +$string['savingwait'] = 'Mesedez, itxaron fitxategia gordetzen den bitartean.'; +$string['unsavedchanges'] = 'Gorde gabeko aldaketak dituzu. Ziur zaude itxi nahi duzula?'; +$string['typeembedded'] = 'Sortu eXeLearning-ekin (editore txertatua)'; $string['typeexewebcreate'] = 'Crear con eXeLearning'; $string['typeexewebedit'] = 'Editar con eXeLearning'; $string['typelocal'] = 'Paquete subido'; diff --git a/lang/gl/exeweb.php b/lang/gl/exeweb.php index 3a61da1..fdcdd65 100644 --- a/lang/gl/exeweb.php +++ b/lang/gl/exeweb.php @@ -75,7 +75,7 @@ $string['exeweb:sendtemplate'] = 'Enviar modelo'; $string['exeweb:sendtemplate_desc'] = 'Envía o modelo predeterminado a eXeLearning ao crear un novo contido.'; $string['exeweb:template'] = 'Novo modelo de paquete.'; -$string['exeweb:template_desc'] = 'O .elp subido aquí empregarase como paquete por defecto para os novos contidos. Amosarase ata que sexa substituído polo enviado por eXeLearning. NON descomprima o ficheiro .zip.'; +$string['exeweb:template_desc'] = 'O paquete (.zip ou .elpx) subido aquí empregarase como paquete por defecto para os novos contidos. Amosarase ata que sexa substituído polo enviado por eXeLearning. NON descomprima o paquete.'; $string['exeweb:editonlineanddisplay'] = 'Ir a eXeLearning e amosar'; $string['exeweb:editonlineandreturntocourse'] = 'Ir a eXeLearning e volver ao curso'; $string['filenotfound'] = 'Sentímolo, o ficheiro non se atopa.'; @@ -150,6 +150,99 @@ Se o tipo de ficheiro é descoñecido para o sistema, nos se amosa.'; $string['uploadeddate'] = 'Enviado {$a}'; +$string['embeddededitorsettings'] = 'Tipo de editor'; +$string['embeddededitorstatus'] = 'Editor embebido'; +$string['editorlatestversionongithub'] = 'Última versión en GitHub:'; +$string['manageembeddededitor'] = 'Xestionar editor embebido'; +$string['manageembeddededitor_desc'] = 'Instalar, actualizar ou reparar o editor embebido de eXeLearning.'; +$string['editorsource_moodledata'] = 'Instalado (xestionado pola administración)'; +$string['editorsource_bundled'] = 'Incluído co plugin'; +$string['editorsource_none'] = 'Non instalado'; +$string['editorinstall'] = 'Instalar a última versión'; +$string['editorupdate'] = 'Actualizar editor'; +$string['editoruninstall'] = 'Eliminar'; +$string['editorinstallsuccess'] = 'O editor eXeLearning v{$a} instalouse correctamente.'; +$string['editoruninstallsuccess'] = 'Eliminouse a instalación do editor embebido.'; +$string['editorversion'] = 'Versión'; +$string['editorinstalledat'] = 'Instalado o'; +$string['editorsource'] = 'Orixe'; +$string['editoractivesource'] = 'Orixe activa'; +$string['editormoodledatadir'] = 'Directorio de datos'; +$string['editorbundleddir'] = 'Directorio incluído'; +$string['editorlatestversion'] = 'Última versión dispoñible'; +$string['editorstatusinfo'] = 'O editor embebido serve recursos estáticos para o editor integrado de eXeLearning. As orixes compróbanse nesta orde: instalado pola administración (moodledata) e despois incluído co plugin (dist/).'; +$string['editorgithubconnecterror'] = 'Non se puido conectar con GitHub: {$a}'; +$string['editorgithubapierror'] = 'GitHub devolveu o estado HTTP {$a}. Ténteo de novo máis tarde.'; +$string['editorgithubparseerror'] = 'Non se puido interpretar a información da última versión publicada en GitHub.'; +$string['editordownloaderror'] = 'Produciuse un erro ao descargar o paquete do editor: {$a}'; +$string['editordownloademptyfile'] = 'O ficheiro descargado está baleiro.'; +$string['editorinvalidzip'] = 'O ficheiro descargado non é un ZIP válido.'; +$string['editorzipextensionmissing'] = 'A extensión PHP ZipArchive non está dispoñible. Pídalle ao administrador do servidor que a habilite.'; +$string['editorextractfailed'] = 'Produciuse un erro ao extraer o paquete do editor: {$a}'; +$string['editorextractwriteerror'] = 'Non se puideron escribir os ficheiros extraídos no directorio temporal.'; +$string['editorinvalidlayout'] = 'O paquete non contén os ficheiros esperados do editor (index.html e directorios de recursos).'; +$string['editorinstallfailed'] = 'Produciuse un erro ao instalar o editor: {$a}'; +$string['editormkdirerror'] = 'Non se puido crear o directorio: {$a}'; +$string['editorbackuperror'] = 'Non se puido crear unha copia de seguridade da instalación existente do editor.'; +$string['editorcopyfailed'] = 'Non se puideron copiar os ficheiros do editor ao directorio de destino.'; +$string['editorinstallconcurrent'] = 'Xa hai unha instalación en curso. Agarde uns minutos e ténteo de novo.'; +$string['editorconfirmuninstall'] = 'Está seguro de que desexa eliminar o editor instalado pola administración? Utilizarase a versión incluída ou remota.'; +$string['editorupdateavailable'] = 'Actualización dispoñible: v{$a}'; +$string['editorcurrentversion'] = 'Versión actual: v{$a}'; +$string['editornotyetinstalled'] = 'Non se atopou ningún editor instalado pola administración.'; +$string['editormoodledatasource'] = 'Instalado polo administrador (moodledata)'; +$string['editorbundledsource'] = 'Incluído co plugin'; +$string['editoravailable'] = 'Dispoñible'; +$string['editornotavailable'] = 'Non dispoñible'; +$string['editormanagelink'] = 'Xestionar editor embebido'; +$string['editorsourceprecedence'] = 'Prioridade de orixe: instalado pola administración > incluído.'; +$string['exeweb:manageembeddededitor'] = 'Xestionar a instalación do editor embebido de eXeLearning'; +$string['editorcheckingerror'] = 'Non se puideron comprobar as actualizacións. É posible que GitHub non estea dispoñible temporalmente.'; +$string['editorinstallconfirm'] = 'Isto descargará e instalará a última versión do editor eXeLearning (v{$a}) desde GitHub. Desexa continuar?'; +$string['editoradminrequired'] = 'O editor embebido de eXeLearning non está instalado. Contacte co administrador do sitio.'; +$string['editormanagementhelp'] = 'Descargue e instale desde GitHub a última versión do editor de eXeLearning. A versión instalada polo administrador ten prioridade sobre a incluída co plugin.'; +$string['editorbundleddesc'] = 'O plugin inclúe unha versión. Pode instalar a última versión publicada en GitHub.'; +$string['editornotinstalleddesc'] = 'Instale o editor desde GitHub para habilitar o modo de edición embebido.'; +$string['invalidaction'] = 'Acción non válida: {$a}'; +$string['installing'] = 'Instalando...'; +$string['checkingforupdates'] = 'Comprobando actualizacións...'; +$string['operationtakinglong'] = 'A operación está tardando máis do esperado. Comprobando estado...'; +$string['checkingstatus'] = 'Comprobando estado...'; +$string['stillworking'] = 'Segue en proceso...'; +$string['editorinstalling'] = 'Instalando...'; +$string['editordownloadingmessage'] = 'Descargando e instalando o editor. Isto pode tardar un minuto...'; +$string['editoruninstalling'] = 'Eliminando...'; +$string['editoruninstallingmessage'] = 'Eliminando a instalación do editor...'; +$string['operationtimedout'] = 'A operación superou o tempo de espera. Comprobe o estado do editor e ténteo de novo.'; +$string['latestversionchecking'] = 'Comprobando...'; +$string['latestversionerror'] = 'Non se puideron comprobar as actualizacións'; +$string['updateavailable'] = 'Actualización dispoñible'; +$string['installstale'] = 'A instalación pode fallar. Ténteo de novo.'; +$string['noeditorinstalled'] = 'Non hai ningún editor instalado'; +$string['confirmuninstall'] = 'Está seguro de que desexa desinstalar o editor embebido? Isto eliminará a copia instalada pola administración de moodledata.'; +$string['confirmuninstalltitle'] = 'Confirmar desinstalación'; +$string['editorinstalledsuccess'] = 'Editor instalado correctamente'; +$string['editoruninstalledsuccess'] = 'Editor desinstalado correctamente'; +$string['editorupdatedsuccess'] = 'Editor actualizado correctamente'; +$string['editorrepairsuccess'] = 'Editor reparado correctamente'; +$string['editormode'] = 'Modo de editor'; +$string['editormodedesc'] = 'Seleccione que editor usar para crear e editar contido eXeLearning. A configuración de conexión online só aplica cando se selecciona o modo "eXeLearning Online".'; +$string['editormodeonline'] = 'eXeLearning Online (servidor remoto)'; +$string['editormodeembedded'] = 'Editor integrado (embebido)'; +$string['embeddednotinstalledcontactadmin'] = 'Os ficheiros do editor integrado non están instalados. Contacte co administrador do sitio para instalalo.'; +$string['embeddednotinstalledadmin'] = 'Os ficheiros do editor integrado non están instalados. Pode instalalo desde a configuración do complemento.'; +$string['editembedded'] = 'Editar con eXeLearning'; +$string['editembedded_integrated'] = 'Integrado'; +$string['editembedded_help'] = 'Abre o editor eXeLearning integrado para editar o contido directamente dentro de Moodle.'; +$string['editormissing'] = 'O editor integrado eXeLearning non está instalado. Contacte co administrador.'; +$string['editorreaderror'] = 'Non se puideron ler os ficheiros do editor integrado eXeLearning. Comprobe os permisos dos ficheiros e contacte co administrador.'; +$string['embeddedtypehelp'] = 'Crearase a actividade e poderá editala usando o editor eXeLearning integrado dende a páxina de visualización da actividade.'; +$string['saving'] = 'Gardando...'; +$string['savedsuccess'] = 'Cambios gardados correctamente'; +$string['savetomoodle'] = 'Gardar en Moodle'; +$string['savingwait'] = 'Por favor, agarde mentres se garda o ficheiro.'; +$string['unsavedchanges'] = 'Ten cambios sen gardar. Está seguro de que desexa pechar?'; +$string['typeembedded'] = 'Crear con eXeLearning (editor integrado)'; $string['typeexewebcreate'] = 'Crear con eXeLearning'; $string['typeexewebedit'] = 'Editar con eXeLearning'; $string['typelocal'] = 'Paquete enviado'; diff --git a/lib.php b/lib.php index ec6f3fc..58f1ec9 100644 --- a/lib.php +++ b/lib.php @@ -26,6 +26,8 @@ define('EXEWEB_ORIGIN_LOCAL', 'local'); /** EXEWEB_ORIGIN_EXEONLINE = exeonline */ define('EXEWEB_ORIGIN_EXEONLINE', 'exeonline'); +/** EXEWEB_ORIGIN_EMBEDDED = embedded */ +define('EXEWEB_ORIGIN_EMBEDDED', 'embedded'); /** * List of features supported in Exeweb module @@ -124,20 +126,25 @@ function exeweb_add_instance($data, $mform) { $DB->set_field('course_modules', 'instance', $data->id, ['id' => $cmid]); $context = context_module::instance($cmid); - if ($data->exeorigin === EXEWEB_ORIGIN_EXEONLINE) { - // We are going to set a template file so activity is complete event if exelearning failure. + if ($data->exeorigin === EXEWEB_ORIGIN_EXEONLINE || $data->exeorigin === EXEWEB_ORIGIN_EMBEDDED) { + // We are going to set a template file so activity is complete even if exelearning failure. $fs = get_file_storage(); $templatename = get_config('exeweb', 'template'); $templatefile = false; + $templateext = 'zip'; + if (! empty($templatename)) { + $templateext = strtolower(pathinfo($templatename, PATHINFO_EXTENSION)) ?: 'zip'; + } + $packagefilename = 'default_package.' . $templateext; $fileinfo = [ 'contextid' => $context->id, 'component' => 'mod_exeweb', 'filearea' => 'package', 'itemid' => $data->revision, 'filepath' => '/', - 'filename' => 'default_package.zip', + 'filename' => $packagefilename, 'userid' => $USER->id, - 'source' => 'default_package.zip', + 'source' => $packagefilename, 'author' => fullname($USER), 'license' => 'unknown', ]; @@ -158,9 +165,9 @@ function exeweb_add_instance($data, $mform) { } $contentslist = exeweb_package::expand_package($package); - $mainfile = exeweb_package::get_mainfile($contentslist, $package->get_contextid()); + $mainfile = exeweb_package::get_mainfile($contentslist, $package->get_contextid(), $package->get_itemid()); if ($mainfile !== false) { - file_set_sortorder($context->id, 'mod_exeweb', 'content', 0, $mainfile->get_filepath(), $mainfile->get_filename(), 1); + file_set_sortorder($context->id, 'mod_exeweb', 'content', $package->get_itemid(), $mainfile->get_filepath(), $mainfile->get_filename(), 1); $data->entrypath = $mainfile->get_filepath(); $data->entryname = $mainfile->get_filename(); $DB->update_record('exeweb', $data); @@ -192,14 +199,15 @@ function exeweb_update_instance($data, $mform) { $data->revision++; $package = exeweb_package::save_draft_file($data); $contentslist = exeweb_package::expand_package($package); - $mainfile = exeweb_package::get_mainfile($contentslist, $package->get_contextid()); + $mainfile = exeweb_package::get_mainfile($contentslist, $package->get_contextid(), $package->get_itemid()); if ($mainfile !== false) { - file_set_sortorder($package->get_contextid(), 'mod_exeweb', 'content', 0, + file_set_sortorder($package->get_contextid(), 'mod_exeweb', 'content', $package->get_itemid(), $mainfile->get_filepath(), $mainfile->get_filename(), 1); $data->entrypath = $mainfile->get_filepath(); $data->entryname = $mainfile->get_filename(); } } + // For embedded origin, package is saved via AJAX from the editor - nothing to do here. $DB->update_record('exeweb', $data); $completiontimeexpected = !empty($data->completionexpected) ? $data->completionexpected : null; @@ -233,6 +241,7 @@ function exeweb_set_display_options($data) { if (!empty($data->showdate)) { $displayoptions['showdate'] = 1; } + $displayoptions['teachermodevisible'] = !empty($data->teachermodevisible) ? 1 : 0; $data->displayoptions = serialize($displayoptions); } @@ -277,7 +286,7 @@ function exeweb_get_coursemodule_info($coursemodule) { $context = context_module::instance($coursemodule->id); if (!$exeweb = $DB->get_record('exeweb', ['id' => $coursemodule->instance], - 'id, name, display, displayoptions, revision, intro, introformat')) { + 'id, name, display, displayoptions, revision, entrypath, entryname, intro, introformat')) { return null; } @@ -309,7 +318,7 @@ function exeweb_get_coursemodule_info($coursemodule) { // add some file details as well to be used later by exeweb_get_optional_details() without retriving. // Do not store filedetails if this is a reference - they will still need to be retrieved every time. if (($filedetails = exeweb_get_file_details($exeweb, $coursemodule)) && empty($filedetails['isref'])) { - $displayoptions = (array) unserialize_array($exeweb->displayoptions); + $displayoptions = empty($exeweb->displayoptions) ? [] : (array) unserialize_array($exeweb->displayoptions); $displayoptions['filedetails'] = $filedetails; $info->customdata['displayoptions'] = serialize($displayoptions); } else { @@ -330,11 +339,14 @@ function exeweb_cm_info_view(cm_info $cm) { global $CFG; require_once($CFG->dirroot . '/mod/exeweb/locallib.php'); - $exeweb = (object) ['displayoptions' => $cm->customdata['displayoptions']]; - $details = exeweb_get_optional_details($exeweb, $cm); - if ($details) { - $cm->set_after_link(' ' . html_writer::tag('span', $details, - ['class' => 'exeweblinkdetails'])); + $customdata = $cm->customdata; + if (is_array($customdata) && isset($customdata['displayoptions'])) { + $exeweb = (object) ['displayoptions' => $customdata['displayoptions']]; + $details = exeweb_get_optional_details($exeweb, $cm); + if ($details) { + $cm->set_after_link(' ' . html_writer::tag('span', $details, + ['class' => 'exeweblinkdetails'])); + } } } @@ -446,6 +458,22 @@ function exeweb_pluginfile($course, $cm, $context, $filearea, $args, $forcedownl return false; } + if ($filearea === 'package') { + // Serve package files (needed for embedded editor to download the ELP). + if (!has_capability('moodle/course:manageactivities', $context)) { + return false; + } + $revision = array_shift($args); + $fs = get_file_storage(); + $relativepath = implode('/', $args); + $fullpath = "/$context->id/mod_exeweb/package/$revision/$relativepath"; + if (!$file = $fs->get_file_by_hash(sha1($fullpath))) { + return false; + } + send_stored_file($file, null, 0, $forcedownload, $options); + return; + } + if ($filearea !== 'content') { // Intro is handled automatically in pluginfile.php. return false; @@ -542,8 +570,10 @@ function exeweb_dndupload_register() { return [ 'files' => [ ['extension' => 'zip', - 'message' => get_string('dnduploadexeweb', 'mod_exeweb')] - ] + 'message' => get_string('dnduploadexeweb', 'mod_exeweb')], + ['extension' => 'elpx', + 'message' => get_string('dnduploadexeweb', 'mod_exeweb')], + ], ]; } @@ -591,7 +621,7 @@ function exeweb_view($exeweb, $course, $cm, $context) { // Trigger course_module_viewed event. $params = [ 'context' => $context, - 'objectid' => $exeweb->id + 'objectid' => $exeweb->id, ]; $event = \mod_exeweb\event\course_module_viewed::create($params); @@ -657,6 +687,97 @@ function mod_exeweb_core_calendar_provide_event_action(calendar_event $event, } +/** + * Get the local directory for the active embedded editor source. + * + * Returns the resolved active directory (moodledata or bundled) according + * to the source precedence policy. Falls back to the bundled path if no + * local source is available (preserves backward compatibility). + * + * @return string Absolute path to the active editor directory. + */ +function exeweb_get_embedded_editor_local_static_dir(): string { + $dir = \mod_exeweb\local\embedded_editor_source_resolver::get_active_dir(); + if ($dir !== null) { + return $dir; + } + // Fallback: return bundled path even if not present (preserves old behavior for callers). + return \mod_exeweb\local\embedded_editor_source_resolver::get_bundled_dir(); +} + +/** + * Check whether any local embedded editor assets are available. + * + * Returns true if either the admin-installed (moodledata) or bundled + * editor passes integrity validation. + * + * @return bool + */ +function exeweb_embedded_editor_uses_local_assets(): bool { + return \mod_exeweb\local\embedded_editor_source_resolver::has_local_source(); +} + +/** + * Get the source used to read the embedded 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. + */ +function exeweb_get_embedded_editor_index_source(): ?string { + return \mod_exeweb\local\embedded_editor_source_resolver::get_index_source(); +} + +/** + * Check if the embedded static editor is available. + * + * Checks the admin editor mode setting and that local editor assets exist. + * + * @return bool True if the editor mode is 'embedded' and local assets are installed. + */ +function exeweb_embedded_editor_available() { + $mode = get_config('exeweb', 'editormode'); + if ($mode === false) { + $mode = 'online'; + } + return $mode === 'embedded'; +} + +/** + * Check if the online eXeLearning editor is available. + * + * Checks that the editor mode is not 'embedded' and that the online base URI is configured. + * + * @return bool True if online editor mode is active and base URI is configured. + */ +function exeweb_online_editor_available() { + $mode = get_config('exeweb', 'editormode'); + if ($mode === false) { + $mode = 'online'; + } + return ($mode !== 'embedded') && !empty(get_config('exeweb', 'exeonlinebaseuri')); +} + +/** + * Get the URL for the package file of an exeweb instance. + * + * @param stdClass $exeweb The exeweb record. + * @param context_module $context The module context. + * @return moodle_url|null The URL to the package file, or null if not found. + */ +function exeweb_get_package_url($exeweb, $context) { + $fs = get_file_storage(); + $files = $fs->get_area_files($context->id, 'mod_exeweb', 'package', false, 'sortorder DESC, id ASC', false); + $package = reset($files); + if (!$package) { + return null; + } + return moodle_url::make_pluginfile_url( + $context->id, 'mod_exeweb', 'package', $exeweb->revision, + $package->get_filepath(), $package->get_filename() + ); +} + /** * Given an array with a file path, it returns the itemid and the filepath for the defined filearea. * @@ -664,7 +785,7 @@ function mod_exeweb_core_calendar_provide_event_action(calendar_event $event, * @param array $args The path (the part after the filearea and before the filename). * @return array The itemid and the filepath inside the $args path, for the defined filearea. */ -function mod_exeweb_get_path_from_pluginfile(string $filearea, array $args) : array { +function mod_exeweb_get_path_from_pluginfile(string $filearea, array $args): array { // Exeweb never has an itemid (the number represents the revision but it's not stored in database). array_shift($args); diff --git a/locallib.php b/locallib.php index 98dbb3d..c0d44c6 100644 --- a/locallib.php +++ b/locallib.php @@ -57,6 +57,10 @@ function exeweb_display_embed($exeweb, $cm, $course, $file) { exeweb_print_header($exeweb, $cm, $course); + if (!exeweb_is_teacher_mode_visible($exeweb)) { + exeweb_require_teacher_mode_hider_for_iframe('exewebobject'); + } + echo $PAGE->get_renderer('mod_exeweb')->generate_embed_general($cm, $moodleurl, $title, $clicktoopen); echo $OUTPUT->footer(); @@ -83,6 +87,9 @@ function exeweb_display_frame($exeweb, $cm, $course, $file) { $PAGE->set_pagelayout('frametop'); $PAGE->activityheader->set_description(exeweb_get_intro($exeweb, $cm, true)); exeweb_print_header($exeweb, $cm, $course); + if (!exeweb_is_teacher_mode_visible($exeweb)) { + exeweb_require_teacher_mode_hider_for_content_frame(); + } echo $OUTPUT->footer(); die; @@ -121,6 +128,67 @@ function exeweb_display_frame($exeweb, $cm, $course, $file) { /** * Internal function - create click to open text with link. */ + +/** + * Check whether teacher mode toggler should be visible for this activity. + * + * @param stdClass $exeweb + * @return bool + */ +function exeweb_is_teacher_mode_visible($exeweb) { + $options = empty($exeweb->displayoptions) ? [] : (array) unserialize_array($exeweb->displayoptions); + if (!array_key_exists('teachermodevisible', $options)) { + return true; + } + return !empty($options['teachermodevisible']); +} + +/** + * Inject CSS into the embedded iframe to hide the teacher mode toggler. + * + * @param string $iframeid + * @return void + */ +function exeweb_require_teacher_mode_hider_for_iframe(string $iframeid): void { + global $PAGE; + + $iframeidjson = json_encode($iframeid); + $cssjson = json_encode('#teacher-mode-toggler-wrapper { visibility: hidden !important; }'); + + $js = "(function(){" + . "var iframe=document.getElementById(" . $iframeidjson . ");" + . "if(!iframe){return;}" + . "var css=" . $cssjson . ";" + . "var inject=function(){try{if(!iframe.contentDocument){return;}" + . "var d=iframe.contentDocument;var st=d.createElement('style');st.textContent=css;" + . "(d.head||d.documentElement).appendChild(st);}catch(e){}};" + . "iframe.addEventListener('load', inject);inject();" + . "})();"; + + $PAGE->requires->js_init_code($js); +} + +/** + * Inject CSS into the frame content document to hide teacher mode toggler. + * + * @return void + */ +function exeweb_require_teacher_mode_hider_for_content_frame(): void { + global $PAGE; + + $cssjson = json_encode('#teacher-mode-toggler-wrapper { visibility: hidden !important; }'); + $js = "(function(){" + . "var css=" . $cssjson . ";" + . "var inject=function(){try{if(!window.parent||!window.parent.frames||!window.parent.frames[1]){return;}" + . "var frameWin=window.parent.frames[1];if(!frameWin.document){return;}" + . "var d=frameWin.document;var st=d.createElement('style');st.textContent=css;" + . "(d.head||d.documentElement).appendChild(st);}catch(e){}};" + . "window.addEventListener('load', inject);setTimeout(inject, 300);" + . "})();"; + + $PAGE->requires->js_init_code($js); +} + function exeweb_get_clicktoopen($file, $revision, $extra='') { global $CFG; @@ -209,11 +277,12 @@ function exeweb_get_file_details($exeweb, $cm) { if (!empty($options['showsize']) || !empty($options['showtype']) || !empty($options['showdate'])) { $context = context_module::instance($cm->id); $fs = get_file_storage(); - $mainfile = $fs->get_file($context->id, 'mod_exeweb', 'content', 0, $exeweb->entrypath, $exeweb->entryname); + $revision = $exeweb->revision ?? 0; + $mainfile = $fs->get_file($context->id, 'mod_exeweb', 'content', $revision, $exeweb->entrypath, $exeweb->entryname); if (!empty($options['showsize'])) { $filedetails['size'] = 0; - $files = $fs->get_area_files($context->id, 'mod_exeweb', 'content', 0, 'id', false); + $files = $fs->get_area_files($context->id, 'mod_exeweb', 'content', $revision, 'id', false); foreach ($files as $file) { // This will also synchronize the file size for external files if needed. $filedetails['size'] += $file->get_filesize(); diff --git a/manage_embedded_editor_upload.php b/manage_embedded_editor_upload.php new file mode 100644 index 0000000..8d1ea7a --- /dev/null +++ b/manage_embedded_editor_upload.php @@ -0,0 +1,110 @@ +. + +/** + * Same-origin upload endpoint for installing the embedded editor in Playground. + * + * The browser downloads the ZIP and posts it here so the PHP WASM runtime only + * performs local extraction/installation work. + * + * @package mod_exeweb + * @copyright 2025 eXeLearning + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +define('AJAX_SCRIPT', true); +require_once(__DIR__ . '/../../config.php'); + +require_once($CFG->dirroot . '/mod/exeweb/classes/local/embedded_editor_installer.php'); +require_once($CFG->dirroot . '/mod/exeweb/classes/external/manage_embedded_editor.php'); + +use mod_exeweb\external\manage_embedded_editor; +use mod_exeweb\local\embedded_editor_installer; + +/** + * Emit a JSON response and terminate the request. + * + * @param array $payload Response payload. + * @param int $status HTTP status code. + * @return never + */ +function mod_exeweb_emit_json(array $payload, int $status = 200): void { + http_response_code($status); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($payload); + exit; +} + +require_login(); +require_sesskey(); + +$context = \context_system::instance(); +require_capability('moodle/site:config', $context); +require_capability('mod/exeweb:manageembeddededitor', $context); + +$action = required_param('action', PARAM_ALPHA); +$version = required_param('version', PARAM_TEXT); +$validactions = ['install', 'update', 'repair']; +if (!in_array($action, $validactions, true)) { + mod_exeweb_emit_json([ + 'success' => false, + 'message' => get_string('invalidaction', 'mod_exeweb', $action), + ], 400); +} + +$file = $_FILES['editorzip'] ?? null; +if (!is_array($file) || empty($file['tmp_name'])) { + mod_exeweb_emit_json([ + 'success' => false, + 'message' => get_string('editoruploadmissingfile', 'mod_exeweb'), + ], 400); +} + +if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + mod_exeweb_emit_json([ + 'success' => false, + 'message' => get_string('editoruploadfailed', 'mod_exeweb', (string)($file['error'] ?? UPLOAD_ERR_NO_FILE)), + ], 400); +} + +$tmpname = (string)($file['tmp_name'] ?? ''); +if ($tmpname === '' || !is_file($tmpname)) { + mod_exeweb_emit_json([ + 'success' => false, + 'message' => get_string('editoruploadmissingfile', 'mod_exeweb'), + ], 400); +} + +try { + $installer = new embedded_editor_installer(); + if ($action === 'repair') { + $installer->uninstall(); + } + $result = $installer->install_from_local_zip($tmpname, $version); + + mod_exeweb_emit_json([ + 'success' => true, + 'action' => $action, + 'message' => manage_embedded_editor::get_action_success_string($action), + 'version' => $result['version'] ?? '', + 'installed_at' => $result['installed_at'] ?? '', + ]); +} catch (\Throwable $e) { + mod_exeweb_emit_json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 500); +} diff --git a/mod_form.php b/mod_form.php index 6e22bbf..c04da98 100644 --- a/mod_form.php +++ b/mod_form.php @@ -61,16 +61,25 @@ public function definition() { $mform->setExpanded('packagehdr', true); $editmode = !empty($this->_instance); - // Package types. + // Package types - show only options relevant to the admin editor mode setting. $exeorigins = [ EXEWEB_ORIGIN_LOCAL => get_string('typelocal', 'mod_exeweb'), ]; $defaulttype = EXEWEB_ORIGIN_LOCAL; - if (!empty($config->exeonlinebaseuri)) { + if (exeweb_embedded_editor_available()) { if ($editmode) { - $exeorigins[EXEWEB_ORIGIN_EXEONLINE] = get_string('typeexewebedit', 'mod_exeweb'); + $exeorigins[EXEWEB_ORIGIN_EMBEDDED] = get_string('typeexewebedit', 'mod_exeweb'); } else { - $exeorigins[EXEWEB_ORIGIN_EXEONLINE] = get_string('typeexewebcreate', 'mod_exeweb'); + $exeorigins[EXEWEB_ORIGIN_EMBEDDED] = get_string('typeembedded', 'mod_exeweb'); + $defaulttype = EXEWEB_ORIGIN_EMBEDDED; + } + } else if (exeweb_online_editor_available()) { + if ($editmode) { + $exeorigins[EXEWEB_ORIGIN_EXEONLINE] = get_string('typeexewebedit', 'mod_exeweb') + . ' (Online)'; + } else { + $exeorigins[EXEWEB_ORIGIN_EXEONLINE] = get_string('typeexewebcreate', 'mod_exeweb') + . ' (Online)'; $defaulttype = EXEWEB_ORIGIN_EXEONLINE; } } @@ -80,7 +89,7 @@ public function definition() { $mform->setDefault('exeorigin', $defaulttype); $mform->setType('exeorigin', PARAM_ALPHA); $mform->addHelpButton('exeorigin', 'exeorigin', 'exeweb'); - // Workarround to hide static element. + // Workaround to hide static element. $group = []; $staticelement = $mform->createElement('static', 'onlinetypehelp', '', get_string('exeweb:onlinetypehelp', 'mod_exeweb')); @@ -88,16 +97,25 @@ public function definition() { $group[] = $staticelement; $mform->addGroup($group, 'typehelpgroup', '', ' ', false); $mform->hideIf('typehelpgroup', 'exeorigin', 'noteq', EXEWEB_ORIGIN_EXEONLINE); + // Help text for embedded editor. + $embeddedgroup = []; + $embeddedelement = $mform->createElement('static', 'embeddedtypehelp', '', + get_string('embeddedtypehelp', 'mod_exeweb')); + $embeddedelement->updateAttributes(['class' => 'font-weight-bold']); + $embeddedgroup[] = $embeddedelement; + $mform->addGroup($embeddedgroup, 'embeddedtypehelpgroup', '', ' ', false); + $mform->hideIf('embeddedtypehelpgroup', 'exeorigin', 'noteq', EXEWEB_ORIGIN_EMBEDDED); // New local package upload. $filemanageroptions = array(); - $filemanageroptions['accepted_types'] = ['.zip', ]; + $filemanageroptions['accepted_types'] = ['.zip', '.elpx']; $filemanageroptions['maxbytes'] = 0; $filemanageroptions['maxfiles'] = 1; $filemanageroptions['subdirs'] = 0; $mform->addElement('filepicker', 'packagefile', get_string('package', 'mod_exeweb'), null, $filemanageroptions); $mform->addHelpButton('packagefile', 'package', 'exeweb'); - $mform->hideIf('packagefile', 'exeorigin', 'noteq', EXEWEB_ORIGIN_LOCAL); + $mform->hideIf('packagefile', 'exeorigin', 'eq', EXEWEB_ORIGIN_EXEONLINE); + $mform->hideIf('packagefile', 'exeorigin', 'eq', EXEWEB_ORIGIN_EMBEDDED); // End of package section. // ------------------------------------------------------- @@ -151,6 +169,12 @@ public function definition() { $mform->setDefault('printintro', $config->printintro); } + $mform->addElement('advcheckbox', 'teachermodevisible', + get_string('teachermodevisible', 'exeweb')); + $mform->addHelpButton('teachermodevisible', 'teachermodevisible', 'exeweb'); + $mform->setDefault('teachermodevisible', 1); + + $options = ['0' => get_string('none'), '1' => get_string('allfiles'), '2' => get_string('htmlfilesonly'), ]; $mform->addElement('select', 'filterfiles', get_string('filterfiles', 'mod_exeweb'), $options); $mform->setDefault('filterfiles', $config->filterfiles); @@ -204,6 +228,11 @@ public function data_preprocessing(&$defaultvalues) { } else { $defaultvalues['showdate'] = 0; } + if (array_key_exists('teachermodevisible', $displayoptions)) { + $defaultvalues['teachermodevisible'] = (int) $displayoptions['teachermodevisible']; + } else { + $defaultvalues['teachermodevisible'] = 1; + } } } @@ -234,7 +263,7 @@ public function validation($data, $files) { } } - } else if ($type !== EXEWEB_ORIGIN_EXEONLINE) { + } else if ($type !== EXEWEB_ORIGIN_EXEONLINE && $type !== EXEWEB_ORIGIN_EMBEDDED) { $errors['exeorigin'] = get_string('invalidpackage', 'mod_exeweb'); } diff --git a/settings.php b/settings.php index d83b30f..4b5ce66 100644 --- a/settings.php +++ b/settings.php @@ -27,9 +27,49 @@ if ($ADMIN->fulltree) { require_once("$CFG->libdir/resourcelib.php"); - // Connection settings. + // Editor mode setting. + $settings->add(new admin_setting_heading('exeweb/embeddededitorsettings', + get_string('embeddededitorsettings', 'mod_exeweb'), '')); + + $editormodedesc = get_string('editormodedesc', 'mod_exeweb'); + $editormodes = [ + 'online' => get_string('editormodeonline', 'mod_exeweb'), + 'embedded' => get_string('editormodeembedded', 'mod_exeweb'), + ]; + $settings->add(new admin_setting_configselect('exeweb/editormode', + get_string('editormode', 'mod_exeweb'), $editormodedesc, + 'online', $editormodes)); + + $settings->add(new \mod_exeweb\admin\admin_setting_embeddededitor( + get_string('embeddededitorstatus', 'mod_exeweb'), + '' + )); + + // Connection settings (only relevant for online mode). + // Inline JS to hide/show connection settings based on editor mode selection. + $connectionsettingsdesc = ''; $settings->add(new admin_setting_heading('exeweb/connectionsettings', - get_string('exeonline:connectionsettings', 'mod_exeweb'), '')); + get_string('exeonline:connectionsettings', 'mod_exeweb'), $connectionsettingsdesc)); $settings->add(new admin_setting_configtext('exeweb/exeonlinebaseuri', get_string('exeonline:baseuri', 'mod_exeweb'), @@ -56,7 +96,7 @@ // Exeweb default template. $filemanageroptions = [ - 'accepted_types' => ['.zip'], + 'accepted_types' => ['.zip', '.elpx'], 'maxbytes' => 0, 'maxfiles' => 1, 'subdirs' => 0, @@ -73,7 +113,7 @@ // The eXeweb package validation rules. $mandatoryfilesre = implode("\n", [ - '/^content(v\d+)?\.xml$/', + '/^content(v\d+)?\.xml$/', ]); $forbiddenfilesre = implode("\n", [ '/.*\.php$/', @@ -124,7 +164,7 @@ new lang_string('popupwidth', 'mod_exeweb'), new lang_string('popupwidthexplain', 'mod_exeweb'), 620, PARAM_INT, 7)); $settings->add(new admin_setting_configtext('exeweb/popupheight', new lang_string('popupheight', 'mod_exeweb'), new lang_string('popupheightexplain', 'mod_exeweb'), 450, PARAM_INT, 7)); - $options = ['0' => new lang_string('none'), '1' => new lang_string('allfiles'), '2' => new lang_string('htmlfilesonly'), ]; + $options = ['0' => new lang_string('none'), '1' => new lang_string('allfiles'), '2' => new lang_string('htmlfilesonly') ]; $settings->add(new admin_setting_configselect('exeweb/filterfiles', new lang_string('filterfiles', 'mod_exeweb'), new lang_string('filterfilesexplain', 'mod_exeweb'), 0, $options)); } diff --git a/styles.css b/styles.css index 4e3d6b8..698b7d8 100644 --- a/styles.css +++ b/styles.css @@ -65,3 +65,104 @@ height: 100vh; border: none; } + +/* Embedded editor modal overlay */ +.exeweb-editor-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 9999; + background: #fff; + display: flex; + flex-direction: column; +} + +.exeweb-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: #f8f9fa; + border-bottom: 1px solid #dee2e6; + flex-shrink: 0; +} + +.exeweb-editor-title { + font-weight: bold; + font-size: 1.1em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.exeweb-editor-buttons { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.exeweb-editor-iframe { + flex: 1; + border: none; + width: 100%; +} + +/* Loading modal for save operations */ +.exeweb-loading-modal { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease; +} + +.exeweb-loading-modal.is-visible { + opacity: 1; + visibility: visible; +} + +.exeweb-loading-modal__content { + background: #fff; + border-radius: 8px; + padding: 32px 40px; + text-align: center; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.24); + max-width: 320px; + width: 90%; +} + +.exeweb-loading-modal__spinner { + width: 48px; + height: 48px; + margin: 0 auto 16px; + border: 4px solid #dcdcde; + border-top-color: #0f6cbf; + border-radius: 50%; + animation: exeweb-spin 0.8s linear infinite; +} + +@keyframes exeweb-spin { + to { + transform: rotate(360deg); + } +} + +.exeweb-loading-modal__title { + margin: 0 0 8px; + font-size: 16px; + font-weight: 600; + color: #1d2125; +} + +.exeweb-loading-modal__message { + margin: 0; + font-size: 13px; + color: #6a737d; +} diff --git a/templates/action_bar.mustache b/templates/action_bar.mustache index 0cbbf3f..3d73e2d 100644 --- a/templates/action_bar.mustache +++ b/templates/action_bar.mustache @@ -13,25 +13,33 @@ }} {{! @template mod_exeweb/action_bar - Actions bar for the user reports page UI. - Classes required for JS: - * none - Data attributes required for JS: - * none + Actions bar for the activity view page. Context variables required for this template: - * action - Example context (json): - { - "editaction": "http://localhost:51235/edit_ode?...." - } -}} + * editaction - URL for eXeLearning Online editing (mutually exclusive with editorurl) + * editorurl - URL for embedded editor bootstrap page (mutually exclusive with editaction) + * saveurl - URL for embedded editor save endpoint + * packageurl - URL of current package to open + * sesskey - Moodle sesskey + * cmid - Course module ID + * activityname - Activity name for modal title}}
+ {{#editorurl}} + + {{/editorurl}} {{#editaction}} - + {{/can_install}} + + {{! Update button — hidden by default; JS shows when update is available }} + + + {{#can_uninstall}} + + {{/can_uninstall}} + +
+ + {{! Spinner — hidden until an action is running }} + + + {{! Result / error message area — hidden until set by JS }} + + + + + + diff --git a/tests/external/manage_embedded_editor_test.php b/tests/external/manage_embedded_editor_test.php new file mode 100644 index 0000000..db65b53 --- /dev/null +++ b/tests/external/manage_embedded_editor_test.php @@ -0,0 +1,152 @@ +. + +/** + * Unit tests for the manage_embedded_editor external functions. + * + * @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; + +defined('MOODLE_INTERNAL') || die(); + +use mod_exeweb\local\embedded_editor_installer; + +/** + * Tests for manage_embedded_editor external API. + * + * @covers \mod_exeweb\external\manage_embedded_editor + */ +class manage_embedded_editor_test extends \advanced_testcase { + + /** + * Test that get_status returns a response with all expected keys. + * + * Uses checklatest=false to avoid any network calls. + */ + public function test_get_status_returns_expected_structure(): void { + $this->resetAfterTest(true); + $this->setAdminUser(); + + $result = manage_embedded_editor::get_status(false); + + $this->assertIsArray($result); + $expectedkeys = [ + 'active_source', + 'moodledata_available', + 'bundled_available', + 'installing', + 'install_stale', + 'can_install', + 'can_update', + 'can_repair', + 'can_uninstall', + ]; + foreach ($expectedkeys as $key) { + $this->assertArrayHasKey($key, $result, "Missing key: $key"); + } + } + + /** + * Test that execute_action throws invalid_parameter_exception for unknown actions. + */ + public function test_execute_action_invalid_action(): void { + $this->resetAfterTest(true); + $this->setAdminUser(); + + $this->expectException(\invalid_parameter_exception::class); + manage_embedded_editor::execute_action('invalid'); + } + + /** + * Test that execute_action requires mod/exeweb:manageembeddededitor capability. + * + * A regular user without the capability should get required_capability_exception. + */ + public function test_execute_action_requires_manageembeddededitor_capability(): void { + $this->resetAfterTest(true); + + // Create a user without mod/exeweb:manageembeddededitor. + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + $this->expectException(\required_capability_exception::class); + manage_embedded_editor::execute_action('install'); + } + + /** + * Test that execute_action requires moodle/site:config capability. + * + * A user with manageembeddededitor but not moodle/site:config should be denied. + */ + public function test_execute_action_requires_site_config_capability(): void { + global $DB; + + $this->resetAfterTest(true); + + // Create a user and assign only mod/exeweb:manageembeddededitor (not moodle/site:config). + $user = $this->getDataGenerator()->create_user(); + $roleid = $this->getDataGenerator()->create_role(); + $systemcontext = \context_system::instance(); + assign_capability('mod/exeweb:manageembeddededitor', CAP_ALLOW, $roleid, $systemcontext->id, true); + role_assign($roleid, $user->id, $systemcontext->id); + $this->setUser($user); + + $this->expectException(\required_capability_exception::class); + manage_embedded_editor::execute_action('install'); + } + + /** + * Test that get_status correctly reports an active install lock. + * + * When CONFIG_INSTALLING is set to a recent timestamp, installing should be + * true and install_stale should be false. + */ + public function test_get_status_detects_installing_lock(): void { + $this->resetAfterTest(true); + $this->setAdminUser(); + + set_config(embedded_editor_installer::CONFIG_INSTALLING, time(), 'exeweb'); + + $result = manage_embedded_editor::get_status(false); + + $this->assertTrue($result['installing'], 'Expected installing to be true with active lock'); + $this->assertFalse($result['install_stale'], 'Expected install_stale to be false with fresh lock'); + } + + /** + * Test that get_status correctly reports a stale install lock. + * + * When CONFIG_INSTALLING is set to a timestamp older than INSTALL_LOCK_TIMEOUT, + * installing should be true (lock present) and install_stale should be true. + */ + public function test_get_status_detects_stale_lock(): void { + $this->resetAfterTest(true); + $this->setAdminUser(); + + // Set a timestamp well beyond the timeout (300s). + set_config(embedded_editor_installer::CONFIG_INSTALLING, time() - 400, 'exeweb'); + + $result = manage_embedded_editor::get_status(false); + + $this->assertFalse($result['installing'], 'Expected installing to be false when lock is stale'); + $this->assertTrue($result['install_stale'], 'Expected install_stale to be true with expired lock'); + } +} diff --git a/tests/lib_test.php b/tests/lib_test.php index 77a9465..92ddc78 100644 --- a/tests/lib_test.php +++ b/tests/lib_test.php @@ -257,6 +257,98 @@ public function test_exeweb_core_calendar_provide_event_action_user_override() { $this->assertTrue($actionevent2->is_actionable()); } + /** + * Test that local embedded editor assets are preferred when available. + * + * @return void + */ + public function test_embedded_editor_index_source_prefers_local_assets(): void { + $this->resetAfterTest(); + set_config('editormode', 'embedded', 'exeweb'); + + $this->with_local_editor_assets(function () { + $this->assertTrue(exeweb_embedded_editor_available()); + $this->assertTrue(exeweb_embedded_editor_uses_local_assets()); + $this->assertSame( + exeweb_get_embedded_editor_local_static_dir() . '/index.html', + exeweb_get_embedded_editor_index_source() + ); + }); + } + + /** + * Test that embedded editor index source returns null when no local assets exist. + * + * @return void + */ + public function test_embedded_editor_index_source_returns_null_when_unavailable(): void { + $this->resetAfterTest(); + set_config('editormode', 'embedded', 'exeweb'); + + $this->with_local_editor_assets_disabled(function () { + $this->assertTrue(exeweb_embedded_editor_available()); + $this->assertFalse(exeweb_embedded_editor_uses_local_assets()); + $this->assertNull(exeweb_get_embedded_editor_index_source()); + }); + } + + /** + * Run a callback while ensuring local editor assets exist. + * + * @param callable $callback + * @return void + */ + private function with_local_editor_assets(callable $callback): void { + $staticdir = exeweb_get_embedded_editor_local_static_dir(); + $indexpath = $staticdir . '/index.html'; + $createdindex = false; + + if (!is_dir($staticdir)) { + mkdir($staticdir, 0777, true); + } + + if (!is_file($indexpath)) { + file_put_contents($indexpath, ''); + $createdindex = true; + } + + try { + $callback(); + } finally { + if ($createdindex && is_file($indexpath)) { + unlink($indexpath); + } + if ($createdindex && is_dir($staticdir)) { + @rmdir($staticdir); + @rmdir(dirname($staticdir)); + } + } + } + + /** + * Run a callback while ensuring local editor assets are unavailable. + * + * @param callable $callback + * @return void + */ + private function with_local_editor_assets_disabled(callable $callback): void { + $staticdir = exeweb_get_embedded_editor_local_static_dir(); + $backupdir = $staticdir . '_backup_' . uniqid('', true); + $renamed = false; + + if (is_dir($staticdir)) { + $renamed = rename($staticdir, $backupdir); + } + + try { + $callback(); + } finally { + if ($renamed && is_dir($backupdir)) { + rename($backupdir, $staticdir); + } + } + } + /** * Creates an action event. * diff --git a/tests/local/embedded_editor_installer_test.php b/tests/local/embedded_editor_installer_test.php new file mode 100644 index 0000000..3b090bc --- /dev/null +++ b/tests/local/embedded_editor_installer_test.php @@ -0,0 +1,348 @@ +. + +/** + * Unit tests for the embedded editor installer. + * + * @package mod_exeweb + * @copyright 2025 eXeLearning + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_exeweb\local; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Tests for embedded_editor_installer. + * + * Does not test live GitHub API calls or actual downloads to avoid flaky tests. + * + * @covers \mod_exeweb\local\embedded_editor_installer + */ +class embedded_editor_installer_test extends \advanced_testcase { + + /** + * Test get_asset_url constructs the correct direct URL outside Playground. + */ + public function test_get_asset_url(): void { + $installer = new embedded_editor_installer(); + + $url = $installer->get_asset_url('4.0.0'); + $this->assertEquals( + 'https://github.com/exelearning/exelearning/releases/download/v4.0.0/exelearning-static-v4.0.0.zip', + $url + ); + + $url = $installer->get_asset_url('4.0.0-beta3'); + $this->assertEquals( + 'https://github.com/exelearning/exelearning/releases/download/v4.0.0-beta3/exelearning-static-v4.0.0-beta3.zip', + $url + ); + } + + /** + * Test validate_zip with valid PK magic bytes. + */ + public function test_validate_zip_valid(): void { + $tmpdir = make_temp_directory('mod_exeweb_test'); + $tmpfile = $tmpdir . '/valid.zip'; + file_put_contents($tmpfile, "PK\x03\x04" . str_repeat("\x00", 100)); + + $installer = new embedded_editor_installer(); + + // Should not throw. + $installer->validate_zip($tmpfile); + $this->assertTrue(true); + + @unlink($tmpfile); + } + + /** + * Test validate_zip rejects non-ZIP files. + */ + public function test_validate_zip_invalid(): void { + $tmpdir = make_temp_directory('mod_exeweb_test'); + $tmpfile = $tmpdir . '/invalid.zip'; + file_put_contents($tmpfile, 'This is not a zip file'); + + $installer = new embedded_editor_installer(); + + $this->expectException(\moodle_exception::class); + $installer->validate_zip($tmpfile); + + @unlink($tmpfile); + } + + /** + * Test normalize_extraction with flat layout (index.html at root). + */ + public function test_normalize_extraction_flat(): void { + $tmpdir = make_temp_directory('mod_exeweb_test/flat_extract'); + file_put_contents($tmpdir . '/index.html', ''); + mkdir($tmpdir . '/app', 0777, true); + + $installer = new embedded_editor_installer(); + $result = $installer->normalize_extraction($tmpdir); + + $this->assertEquals($tmpdir, $result); + + remove_dir($tmpdir); + } + + /** + * Test normalize_extraction with single nested directory. + */ + public function test_normalize_extraction_single_nested(): void { + $tmpdir = make_temp_directory('mod_exeweb_test/nested_extract'); + $innerdir = $tmpdir . '/exelearning-static-v4.0.0'; + mkdir($innerdir, 0777, true); + file_put_contents($innerdir . '/index.html', ''); + mkdir($innerdir . '/app', 0777, true); + + $installer = new embedded_editor_installer(); + $result = $installer->normalize_extraction($tmpdir); + + $this->assertEquals($innerdir, $result); + + remove_dir($tmpdir); + } + + /** + * Test normalize_extraction with double nested directory. + */ + public function test_normalize_extraction_double_nested(): void { + $tmpdir = make_temp_directory('mod_exeweb_test/double_nested'); + $innerdir = $tmpdir . '/wrapper/exelearning-static'; + mkdir($innerdir, 0777, true); + file_put_contents($innerdir . '/index.html', ''); + mkdir($innerdir . '/app', 0777, true); + + $installer = new embedded_editor_installer(); + $result = $installer->normalize_extraction($tmpdir); + + $this->assertEquals($innerdir, $result); + + remove_dir($tmpdir); + } + + /** + * Test normalize_extraction fails when no index.html found. + */ + public function test_normalize_extraction_no_index(): void { + $tmpdir = make_temp_directory('mod_exeweb_test/no_index_extract'); + mkdir($tmpdir . '/somedir/app', 0777, true); + + $installer = new embedded_editor_installer(); + + $this->expectException(\moodle_exception::class); + $installer->normalize_extraction($tmpdir); + + remove_dir($tmpdir); + } + + /** + * Test validate_editor_contents with valid directory. + */ + public function test_validate_editor_contents_valid(): void { + $tmpdir = make_temp_directory('mod_exeweb_test/valid_contents'); + file_put_contents($tmpdir . '/index.html', ''); + mkdir($tmpdir . '/app', 0777, true); + + $installer = new embedded_editor_installer(); + + // Should not throw. + $installer->validate_editor_contents($tmpdir); + $this->assertTrue(true); + + remove_dir($tmpdir); + } + + /** + * Test validate_editor_contents fails with invalid directory. + */ + public function test_validate_editor_contents_invalid(): void { + $tmpdir = make_temp_directory('mod_exeweb_test/invalid_contents'); + // Only index.html, no asset dirs. + file_put_contents($tmpdir . '/index.html', ''); + + $installer = new embedded_editor_installer(); + + $this->expectException(\moodle_exception::class); + $installer->validate_editor_contents($tmpdir); + + remove_dir($tmpdir); + } + + /** + * Test store_metadata writes config values. + */ + public function test_store_metadata(): void { + $this->resetAfterTest(true); + + $installer = new embedded_editor_installer(); + $installer->store_metadata('4.0.0'); + + $this->assertEquals('4.0.0', get_config('exeweb', 'embedded_editor_version')); + $this->assertNotEmpty(get_config('exeweb', 'embedded_editor_installed_at')); + } + + /** + * Test clear_metadata removes config values. + */ + public function test_clear_metadata(): void { + $this->resetAfterTest(true); + + set_config('embedded_editor_version', '4.0.0', 'exeweb'); + set_config('embedded_editor_installed_at', '2025-01-15 10:30:00', 'exeweb'); + + $installer = new embedded_editor_installer(); + $installer->clear_metadata(); + + $this->assertFalse(get_config('exeweb', 'embedded_editor_version')); + $this->assertFalse(get_config('exeweb', 'embedded_editor_installed_at')); + } + + /** + * Test uninstall removes directory and clears metadata. + */ + public function test_uninstall(): void { + $this->resetAfterTest(true); + + // Create a fake moodledata editor. + $moodledatadir = embedded_editor_source_resolver::get_moodledata_dir(); + @mkdir($moodledatadir, 0777, true); + file_put_contents($moodledatadir . '/index.html', ''); + mkdir($moodledatadir . '/app', 0777, true); + set_config('embedded_editor_version', '4.0.0', 'exeweb'); + set_config('embedded_editor_installed_at', '2025-01-15 10:30:00', 'exeweb'); + + $installer = new embedded_editor_installer(); + $installer->uninstall(); + + $this->assertFalse(is_dir($moodledatadir)); + $this->assertFalse(get_config('exeweb', 'embedded_editor_version')); + $this->assertFalse(get_config('exeweb', 'embedded_editor_installed_at')); + } + + /** + * Test safe_install copies files to the target directory. + */ + public function test_safe_install(): void { + $this->resetAfterTest(true); + + // Create a source directory. + $sourcedir = make_temp_directory('mod_exeweb_test/install_source'); + file_put_contents($sourcedir . '/index.html', ''); + mkdir($sourcedir . '/app', 0777, true); + file_put_contents($sourcedir . '/app/main.js', 'console.log("test");'); + + // Ensure target does not exist. + $targetdir = embedded_editor_source_resolver::get_moodledata_dir(); + if (is_dir($targetdir)) { + remove_dir($targetdir); + } + + $installer = new embedded_editor_installer(); + $installer->safe_install($sourcedir); + + $this->assertTrue(is_dir($targetdir)); + $this->assertTrue(is_file($targetdir . '/index.html')); + $this->assertTrue(is_dir($targetdir . '/app')); + + // Clean up. + remove_dir($targetdir); + // Source may have been moved, clean up if still there. + if (is_dir($sourcedir)) { + remove_dir($sourcedir); + } + } + + /** + * Test safe_install with existing installation (backup/replace). + */ + public function test_safe_install_replaces_existing(): void { + $this->resetAfterTest(true); + + $targetdir = embedded_editor_source_resolver::get_moodledata_dir(); + + // Create existing installation. + @mkdir($targetdir, 0777, true); + file_put_contents($targetdir . '/index.html', 'OLD'); + mkdir($targetdir . '/app', 0777, true); + + // Create new source. + $sourcedir = make_temp_directory('mod_exeweb_test/replace_source'); + file_put_contents($sourcedir . '/index.html', 'NEW'); + mkdir($sourcedir . '/app', 0777, true); + mkdir($sourcedir . '/libs', 0777, true); + + $installer = new embedded_editor_installer(); + $installer->safe_install($sourcedir); + + // Verify new content is in place. + $this->assertEquals('NEW', file_get_contents($targetdir . '/index.html')); + $this->assertTrue(is_dir($targetdir . '/libs')); + + // Clean up. + remove_dir($targetdir); + if (is_dir($sourcedir)) { + remove_dir($sourcedir); + } + } + + /** + * Test concurrent install lock. + */ + public function test_concurrent_install_lock(): void { + $this->resetAfterTest(true); + + // Simulate an active lock. + set_config('embedded_editor_installing', time(), 'exeweb'); + + $installer = new embedded_editor_installer(); + + $this->expectException(\moodle_exception::class); + // This uses a reflection trick to call acquire_lock directly. + $method = new \ReflectionMethod($installer, 'acquire_lock'); + $method->setAccessible(true); + $method->invoke($installer); + } + + /** + * Test stale lock is ignored. + */ + public function test_stale_lock_ignored(): void { + $this->resetAfterTest(true); + + // Simulate a stale lock (older than timeout). + set_config('embedded_editor_installing', time() - 600, 'exeweb'); + + $installer = new embedded_editor_installer(); + + // Should not throw -- stale lock is overridden. + $method = new \ReflectionMethod($installer, 'acquire_lock'); + $method->setAccessible(true); + $method->invoke($installer); + + // Clean up. + $releasemethod = new \ReflectionMethod($installer, 'release_lock'); + $releasemethod->setAccessible(true); + $releasemethod->invoke($installer); + + $this->assertTrue(true); + } +} diff --git a/tests/local/embedded_editor_source_resolver_test.php b/tests/local/embedded_editor_source_resolver_test.php new file mode 100644 index 0000000..21b3cf0 --- /dev/null +++ b/tests/local/embedded_editor_source_resolver_test.php @@ -0,0 +1,244 @@ +. + +/** + * Unit tests for the embedded editor source resolver. + * + * @package mod_exeweb + * @copyright 2025 eXeLearning + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_exeweb\local; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Tests for embedded_editor_source_resolver. + * + * @covers \mod_exeweb\local\embedded_editor_source_resolver + */ +class embedded_editor_source_resolver_test extends \advanced_testcase { + + /** + * Test that get_moodledata_dir returns a path under dataroot. + */ + public function test_get_moodledata_dir(): void { + global $CFG; + $dir = embedded_editor_source_resolver::get_moodledata_dir(); + $this->assertStringStartsWith($CFG->dataroot, $dir); + $this->assertStringContainsString('mod_exeweb', $dir); + } + + /** + * Test that get_bundled_dir returns a path under dirroot. + */ + public function test_get_bundled_dir(): void { + global $CFG; + $dir = embedded_editor_source_resolver::get_bundled_dir(); + $this->assertStringStartsWith($CFG->dirroot, $dir); + $this->assertStringContainsString('dist/static', $dir); + } + + /** + * Test validate_editor_dir with a valid directory. + */ + public function test_validate_editor_dir_with_valid_dir(): void { + $tmpdir = make_temp_directory('mod_exeweb_test/valid_editor'); + file_put_contents($tmpdir . '/index.html', ''); + mkdir($tmpdir . '/app', 0777, true); + + $this->assertTrue(embedded_editor_source_resolver::validate_editor_dir($tmpdir)); + + remove_dir($tmpdir); + } + + /** + * Test validate_editor_dir with multiple valid asset dirs. + */ + public function test_validate_editor_dir_with_libs_dir(): void { + $tmpdir = make_temp_directory('mod_exeweb_test/libs_editor'); + file_put_contents($tmpdir . '/index.html', ''); + mkdir($tmpdir . '/libs', 0777, true); + + $this->assertTrue(embedded_editor_source_resolver::validate_editor_dir($tmpdir)); + + remove_dir($tmpdir); + } + + /** + * Test validate_editor_dir with files dir. + */ + public function test_validate_editor_dir_with_files_dir(): void { + $tmpdir = make_temp_directory('mod_exeweb_test/files_editor'); + file_put_contents($tmpdir . '/index.html', ''); + mkdir($tmpdir . '/files', 0777, true); + + $this->assertTrue(embedded_editor_source_resolver::validate_editor_dir($tmpdir)); + + remove_dir($tmpdir); + } + + /** + * Test validate_editor_dir fails when index.html is missing. + */ + public function test_validate_editor_dir_missing_index(): void { + $tmpdir = make_temp_directory('mod_exeweb_test/no_index'); + mkdir($tmpdir . '/app', 0777, true); + + $this->assertFalse(embedded_editor_source_resolver::validate_editor_dir($tmpdir)); + + remove_dir($tmpdir); + } + + /** + * Test validate_editor_dir fails when no asset directories exist. + */ + public function test_validate_editor_dir_missing_asset_dirs(): void { + $tmpdir = make_temp_directory('mod_exeweb_test/no_assets'); + file_put_contents($tmpdir . '/index.html', ''); + + $this->assertFalse(embedded_editor_source_resolver::validate_editor_dir($tmpdir)); + + remove_dir($tmpdir); + } + + /** + * Test validate_editor_dir fails for nonexistent directory. + */ + public function test_validate_editor_dir_nonexistent(): void { + $this->assertFalse(embedded_editor_source_resolver::validate_editor_dir('/nonexistent/path')); + } + + /** + * Test active source precedence: moodledata wins over bundled. + */ + public function test_get_active_source_moodledata_first(): void { + $this->resetAfterTest(true); + + // Create a valid moodledata editor. + $moodledatadir = embedded_editor_source_resolver::get_moodledata_dir(); + @mkdir($moodledatadir, 0777, true); + file_put_contents($moodledatadir . '/index.html', ''); + mkdir($moodledatadir . '/app', 0777, true); + + $source = embedded_editor_source_resolver::get_active_source(); + $this->assertEquals(embedded_editor_source_resolver::SOURCE_MOODLEDATA, $source); + + remove_dir($moodledatadir); + } + + /** + * Test active source falls back to remote when no local sources exist. + */ + public function test_get_active_source_remote_fallback(): void { + $this->resetAfterTest(true); + + // Ensure moodledata dir does not exist. + $moodledatadir = embedded_editor_source_resolver::get_moodledata_dir(); + if (is_dir($moodledatadir)) { + remove_dir($moodledatadir); + } + + // The bundled dir likely doesn't exist in test environment either. + $source = embedded_editor_source_resolver::get_active_source(); + + // Should be either bundled (if dist/static exists) or none. + $this->assertContains($source, [ + embedded_editor_source_resolver::SOURCE_BUNDLED, + embedded_editor_source_resolver::SOURCE_NONE, + ]); + } + + /** + * Test has_local_source returns true when moodledata editor exists. + */ + public function test_has_local_source_with_moodledata(): void { + $this->resetAfterTest(true); + + $moodledatadir = embedded_editor_source_resolver::get_moodledata_dir(); + @mkdir($moodledatadir, 0777, true); + file_put_contents($moodledatadir . '/index.html', ''); + mkdir($moodledatadir . '/app', 0777, true); + + $this->assertTrue(embedded_editor_source_resolver::has_local_source()); + + remove_dir($moodledatadir); + } + + /** + * Test get_status returns all expected fields. + */ + public function test_get_status_returns_complete_object(): void { + $this->resetAfterTest(true); + + $status = embedded_editor_source_resolver::get_status(); + + $this->assertObjectHasProperty('active_source', $status); + $this->assertObjectHasProperty('active_dir', $status); + $this->assertObjectHasProperty('moodledata_dir', $status); + $this->assertObjectHasProperty('moodledata_available', $status); + $this->assertObjectHasProperty('moodledata_version', $status); + $this->assertObjectHasProperty('moodledata_installed_at', $status); + $this->assertObjectHasProperty('bundled_dir', $status); + $this->assertObjectHasProperty('bundled_available', $status); + + $this->assertIsBool($status->moodledata_available); + $this->assertIsBool($status->bundled_available); + } + + /** + * Test get_moodledata_version returns config value. + */ + public function test_get_moodledata_version(): void { + $this->resetAfterTest(true); + + $this->assertNull(embedded_editor_source_resolver::get_moodledata_version()); + + set_config('embedded_editor_version', '4.0.0', 'exeweb'); + $this->assertEquals('4.0.0', embedded_editor_source_resolver::get_moodledata_version()); + } + + /** + * Test get_moodledata_installed_at returns config value. + */ + public function test_get_moodledata_installed_at(): void { + $this->resetAfterTest(true); + + $this->assertNull(embedded_editor_source_resolver::get_moodledata_installed_at()); + + set_config('embedded_editor_installed_at', '2025-01-15 10:30:00', 'exeweb'); + $this->assertEquals('2025-01-15 10:30:00', embedded_editor_source_resolver::get_moodledata_installed_at()); + } + + /** + * Test get_index_source returns file path when local source exists. + */ + public function test_get_index_source_local(): void { + $this->resetAfterTest(true); + + $moodledatadir = embedded_editor_source_resolver::get_moodledata_dir(); + @mkdir($moodledatadir, 0777, true); + file_put_contents($moodledatadir . '/index.html', ''); + mkdir($moodledatadir . '/app', 0777, true); + + $source = embedded_editor_source_resolver::get_index_source(); + $this->assertStringEndsWith('/index.html', $source); + $this->assertStringStartsNotWith('http', $source); + + remove_dir($moodledatadir); + } +} diff --git a/version.php b/version.php index 22709b4..56065da 100644 --- a/version.php +++ b/version.php @@ -24,7 +24,8 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2025062000; // The current module version (Date: YYYYMMDDXX). -$plugin->requires = 2020061500; // Moodle 3.9 to 4.1. +$plugin->version = 9999999999; // Dev value, overridden by CI/CD (Date: YYYYMMDDXX). +$plugin->release = 'dev'; // Dev value, overridden by CI/CD (semver from git tag). +$plugin->requires = 2023042400; // Moodle 4.2+. $plugin->component = 'mod_exeweb'; // Full name of the plugin (used for diagnostics). $plugin->cron = 0; diff --git a/view.php b/view.php index 6222a2e..7c8f1a5 100644 --- a/view.php +++ b/view.php @@ -88,6 +88,7 @@ $PAGE->requires->js_call_amd('mod_exeweb/fullscreen', 'init'); $PAGE->requires->js_call_amd('mod_exeweb/resize', 'init', ['exewebobject', ]); +$PAGE->requires->js_call_amd('mod_exeweb/editor_modal', 'init'); switch ($displaytype) { case RESOURCELIB_DISPLAY_EMBED: exeweb_display_embed($exeweb, $cm, $course, $file);