Skip to content

Commit af19e89

Browse files
committed
feat(styles): administrator-managed style registry for the embedded editor
Adds an admin Styles management page (Site administration → Plugins → Activity modules → eXeLearning (website) → Styles) where managers can upload eXeLearning style .zip packages, enable/disable built-in styles, and enable/disable/delete uploaded ones — without rebuilding the static editor bundle. Architecture - `mod_exeweb\local\styles_service` owns the pure logic: ZIP validation (path traversal, absolute paths, size cap, extension allow-list, single config.xml), slug allocation with collision suffix, registry persistence in config_plugin(exeweb), and the `build_theme_registry_override()` payload the editor consumes. - `admin/styles.php` is a standard Moodle admin_externalpage that renders the upload form and lists, using HTTP POST + sesskey (no custom AJAX). - `editor/styles.php/{slug}/{file}` serves the extracted style assets from moodledata with PATH_INFO, gated by site capability checks and a registry membership check. - Uploaded style bundles extract to `{dataroot}/mod_exeweb/styles/{slug}/` — a sibling of `mod_exeweb/embedded_editor/` so reinstalling the embedded editor never destroys admin-managed styles. - `editor/index.php` injects `window.eXeLearning.config.themeRegistryOverride` and mirrors `blockImportInstall` onto the pre-existing `userStyles` (ONLINE_THEMES_INSTALL) flag so the 'Import this project style?' modal is suppressed end-to-end. Admin toggle - `stylesblockimport` (default: 1) controls whether imported project styles are refused. When on, the editor hides the 'Imported' tab (see companion core PR) and silently falls back to the default style instead of offering to install. Behavior - Disabled built-ins disappear from the editor's selector. - Uploaded styles show up alongside built-ins with stable ids. - Projects referencing a missing/disabled style fall back to `base`. - The admin link appears only when editor mode is 'embedded'. Tests - `tests/local/styles_service_test.php`: ZIP validator edge cases, install, unique-slug on collision, delete, override enabled flag, import-blocked config contract. Language - Adds strings under `styles*` in `lang/en/exeweb.php`. Depends on - Core editor hook: exelearning/exelearning#1722 (merged). - Core editor UI follow-up: exelearning/exelearning#1724 (hides the 'Imported' tab when blockImportInstall is set).
1 parent e3cd648 commit af19e89

7 files changed

Lines changed: 1389 additions & 0 deletions

File tree

admin/styles.php

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
<?php
2+
// This file is part of Moodle - http://moodle.org/
3+
//
4+
// Moodle is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Moodle is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU General Public License
15+
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16+
17+
/**
18+
* Admin page for managing eXeLearning styles exposed to the embedded editor.
19+
*
20+
* @package mod_exeweb
21+
* @copyright 2025 eXeLearning
22+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23+
*/
24+
25+
require('../../../config.php');
26+
require_once($CFG->libdir . '/adminlib.php');
27+
28+
use mod_exeweb\local\styles_service;
29+
30+
admin_externalpage_setup('mod_exeweb_styles');
31+
32+
$context = \context_system::instance();
33+
require_capability('moodle/site:config', $context);
34+
require_capability('mod/exeweb:manageembeddededitor', $context);
35+
36+
$action = optional_param('action', '', PARAM_ALPHA);
37+
$returnurl = new moodle_url('/mod/exeweb/admin/styles.php');
38+
39+
// --------------------------------------------------------------------
40+
// Actions.
41+
// --------------------------------------------------------------------
42+
if ($action !== '') {
43+
require_sesskey();
44+
switch ($action) {
45+
case 'upload':
46+
$uploaded = $_FILES['style_zip'] ?? null;
47+
if (!is_array($uploaded) || (int) $uploaded['error'] !== UPLOAD_ERR_OK) {
48+
redirect($returnurl, get_string('stylesupload_failed', 'mod_exeweb'), null,
49+
\core\output\notification::NOTIFY_ERROR);
50+
}
51+
if (!is_uploaded_file($uploaded['tmp_name'])) {
52+
redirect($returnurl, get_string('stylesupload_failed', 'mod_exeweb'), null,
53+
\core\output\notification::NOTIFY_ERROR);
54+
}
55+
try {
56+
$entry = styles_service::install_from_zip(
57+
$uploaded['tmp_name'],
58+
clean_param($uploaded['name'] ?? '', PARAM_FILE)
59+
);
60+
redirect($returnurl,
61+
get_string('stylesupload_success', 'mod_exeweb', s($entry['title'])),
62+
null,
63+
\core\output\notification::NOTIFY_SUCCESS
64+
);
65+
} catch (\moodle_exception $e) {
66+
redirect($returnurl, $e->getMessage(), null,
67+
\core\output\notification::NOTIFY_ERROR);
68+
}
69+
break;
70+
71+
case 'toggleuploaded':
72+
$slug = required_param('slug', PARAM_TEXT);
73+
$enabled = (bool) required_param('enabled', PARAM_INT);
74+
styles_service::set_uploaded_enabled($slug, $enabled);
75+
redirect($returnurl);
76+
break;
77+
78+
case 'togglebuiltin':
79+
$id = required_param('id', PARAM_TEXT);
80+
$enabled = (bool) required_param('enabled', PARAM_INT);
81+
styles_service::set_builtin_enabled($id, $enabled);
82+
redirect($returnurl);
83+
break;
84+
85+
case 'delete':
86+
$slug = required_param('slug', PARAM_TEXT);
87+
styles_service::delete_uploaded($slug);
88+
redirect($returnurl,
89+
get_string('stylesdelete_success', 'mod_exeweb'),
90+
null,
91+
\core\output\notification::NOTIFY_SUCCESS
92+
);
93+
break;
94+
}
95+
}
96+
97+
// --------------------------------------------------------------------
98+
// Render.
99+
// --------------------------------------------------------------------
100+
echo $OUTPUT->header();
101+
echo $OUTPUT->heading(get_string('stylesmanager', 'mod_exeweb'));
102+
103+
if (get_config('exeweb', 'editormode') !== 'embedded') {
104+
echo $OUTPUT->notification(get_string('stylesonlywhenembedded', 'mod_exeweb'),
105+
\core\output\notification::NOTIFY_WARNING);
106+
}
107+
108+
echo html_writer::tag('p', get_string('stylesmanager_intro', 'mod_exeweb'));
109+
110+
// Upload form.
111+
echo html_writer::start_tag('form', [
112+
'method' => 'post',
113+
'action' => $returnurl->out(false),
114+
'enctype' => 'multipart/form-data',
115+
'class' => 'mt-3 mb-4',
116+
]);
117+
echo html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'action', 'value' => 'upload']);
118+
echo html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey()]);
119+
echo html_writer::tag('label',
120+
get_string('stylesupload_label', 'mod_exeweb'),
121+
['for' => 'style_zip', 'class' => 'd-block mb-1']
122+
);
123+
echo html_writer::empty_tag('input', [
124+
'type' => 'file',
125+
'id' => 'style_zip',
126+
'name' => 'style_zip',
127+
'accept' => '.zip,application/zip,application/x-zip-compressed',
128+
'required' => 'required',
129+
]);
130+
echo ' ';
131+
echo html_writer::empty_tag('input', [
132+
'type' => 'submit',
133+
'class' => 'btn btn-primary',
134+
'value' => get_string('stylesupload_submit', 'mod_exeweb'),
135+
]);
136+
echo html_writer::tag('p',
137+
get_string('stylesupload_hint', 'mod_exeweb',
138+
display_size(styles_service::get_max_zip_size())),
139+
['class' => 'text-muted small mt-2']
140+
);
141+
echo html_writer::end_tag('form');
142+
143+
// Uploaded styles table.
144+
$uploaded = styles_service::list_uploaded_styles();
145+
echo $OUTPUT->heading(get_string('stylesuploaded', 'mod_exeweb'), 3);
146+
if (empty($uploaded)) {
147+
echo html_writer::tag('p', get_string('stylesuploaded_empty', 'mod_exeweb'), ['class' => 'text-muted']);
148+
} else {
149+
$table = new html_table();
150+
$table->head = [
151+
get_string('stylestable_title', 'mod_exeweb'),
152+
get_string('stylestable_id', 'mod_exeweb'),
153+
get_string('stylestable_version', 'mod_exeweb'),
154+
get_string('stylestable_installed', 'mod_exeweb'),
155+
get_string('stylestable_enabled', 'mod_exeweb'),
156+
get_string('stylestable_actions', 'mod_exeweb'),
157+
];
158+
foreach ($uploaded as $style) {
159+
$toggleurl = new moodle_url('/mod/exeweb/admin/styles.php', [
160+
'action' => 'toggleuploaded',
161+
'slug' => $style['id'],
162+
'enabled' => empty($style['enabled']) ? 1 : 0,
163+
'sesskey' => sesskey(),
164+
]);
165+
$togglelabel = empty($style['enabled'])
166+
? get_string('stylesenable', 'mod_exeweb')
167+
: get_string('stylesdisable', 'mod_exeweb');
168+
$deleteurl = new moodle_url('/mod/exeweb/admin/styles.php', [
169+
'action' => 'delete',
170+
'slug' => $style['id'],
171+
'sesskey' => sesskey(),
172+
]);
173+
$table->data[] = [
174+
s($style['title'] ?? $style['id']),
175+
html_writer::tag('code', s($style['id'])),
176+
s($style['version'] ?? ''),
177+
s($style['installed_at'] ?? ''),
178+
html_writer::link($toggleurl, $togglelabel, ['class' => 'btn btn-secondary btn-sm']),
179+
html_writer::link(
180+
$deleteurl,
181+
get_string('stylesdelete', 'mod_exeweb'),
182+
[
183+
'class' => 'btn btn-danger btn-sm',
184+
'onclick' => "return confirm('"
185+
. addslashes_js(get_string('stylesdelete_confirm', 'mod_exeweb'))
186+
. "');",
187+
]
188+
),
189+
];
190+
}
191+
echo html_writer::table($table);
192+
}
193+
194+
// Built-in styles table.
195+
$builtins = styles_service::list_builtin_themes();
196+
echo $OUTPUT->heading(get_string('stylesbuiltin', 'mod_exeweb'), 3);
197+
if (empty($builtins)) {
198+
echo html_writer::tag('p', get_string('stylesbuiltin_empty', 'mod_exeweb'), ['class' => 'text-muted']);
199+
} else {
200+
$registry = styles_service::get_registry();
201+
$disabledlist = $registry['disabled_builtins'];
202+
$table = new html_table();
203+
$table->head = [
204+
get_string('stylestable_title', 'mod_exeweb'),
205+
get_string('stylestable_id', 'mod_exeweb'),
206+
get_string('stylestable_version', 'mod_exeweb'),
207+
get_string('stylestable_enabled', 'mod_exeweb'),
208+
];
209+
foreach ($builtins as $style) {
210+
$isdisabled = in_array($style['id'], $disabledlist, true);
211+
$toggleurl = new moodle_url('/mod/exeweb/admin/styles.php', [
212+
'action' => 'togglebuiltin',
213+
'id' => $style['id'],
214+
'enabled' => $isdisabled ? 1 : 0,
215+
'sesskey' => sesskey(),
216+
]);
217+
$togglelabel = $isdisabled
218+
? get_string('stylesenable', 'mod_exeweb')
219+
: get_string('stylesdisable', 'mod_exeweb');
220+
$table->data[] = [
221+
s($style['title']),
222+
html_writer::tag('code', s($style['id'])),
223+
s($style['version']),
224+
html_writer::link($toggleurl, $togglelabel, ['class' => 'btn btn-secondary btn-sm']),
225+
];
226+
}
227+
echo html_writer::table($table);
228+
}
229+
230+
echo $OUTPUT->footer();

0 commit comments

Comments
 (0)