diff --git a/.grunt/tasks/stylelint.js b/.grunt/tasks/stylelint.js index 864e46151d150..9e33aa9ad2cef 100644 --- a/.grunt/tasks/stylelint.js +++ b/.grunt/tasks/stylelint.js @@ -157,11 +157,11 @@ module.exports = grunt => { return [scssMatch]; } - if (grunt.moodleEnv.runDir.startsWith('theme')) { + if (grunt.moodleEnv.runDir.startsWith('public/theme')) { return [`*/${scssMatch}`]; } - return [`theme/*/${scssMatch}`]; + return [`public/theme/*/${scssMatch}`]; }; // Add the watch configuration for rawcss, and scss. diff --git a/UPGRADING.md b/UPGRADING.md index 7485dcc84e013..f25a8412270d2 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -6,6 +6,56 @@ More detailed information on key changes can be found in the [Developer update n The format of this change log follows the advice given at [Keep a CHANGELOG](https://keepachangelog.com). +## 5.1.6 + +### core + +#### Changed + +- The `search` landmark role in the `core/search_input_auto` template is enclosed within a `searchrole` Mustache block so that templates that use this template can override and remove the `search` landmark role when deemed unnecessary. + + For more information see [MDL-88833](https://tracker.moodle.org/browse/MDL-88833) + +### assignfeedback_editpdf + +#### Fixed + +- Fixed multi-page assignment feedback PDF conversion on Windows. Ghostscript's page number placeholder is no longer stripped by escapeshellarg(). + + For more information see [MDL-76966](https://tracker.moodle.org/browse/MDL-76966) + +### block_myoverview + +#### Changed + +- For the correct display of title and context menus, fields like fullname are returned with numeric HTML entities (<) instead of named entities (<) and unencoded quotes. + + For more information see [MDL-79755](https://tracker.moodle.org/browse/MDL-79755) + +### core\task\adhoc_task + +#### Added + +- Added set_soft_retry_delay(), get_soft_retry_delay() and is_adhoc_task_delayed() methods. Call set_soft_retry_delay() from within an adhoc task's execute() method to request a soft retry via manager::adhoc_task_delayed() without marking the task as failed. Pass null for automatic exponential backoff or a positive integer for an explicit delay in seconds. + + For more information see [MDL-79763](https://tracker.moodle.org/browse/MDL-79763) + +### core\task\manager + +#### Added + +- Added adhoc_task_delayed() method to allow an adhoc task to be retried after a delay without marking it as failed. The delay uses exponential backoff based on elapsed time since the task first started, capped at 24 hours. + + For more information see [MDL-79763](https://tracker.moodle.org/browse/MDL-79763) + +### mod_assign + +#### Changed + +- The `assign::calculate_penalised_grade()` method now applies grade-item scaling so the returned value now matches the `finalgrade` stored in the gradebook. It also accepts an optional `\grade_grade $usergraderecord` parameter to avoid redundant database lookups. Callers that previously applied their own grade-item scaling to the returned value should remove it to avoid double scaling. + + For more information see [MDL-88407](https://tracker.moodle.org/browse/MDL-88407) + ## 5.1.4 ### core diff --git a/admin/cli/uninstall_plugins.php b/admin/cli/uninstall_plugins.php index 18860474f699b..6096fb1e0222f 100644 --- a/admin/cli/uninstall_plugins.php +++ b/admin/cli/uninstall_plugins.php @@ -24,6 +24,7 @@ */ define('CLI_SCRIPT', true); +define('IGNORE_COMPONENT_CACHE', true); require(__DIR__ . '/../../config.php'); require_once($CFG->libdir . '/clilib.php'); @@ -98,6 +99,7 @@ $DB->set_debug(true); } +core_plugin_manager::reset_caches(); $pluginman = core_plugin_manager::instance(); $plugininfo = $pluginman->get_plugins(); diff --git a/config-dist.php b/config-dist.php index 4bf70da9b3270..2f9be4274b577 100644 --- a/config-dist.php +++ b/config-dist.php @@ -817,6 +817,8 @@ // // $CFG->maxgradesperpage = 200000; // +// Maximum character length for a user profile description. +// define('USER_DESCRIPTION_MAX_LENGTH', 50000); // //========================================================================= // 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!! diff --git a/public/admin/environment.xml b/public/admin/environment.xml index 30a56c7abe5b1..4e875f3c8876b 100644 --- a/public/admin/environment.xml +++ b/public/admin/environment.xml @@ -5312,4 +5312,209 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/admin/mnet/peer_forms.php b/public/admin/mnet/peer_forms.php index c56fc48e0f966..56824a028aa76 100644 --- a/public/admin/mnet/peer_forms.php +++ b/public/admin/mnet/peer_forms.php @@ -58,6 +58,10 @@ function validation($data, $files) { if (strtolower(substr($wwwroot, 0, 4)) != 'http') { $wwwroot = 'http://'.$wwwroot; } + $securityhelper = new \core\files\curl_security_helper(); + if ($securityhelper->url_is_blocked($wwwroot)) { + return ['wwwroot' => $securityhelper->get_blocked_url_string()]; + } if ($host = $DB->get_record('mnet_host', array('wwwroot' => $wwwroot))) { $str = get_string('hostexists', 'mnet', (new moodle_url('/admin/mnet/peers.php', ['hostid' => $host->id]))->out()); return array('wwwroot' => $str); diff --git a/public/admin/presets/classes/local/setting/adminpresets_admin_setting_configexecutable.php b/public/admin/presets/classes/local/setting/adminpresets_admin_setting_configexecutable.php new file mode 100644 index 0000000000000..f93442ea6b72a --- /dev/null +++ b/public/admin/presets/classes/local/setting/adminpresets_admin_setting_configexecutable.php @@ -0,0 +1,58 @@ +. + +namespace core_adminpresets\local\setting; + +/** + * Executable path setting for admin presets. + * + * @package core_adminpresets + * @copyright 2026 Anupama Sarjoshi + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class adminpresets_admin_setting_configexecutable extends adminpresets_admin_setting_configtext { + /** + * Saves the setting value only when $CFG->preventexecpath is unset + * and the path is a valid executable. + * + * @param bool|string $name Setting name to use, or false to use the setting's own name. + * @param mixed $value Setting value to store. + * @return int|false config_log inserted id, or false if nothing was saved. + */ + public function save_value($name = false, $value = null) { + global $CFG; + + // When $CFG->preventexecpath is set, executable paths are managed through + // config.php and must not be overwritten by admin presets. + if (!empty($CFG->preventexecpath)) { + return false; + } + + // Resolve the value that would be written. + $execpath = ($value !== null) ? $value : $this->value; + + // Validate non-empty paths: the target must be an existing, non-directory, + // executable file. + if (!empty($execpath)) { + require_once($CFG->libdir . '/filelib.php'); + if (!file_exists($execpath) || is_dir($execpath) || !file_is_executable($execpath)) { + return false; + } + } + + return parent::save_value($name, $value); + } +} diff --git a/public/admin/presets/classes/manager.php b/public/admin/presets/classes/manager.php index d7d0a265b4827..665007e61ef20 100644 --- a/public/admin/presets/classes/manager.php +++ b/public/admin/presets/classes/manager.php @@ -48,7 +48,6 @@ class manager { 'adminpresets_admin_setting_configduration_with_advanced' => 'adminpresets_admin_setting_configtext_with_advanced', 'adminpresets_admin_setting_configduration' => 'adminpresets_admin_setting_configtext', 'adminpresets_admin_setting_configempty' => 'adminpresets_admin_setting_configtext', - 'adminpresets_admin_setting_configexecutable' => 'adminpresets_admin_setting_configtext', 'adminpresets_admin_setting_configfile' => 'adminpresets_admin_setting_configtext', 'adminpresets_admin_setting_confightmleditor' => 'adminpresets_admin_setting_configtext', 'adminpresets_admin_setting_configmixedhostiplist' => 'adminpresets_admin_setting_configtext', @@ -620,7 +619,7 @@ public function download_preset(int $presetid): array { * to define if any setting has been found and another boolean to specify if any plugin has been found. */ public function import_preset(string $xmlcontent, ?string $presetname = null): array { - global $DB, $USER; + global $DB, $USER, $CFG; $settingsfound = false; $pluginsfound = false; @@ -684,6 +683,13 @@ public function import_preset(string $xmlcontent, ?string $presetname = null): a continue; } + // When $CFG->preventexecpath is set, executable paths are managed through + // config.php and cannot be changed via presets. + $settingdata = $sitesettings[$plugin][$name]->get_settingdata(); + if ($settingdata instanceof \admin_setting_configfile && !empty($CFG->preventexecpath)) { + continue; + } + $settingsfound = true; // New item. @@ -987,7 +993,7 @@ public function revert_preset(int $presetappid): array { * @return array List with an array with the applied settings, another with the skipped ones and the adminpresetapplyid. */ protected function apply_settings(int $presetid, bool $simulate = false, ?int $adminpresetapplyid = null): array { - global $DB, $USER; + global $CFG, $DB, $USER; $applied = []; $skipped = []; @@ -1046,6 +1052,25 @@ protected function apply_settings(int $presetid, bool $simulate = false, ?int $a // Saving data. if ($updatesetting) { + // Do not overwrite executable/directory paths when $CFG->preventexecpath is enabled. + $settingdata = $presetsetting->get_settingdata(); + if ($settingdata instanceof \admin_setting_configfile && !empty($CFG->preventexecpath)) { + $skipped[] = $data; + continue; + } + + // Ensure executable-path settings point to a valid executable file before saving. + if ($settingdata instanceof \admin_setting_configexecutable) { + $execpath = $presetsetting->get_value(); + if (!empty($execpath)) { + require_once($CFG->libdir . '/filelib.php'); + if (!file_exists($execpath) || is_dir($execpath) || !file_is_executable($execpath)) { + $skipped[] = $data; + continue; + } + } + } + // The preset application it's only saved when differences (in their values) are found. if (empty($applieditem)) { // Save the preset application and store the preset applied id. diff --git a/public/admin/presets/tests/fixtures/import_execpath_setting.xml b/public/admin/presets/tests/fixtures/import_execpath_setting.xml new file mode 100644 index 0000000000000..12cee1e494333 --- /dev/null +++ b/public/admin/presets/tests/fixtures/import_execpath_setting.xml @@ -0,0 +1,19 @@ + + + Exec path preset + Preset containing an executable-path setting (aspellpath) for testing preventexecpath enforcement. + 1631615985 + http://demo.moodle + Ada Lovelace + 2021091100 + 4.0dev (Build: 20210911) + + + + /usr/bin/aspell + /path/to/GeoLite2-City.mmdb + 0 + + + + diff --git a/public/admin/presets/tests/local/setting/adminpresets_admin_setting_configexecutable_test.php b/public/admin/presets/tests/local/setting/adminpresets_admin_setting_configexecutable_test.php new file mode 100644 index 0000000000000..97907002dcc30 --- /dev/null +++ b/public/admin/presets/tests/local/setting/adminpresets_admin_setting_configexecutable_test.php @@ -0,0 +1,140 @@ +. + +namespace core_adminpresets\local\setting; + +/** + * Tests for the adminpresets_admin_setting_configexecutable class. + * + * @package core_adminpresets + * @category test + * @copyright 2026 Anupama Sarjoshi + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \core_adminpresets\local\setting\adminpresets_admin_setting_configexecutable + */ +final class adminpresets_admin_setting_configexecutable_test extends \advanced_testcase { + /** + * Test the behaviour of save_value() method. + * + * @covers ::save_value + * @dataProvider save_value_provider + * + * @param bool $preventexecpath Whether to set $CFG->preventexecpath. + * @param string $newpath Executable path value to save. + * @param bool $expectedsaved Whether the value should be saved (true) or rejected (false). + */ + public function test_save_value(bool $preventexecpath, string $newpath, bool $expectedsaved): void { + global $CFG, $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $pathtophp = ''; + $isdefined = false; + if ($CFG->pathtophp != '') { + $pathtophp = $CFG->pathtophp; + $isdefined = true; + } + + if ($preventexecpath) { + $CFG->preventexecpath = true; + } else { + unset($CFG->preventexecpath); + } + + $generator = $this->getDataGenerator()->get_plugin_generator('core_adminpresets'); + $setting = $generator->get_admin_preset_setting('systempaths', 'pathtophp'); + + $result = $setting->save_value(false, $newpath); + + if ($expectedsaved) { + $this->assertIsInt($result); + $this->assertCount(1, $DB->get_records('config_log', ['id' => $result])); + $configlog = $DB->get_record('config_log', ['id' => $result]); + $this->assertEquals($newpath, $configlog->value); + if (!$isdefined) { + $this->assertEquals($newpath, get_config('core', 'pathtophp')); + } else { + $this->assertEquals($pathtophp, get_config('core', 'pathtophp')); + } + } else { + $this->assertFalse($result); + $this->assertEquals($pathtophp, get_config('core', 'pathtophp')); + } + } + + /** + * Data provider for test_save_value(). + * + * @return array + */ + public static function save_value_provider(): array { + global $CFG; + return [ + 'preventexecpath set: save_value returns false without writing' => [ + 'preventexecpath' => true, + 'newpath' => PHP_BINARY, + 'expectedsaved' => false, + ], + 'preventexecpath not set, non-existent path: save_value returns false' => [ + 'preventexecpath' => false, + 'newpath' => '/this/path/does/not/exist/phpbinary', + 'expectedsaved' => false, + ], + 'preventexecpath not set, valid executable: value is saved' => [ + 'preventexecpath' => false, + 'newpath' => PHP_BINARY, + 'expectedsaved' => PHP_BINARY !== $CFG->pathtophp, // Only expect saved if the new path is different from existing. + ], + ]; + } + + /** + * Test that save_value() returns false for a file that exists but is not executable. + * + * @covers ::save_value + */ + public function test_save_value_non_executable_file(): void { + global $CFG; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $pathtophp = ''; + if ($CFG->pathtophp != '') { + $pathtophp = $CFG->pathtophp; + } + + unset($CFG->preventexecpath); + + // Create a temporary file and remove executable permission. + $tempfile = tempnam(sys_get_temp_dir(), 'moodle_test_'); + $this->assertNotFalse($tempfile); + chmod($tempfile, 0644); + + $generator = $this->getDataGenerator()->get_plugin_generator('core_adminpresets'); + $setting = $generator->get_admin_preset_setting('systempaths', 'pathtophp'); + + try { + $result = $setting->save_value(false, $tempfile); + } finally { + unlink($tempfile); + } + + $this->assertFalse($result); + $this->assertEquals($pathtophp, get_config('core', 'pathtophp')); + } +} diff --git a/public/admin/presets/tests/manager_test.php b/public/admin/presets/tests/manager_test.php index ebe56828190b1..43ccfdf0bc260 100644 --- a/public/admin/presets/tests/manager_test.php +++ b/public/admin/presets/tests/manager_test.php @@ -849,4 +849,151 @@ public function test_apply_preset_deprecated_plugintype(): void { $this->assertdebuggingcalledcount(2); // Expected unit-test-only debugging, as above. $this->assertContains('fake', array_column($skipped, 'plugin')); } + + /** + * Test import_preset() behaviour for executable-path settings depending on $CFG->preventexecpath. + * + * @dataProvider import_preset_execpath_provider + * @covers ::import_preset + * + * @param bool $preventexecpath Whether to set $CFG->preventexecpath before importing. + * @param int $expecteditemcount Expected number of items stored in the preset. + * @param string[] $presentnames Setting names that must appear in the stored items. + * @param string[] $absentnames Setting names that must not appear in the stored items. + */ + public function test_import_preset_execpath( + bool $preventexecpath, + int $expecteditemcount, + array $presentnames, + array $absentnames + ): void { + global $CFG, $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + if ($preventexecpath) { + $CFG->preventexecpath = true; + } else { + unset($CFG->preventexecpath); + } + + $xml = file_get_contents(self::get_fixture_path(__NAMESPACE__, 'import_execpath_setting.xml')); + [, $preset, $settingsfound] = (new manager())->import_preset($xml); + + $this->assertNotNull($preset); + $this->assertTrue($settingsfound); + + $names = array_column($DB->get_records('adminpresets_it', ['adminpresetid' => $preset->id]), 'name'); + $this->assertCount($expecteditemcount, $names); + foreach ($presentnames as $name) { + $this->assertContains($name, $names); + } + foreach ($absentnames as $name) { + $this->assertNotContains($name, $names); + } + } + + /** + * Data provider for test_import_preset_execpath(). + * + * @return array + */ + public static function import_preset_execpath_provider(): array { + return [ + 'preventexecpath set: execpath and configfile settings dropped' => [ + 'preventexecpath' => true, + 'expecteditemcount' => 1, + 'presentnames' => ['enablebadges'], + 'absentnames' => ['aspellpath', 'geoip2file'], + ], + 'preventexecpath not set: all settings stored' => [ + 'preventexecpath' => false, + 'expecteditemcount' => 3, + 'presentnames' => ['enablebadges', 'aspellpath', 'geoip2file'], + 'absentnames' => [], + ], + ]; + } + + /** + * Test apply_settings() behaviour for executable-path settings: verifies both the + * guard (skipped) and happy-path (applied) branches depending on $CFG->preventexecpath + * and whether the path is a valid executable. + * + * @dataProvider apply_settings_execpath_provider + * @covers ::apply_preset + * + * @param bool $preventexecpath Whether to set $CFG->preventexecpath. + * @param string $presetpath The executable path value stored in the preset. + * @param bool $expectedapplied Whether the setting should end up in $applied (true) or $skipped (false). + */ + public function test_apply_settings_execpath(bool $preventexecpath, string $presetpath, bool $expectedapplied): void { + global $CFG; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $pathtophp = ''; + $isdefined = false; + if ($CFG->pathtophp != '') { + $pathtophp = $CFG->pathtophp; + $isdefined = true; + } + + if ($preventexecpath) { + $CFG->preventexecpath = true; + } else { + unset($CFG->preventexecpath); + } + + $generator = $this->getDataGenerator()->get_plugin_generator('core_adminpresets'); + $presetid = $generator->create_preset(); + helper::add_item($presetid, 'pathtophp', $presetpath); + + [$applied, $skipped] = (new manager())->apply_preset($presetid); + + $phpvisiblename = get_string('pathtophp', 'admin'); + $appliednames = array_map('strval', array_column($applied, 'visiblename')); + $skippednames = array_map('strval', array_column($skipped, 'visiblename')); + + if ($expectedapplied) { + $this->assertContains($phpvisiblename, $appliednames); + $this->assertNotContains($phpvisiblename, $skippednames); + if (!$isdefined) { + $this->assertEquals($presetpath, get_config('core', 'pathtophp')); + } else { + $this->assertEquals($pathtophp, get_config('core', 'pathtophp')); + } + } else { + $this->assertContains($phpvisiblename, $skippednames); + $this->assertNotContains($phpvisiblename, $appliednames); + $this->assertEquals($pathtophp, get_config('core', 'pathtophp')); + } + } + + /** + * Data provider for test_apply_settings_execpath(). + * + * @return array + */ + public static function apply_settings_execpath_provider(): array { + return [ + 'preventexecpath set: execpath setting skipped' => [ + 'preventexecpath' => true, + 'presetpath' => PHP_BINARY, + 'expectedapplied' => false, + ], + 'preventexecpath not set, invalid path: execpath setting skipped' => [ + 'preventexecpath' => false, + 'presetpath' => '/this/path/does/not/exist/phpbinary', + 'expectedapplied' => false, + ], + 'preventexecpath not set, valid executable: execpath setting applied' => [ + 'preventexecpath' => false, + 'presetpath' => PHP_BINARY, + 'expectedapplied' => true, + ], + ]; + } } diff --git a/public/admin/renderer.php b/public/admin/renderer.php index 11c52fcbc4ff0..2bbaae3843196 100644 --- a/public/admin/renderer.php +++ b/public/admin/renderer.php @@ -2264,11 +2264,27 @@ public function environment_check_table($result, $environment_results) { * @return string */ public function upgradekey_form_page($url) { + return $this->upgradekey_form_page_with_validation($url, false); + } + /** + * Render a simple page for providing the upgrade key, providing validation for failed attempts + * + * @param moodle_url $url + * @param bool $upgradekeyerror + * @return string + */ + public function upgradekey_form_page_with_validation(moodle_url $url, bool $upgradekeyerror): string { $output = ''; $output .= $this->header(); $output .= $this->heading(get_string('upgradekeyreq', 'core_admin')); $output .= $this->container_start('upgradekeyreq w-25'); + + // Inform user if they got it wrong. + if ($upgradekeyerror) { + $output .= $this->warning(get_string('upgradekeyerror', 'core_admin'), 'danger'); + } + $output .= html_writer::start_tag('form', array('method' => 'POST', 'action' => $url)); $output .= html_writer::empty_tag('input', [ 'id' => 'upgradekey', diff --git a/public/admin/settings/security.php b/public/admin/settings/security.php index 3527d3c0e3059..0ea14fde8ae5f 100644 --- a/public/admin/settings/security.php +++ b/public/admin/settings/security.php @@ -75,8 +75,6 @@ 2700 => new lang_string('numminutes', '', 45), 3600 => new lang_string('numminutes', '', 60)))); - $temp->add(new admin_setting_configcheckbox('extendedusernamechars', new lang_string('extendedusernamechars', 'admin'), new lang_string('configextendedusernamechars', 'admin'), 0)); - $temp->add(new admin_setting_configcheckbox('extendedusernamechars', new lang_string('extendedusernamechars', 'admin'), new lang_string('configextendedusernamechars', 'admin'), 0)); $temp->add(new admin_setting_configcheckbox('keeptagnamecase', new lang_string('keeptagnamecase','admin'),new lang_string('configkeeptagnamecase', 'admin'),'1')); diff --git a/public/admin/swaggerui.php b/public/admin/swaggerui.php index 45a66d1470daa..672b05bbf1678 100644 --- a/public/admin/swaggerui.php +++ b/public/admin/swaggerui.php @@ -26,6 +26,7 @@ require_once($CFG->libdir . '/adminlib.php'); $swaggerversion = '5.17.14'; +$swaggeruipluginversion = '1.0.4'; $PAGE->set_url('/admin/swaggerui.php'); @@ -48,7 +49,7 @@ tagname: 'script', contents: '', attributes: [ - 'src' => new moodle_url("https://unpkg.com/swagger-ui-plugin-hierarchical-tags"), + 'src' => new moodle_url("https://unpkg.com/swagger-ui-plugin-hierarchical-tags@{$swaggeruipluginversion}/build/index.js"), 'crossorigin' => 'crossorigin', ], ); diff --git a/public/admin/tool/admin_presets/tests/behat/apply_presets.feature b/public/admin/tool/admin_presets/tests/behat/apply_presets.feature index cdfc81e37ea33..82dce5d9bfb65 100644 --- a/public/admin/tool/admin_presets/tests/behat/apply_presets.feature +++ b/public/admin/tool/admin_presets/tests/behat/apply_presets.feature @@ -22,7 +22,7 @@ Feature: I can apply presets And I navigate to "Plugins > Question types > Manage question types" in site administration And "Enabled" "link" should exist in the "Calculated multichoice" "table_row" When I navigate to "Site admin presets" in site administration - And I press "Review settings and apply" action in the "Starter" report row + And I press "Review settings and apply" action in the "Moodle with all of the most popular features" report row And I should see "Setting changes" # Checking all the settings to be applied for the Starter (if will help to identify possible regressions). And I should see "Activities" in the "Setting changes" "table" @@ -111,11 +111,11 @@ Feature: I can apply presets Scenario: Re-applying Starter Moodle preset does not display setting changes # Apply Starter preset. Given I navigate to "Site admin presets" in site administration - When I press "Review settings and apply" action in the "Starter" report row + When I press "Review settings and apply" action in the "Moodle with all of the most popular features" report row And I click on "Apply" "button" And I click on "Continue" "button" # When the Starter preset it's applied again, no changes should be displayed. - And I press "Review settings and apply" action in the "Starter" report row + And I press "Review settings and apply" action in the "Moodle with all of the most popular features" report row Then I should not see "Setting changes" Scenario: Applied exported settings diff --git a/public/admin/tool/behat/tests/behat/edit_permissions.feature b/public/admin/tool/behat/tests/behat/edit_permissions.feature index da2406036b5c0..4739ef68ce9e9 100644 --- a/public/admin/tool/behat/tests/behat/edit_permissions.feature +++ b/public/admin/tool/behat/tests/behat/edit_permissions.feature @@ -76,7 +76,7 @@ Feature: Edit capabilities | Your word for 'Non-editing teacher' | Teacher < "editing" | | Your word for 'Student' | Studier & 'learner' | And I press "Save" - And I navigate to course participants + And I am on the "Course 1" "enrolled users" page Then I should see "Teacher >= editing (Teacher)" in the "Teacher 1" "table_row" And I should see "Teacher < \"editing\" (Non-editing teacher)" in the "Teaching Assistant" "table_row" And I should see "Studier & 'learner' (Student)" in the "Student One" "table_row" diff --git a/public/admin/tool/behat/tests/behat/keyboard.feature b/public/admin/tool/behat/tests/behat/keyboard.feature index c72b3d3bbf79b..269b2bafaeba8 100644 --- a/public/admin/tool/behat/tests/behat/keyboard.feature +++ b/public/admin/tool/behat/tests/behat/keyboard.feature @@ -44,8 +44,7 @@ Feature: Verify that keyboard steps work as expected | fullname | C1| | shortname | C1 | And I log in as "admin" - And I am on "C1" course homepage - And I navigate to course participants + And I am on the "C1" "enrolled users" page And I press "Enrol users" And "Enrol users" "dialogue" should be visible When I press the escape key diff --git a/public/admin/tool/customlang/templates/translator.mustache b/public/admin/tool/customlang/templates/translator.mustache index d6ffb9ecec5d1..d78d4c8dbc691 100644 --- a/public/admin/tool/customlang/templates/translator.mustache +++ b/public/admin/tool/customlang/templates/translator.mustache @@ -69,7 +69,7 @@
-
+
{{#str}}headingcomponent, tool_customlang{{/str}} diff --git a/public/admin/tool/generator/tests/behat/make_test_course.feature b/public/admin/tool/generator/tests/behat/make_test_course.feature index c76fc5111284b..8a84b628253e4 100644 --- a/public/admin/tool/generator/tests/behat/make_test_course.feature +++ b/public/admin/tool/generator/tests/behat/make_test_course.feature @@ -16,7 +16,7 @@ Feature: Admins can create test courses | Course short name | fake | And I press "Create course" And I click on "Continue" "link" - And I navigate to course participants + And I am on the "Fake course for testing" "enrolled users" page Then I should not see "Teacher" And I should not see "Nothing to display" And the following config values are set as admin: @@ -29,5 +29,5 @@ Feature: Admins can create test courses | Course short name | newfake | And I press "Create course" And I click on "Continue" "link" - And I navigate to course participants + And I am on the "New fake course for testing" "enrolled users" page And I should see "Teacher" diff --git a/public/admin/tool/generator/tests/behat/testscenario.feature b/public/admin/tool/generator/tests/behat/testscenario.feature index 775acd93eb121..4912b26e463d0 100644 --- a/public/admin/tool/generator/tests/behat/testscenario.feature +++ b/public/admin/tool/generator/tests/behat/testscenario.feature @@ -14,7 +14,7 @@ Feature: Create testing scenarios using generators Then I am on the "C1" "Course" page And I should see "Activity sample 1" in the "Section 1" "section" And I should see "Activity sample 2" in the "Section 1" "section" - And I navigate to course participants + And I am on the "C1" "enrolled users" page And I should see "Teacher Test1" And I should see "Student Test1" And I should see "Student Test2" diff --git a/public/admin/tool/installaddon/templates/chooser_footer.mustache b/public/admin/tool/installaddon/templates/chooser_footer.mustache index fb88aa65c249a..ed4b4c3fee8b8 100644 --- a/public/admin/tool/installaddon/templates/chooser_footer.mustache +++ b/public/admin/tool/installaddon/templates/chooser_footer.mustache @@ -24,7 +24,7 @@ "url": "https://marketplace.moodle.com/?site=hash" } }} -
+
{{#str}} activitychooserfootertext, tool_installaddon {{/str}} {{> tool_installaddon/marketplace_link }}
diff --git a/public/admin/tool/lp/classes/output/template_cohorts_table.php b/public/admin/tool/lp/classes/output/template_cohorts_table.php index b5cdc18b3a978..bb1623286c38b 100644 --- a/public/admin/tool/lp/classes/output/template_cohorts_table.php +++ b/public/admin/tool/lp/classes/output/template_cohorts_table.php @@ -177,6 +177,16 @@ public function query_db($pagesize, $useinitialsbar = true) { $this->pagesize($pagesize, $total); $this->rawdata = $DB->get_records_sql($sql, $params, $this->get_page_start(), $this->get_page_size()); + // Filter the 'name' column. + if (!empty($this->rawdata)) { + foreach ($this->rawdata as $key => $record) { + if (is_object($record) && property_exists($record, 'name')) { + $record->name = format_string($record->name, options: ['context' => $this->context]); + $this->rawdata[$key] = $record; + } + } + } + // Set initial bars. if ($useinitialsbar) { $this->initialbars($total > $pagesize); diff --git a/public/admin/tool/lp/styles.css b/public/admin/tool/lp/styles.css index 774db05212aff..1e5fd0a1004c4 100644 --- a/public/admin/tool/lp/styles.css +++ b/public/admin/tool/lp/styles.css @@ -143,7 +143,7 @@ } .path-admin-tool-lp .competency-rule-points input[type="number"] { - width: 50px; + width: 7ch; } .competency-heading { diff --git a/public/admin/tool/lp/templates/manage_competencies_page.mustache b/public/admin/tool/lp/templates/manage_competencies_page.mustache index 68b05f6ab3a37..2e66c40ef7ef3 100644 --- a/public/admin/tool/lp/templates/manage_competencies_page.mustache +++ b/public/admin/tool/lp/templates/manage_competencies_page.mustache @@ -135,8 +135,8 @@ {{#js}} // Initialise the JS. -require(['tool_lp/tree', 'tool_lp/competencytree', 'tool_lp/competencyactions', 'jquery'], - function(ariatree, treeModel, actions, $) { +require(['tool_lp/tree', 'tool_lp/competencytree', 'tool_lp/competencyactions'], + function(ariatree, treeModel, actions) { treeModel.init({{framework.id}}, {{#quote}} {{{framework.shortname}}} {{/quote}}, diff --git a/public/admin/tool/lp/tests/behat/synchronize_cohorts_lp.feature b/public/admin/tool/lp/tests/behat/synchronize_cohorts_lp.feature index 70c2d38f936da..b89d5eec15e09 100644 --- a/public/admin/tool/lp/tests/behat/synchronize_cohorts_lp.feature +++ b/public/admin/tool/lp/tests/behat/synchronize_cohorts_lp.feature @@ -58,3 +58,21 @@ Feature: Cohorts can be synchronized with learning plans | Name | First name / Last name | Email address | | LPT1 | User One | user1@example.com | | LPT1 | User Two | user2@example.com | + + @javascript + Scenario: Multilang filter correctly displays in learning plan templates + Given I log in as "admin" + And the following "cohorts" exist: + | name | idnumber | + | Cohort 2Kohorte 2 & < > ' " | CH2 | + And the "multilang" filter is "on" + And the "multilang" filter applies to "content and headings" + And I navigate to "Competencies > Learning plan templates" in site administration + And I click on "Add cohorts to sync" of edit menu in the "LPT1" row + And I set the field "Select cohorts to sync" to "Cohort 2" + When I press "Add cohorts" + And I wait until the page is ready + Then the following should exist in the "generaltable" table: + | Name | Cohort ID | + | Cohort 2 & < > ' " | CH2 | + And I should not see "Kohorte 2" diff --git a/public/admin/tool/lp/tests/behat/view_competencies.feature b/public/admin/tool/lp/tests/behat/view_competencies.feature index 118fc5f4e52d6..1cf180cd6cd38 100644 --- a/public/admin/tool/lp/tests/behat/view_competencies.feature +++ b/public/admin/tool/lp/tests/behat/view_competencies.feature @@ -152,7 +152,7 @@ Feature: View competencies # Participant learning plans And I click on "Close" "button" in the "Cakes" "dialogue" And I click on "Close" "button" in the "User competency summary" "dialogue" - And I navigate to course participants + And I am on the "C1" "enrolled users" page And I click on "Student first" "link" And I click on "Learning plans" "link" And I should see "Cookery" diff --git a/public/admin/tool/messageinbound/classes/manager.php b/public/admin/tool/messageinbound/classes/manager.php index a0d1137276c82..b66822424d383 100644 --- a/public/admin/tool/messageinbound/classes/manager.php +++ b/public/admin/tool/messageinbound/classes/manager.php @@ -733,14 +733,27 @@ private function process_message_data_body_part( if ($messages) { $messagedata = reset($messages); + // The upstream IMAP body-structure parser can deliver an incomplete part + // structure when the incoming message has a malformed or absent Content-Type + // header. Slots [1] (subtype), [2] (parameters) and [5] (encoding) may be + // missing or of the wrong type. Without these guards, strtoupper() and the + // typed `array $attributes` parameter of process_message_body_structure_parameters() + // raise TypeError on null. Falling back to empty values lets processing degrade + // gracefully -- unknown subtype skips the PLAIN/HTML branches, unknown encoding + // is treated as raw bytes by the existing array_search/else cascade below, and + // empty attributes leave $parameters untouched. + $subtyperaw = $partstructure[1] ?? ''; + $attributes = isset($partstructure[2]) && is_array($partstructure[2]) ? $partstructure[2] : []; + $encodingraw = $partstructure[5] ?? ''; + // Parse encoding. $encoding = array_search( - needle: strtoupper($partstructure[5]), + needle: strtoupper($encodingraw), haystack: utils::get_body_encoding(), ); // Parse subtype. - $subtype = strtoupper($partstructure[1]); + $subtype = strtoupper($subtyperaw); // Section part may be encoded, even plain text messages, so check everything. if ($encoding == utils::ENCQUOTEDPRINTABLE) { @@ -753,7 +766,7 @@ private function process_message_data_body_part( // Parse parameters. $parameters = $this->process_message_body_structure_parameters( - attributes: $partstructure[2], + attributes: $attributes, parameters: $parameters, ); @@ -770,20 +783,28 @@ private function process_message_data_body_part( } // Parse size of contents in bytes. - $bytes = intval($partstructure[6]); + $bytes = intval($partstructure[6] ?? 0); + + // Fall back to utf-8 when the sender omits the Content-Type charset parameter. + // This matches Moodle's outbound default (lib/moodlelib.php email_to_user()) and + // reflects the practical reality of modern mail. RFC 2046 §4.1.2 prescribes + // us-ascii as the spec default, but in practice undeclared modern mail is + // overwhelmingly utf-8, so the stricter default would silently corrupt real + // content via core_text::convert(). + $charset = $parameters['CHARSET'] ?? 'utf-8'; // PLAIN text. if ($subtype == 'PLAIN') { $contentplain = $this->process_message_part_body( bodycontent: $data, - charset: $parameters['CHARSET'], + charset: $charset, ); } // HTML. if ($subtype == 'HTML') { $contenthtml = $this->process_message_part_body( bodycontent: $data, - charset: $parameters['CHARSET'], + charset: $charset, ); } // ATTACHMENT. @@ -800,7 +821,7 @@ private function process_message_data_body_part( ) { // Parse disposition. $disposition = null; - if (is_array($partstructure[8])) { + if (is_array($partstructure[8] ?? null)) { $disposition = strtolower($partstructure[8][0]); } $disposition = $disposition == 'inline' ? 'inline' : 'attachment'; diff --git a/public/admin/tool/messageinbound/tests/manager_test.php b/public/admin/tool/messageinbound/tests/manager_test.php index badc40fb65d8b..07a9af84c21c7 100644 --- a/public/admin/tool/messageinbound/tests/manager_test.php +++ b/public/admin/tool/messageinbound/tests/manager_test.php @@ -31,8 +31,8 @@ * @author Frédéric Massart * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +#[\PHPUnit\Framework\Attributes\CoversClass(manager::class)] final class manager_test extends provider_testcase { - public function setUp(): void { global $CFG; parent::setUp(); @@ -84,4 +84,228 @@ protected function create_messagelist(array $params) { return $record; } + /** + * Build a manager instance with a stub IMAP client that returns the supplied bodypart + * bytes for any fetch() call. Lets the private process_message_data_body_part() be + * exercised in isolation without touching a real IMAP server. + * + * @param string $bodypartbytes Raw bytes the stub returns from $messagedata->bodypart[$section]. + * @param string $section Section key used in the returned bodypart array. + * @return manager + */ + private function manager_with_stub_client(string $bodypartbytes, string $section = '1'): manager { + $stub = new class ($bodypartbytes, $section) { + /** @var string Mailbox name. Read by manager::get_mailbox() via property access. */ + public string $selected = 'INBOX'; + + /** + * Constructor. + * + * @param string $bytes Raw bytes returned by fetch(). + * @param string $section Section key under which the bytes are returned. + */ + public function __construct( + /** @var string Raw bytes returned by fetch(). */ + private string $bytes, + /** @var string Section key under which the bytes are returned. */ + private string $section, + ) { + } + + /** + * Stub fetch() matching the real IMAP client's signature. + * + * @param mixed ...$args Unused; present to match the real client's signature. + * @return array + */ + public function fetch(...$args): array { + $msg = new \stdClass(); + $msg->bodypart = [$this->section => $this->bytes]; + return [$msg]; + } + }; + + $manager = new manager(); + // Since PHP 8.1, all Reflection*::setAccessible() calls are no-ops and are + // deprecation-warned as of PHP 8.5, so we skip them and write directly. + (new \ReflectionProperty($manager, 'client'))->setValue($manager, $stub); + return $manager; + } + + /** + * Invoke the private process_message_data_body_part() while preserving the four + * by-reference output parameters. ReflectionMethod::invokeArgs() does not preserve + * references in PHP 8, so we use Closure::bind to drop into the class scope and call + * the private method directly. + * + * @param manager $manager The manager instance (with stub client injected). + * @param array $partstructure The IMAP body-structure tuple under test. + * @param string $section Section identifier for the bodypart fetch. + * @param string $contentplain By-reference output: accumulated text/plain content. + * @param string $contenthtml By-reference output: accumulated text/html content. + * @param array $attachments By-reference output: accumulated attachments by disposition. + * @param array $parameters By-reference input/output: parameters accumulated across parts. + */ + private function invoke_process_part( + manager $manager, + array $partstructure, + string $section, + string &$contentplain, + string &$contenthtml, + array &$attachments, + array &$parameters, + ): void { + $caller = function ( + array $ps, + string $sec, + string &$cp, + string &$ch, + array &$at, + array &$pa, + ): void { + $this->process_message_data_body_part( + messageuid: 1, + partstructure: $ps, + section: $sec, + contentplain: $cp, + contenthtml: $ch, + attachments: $at, + parameters: $pa, + ); + }; + $bound = \Closure::bind($caller, $manager, manager::class); + $bound($partstructure, $section, $contentplain, $contenthtml, $attachments, $parameters); + } + + /** + * Regression test for MDL-85256: when the upstream IMAP body-structure parser leaves + * $partstructure[1] (subtype), [2] (parameters) and [5] (encoding) unset, the function + * must degrade gracefully instead of fataling on strtoupper(null) or on the typed + * `array $attributes` argument of process_message_body_structure_parameters(). + */ + public function test_process_part_handles_missing_partstructure_slots(): void { + $manager = $this->manager_with_stub_client('Hello world'); + + // Slots [1] (subtype), [2] (parameters) and [5] (encoding) are NIL -- the failure + // mode reported in MDL-85256. The other slots are present so the test exercises + // the three named regressions in isolation without tripping unrelated warnings + // on $partstructure[6] (size) and [8] (disposition). + $partstructure = [null, null, null, null, null, null, 0, null, null]; + $contentplain = ''; + $contenthtml = ''; + $attachments = []; + $parameters = []; + + $this->invoke_process_part( + $manager, + $partstructure, + '1', + $contentplain, + $contenthtml, + $attachments, + $parameters, + ); + + // With unknown subtype the PLAIN/HTML branches must skip; nothing should be added. + $this->assertSame('', $contentplain); + $this->assertSame('', $contenthtml); + $this->assertSame([], $attachments); + } + + /** + * Regression test for MDL-85256: a NIL or otherwise non-array attributes slot must not + * fatal at process_message_body_structure_parameters()'s typed `array $attributes` + * parameter. The part should still be processed using the existing fallbacks. + */ + public function test_process_part_handles_non_array_attributes_slot(): void { + $manager = $this->manager_with_stub_client('Hello world'); + + // Valid subtype/encoding/bytes, but $partstructure[2] is NIL (null). + $partstructure = ['text', 'PLAIN', null, '', '', '7BIT', 11, '', null]; + $contentplain = ''; + $contenthtml = ''; + $attachments = []; + $parameters = []; + + $this->invoke_process_part( + $manager, + $partstructure, + '1', + $contentplain, + $contenthtml, + $attachments, + $parameters, + ); + + $this->assertSame('Hello world', $contentplain); + } + + /** + * Regression test for MDL-85256: when a text/* part omits the CHARSET attribute, + * the body must be decoded as UTF-8 (matching Moodle's outbound default in + * email_to_user()). Decoding as us-ascii would strip or replace high-byte sequences + * via core_text::convert(). + */ + public function test_process_part_defaults_missing_charset_to_utf8(): void { + // Café encoded as raw UTF-8 bytes (the 'é' is 0xC3 0xA9, invalid us-ascii). + $manager = $this->manager_with_stub_client('Café'); + + // Attributes list contains NAME but no CHARSET. + $partstructure = ['text', 'PLAIN', ['NAME', 'note.txt'], '', '', '7BIT', 6, '', null]; + $contentplain = ''; + $contenthtml = ''; + $attachments = []; + $parameters = []; + + $this->invoke_process_part( + $manager, + $partstructure, + '1', + $contentplain, + $contenthtml, + $attachments, + $parameters, + ); + + $this->assertSame('Café', $contentplain); + } + + /** + * Regression test for MDL-85256: a malformed part processed after a well-formed + * attachment part must not inherit the prior part's NAME / FILENAME and re-run + * the attachment branch with its own body content -- which would create a + * spurious duplicate attachment using the earlier filename but this part's data. + * Per-part semantics: each part contributes only its own declared attributes, + * and attachments produced by earlier parts are already stored in $attachments + * by the time the next part runs. + */ + public function test_process_part_does_not_leak_parameters_into_malformed_part(): void { + $manager = $this->manager_with_stub_client('leaked body content'); + + // Malformed part: slots [1] / [2] / [5] are NIL (the MDL-85256 scenario). + $partstructure = [null, null, null, null, null, null, 0, null, null]; + $contentplain = ''; + $contenthtml = ''; + $attachments = ['inline' => [], 'attachment' => []]; + // Simulate parameters left over from an earlier attachment part. + $parameters = ['NAME' => 'report.pdf', 'CHARSET' => 'iso-8859-1']; + + $this->invoke_process_part( + $manager, + $partstructure, + '1', + $contentplain, + $contenthtml, + $attachments, + $parameters, + ); + + // The stale NAME must not produce a spurious attachment. + $this->assertSame(['inline' => [], 'attachment' => []], $attachments); + // Parameters are per-part: a part declaring no attributes contributes none. + $this->assertSame([], $parameters); + // Unknown subtype: the PLAIN/HTML branches must not run. + $this->assertSame('', $contentplain); + $this->assertSame('', $contenthtml); + } } diff --git a/public/admin/tool/mfa/classes/local/factor/object_factor_base.php b/public/admin/tool/mfa/classes/local/factor/object_factor_base.php index 3195451bf5965..1e7172f9ac64e 100644 --- a/public/admin/tool/mfa/classes/local/factor/object_factor_base.php +++ b/public/admin/tool/mfa/classes/local/factor/object_factor_base.php @@ -654,13 +654,18 @@ public function increment_lock_counter(): void { return; } + // Do not increment beyond the lockout threshold. + $lockthreshold = get_config('tool_mfa', 'lockout'); + if ($this->lockcounter >= $lockthreshold) { + return; + } + $this->lockcounter++; // Update record in DB. $DB->set_field('tool_mfa', 'lockcounter', $this->lockcounter, ['userid' => $USER->id, 'factor' => $this->name]); - // Now lock this factor if over the counter. - $lockthreshold = get_config('tool_mfa', 'lockout'); - if ($this->lockcounter >= $lockthreshold) { + // Now lock this factor if the counter has reached the threshold. + if ($this->lockcounter == $lockthreshold) { $this->set_state(\tool_mfa\plugininfo\factor::STATE_LOCKED); } } diff --git a/public/admin/tool/mfa/factor/email/email.php b/public/admin/tool/mfa/factor/email/email.php index 6f91a543296f0..114181909cef8 100644 --- a/public/admin/tool/mfa/factor/email/email.php +++ b/public/admin/tool/mfa/factor/email/email.php @@ -46,6 +46,9 @@ // Require login to force $SESSION and user, and pass for that session. if (!empty($instance) && $pass != 0 && $secret != 0) { require_login(); + if ((int)$instance->userid !== (int)$USER->id) { + throw new moodle_exception('error:parameters', 'factor_email'); + } if ($factor->get_state() === \tool_mfa\plugininfo\factor::STATE_LOCKED) { // Redirect through to auth, this will bounce them to the next factor. redirect(new moodle_url('/admin/tool/mfa/auth.php')); diff --git a/public/admin/tool/mfa/tests/object_factor_base_test.php b/public/admin/tool/mfa/tests/object_factor_base_test.php index 4602802c6b6ff..98c8ca4832daa 100644 --- a/public/admin/tool/mfa/tests/object_factor_base_test.php +++ b/public/admin/tool/mfa/tests/object_factor_base_test.php @@ -111,4 +111,41 @@ public function test_replace_user_factor(): void { $this->assertEquals(1, count($activefactors)); $this->assertEquals($factor2->id, $activefactors[0]->id); } + + /** + * Tests that the lock counter does not exceed the lockout threshold. + * + * @covers ::increment_lock_counter + * @covers ::get_remaining_attempts + * @return void + */ + public function test_increment_lock_counter_does_not_exceed_threshold(): void { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + $this->set_factor_state('totp', 1, 100); + + $lockoutthreshold = 3; + set_config('lockout', $lockoutthreshold, 'tool_mfa'); + + $totpfactor = \tool_mfa\plugininfo\factor::get_factor('totp'); + $totpfactor->setup_user_factor((object) ['secret' => 'fakekey', 'devicename' => 'fakedevice']); + + // Reach the lockout threshold. + $factor = \tool_mfa\plugininfo\factor::get_factor('totp'); + for ($i = 0; $i < $lockoutthreshold; $i++) { + $factor->increment_lock_counter(); + } + + $this->assertEquals(0, $factor->get_remaining_attempts()); + $this->assertEquals(\tool_mfa\plugininfo\factor::STATE_LOCKED, $factor->get_state()); + + // Simulate a page refresh, remaining attempts must not go negative. + $refreshedfactor = \tool_mfa\plugininfo\factor::get_factor('totp'); + $refreshedfactor->increment_lock_counter(); + + $this->assertEquals(0, $refreshedfactor->get_remaining_attempts()); + $this->assertEquals(\tool_mfa\plugininfo\factor::STATE_LOCKED, $refreshedfactor->get_state()); + } } diff --git a/public/admin/tool/policy/tests/api_test.php b/public/admin/tool/policy/tests/api_test.php index 14f62acb7253a..5db336d2cf096 100644 --- a/public/admin/tool/policy/tests/api_test.php +++ b/public/admin/tool/policy/tests/api_test.php @@ -100,14 +100,14 @@ public function test_policy_document_life_cycle(): void { $current = api::list_current_versions(); $this->assertEquals(1, count($current)); $first = reset($current); - $this->assertEquals('Test terms & conditions', $first->name); + $this->assertEquals('Test terms & conditions', $first->name); // Activate another policy version. api::make_current($new->get('id')); $current = api::list_current_versions(); $this->assertEquals(1, count($current)); $first = reset($current); - $this->assertEquals('New terms & conditions', $first->name); + $this->assertEquals('New terms & conditions', $first->name); // Inactivate the policy. api::inactivate($new->get('policyid')); diff --git a/public/admin/tool/policy/tests/behat/acceptances.feature b/public/admin/tool/policy/tests/behat/acceptances.feature index 3d60d989537fa..90898b424efcc 100644 --- a/public/admin/tool/policy/tests/behat/acceptances.feature +++ b/public/admin/tool/policy/tests/behat/acceptances.feature @@ -206,8 +206,7 @@ Feature: Viewing acceptances reports and accepting on behalf of other users And I follow "Policies and agreements" And "Accepted" "text" should exist in the "This site policy" "table_row" # User can't see agreements link in other user profiles. - And I am on "Course1" course homepage - And I navigate to course participants + And I am on the "Course1" "enrolled users" page And I follow "User Two" And I should not see "Policies and agreements" @@ -220,8 +219,7 @@ Feature: Viewing acceptances reports and accepting on behalf of other users And I set the field "I agree to the This site policy." to "1" And I press "Next" # User can see agreements link in other user profiles because has the capability for accepting on behalf of them. - When I am on "Course1" course homepage - And I navigate to course participants + When I am on the "Course1" "enrolled users" page And I follow "User Two" Then I should see "Policies and agreements" diff --git a/public/admin/tool/recyclebin/index.php b/public/admin/tool/recyclebin/index.php index 24334b184de97..dd6e993765db1 100644 --- a/public/admin/tool/recyclebin/index.php +++ b/public/admin/tool/recyclebin/index.php @@ -92,6 +92,7 @@ if ($action == 'restore' || $action == 'delete') { $itemid = required_param('itemid', PARAM_INT); $item = $recyclebin->get_item($itemid); + $item->name = format_string($item->name, options: ['context' => $context]); } switch ($action) { @@ -182,7 +183,7 @@ $row = array(); // Build item name. - $name = $item->name; + $name = format_string($item->name, options: ['context' => $context]); if ($context->contextlevel == CONTEXT_COURSE) { if (isset($modules[$item->module])) { $mod = $modules[$item->module]; diff --git a/public/admin/tool/recyclebin/tests/behat/basic_functionality.feature b/public/admin/tool/recyclebin/tests/behat/basic_functionality.feature index 307b328e49e84..9da6c2af88c07 100644 --- a/public/admin/tool/recyclebin/tests/behat/basic_functionality.feature +++ b/public/admin/tool/recyclebin/tests/behat/basic_functionality.feature @@ -11,9 +11,9 @@ Feature: Basic recycle bin functionality | student1 | Student | 1 | student@asd.com | | student2 | Student | 2 | student2@asd.com | And the following "courses" exist: - | fullname | shortname | initsections | - | Course 1 | C1 | 1 | - | Course 2 | C2 | 0 | + | fullname | shortname | initsections | + | Course 1 | C1 | 1 | + | CourseCurso 2 | C2 | 0 | And the following "activities" exist: | activity | course | section | name | intro | | assign | C1 | 1 | Test assign 1 | Test 1 | @@ -52,6 +52,8 @@ Feature: Basic recycle bin functionality | badge | role | | My course 1 badge | editingteacher | | My course 2 badge | editingteacher | + And the "multilang" filter is "on" + And the "multilang" filter applies to "content and headings" Scenario: Restore a deleted assignment Given I log in as "teacher1" @@ -89,13 +91,14 @@ Feature: Basic recycle bin functionality And I should not see "Course 2" When I navigate to "Recycle bin" in current page administration Then I should see "Course 2" + But I should not see "Curso 2" And I should see "Contents will be permanently deleted after 14 days" And I click on "Restore" "link" in the "region-main" "region" And I should see "'Course 2' has been restored" And I wait to be redirected And I go to the courses management page And I should see "Course 2" in the "#course-listing" "css_element" - And I am on the "Course 2" "groups overview" page + And I am on the "C2" "groups overview" page And "Student 1" "text" should exist in the "Group A" "table_row" And "Student 2" "text" should exist in the "Group A" "table_row" And "Student 2" "text" should exist in the "Group B" "table_row" diff --git a/public/admin/tool/uploadcourse/classes/course.php b/public/admin/tool/uploadcourse/classes/course.php index b4a35053d3a54..eee79bb03d695 100644 --- a/public/admin/tool/uploadcourse/classes/course.php +++ b/public/admin/tool/uploadcourse/classes/course.php @@ -100,6 +100,9 @@ class tool_uploadcourse_course { /** @var int update mode. Matches tool_uploadcourse_processor::UPDATE_* */ protected $updatemode; + /** @var array Fields provided in the CSV that should not be overwritten from the template course. */ + protected $skiptemplatefields = []; + /** @var array fields allowed as course data. */ static protected $validfields = array('fullname', 'shortname', 'idnumber', 'category', 'visible', 'startdate', 'enddate', 'summary', 'format', 'theme', 'lang', 'newsitems', 'showgrades', 'showreports', 'legacyfiles', 'maxbytes', @@ -485,7 +488,10 @@ public function prepare() { foreach ($this->rawdata as $field => $value) { if (!in_array($field, self::$validfields)) { continue; - } else if ($field == 'shortname') { + } + // Track fields provided in the CSV so they are not overwritten by template course values. + $this->skiptemplatefields[] = $field; + if ($field == 'shortname') { // Let's leave it apart from now, use $this->shortname only. continue; } @@ -911,8 +917,18 @@ public function proceed() { // Restore a course. if (!empty($this->restoredata)) { - $rc = new restore_controller($this->restoredata, $course->id, backup::INTERACTIVE_NO, - backup::MODE_IMPORT, $USER->id, backup::TARGET_CURRENT_ADDING); + $rc = new restore_controller( + $this->restoredata, + $course->id, + backup::INTERACTIVE_NO, + backup::MODE_IMPORT, + $USER->id, + backup::TARGET_CURRENT_ADDING, + null, + null, + null, + $this->skiptemplatefields + ); // Check if the format conversion must happen first. if ($rc->get_status() == backup::STATUS_REQUIRE_CONV) { diff --git a/public/admin/tool/uploadcourse/classes/processor.php b/public/admin/tool/uploadcourse/classes/processor.php index bbd2ddaec2319..309d7551d5485 100644 --- a/public/admin/tool/uploadcourse/classes/processor.php +++ b/public/admin/tool/uploadcourse/classes/processor.php @@ -246,7 +246,7 @@ protected function get_course($data) { 'canreset' => $this->allowresets, 'reset' => $this->reset, 'restoredir' => $this->get_restore_content_dir(), - 'shortnametemplate' => $this->shortnametemplate + 'shortnametemplate' => $this->shortnametemplate, ); return new tool_uploadcourse_course($this->mode, $this->updatemode, $data, $this->defaults, $importoptions); } diff --git a/public/admin/tool/uploadcourse/tests/behat/cohorts.feature b/public/admin/tool/uploadcourse/tests/behat/cohorts.feature index a10963623289e..9fb49aea6d929 100644 --- a/public/admin/tool/uploadcourse/tests/behat/cohorts.feature +++ b/public/admin/tool/uploadcourse/tests/behat/cohorts.feature @@ -138,7 +138,6 @@ Feature: An admin can create courses with cohort enrolments using a CSV file And I upload "admin/tool/uploadcourse/tests/fixtures/enrolment_cohort_multiple.csv" file to "File" filemanager And I click on "Preview" "button" And I click on "Upload courses" "button" - When I am on the "Course 1" "course" page - And I navigate to course participants + And I am on the "Course 1" "enrolled users" page Then I should see "Non-editing teacher" in the "Teacher 1" "table_row" And I should not see "Student" in the "Teacher 1" "table_row" diff --git a/public/admin/tool/uploadcourse/tests/course_test.php b/public/admin/tool/uploadcourse/tests/course_test.php index 60dfa4f208c54..1b9631a5a6d9c 100644 --- a/public/admin/tool/uploadcourse/tests/course_test.php +++ b/public/admin/tool/uploadcourse/tests/course_test.php @@ -956,6 +956,99 @@ public function test_restore_course(): void { $this->assertTrue($found); } + /** + * Test that template course summary and overview files are copied to the new course + * when no explicit values are provided in the CSV. + * + * @covers \tool_uploadcourse_course::proceed + */ + public function test_upload_course_imports_template_summary_and_overviewfiles(): void { + global $DB; + $this->initialise_test(); + $this->setAdminUser(); + + $templatesummary = 'This is the template course summary'; + $c1 = $this->getDataGenerator()->create_course(['summary' => $templatesummary, 'summaryformat' => FORMAT_HTML]); + $c1context = \context_course::instance($c1->id); + + $fs = get_file_storage(); + $overviewfile = $fs->create_file_from_string( + [ + 'contextid' => $c1context->id, + 'component' => 'course', + 'filearea' => 'overviewfiles', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'template-course-image.png', + ], + 'fake image content' + ); + + $mode = tool_uploadcourse_processor::MODE_CREATE_NEW; + $updatemode = tool_uploadcourse_processor::UPDATE_ALL_WITH_DATA_ONLY; + // Deliberately omit summary so the template course's summary is copied. + $data = [ + 'shortname' => 'A1', + 'templatecourse' => $c1->shortname, + 'category' => 1, + 'fullname' => 'A1', + ]; + $co = new tool_uploadcourse_course($mode, $updatemode, $data); + $this->assertTrue($co->prepare()); + $co->proceed(); + + $course = $DB->get_record('course', ['shortname' => 'A1'], '*', MUST_EXIST); + $this->assertEquals($templatesummary, $course->summary); + + $newcontext = \context_course::instance($course->id); + $newoverviewfiles = $fs->get_area_files($newcontext->id, 'course', 'overviewfiles', 0, 'filename', false); + $this->assertCount(1, $newoverviewfiles); + $newoverviewfile = reset($newoverviewfiles); + $this->assertEquals($overviewfile->get_filename(), $newoverviewfile->get_filename()); + $this->assertEquals($overviewfile->get_content(), $newoverviewfile->get_content()); + } + + /** + * Test that explicit course values provided in the CSV are not overwritten by + * corresponding values from the template course. + * + * @covers \tool_uploadcourse_course::proceed + */ + public function test_upload_course_keeps_explicit_values_over_template(): void { + global $DB; + + $this->initialise_test(); + $this->setAdminUser(); + + $templatecourse = $this->getDataGenerator()->create_course([ + 'format' => 'weeks', + 'summary' => 'Template summary', + 'summaryformat' => FORMAT_HTML, + ]); + + $mode = tool_uploadcourse_processor::MODE_CREATE_NEW; + $updatemode = tool_uploadcourse_processor::UPDATE_ALL_WITH_DATA_ONLY; + + // Explicitly provide values, which should take priority over the template course values. + $data = [ + 'shortname' => 'A1', + 'fullname' => 'A1', + 'category' => 1, + 'templatecourse' => $templatecourse->shortname, + 'format' => 'topics', + 'summary' => 'Explicit CSV summary', + ]; + + $co = new tool_uploadcourse_course($mode, $updatemode, $data); + $this->assertTrue($co->prepare()); + $co->proceed(); + + $course = $DB->get_record('course', ['shortname' => 'A1'], '*', MUST_EXIST); + + $this->assertSame('topics', $course->format); + $this->assertSame('Explicit CSV summary', $course->summary); + } + public function test_restore_file(): void { global $DB; $this->initialise_test(); diff --git a/public/admin/tool/uploaduser/classes/process.php b/public/admin/tool/uploaduser/classes/process.php index 70928dd2bc48d..330b9af442bff 100644 --- a/public/admin/tool/uploaduser/classes/process.php +++ b/public/admin/tool/uploaduser/classes/process.php @@ -102,6 +102,8 @@ class process { protected $manualcache = []; /** @var array officially supported plugins that are enabled */ protected $supportedauths = []; + /** @var array Track unique profile field values within the current import */ + protected $profilefieldvaluesinfile = []; /** * process constructor. @@ -612,6 +614,21 @@ public function process_line(array $line) { // We do not need the deleted flag anymore. unset($user->deleted); + // Validate custom profile fields data before processing. + $rowcols = (array) $user; + $rowcols['status'] = []; + unset($rowcols['id']); // Prevent CSV-supplied id from leaking in. + if ($existinguser) { + $rowcols['id'] = $existinguser->id; + } + if (!uu_check_custom_profile_data($rowcols, $this->profilefieldvaluesinfile)) { + foreach ($rowcols['status'] as $status) { + $this->upt->track('status', $status, 'error'); + } + $this->userserrors++; + return; + } + $matchonemailallowrename = $this->get_match_on_email() && $this->get_allow_renames(); if ($matchonemailallowrename && $user->username && ($user->username !== $existinguser->username)) { $user->oldusername = $existinguser->username; diff --git a/public/admin/tool/uploaduser/index.php b/public/admin/tool/uploaduser/index.php index 325e24d626591..710f53076e26f 100644 --- a/public/admin/tool/uploaduser/index.php +++ b/public/admin/tool/uploaduser/index.php @@ -104,11 +104,6 @@ die; } -// Print the header. -echo $OUTPUT->header(); - -echo $OUTPUT->heading(get_string('uploaduserspreview', 'tool_uploaduser')); - // NOTE: this is JUST csv processing preview, we must not prevent import from here if there is something in the file!! // this was intended for validation of csv formatting and encoding, not filtering the data!!!! // we definitely must not process the whole file! @@ -116,11 +111,16 @@ // Preview table data. $table = new \tool_uploaduser\preview($cir, $filecolumns, $previewrows); -echo html_writer::tag('div', html_writer::table($table), ['class' => 'flexible-wrap']); +// Print the header. +echo $OUTPUT->header(); -// Print the form if valid values are available. -if ($table->get_no_error()) { - $mform2->display(); +// Display preview table and show warning if CSV contains errors. +if (!$table->get_no_error()) { + echo $OUTPUT->notification(get_string('csvcontainserrors', 'tool_uploaduser'), 'warning'); } +echo $OUTPUT->heading(get_string('uploaduserspreview', 'tool_uploaduser')); +echo html_writer::tag('div', html_writer::table($table), ['class' => 'flexible-wrap']); + +$mform2->display(); echo $OUTPUT->footer(); die; diff --git a/public/admin/tool/uploaduser/lang/en/tool_uploaduser.php b/public/admin/tool/uploaduser/lang/en/tool_uploaduser.php index 8af25b2109b11..4501a10fb6435 100644 --- a/public/admin/tool/uploaduser/lang/en/tool_uploaduser.php +++ b/public/admin/tool/uploaduser/lang/en/tool_uploaduser.php @@ -35,10 +35,12 @@ $string['climissingargument'] = 'Argument --{$a} is required'; $string['clititle'] = 'Command line Upload user tool.'; $string['clivalidationerror'] = 'Validation error:'; +$string['csvcontainserrors'] = 'Errors were found in the CSV file. Invalid rows will be skipped.'; $string['csvdelimiter'] = 'CSV separator'; $string['defaultvalues'] = 'Default values'; $string['deleteerrors'] = 'Delete errors'; $string['duplicateemail'] = 'Multiple users with email {$a} detected'; +$string['duplicatevalueupload'] = 'This value has already been used in the uploaded users file.'; $string['encoding'] = 'Encoding'; $string['errormnetadd'] = 'Can not add remote users'; $string['errorprefix'] = 'Error:'; diff --git a/public/admin/tool/uploaduser/locallib.php b/public/admin/tool/uploaduser/locallib.php index 6b8c88e19b363..be720afe4611c 100644 --- a/public/admin/tool/uploaduser/locallib.php +++ b/public/admin/tool/uploaduser/locallib.php @@ -481,7 +481,11 @@ function uu_check_custom_profile_data(&$data, array &$profilefieldvalues = []) { $noerror = true; $testuserid = null; - if (!empty($data['username'])) { + // Allow callers (e.g. process.php) to supply the existing user ID directly. + if (!empty($data['id'])) { + $testuserid = $data['id']; + } else if (!empty($data['username'])) { + // Fallback: preview.php wraps the username in an HTML anchor whose href contains the user ID, so parse it out. if (preg_match('/id=(.*)"/i', $data['username'], $result)) { $testuserid = $result[1]; } @@ -504,7 +508,7 @@ function uu_check_custom_profile_data(&$data, array &$profilefieldvalues = []) { if ($formfieldunique && array_key_exists($shortname, $profilefieldvalues) && (array_search($value, $profilefieldvalues[$shortname]) !== false)) { - $data['status'][] = get_string('valuealreadyused') . " ({$key})"; + $data['status'][] = get_string('duplicatevalueupload', 'tool_uploaduser') . " ({$key})"; $noerror = false; } diff --git a/public/admin/tool/uploaduser/tests/behat/upload_users.feature b/public/admin/tool/uploaduser/tests/behat/upload_users.feature index ff705552e94d8..d9530831ab1e1 100644 --- a/public/admin/tool/uploaduser/tests/behat/upload_users.feature +++ b/public/admin/tool/uploaduser/tests/behat/upload_users.feature @@ -169,8 +169,7 @@ Feature: Upload users Then I should see "Upload users preview" And I press "Upload users" # Check user enrolment start date and period - And I am on "Maths" course homepage - Then I navigate to course participants + And I am on the "Maths" "enrolled users" page And I click on "Manual enrolments" "link" in the "Student One" "table_row" Then I should see "1 January 2019" in the "Enrolment starts" "table_row" And I should not see "Enrolment ends" diff --git a/public/admin/tool/uploaduser/tests/upload_users_test.php b/public/admin/tool/uploaduser/tests/upload_users_test.php index ce25060ad40d5..f0362605efc2c 100644 --- a/public/admin/tool/uploaduser/tests/upload_users_test.php +++ b/public/admin/tool/uploaduser/tests/upload_users_test.php @@ -239,4 +239,93 @@ protected function process_csv_upload(string $filecontent, array $mockargv = []) return $output; } + + /** + * Test that uploading users respects unique custom profile field constraints: + * - Users with different unique values are all created. + * - Duplicate values within the same CSV are rejected (only first is created). + * - Values that already exist in the database are rejected. + * - Updating a user with their own existing unique value is not rejected as a duplicate. + * + * @covers \tool_uploaduser\process::process_line + */ + public function test_upload_users_unique_profile_field_no_duplicates(): void { + global $DB; + + $this->resetAfterTest(); + set_config('passwordpolicy', 0); + $this->setAdminUser(); + + // Create a unique custom profile field. + $this->getDataGenerator()->create_custom_profile_field([ + 'shortname' => 'uniquecode', + 'name' => 'Unique Code', + 'datatype' => 'text', + 'forceunique' => 1, + ]); + + // 1. Upload users with different unique values — all should be created. + $csv = <<process_csv_upload($csv, ['--uutype=' . UU_USER_ADDNEW]); + + $user1 = $DB->get_record('user', ['username' => 'user1']); + $this->assertNotEmpty($user1, 'User1 should be created'); + $this->assertEquals('CODE001', profile_user_record($user1->id)->uniquecode); + + $user2 = $DB->get_record('user', ['username' => 'user2']); + $this->assertNotEmpty($user2, 'User2 should be created'); + $this->assertEquals('CODE002', profile_user_record($user2->id)->uniquecode); + + // 2. Upload users where two rows share the same unique value — only the first should be created. + $csv = <<process_csv_upload($csv, ['--uutype=' . UU_USER_ADDNEW]); + + $user3 = $DB->get_record('user', ['username' => 'user3']); + $this->assertNotEmpty($user3, 'User3 should be created (first with CODE003)'); + $this->assertEquals('CODE003', profile_user_record($user3->id)->uniquecode); + + $user4 = $DB->get_record('user', ['username' => 'user4']); + $this->assertEmpty($user4, 'User4 should not be created (duplicate CODE003 in CSV)'); + $this->assertStringContainsString('This value has already been used in the uploaded users file.', $output); + $this->assertStringContainsString('This value has already been used.', $output); + + // 3. Upload a new user with a value that already exists in the database — should be rejected. + $csv = <<process_csv_upload($csv, ['--uutype=' . UU_USER_ADDNEW]); + + $user5 = $DB->get_record('user', ['username' => 'user5']); + $this->assertEmpty($user5, 'User5 should not be created (CODE001 already exists in DB)'); + $this->assertStringContainsString('This value has already been used.', $output); + + // 4. Update an existing user re-supplying their own unique value — must not be rejected as a duplicate. + $csv = <<process_csv_upload($csv, ['--uutype=' . UU_USER_UPDATE, '--uuupdatetype=' . UU_UPDATE_FILEOVERRIDE]); + + $user1 = $DB->get_record('user', ['username' => 'user1']); + $this->assertEquals('Updated', $user1->lastname, 'User1 lastname should be updated'); + $this->assertEquals('CODE001', profile_user_record($user1->id)->uniquecode); + $this->assertStringNotContainsString( + 'This value has already been used.', + $output, + 'Updating a user with their own unique value must not produce a duplicate error' + ); + } } diff --git a/public/admin/tool/usertours/classes/local/forms/editstep.php b/public/admin/tool/usertours/classes/local/forms/editstep.php index b36ef1f57fcbe..e7253fe50378a 100644 --- a/public/admin/tool/usertours/classes/local/forms/editstep.php +++ b/public/admin/tool/usertours/classes/local/forms/editstep.php @@ -170,7 +170,7 @@ public function validation($data, $files): array { ]; $value = preg_replace($stripvalues, '', (string)$value); if (empty($value)) { - $errors['contenthtmlgrp'] = get_string('required'); + $errors['content'] = get_string('required'); } } diff --git a/public/ai/classes/aiactions/responses/response_explain_text.php b/public/ai/classes/aiactions/responses/response_explain_text.php index 9a62055813a7e..95dddc3203a63 100644 --- a/public/ai/classes/aiactions/responses/response_explain_text.php +++ b/public/ai/classes/aiactions/responses/response_explain_text.php @@ -16,6 +16,8 @@ namespace core_ai\aiactions\responses; +use core_ai\helper; + /** * Explain text action response class. * @@ -71,7 +73,10 @@ public function __construct( public function set_response_data(array $response): void { $this->id = $response['id'] ?? null; $this->fingerprint = $response['fingerprint'] ?? null; - $this->generatedcontent = $response['generatedcontent'] ?? null; + $generatedcontent = $response['generatedcontent'] ?? null; + $this->generatedcontent = $generatedcontent !== null + ? helper::strip_reasoning_tags($generatedcontent) + : null; $this->finishreason = $response['finishreason'] ?? null; $this->prompttokens = $response['prompttokens'] ?? null; $this->completiontokens = $response['completiontokens'] ?? null; diff --git a/public/ai/classes/aiactions/responses/response_generate_text.php b/public/ai/classes/aiactions/responses/response_generate_text.php index 1acf34962b1f0..c510d7995aeb5 100644 --- a/public/ai/classes/aiactions/responses/response_generate_text.php +++ b/public/ai/classes/aiactions/responses/response_generate_text.php @@ -16,6 +16,8 @@ namespace core_ai\aiactions\responses; +use core_ai\helper; + /** * Generate text action response class. * @@ -71,7 +73,10 @@ public function __construct( public function set_response_data(array $response): void { $this->id = $response['id'] ?? null; $this->fingerprint = $response['fingerprint'] ?? null; - $this->generatedcontent = $response['generatedcontent'] ?? null; + $generatedcontent = $response['generatedcontent'] ?? null; + $this->generatedcontent = $generatedcontent !== null + ? helper::strip_reasoning_tags($generatedcontent) + : null; $this->finishreason = $response['finishreason'] ?? null; $this->prompttokens = $response['prompttokens'] ?? null; $this->completiontokens = $response['completiontokens'] ?? null; diff --git a/public/ai/classes/aiactions/responses/response_summarise_text.php b/public/ai/classes/aiactions/responses/response_summarise_text.php index d340163df55a8..3705d37db5d8c 100644 --- a/public/ai/classes/aiactions/responses/response_summarise_text.php +++ b/public/ai/classes/aiactions/responses/response_summarise_text.php @@ -16,6 +16,8 @@ namespace core_ai\aiactions\responses; +use core_ai\helper; + /** * Summarise text action response class. * @@ -71,7 +73,10 @@ public function __construct( public function set_response_data(array $response): void { $this->id = $response['id'] ?? null; $this->fingerprint = $response['fingerprint'] ?? null; - $this->generatedcontent = $response['generatedcontent'] ?? null; + $generatedcontent = $response['generatedcontent'] ?? null; + $this->generatedcontent = $generatedcontent !== null + ? helper::strip_reasoning_tags($generatedcontent) + : null; $this->finishreason = $response['finishreason'] ?? null; $this->prompttokens = $response['prompttokens'] ?? null; $this->completiontokens = $response['completiontokens'] ?? null; diff --git a/public/ai/classes/helper.php b/public/ai/classes/helper.php new file mode 100644 index 0000000000000..e8603e169a7ac --- /dev/null +++ b/public/ai/classes/helper.php @@ -0,0 +1,53 @@ +. + +namespace core_ai; + +/** + * AI helper class. + * + * @package core_ai + * @copyright 2026 Muhammad Arnaldo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class helper { + /** + * Reasoning tag names to strip from AI-generated text. + * + * Add new tag names here when additional AI models are found to include + * reasoning content in their responses. + * + * @var string[] + */ + public const REASONING_TAGS = [ + 'think', + ]; + + /** + * Strip reasoning tags from AI-generated content. + * + * Some AI models include reasoning or chain-of-thought content wrapped in + * XML-like tags (e.g. ...). This method removes those tags + * and their content so only the final response is returned to the user. + * + * @param string $content The AI-generated content. + * @return string The content with reasoning tags removed. + */ + public static function strip_reasoning_tags(string $content): string { + $pattern = implode('|', array_map('preg_quote', self::REASONING_TAGS)); + return trim(preg_replace('/<(' . $pattern . ')>.*?<\/\1>\s*/is', '', $content) ?? $content); + } +} diff --git a/public/ai/classes/manager.php b/public/ai/classes/manager.php index ec1339726c4bd..fa786c86466bc 100644 --- a/public/ai/classes/manager.php +++ b/public/ai/classes/manager.php @@ -69,6 +69,62 @@ public static function get_supported_actions(string $pluginname): array { return $pluginclassname::get_action_list(); } + /** + * Get the enabled AI placements available in a context. + * + * @param \context $context The context. + * @return array An array of placement class names indexed by component name. + */ + public static function get_placements_available_in_context(\context $context): array { + $placements = []; + foreach (static::get_enabled_placements() as $component => $classname) { + if ($classname::is_available_in_context($context)) { + $placements[$component] = $classname; + } + } + + return $placements; + } + + /** + * Get all enabled AI placements. + * + * This is useful where a placement must be selected before a concrete + * context exists, for example while creating a course. + * + * @return array An array of placement class names indexed by component name. + */ + public static function get_enabled_placements(): array { + $placements = []; + foreach (\core_plugin_manager::instance()->get_plugins_of_type('aiplacement') as $placement) { + if (!$placement->is_enabled()) { + continue; + } + + $component = 'aiplacement_' . $placement->name; + $placements[$component] = static::get_ai_plugin_classname($component); + } + + return $placements; + } + + /** + * Get the available actions from all enabled AI placements. + * + * @param \context $context The context. + * @param bool $checkcontext Whether to check the action is available in the context. + * @return array The available actions. + */ + public static function get_placement_actions_available(\context $context, bool $checkcontext = true): array { + $actions = []; + + foreach (static::get_placements_available_in_context($context) as $placement) { + $actions = array_merge($actions, $placement::get_actions_available($context, $checkcontext)); + } + + return $actions; + } + /** * Given a list of actions get the provider instances that support them. * diff --git a/public/ai/classes/placement.php b/public/ai/classes/placement.php index a067b5c90e658..4d6f0f0c85ba0 100644 --- a/public/ai/classes/placement.php +++ b/public/ai/classes/placement.php @@ -33,6 +33,31 @@ abstract class placement { */ abstract public static function get_action_list(): array; + /** + * Check whether this placement is available in a context. + * + * Placement plugins should override this method when they provide actions + * for a context. The default keeps existing placements compatible until + * they opt in to the context-aware placement API. + * + * @param \context $context The context to check. + * @return bool Whether the placement is available in the context. + */ + public static function is_available_in_context(\context $context): bool { + return false; + } + + /** + * Get the available actions for this placement in a context. + * + * @param \context $context The context. + * @param bool $checkcontext Whether to check the action is enabled in the context. + * @return array The available actions. + */ + public static function get_actions_available(\context $context, bool $checkcontext = true): array { + return []; + } + /** * Given an action class name. * diff --git a/public/ai/configure_actions.php b/public/ai/configure_actions.php index 98852a126a4a5..73a0850d8b84f 100644 --- a/public/ai/configure_actions.php +++ b/public/ai/configure_actions.php @@ -57,13 +57,13 @@ $urlparams = [ 'provider' => $provider, 'action' => $action, - 'id' => $id, + 'providerid' => $id, ]; // Page setup. $title = get_string('actionsettingprovider', 'core_ai', $action::get_name()); $PAGE->set_context($context); -$PAGE->set_url('/ai/configure.php_actions', $urlparams); +$PAGE->set_url('/ai/configure_actions.php', $urlparams); $PAGE->set_pagelayout('admin'); $PAGE->set_title($title); $PAGE->set_heading($title); diff --git a/public/ai/placement/courseassist/amd/build/placement.min.js b/public/ai/placement/courseassist/amd/build/placement.min.js index ee48616ed3b44..566f93549f472 100644 --- a/public/ai/placement/courseassist/amd/build/placement.min.js +++ b/public/ai/placement/courseassist/amd/build/placement.min.js @@ -1,3 +1,3 @@ -define("aiplacement_courseassist/placement",["exports","core/templates","core/ajax","core/copy_to_clipboard","core/notification","aiplacement_courseassist/selectors","core_ai/policy","core_ai/helper","core/drawer_events","core/pubsub","core_message/message_drawer_helper","core/str","core/local/aria/focuslock","core/pagehelpers"],(function(_exports,_templates,_ajax,_copy_to_clipboard,_notification,_selectors,_policy,_helper,_drawer_events,_pubsub,MessageDrawerHelper,_str,FocusLock,_pagehelpers){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_templates=_interopRequireDefault(_templates),_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification),_selectors=_interopRequireDefault(_selectors),_policy=_interopRequireDefault(_policy),_helper=_interopRequireDefault(_helper),_drawer_events=_interopRequireDefault(_drawer_events),MessageDrawerHelper=_interopRequireWildcard(MessageDrawerHelper),FocusLock=_interopRequireWildcard(FocusLock);var _default=class{constructor(userId,contextId){_defineProperty(this,"userId",void 0),_defineProperty(this,"contextId",void 0),this.userId=userId,this.contextId=contextId,this.aiDrawerElement=document.querySelector(_selectors.default.ELEMENTS.AIDRAWER),this.aiDrawerBodyElement=document.querySelector(_selectors.default.ELEMENTS.AIDRAWER_BODY),this.pageElement=document.querySelector(_selectors.default.ELEMENTS.PAGE),this.jumpToElement=document.querySelector(_selectors.default.ELEMENTS.JUMPTO),this.actionElement=document.querySelector(_selectors.default.ELEMENTS.ACTION),this.aiDrawerCloseElement=this.aiDrawerElement.querySelector(_selectors.default.ELEMENTS.AIDRAWER_CLOSE),this.lastAction="",this.responses=new Map,this.isDrawerFocusLocked=!1,this.registerEventListeners()}registerEventListeners(){document.addEventListener("click",(async e=>{if(e.target.closest(_selectors.default.ACTIONS.SUMMARY)){e.preventDefault(),this.openAIDrawer(),this.lastAction="summarise_text",this.actionElement.focus();if(!await this.isPolicyAccepted())return void this.displayPolicy();this.displayAction(this.lastAction)}if(e.target.closest(_selectors.default.ACTIONS.EXPLAIN)){e.preventDefault(),this.openAIDrawer(),this.lastAction="explain_text",this.actionElement.focus();if(!await this.isPolicyAccepted())return void this.displayPolicy();this.displayAction(this.lastAction)}e.target.closest(_selectors.default.ELEMENTS.AIDRAWER_CLOSE)&&(e.preventDefault(),this.closeAIDrawer())})),document.addEventListener("keydown",(e=>{this.isAIDrawerOpen()&&"Escape"===e.key&&this.closeAIDrawer()})),(0,_pubsub.subscribe)(_drawer_events.default.DRAWER_SHOWN,(()=>{this.isAIDrawerOpen()&&this.closeAIDrawer()})),this.jumpToElement&&this.jumpToElement.addEventListener("focus",(()=>{this.aiDrawerCloseElement.focus()})),this.aiDrawerElement.addEventListener("focus",(()=>{this.actionElement.focus()})),this.actionElement&&this.actionElement.addEventListener("blur",(()=>{this.actionElement.classList.remove("active")}))}registerPolicyEventListeners(){const acceptAction=document.querySelector(_selectors.default.ACTIONS.ACCEPT),declineAction=document.querySelector(_selectors.default.ACTIONS.DECLINE);acceptAction&&this.lastAction.length&&acceptAction.addEventListener("click",(e=>{e.preventDefault(),this.acceptPolicy().then((()=>this.displayAction(this.lastAction))).catch(_notification.default.exception)})),declineAction&&declineAction.addEventListener("click",(e=>{e.preventDefault(),this.closeAIDrawer()}))}registerErrorEventListeners(){const retryAction=document.querySelector(_selectors.default.ACTIONS.RETRY);retryAction&&this.lastAction.length&&retryAction.addEventListener("click",(e=>{e.preventDefault(),this.displayAction(this.lastAction)}))}registerResponseEventListeners(){document.querySelectorAll(_selectors.default.ACTIONS.REGENERATE).forEach((regenerateAction=>{const responseElement=regenerateAction.closest(_selectors.default.ELEMENTS.RESPONSE);if(regenerateAction&&responseElement){const actionPerformed=responseElement.getAttribute("data-action-performed");regenerateAction.addEventListener("click",(e=>{e.preventDefault(),this.removeResponseFromStack(actionPerformed),this.displayAction(actionPerformed)}))}}))}registerLoadingEventListeners(){const cancelAction=document.querySelector(_selectors.default.ACTIONS.CANCEL);cancelAction&&cancelAction.addEventListener("click",(e=>{e.preventDefault(),this.setRequestCancelled(),this.toggleAIDrawer(),this.removeResponseFromStack("loading");const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses}))}isAIDrawerOpen(){return this.aiDrawerElement.classList.contains("show")}isRequestCancelled(){return"1"===this.aiDrawerBodyElement.dataset.cancelled}setRequestCancelled(){this.aiDrawerBodyElement.dataset.cancelled="1"}openAIDrawer(){MessageDrawerHelper.hide(),this.aiDrawerElement.classList.add("show"),this.aiDrawerElement.setAttribute("tabindex",0),this.aiDrawerBodyElement.setAttribute("aria-live","polite"),this.pageElement.classList.contains("show-drawer-right")||this.addPadding(),this.jumpToElement.setAttribute("tabindex",0),this.jumpToElement.focus(),(0,_pagehelpers.isSmall)()&&(FocusLock.trapFocus(this.aiDrawerElement),this.aiDrawerElement.setAttribute("aria-modal","true"),this.aiDrawerElement.setAttribute("role","dialog"),this.isDrawerFocusLocked=!0)}closeAIDrawer(){this.isDrawerFocusLocked&&(FocusLock.untrapFocus(),this.aiDrawerElement.removeAttribute("aria-modal"),this.aiDrawerElement.setAttribute("role","region")),this.aiDrawerElement.classList.remove("show"),this.aiDrawerElement.setAttribute("tabindex",-1),this.aiDrawerBodyElement.removeAttribute("aria-live"),this.pageElement.classList.contains("show-drawer-right")&&"1"===this.aiDrawerBodyElement.dataset.removepadding&&this.removePadding(),this.jumpToElement.setAttribute("tabindex",-1),this.actionElement.classList.add("active"),this.actionElement.focus()}toggleAIDrawer(){this.isAIDrawerOpen()?this.closeAIDrawer():this.openAIDrawer()}addPadding(){this.pageElement.classList.add("show-drawer-right"),this.aiDrawerBodyElement.dataset.removepadding="1"}removePadding(){this.pageElement.classList.remove("show-drawer-right"),this.aiDrawerBodyElement.dataset.removepadding="0"}async getParamsForAction(action){let params={};switch(action){case"summarise_text":params.method="aiplacement_courseassist_summarise_text",params.heading=await(0,_str.getString)("aisummary","aiplacement_courseassist");break;case"explain_text":params.method="aiplacement_courseassist_explain_text",params.heading=await(0,_str.getString)("aiexplain","aiplacement_courseassist")}return params}async isPolicyAccepted(){return await _policy.default.getPolicyStatus(this.userId)}acceptPolicy(){return _policy.default.acceptPolicy()}hasGeneratedContent(action){return this.responses.has(action)}displayPolicy(){_templates.default.render("core_ai/policyblock",{}).then((html=>{this.aiDrawerBodyElement.innerHTML=html,this.registerPolicyEventListeners()})).catch(_notification.default.exception)}displayLoading(){_templates.default.render("aiplacement_courseassist/loading",{}).then((html=>{this.addResponseToStack("loading",html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerLoadingEventListeners()})).then((()=>{this.removeResponseFromStack("loading")})).catch(_notification.default.exception)}async displayAction(action){if(this.hasGeneratedContent(action)){const existingReponse=document.querySelector('[data-action-performed="'+action+'"]');existingReponse&&(this.aiDrawerBodyElement.scrollTop=existingReponse.offsetTop)}else{this.displayLoading(),this.aiDrawerBodyElement.innerHTML="";const request={methodname:(await this.getParamsForAction(action)).method,args:{contextid:this.contextId,prompttext:this.getTextContent()}};try{const responseObj=await _ajax.default.call([request])[0];if(responseObj.error)return void this.displayError(responseObj.error,responseObj.errormessage);if(!this.isRequestCancelled()){const generatedContent=_helper.default.formatResponse(responseObj.generatedcontent);return void this.displayResponse(generatedContent,action)}this.aiDrawerBodyElement.dataset.cancelled="0"}catch(error){window.console.log(error),this.displayError()}}}addResponseToStack(action,html){this.responses.set(action,html)}removeResponseFromStack(action){this.responses.has(action)&&this.responses.delete(action)}getResponseStack(){let stack="";const responses=[...this.responses.values()].reverse();for(const response of responses)stack+=response;return stack}async displayResponse(content,action){const args={content:content,heading:(await this.getParamsForAction(action)).heading,action:action};_templates.default.render("aiplacement_courseassist/response",args).then((html=>{this.addResponseToStack(action,html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerResponseEventListeners()})).catch(_notification.default.exception)}async displayError(){let error=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",errorMessage=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";error||(error=await(0,_str.getString)("error:defaultname","core_ai"),errorMessage=await(0,_str.getString)("error:defaultmessage","core_ai")),_templates.default.render("aiplacement_courseassist/error",{error:error,errorMessage:errorMessage}).then((html=>{this.addResponseToStack("error",html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerErrorEventListeners()})).then((()=>{this.removeResponseFromStack("error")})).catch(_notification.default.exception)}getTextContent(){const mainRegion=document.querySelector(_selectors.default.ELEMENTS.MAIN_REGION);return mainRegion.innerText||mainRegion.textContent}};return _exports.default=_default,_exports.default})); +define("aiplacement_courseassist/placement",["exports","core/templates","core/ajax","core/copy_to_clipboard","core/notification","aiplacement_courseassist/selectors","core_ai/policy","core_ai/helper","core/drawer_events","core/pubsub","core_message/message_drawer_helper","core/str","core/local/aria/focuslock","core/pagehelpers"],(function(_exports,_templates,_ajax,_copy_to_clipboard,_notification,_selectors,_policy,_helper,_drawer_events,_pubsub,MessageDrawerHelper,_str,FocusLock,_pagehelpers){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_templates=_interopRequireDefault(_templates),_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification),_selectors=_interopRequireDefault(_selectors),_policy=_interopRequireDefault(_policy),_helper=_interopRequireDefault(_helper),_drawer_events=_interopRequireDefault(_drawer_events),MessageDrawerHelper=_interopRequireWildcard(MessageDrawerHelper),FocusLock=_interopRequireWildcard(FocusLock);var _default=class{constructor(userId,contextId){_defineProperty(this,"userId",void 0),_defineProperty(this,"contextId",void 0),this.userId=userId,this.contextId=contextId,this.aiDrawerElement=document.querySelector(_selectors.default.ELEMENTS.AIDRAWER),this.aiDrawerBodyElement=document.querySelector(_selectors.default.ELEMENTS.AIDRAWER_BODY),this.pageElement=document.querySelector(_selectors.default.ELEMENTS.PAGE),this.jumpToElement=document.querySelector(_selectors.default.ELEMENTS.JUMPTO),this.actionElement=document.querySelector(_selectors.default.ELEMENTS.ACTION),this.aiDrawerCloseElement=this.aiDrawerElement.querySelector(_selectors.default.ELEMENTS.AIDRAWER_CLOSE),this.lastAction="",this.responses=new Map,this.isDrawerFocusLocked=!1,this.registerEventListeners()}registerEventListeners(){document.addEventListener("click",(async e=>{if(e.target.closest(_selectors.default.ACTIONS.SUMMARY)){e.preventDefault(),this.openAIDrawer(),this.lastAction="summarise_text",this.actionElement.focus();if(!await this.isPolicyAccepted())return void this.displayPolicy();this.displayAction(this.lastAction)}if(e.target.closest(_selectors.default.ACTIONS.EXPLAIN)){e.preventDefault(),this.openAIDrawer(),this.lastAction="explain_text",this.actionElement.focus();if(!await this.isPolicyAccepted())return void this.displayPolicy();this.displayAction(this.lastAction)}e.target.closest(_selectors.default.ELEMENTS.AIDRAWER_CLOSE)&&(e.preventDefault(),this.closeAIDrawer())})),document.addEventListener("keydown",(e=>{this.isAIDrawerOpen()&&"Escape"===e.key&&this.closeAIDrawer()})),(0,_pubsub.subscribe)(_drawer_events.default.DRAWER_SHOWN,(()=>{this.isAIDrawerOpen()&&this.closeAIDrawer()})),this.jumpToElement&&this.jumpToElement.addEventListener("focus",(()=>{this.aiDrawerCloseElement.focus()})),this.aiDrawerElement.addEventListener("focus",(()=>{this.actionElement.focus()})),this.actionElement&&this.actionElement.addEventListener("blur",(()=>{this.actionElement.classList.remove("active")}))}registerPolicyEventListeners(){const acceptAction=document.querySelector(_selectors.default.ACTIONS.ACCEPT),declineAction=document.querySelector(_selectors.default.ACTIONS.DECLINE);acceptAction&&this.lastAction.length&&acceptAction.addEventListener("click",(e=>{e.preventDefault(),this.acceptPolicy().then((()=>this.displayAction(this.lastAction))).catch(_notification.default.exception)})),declineAction&&declineAction.addEventListener("click",(e=>{e.preventDefault(),this.closeAIDrawer()}))}registerErrorEventListeners(){const retryAction=document.querySelector(_selectors.default.ACTIONS.RETRY);retryAction&&this.lastAction.length&&retryAction.addEventListener("click",(e=>{e.preventDefault(),this.displayAction(this.lastAction)}))}registerResponseEventListeners(){document.querySelectorAll(_selectors.default.ACTIONS.REGENERATE).forEach((regenerateAction=>{const responseElement=regenerateAction.closest(_selectors.default.ELEMENTS.RESPONSE);if(regenerateAction&&responseElement){const actionPerformed=responseElement.getAttribute("data-action-performed");regenerateAction.addEventListener("click",(e=>{e.preventDefault(),this.removeResponseFromStack(actionPerformed),this.displayAction(actionPerformed)}))}}))}registerLoadingEventListeners(){const cancelAction=document.querySelector(_selectors.default.ACTIONS.CANCEL);cancelAction&&cancelAction.addEventListener("click",(e=>{e.preventDefault(),this.setRequestCancelled(),this.toggleAIDrawer(),this.removeResponseFromStack("loading");const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses}))}isAIDrawerOpen(){return this.aiDrawerElement.classList.contains("show")}isRequestCancelled(){return"1"===this.aiDrawerBodyElement.dataset.cancelled}setRequestCancelled(){this.aiDrawerBodyElement.dataset.cancelled="1"}openAIDrawer(){MessageDrawerHelper.hide(),this.aiDrawerElement.classList.add("show"),this.aiDrawerElement.setAttribute("tabindex",0),this.aiDrawerBodyElement.setAttribute("aria-live","polite"),this.pageElement.classList.contains("show-drawer-right")||this.addPadding(),this.jumpToElement.setAttribute("tabindex",0),this.jumpToElement.focus(),(0,_pagehelpers.isSmall)()&&(FocusLock.trapFocus(this.aiDrawerElement),this.aiDrawerElement.setAttribute("aria-modal","true"),this.aiDrawerElement.setAttribute("role","dialog"),this.isDrawerFocusLocked=!0)}closeAIDrawer(){this.isDrawerFocusLocked&&(FocusLock.untrapFocus(),this.aiDrawerElement.removeAttribute("aria-modal"),this.aiDrawerElement.setAttribute("role","region")),this.aiDrawerElement.classList.remove("show"),this.aiDrawerElement.setAttribute("tabindex",-1),this.aiDrawerBodyElement.removeAttribute("aria-live"),this.pageElement.classList.contains("show-drawer-right")&&"1"===this.aiDrawerBodyElement.dataset.removepadding&&this.removePadding(),this.jumpToElement.setAttribute("tabindex",-1),this.actionElement.classList.add("active"),this.actionElement.focus()}toggleAIDrawer(){this.isAIDrawerOpen()?this.closeAIDrawer():this.openAIDrawer()}addPadding(){this.pageElement.classList.add("show-drawer-right"),this.aiDrawerBodyElement.dataset.removepadding="1"}removePadding(){this.pageElement.classList.remove("show-drawer-right"),this.aiDrawerBodyElement.dataset.removepadding="0"}async getParamsForAction(action){let params={};switch(action){case"summarise_text":params.method="aiplacement_courseassist_summarise_text",params.heading=await(0,_str.getString)("aisummary","aiplacement_courseassist"),params.copylabel=await(0,_str.getString)("copyaisummary","aiplacement_courseassist"),params.regeneratelabel=await(0,_str.getString)("regenerateaisummary","aiplacement_courseassist");break;case"explain_text":params.method="aiplacement_courseassist_explain_text",params.heading=await(0,_str.getString)("aiexplain","aiplacement_courseassist"),params.copylabel=await(0,_str.getString)("copyaiexplanation","aiplacement_courseassist"),params.regeneratelabel=await(0,_str.getString)("regenerateaiexplanation","aiplacement_courseassist")}return params}async isPolicyAccepted(){return await _policy.default.getPolicyStatus(this.userId)}acceptPolicy(){return _policy.default.acceptPolicy()}hasGeneratedContent(action){return this.responses.has(action)}displayPolicy(){_templates.default.render("core_ai/policyblock",{}).then((html=>{this.aiDrawerBodyElement.innerHTML=html,this.registerPolicyEventListeners()})).catch(_notification.default.exception)}displayLoading(){_templates.default.render("aiplacement_courseassist/loading",{}).then((html=>{this.addResponseToStack("loading",html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerLoadingEventListeners()})).then((()=>{this.removeResponseFromStack("loading")})).catch(_notification.default.exception)}async displayAction(action){if(this.hasGeneratedContent(action)){const existingReponse=document.querySelector('[data-action-performed="'+action+'"]');existingReponse&&(this.aiDrawerBodyElement.scrollTop=existingReponse.offsetTop)}else{const prompttext=this.getTextContent();this.aiDrawerBodyElement.innerHTML="";const params=await this.getParamsForAction(action);this.displayLoading();const request={methodname:params.method,args:{contextid:this.contextId,prompttext:prompttext}};try{const responseObj=await _ajax.default.call([request])[0];if(responseObj.error)return void this.displayError(responseObj.error,responseObj.errormessage);if(!this.isRequestCancelled()){const generatedContent=_helper.default.formatResponse(responseObj.generatedcontent);return void this.displayResponse(generatedContent,action)}this.aiDrawerBodyElement.dataset.cancelled="0"}catch(error){window.console.log(error),this.displayError()}}}addResponseToStack(action,html){this.responses.set(action,html)}removeResponseFromStack(action){this.responses.has(action)&&this.responses.delete(action)}getResponseStack(){let stack="";const responses=[...this.responses.values()].reverse();for(const response of responses)stack+=response;return stack}async displayResponse(content,action){const params=await this.getParamsForAction(action),args={content:content,heading:params.heading,action:action,copylabel:params.copylabel,regeneratelabel:params.regeneratelabel};_templates.default.render("aiplacement_courseassist/response",args).then((html=>{this.addResponseToStack(action,html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerResponseEventListeners()})).catch(_notification.default.exception)}async displayError(){let error=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",errorMessage=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";error||(error=await(0,_str.getString)("error:defaultname","core_ai"),errorMessage=await(0,_str.getString)("error:defaultmessage","core_ai")),_templates.default.render("aiplacement_courseassist/error",{error:error,errorMessage:errorMessage}).then((html=>{this.addResponseToStack("error",html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerErrorEventListeners()})).then((()=>{this.removeResponseFromStack("error")})).catch(_notification.default.exception)}getTextContent(){const mainRegion=document.querySelector(_selectors.default.ELEMENTS.MAIN_REGION);if(!mainRegion)return"";const aiElements=mainRegion.querySelectorAll("".concat(_selectors.default.ELEMENTS.AIDRAWER,", ").concat(_selectors.default.ELEMENTS.RESPONSE,", ").concat(_selectors.default.ELEMENTS.COURSE_ASSIST_CONTROLS)),previousDisplay=[];aiElements.forEach((element=>{previousDisplay.push(element.style.display),element.style.display="none"}));try{const rawText=mainRegion.innerText||mainRegion.textContent||"";return this.normalizePromptText(rawText)}finally{aiElements.forEach(((element,index)=>{element.style.display=previousDisplay[index]}))}}normalizePromptText(text){return text.replace(/\r\n/g,"\n").split("\n").map((line=>line.replace(/\s+/g," ").trim())).join("\n").replace(/\n{3,}/g,"\n\n").trim()}};return _exports.default=_default,_exports.default})); //# sourceMappingURL=placement.min.js.map \ No newline at end of file diff --git a/public/ai/placement/courseassist/amd/build/placement.min.js.map b/public/ai/placement/courseassist/amd/build/placement.min.js.map index a0dfdeee86447..e3d07de23b891 100644 --- a/public/ai/placement/courseassist/amd/build/placement.min.js.map +++ b/public/ai/placement/courseassist/amd/build/placement.min.js.map @@ -1 +1 @@ -{"version":3,"file":"placement.min.js","sources":["../src/placement.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 * Module to load and render the tools for the AI assist plugin.\n *\n * @module aiplacement_courseassist/placement\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Templates from 'core/templates';\nimport Ajax from 'core/ajax';\nimport 'core/copy_to_clipboard';\nimport Notification from 'core/notification';\nimport Selectors from 'aiplacement_courseassist/selectors';\nimport Policy from 'core_ai/policy';\nimport AIHelper from 'core_ai/helper';\nimport DrawerEvents from 'core/drawer_events';\nimport {subscribe} from 'core/pubsub';\nimport * as MessageDrawerHelper from 'core_message/message_drawer_helper';\nimport {getString} from 'core/str';\nimport * as FocusLock from 'core/local/aria/focuslock';\nimport {isSmall} from \"core/pagehelpers\";\n\nconst AICourseAssist = class {\n\n /**\n * The user ID.\n * @type {Integer}\n */\n userId;\n /**\n * The context ID.\n * @type {Integer}\n */\n contextId;\n\n /**\n * Constructor.\n * @param {Integer} userId The user ID.\n * @param {Integer} contextId The context ID.\n */\n constructor(userId, contextId) {\n this.userId = userId;\n this.contextId = contextId;\n\n this.aiDrawerElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER);\n this.aiDrawerBodyElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER_BODY);\n this.pageElement = document.querySelector(Selectors.ELEMENTS.PAGE);\n this.jumpToElement = document.querySelector(Selectors.ELEMENTS.JUMPTO);\n this.actionElement = document.querySelector(Selectors.ELEMENTS.ACTION);\n this.aiDrawerCloseElement = this.aiDrawerElement.querySelector(Selectors.ELEMENTS.AIDRAWER_CLOSE);\n this.lastAction = '';\n this.responses = new Map();\n this.isDrawerFocusLocked = false;\n\n this.registerEventListeners();\n }\n\n /**\n * Register event listeners.\n */\n registerEventListeners() {\n document.addEventListener('click', async(e) => {\n // Display summarise.\n const summariseAction = e.target.closest(Selectors.ACTIONS.SUMMARY);\n if (summariseAction) {\n e.preventDefault();\n this.openAIDrawer();\n this.lastAction = 'summarise_text';\n this.actionElement.focus();\n const isPolicyAccepted = await this.isPolicyAccepted();\n if (!isPolicyAccepted) {\n // Display policy.\n this.displayPolicy();\n return;\n }\n this.displayAction(this.lastAction);\n }\n // Display explain.\n const explainAction = e.target.closest(Selectors.ACTIONS.EXPLAIN);\n if (explainAction) {\n e.preventDefault();\n this.openAIDrawer();\n this.lastAction = 'explain_text';\n this.actionElement.focus();\n const isPolicyAccepted = await this.isPolicyAccepted();\n if (!isPolicyAccepted) {\n // Display policy.\n this.displayPolicy();\n return;\n }\n this.displayAction(this.lastAction);\n }\n // Close AI drawer.\n const closeAiDrawer = e.target.closest(Selectors.ELEMENTS.AIDRAWER_CLOSE);\n if (closeAiDrawer) {\n e.preventDefault();\n this.closeAIDrawer();\n }\n });\n\n document.addEventListener('keydown', e => {\n if (this.isAIDrawerOpen() && e.key === 'Escape') {\n this.closeAIDrawer();\n }\n });\n\n // Close AI drawer if message drawer is shown.\n subscribe(DrawerEvents.DRAWER_SHOWN, () => {\n if (this.isAIDrawerOpen()) {\n this.closeAIDrawer();\n }\n });\n\n // Check if there is course assist control region in the page.\n if (this.jumpToElement) {\n // Focus on the AI drawer's close button when the jump-to element is focused.\n this.jumpToElement.addEventListener('focus', () => {\n this.aiDrawerCloseElement.focus();\n });\n }\n\n // Focus on the action element when the AI drawer container receives focus.\n this.aiDrawerElement.addEventListener('focus', () => {\n this.actionElement.focus();\n });\n\n // Check if the action element exists.\n if (this.actionElement) {\n // Remove active from the action element when it loses focus.\n this.actionElement.addEventListener('blur', () => {\n this.actionElement.classList.remove('active');\n });\n }\n }\n\n /**\n * Register event listeners for the policy.\n */\n registerPolicyEventListeners() {\n const acceptAction = document.querySelector(Selectors.ACTIONS.ACCEPT);\n const declineAction = document.querySelector(Selectors.ACTIONS.DECLINE);\n if (acceptAction && this.lastAction.length) {\n acceptAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.acceptPolicy().then(() => {\n return this.displayAction(this.lastAction);\n }).catch(Notification.exception);\n });\n }\n if (declineAction) {\n declineAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.closeAIDrawer();\n });\n }\n }\n\n /**\n * Register event listeners for the error.\n */\n registerErrorEventListeners() {\n const retryAction = document.querySelector(Selectors.ACTIONS.RETRY);\n if (retryAction && this.lastAction.length) {\n retryAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.displayAction(this.lastAction);\n });\n }\n }\n\n /**\n * Register event listeners for the responses.\n */\n registerResponseEventListeners() {\n // Get all regenerate action buttons (one per response in the AI drawer).\n const regenerateActions = document.querySelectorAll(Selectors.ACTIONS.REGENERATE);\n // Add event listeners for each regenerate action.\n regenerateActions.forEach(regenerateAction => {\n const responseElement = regenerateAction.closest(Selectors.ELEMENTS.RESPONSE);\n if (regenerateAction && responseElement) {\n // Get the action that this response is associated with.\n const actionPerformed = responseElement.getAttribute('data-action-performed');\n regenerateAction.addEventListener('click', (e) => {\n e.preventDefault();\n // Remove the old response before displaying the new one.\n this.removeResponseFromStack(actionPerformed);\n this.displayAction(actionPerformed);\n });\n }\n });\n }\n\n registerLoadingEventListeners() {\n const cancelAction = document.querySelector(Selectors.ACTIONS.CANCEL);\n if (cancelAction) {\n cancelAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.setRequestCancelled();\n this.toggleAIDrawer();\n this.removeResponseFromStack('loading');\n // Refresh the response stack to avoid false indication of loading.\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n });\n }\n }\n\n /**\n * Check if the AI drawer is open.\n * @return {boolean} True if the AI drawer is open, false otherwise.\n */\n isAIDrawerOpen() {\n return this.aiDrawerElement.classList.contains('show');\n }\n\n /**\n * Check if the request is cancelled.\n * @return {boolean} True if the request is cancelled, false otherwise.\n */\n isRequestCancelled() {\n return this.aiDrawerBodyElement.dataset.cancelled === '1';\n }\n\n setRequestCancelled() {\n this.aiDrawerBodyElement.dataset.cancelled = '1';\n }\n\n /**\n * Open the AI drawer.\n */\n openAIDrawer() {\n // Close message drawer if it is shown.\n MessageDrawerHelper.hide();\n this.aiDrawerElement.classList.add('show');\n this.aiDrawerElement.setAttribute('tabindex', 0);\n this.aiDrawerBodyElement.setAttribute('aria-live', 'polite');\n if (!this.pageElement.classList.contains('show-drawer-right')) {\n this.addPadding();\n }\n this.jumpToElement.setAttribute('tabindex', 0);\n this.jumpToElement.focus();\n\n // If the AI drawer is opened on a small screen, we need to trap the focus tab within the AI drawer.\n if (isSmall()) {\n FocusLock.trapFocus(this.aiDrawerElement);\n this.aiDrawerElement.setAttribute('aria-modal', 'true');\n this.aiDrawerElement.setAttribute('role', 'dialog');\n this.isDrawerFocusLocked = true;\n }\n }\n\n /**\n * Close the AI drawer.\n */\n closeAIDrawer() {\n // Untrap focus if it was locked.\n if (this.isDrawerFocusLocked) {\n FocusLock.untrapFocus();\n this.aiDrawerElement.removeAttribute('aria-modal');\n this.aiDrawerElement.setAttribute('role', 'region');\n }\n\n this.aiDrawerElement.classList.remove('show');\n this.aiDrawerElement.setAttribute('tabindex', -1);\n this.aiDrawerBodyElement.removeAttribute('aria-live');\n if (this.pageElement.classList.contains('show-drawer-right') && this.aiDrawerBodyElement.dataset.removepadding === '1') {\n this.removePadding();\n }\n this.jumpToElement.setAttribute('tabindex', -1);\n\n // We can enforce a focus-visible state on the focus element using element.focus({focusVisible: true}).\n // Unfortunately, this feature isn't supported in all browsers, only Firefox provides support for it.\n // Therefore, we will apply the active class to the action element and set focus on it.\n // This action will make the action element appear focused.\n // When the action element loses focus,\n // we will remove the active class at {@see registerEventListeners()}\n this.actionElement.classList.add('active');\n this.actionElement.focus();\n }\n\n /**\n * Toggle the AI drawer.\n */\n toggleAIDrawer() {\n if (this.isAIDrawerOpen()) {\n this.closeAIDrawer();\n } else {\n this.openAIDrawer();\n }\n }\n\n /**\n * Add padding to the page to make space for the AI drawer.\n */\n addPadding() {\n this.pageElement.classList.add('show-drawer-right');\n this.aiDrawerBodyElement.dataset.removepadding = '1';\n }\n\n /**\n * Remove padding from the page.\n */\n removePadding() {\n this.pageElement.classList.remove('show-drawer-right');\n this.aiDrawerBodyElement.dataset.removepadding = '0';\n }\n\n /**\n * Get important params related to the action.\n * @param {string} action The action to use.\n * @returns {object} The params to use for the action.\n */\n async getParamsForAction(action) {\n let params = {};\n\n switch (action) {\n case 'summarise_text':\n params.method = 'aiplacement_courseassist_summarise_text';\n params.heading = await getString('aisummary', 'aiplacement_courseassist');\n break;\n\n case 'explain_text':\n params.method = 'aiplacement_courseassist_explain_text';\n params.heading = await getString('aiexplain', 'aiplacement_courseassist');\n break;\n }\n\n return params;\n }\n\n /**\n * Check if the policy is accepted.\n * @return {bool} True if the policy is accepted, false otherwise.\n */\n async isPolicyAccepted() {\n return await Policy.getPolicyStatus(this.userId);\n }\n\n /**\n * Accept the policy.\n * @return {Promise}\n */\n acceptPolicy() {\n return Policy.acceptPolicy();\n }\n\n /**\n * Check if the AI drawer has already generated content for a particular action.\n * @param {string} action The action to check.\n * @return {boolean} True if the AI drawer has generated content, false otherwise.\n */\n hasGeneratedContent(action) {\n return this.responses.has(action);\n }\n\n /**\n * Display the policy.\n */\n displayPolicy() {\n Templates.render('core_ai/policyblock', {}).then((html) => {\n this.aiDrawerBodyElement.innerHTML = html;\n this.registerPolicyEventListeners();\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the loading spinner.\n */\n displayLoading() {\n Templates.render('aiplacement_courseassist/loading', {}).then((html) => {\n this.addResponseToStack('loading', html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerLoadingEventListeners();\n return;\n }).then(() => {\n this.removeResponseFromStack('loading');\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the action result in the AI drawer.\n * @param {string} action The action to display.\n */\n async displayAction(action) {\n if (this.hasGeneratedContent(action)) {\n // Scroll to generated content.\n const existingReponse = document.querySelector('[data-action-performed=\"' + action + '\"]');\n if (existingReponse) {\n this.aiDrawerBodyElement.scrollTop = existingReponse.offsetTop;\n }\n } else {\n // Display loading spinner.\n this.displayLoading();\n // Clear the drawer to prevent including the previously generated response in the new response prompt.\n this.aiDrawerBodyElement.innerHTML = '';\n const params = await this.getParamsForAction(action);\n const request = {\n methodname: params.method,\n args: {\n contextid: this.contextId,\n prompttext: this.getTextContent(),\n }\n };\n try {\n const responseObj = await Ajax.call([request])[0];\n if (responseObj.error) {\n this.displayError(responseObj.error, responseObj.errormessage);\n return;\n } else {\n if (!this.isRequestCancelled()) {\n // Perform replacements on the generated context to ensure it is formatted correctly.\n const generatedContent = AIHelper.formatResponse(responseObj.generatedcontent);\n this.displayResponse(generatedContent, action);\n return;\n } else {\n this.aiDrawerBodyElement.dataset.cancelled = '0';\n }\n }\n } catch (error) {\n window.console.log(error);\n this.displayError();\n }\n }\n }\n\n /**\n * Add the HTML response to the response stack.\n * The stack will be used to display all responses in the AI drawer.\n * @param {String} action The action key.\n * @param {String} html The HTML to store.\n */\n addResponseToStack(action, html) {\n this.responses.set(action, html);\n }\n\n /**\n * Remove a stored response, allowing for a regenerated one.\n * @param {String} action The action key.\n */\n removeResponseFromStack(action) {\n if (this.responses.has(action)) {\n this.responses.delete(action);\n }\n }\n\n /**\n * Return a stack of HTML responses.\n * @return {String} HTML responses.\n */\n getResponseStack() {\n let stack = '';\n // Reverse to get newest first.\n const responses = [...this.responses.values()].reverse();\n for (const response of responses) {\n stack += response;\n }\n return stack;\n }\n\n /**\n * Display the responses.\n * @param {String} content The content to display.\n * @param {String} action The action used.\n */\n async displayResponse(content, action) {\n const params = await this.getParamsForAction(action);\n const args = {\n content: content,\n heading: params.heading,\n action: action,\n };\n Templates.render('aiplacement_courseassist/response', args).then((html) => {\n this.addResponseToStack(action, html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerResponseEventListeners();\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the error.\n *\n * @param {String} error The error name to display.\n * @param {String} errorMessage The error message to display.\n */\n async displayError(error = '', errorMessage = '') {\n if (!error) {\n // Get the default error message.\n error = await getString('error:defaultname', 'core_ai');\n errorMessage = await getString('error:defaultmessage', 'core_ai');\n }\n Templates.render('aiplacement_courseassist/error', {'error': error, 'errorMessage': errorMessage}).then((html) => {\n this.addResponseToStack('error', html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerErrorEventListeners();\n return;\n }).then(() => {\n this.removeResponseFromStack('error');\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Get the text content of the main region.\n * @return {String} The text content.\n */\n getTextContent() {\n const mainRegion = document.querySelector(Selectors.ELEMENTS.MAIN_REGION);\n return mainRegion.innerText || mainRegion.textContent;\n }\n};\n\nexport default AICourseAssist;\n"],"names":["constructor","userId","contextId","aiDrawerElement","document","querySelector","Selectors","ELEMENTS","AIDRAWER","aiDrawerBodyElement","AIDRAWER_BODY","pageElement","PAGE","jumpToElement","JUMPTO","actionElement","ACTION","aiDrawerCloseElement","this","AIDRAWER_CLOSE","lastAction","responses","Map","isDrawerFocusLocked","registerEventListeners","addEventListener","async","e","target","closest","ACTIONS","SUMMARY","preventDefault","openAIDrawer","focus","isPolicyAccepted","displayPolicy","displayAction","EXPLAIN","closeAIDrawer","isAIDrawerOpen","key","DrawerEvents","DRAWER_SHOWN","classList","remove","registerPolicyEventListeners","acceptAction","ACCEPT","declineAction","DECLINE","length","acceptPolicy","then","catch","Notification","exception","registerErrorEventListeners","retryAction","RETRY","registerResponseEventListeners","querySelectorAll","REGENERATE","forEach","regenerateAction","responseElement","RESPONSE","actionPerformed","getAttribute","removeResponseFromStack","registerLoadingEventListeners","cancelAction","CANCEL","setRequestCancelled","toggleAIDrawer","getResponseStack","innerHTML","contains","isRequestCancelled","dataset","cancelled","MessageDrawerHelper","hide","add","setAttribute","addPadding","FocusLock","trapFocus","untrapFocus","removeAttribute","removepadding","removePadding","action","params","method","heading","Policy","getPolicyStatus","hasGeneratedContent","has","render","html","displayLoading","addResponseToStack","existingReponse","scrollTop","offsetTop","request","methodname","getParamsForAction","args","contextid","prompttext","getTextContent","responseObj","Ajax","call","error","displayError","errormessage","generatedContent","AIHelper","formatResponse","generatedcontent","displayResponse","window","console","log","set","delete","stack","values","reverse","response","content","errorMessage","mainRegion","MAIN_REGION","innerText","textContent"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"placement.min.js","sources":["../src/placement.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 * Module to load and render the tools for the AI assist plugin.\n *\n * @module aiplacement_courseassist/placement\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Templates from 'core/templates';\nimport Ajax from 'core/ajax';\nimport 'core/copy_to_clipboard';\nimport Notification from 'core/notification';\nimport Selectors from 'aiplacement_courseassist/selectors';\nimport Policy from 'core_ai/policy';\nimport AIHelper from 'core_ai/helper';\nimport DrawerEvents from 'core/drawer_events';\nimport {subscribe} from 'core/pubsub';\nimport * as MessageDrawerHelper from 'core_message/message_drawer_helper';\nimport {getString} from 'core/str';\nimport * as FocusLock from 'core/local/aria/focuslock';\nimport {isSmall} from \"core/pagehelpers\";\n\nconst AICourseAssist = class {\n\n /**\n * The user ID.\n * @type {Integer}\n */\n userId;\n /**\n * The context ID.\n * @type {Integer}\n */\n contextId;\n\n /**\n * Constructor.\n * @param {Integer} userId The user ID.\n * @param {Integer} contextId The context ID.\n */\n constructor(userId, contextId) {\n this.userId = userId;\n this.contextId = contextId;\n\n this.aiDrawerElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER);\n this.aiDrawerBodyElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER_BODY);\n this.pageElement = document.querySelector(Selectors.ELEMENTS.PAGE);\n this.jumpToElement = document.querySelector(Selectors.ELEMENTS.JUMPTO);\n this.actionElement = document.querySelector(Selectors.ELEMENTS.ACTION);\n this.aiDrawerCloseElement = this.aiDrawerElement.querySelector(Selectors.ELEMENTS.AIDRAWER_CLOSE);\n this.lastAction = '';\n this.responses = new Map();\n this.isDrawerFocusLocked = false;\n\n this.registerEventListeners();\n }\n\n /**\n * Register event listeners.\n */\n registerEventListeners() {\n document.addEventListener('click', async(e) => {\n // Display summarise.\n const summariseAction = e.target.closest(Selectors.ACTIONS.SUMMARY);\n if (summariseAction) {\n e.preventDefault();\n this.openAIDrawer();\n this.lastAction = 'summarise_text';\n this.actionElement.focus();\n const isPolicyAccepted = await this.isPolicyAccepted();\n if (!isPolicyAccepted) {\n // Display policy.\n this.displayPolicy();\n return;\n }\n this.displayAction(this.lastAction);\n }\n // Display explain.\n const explainAction = e.target.closest(Selectors.ACTIONS.EXPLAIN);\n if (explainAction) {\n e.preventDefault();\n this.openAIDrawer();\n this.lastAction = 'explain_text';\n this.actionElement.focus();\n const isPolicyAccepted = await this.isPolicyAccepted();\n if (!isPolicyAccepted) {\n // Display policy.\n this.displayPolicy();\n return;\n }\n this.displayAction(this.lastAction);\n }\n // Close AI drawer.\n const closeAiDrawer = e.target.closest(Selectors.ELEMENTS.AIDRAWER_CLOSE);\n if (closeAiDrawer) {\n e.preventDefault();\n this.closeAIDrawer();\n }\n });\n\n document.addEventListener('keydown', e => {\n if (this.isAIDrawerOpen() && e.key === 'Escape') {\n this.closeAIDrawer();\n }\n });\n\n // Close AI drawer if message drawer is shown.\n subscribe(DrawerEvents.DRAWER_SHOWN, () => {\n if (this.isAIDrawerOpen()) {\n this.closeAIDrawer();\n }\n });\n\n // Check if there is course assist control region in the page.\n if (this.jumpToElement) {\n // Focus on the AI drawer's close button when the jump-to element is focused.\n this.jumpToElement.addEventListener('focus', () => {\n this.aiDrawerCloseElement.focus();\n });\n }\n\n // Focus on the action element when the AI drawer container receives focus.\n this.aiDrawerElement.addEventListener('focus', () => {\n this.actionElement.focus();\n });\n\n // Check if the action element exists.\n if (this.actionElement) {\n // Remove active from the action element when it loses focus.\n this.actionElement.addEventListener('blur', () => {\n this.actionElement.classList.remove('active');\n });\n }\n }\n\n /**\n * Register event listeners for the policy.\n */\n registerPolicyEventListeners() {\n const acceptAction = document.querySelector(Selectors.ACTIONS.ACCEPT);\n const declineAction = document.querySelector(Selectors.ACTIONS.DECLINE);\n if (acceptAction && this.lastAction.length) {\n acceptAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.acceptPolicy().then(() => {\n return this.displayAction(this.lastAction);\n }).catch(Notification.exception);\n });\n }\n if (declineAction) {\n declineAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.closeAIDrawer();\n });\n }\n }\n\n /**\n * Register event listeners for the error.\n */\n registerErrorEventListeners() {\n const retryAction = document.querySelector(Selectors.ACTIONS.RETRY);\n if (retryAction && this.lastAction.length) {\n retryAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.displayAction(this.lastAction);\n });\n }\n }\n\n /**\n * Register event listeners for the responses.\n */\n registerResponseEventListeners() {\n // Get all regenerate action buttons (one per response in the AI drawer).\n const regenerateActions = document.querySelectorAll(Selectors.ACTIONS.REGENERATE);\n // Add event listeners for each regenerate action.\n regenerateActions.forEach(regenerateAction => {\n const responseElement = regenerateAction.closest(Selectors.ELEMENTS.RESPONSE);\n if (regenerateAction && responseElement) {\n // Get the action that this response is associated with.\n const actionPerformed = responseElement.getAttribute('data-action-performed');\n regenerateAction.addEventListener('click', (e) => {\n e.preventDefault();\n // Remove the old response before displaying the new one.\n this.removeResponseFromStack(actionPerformed);\n this.displayAction(actionPerformed);\n });\n }\n });\n }\n\n registerLoadingEventListeners() {\n const cancelAction = document.querySelector(Selectors.ACTIONS.CANCEL);\n if (cancelAction) {\n cancelAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.setRequestCancelled();\n this.toggleAIDrawer();\n this.removeResponseFromStack('loading');\n // Refresh the response stack to avoid false indication of loading.\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n });\n }\n }\n\n /**\n * Check if the AI drawer is open.\n * @return {boolean} True if the AI drawer is open, false otherwise.\n */\n isAIDrawerOpen() {\n return this.aiDrawerElement.classList.contains('show');\n }\n\n /**\n * Check if the request is cancelled.\n * @return {boolean} True if the request is cancelled, false otherwise.\n */\n isRequestCancelled() {\n return this.aiDrawerBodyElement.dataset.cancelled === '1';\n }\n\n setRequestCancelled() {\n this.aiDrawerBodyElement.dataset.cancelled = '1';\n }\n\n /**\n * Open the AI drawer.\n */\n openAIDrawer() {\n // Close message drawer if it is shown.\n MessageDrawerHelper.hide();\n this.aiDrawerElement.classList.add('show');\n this.aiDrawerElement.setAttribute('tabindex', 0);\n this.aiDrawerBodyElement.setAttribute('aria-live', 'polite');\n if (!this.pageElement.classList.contains('show-drawer-right')) {\n this.addPadding();\n }\n this.jumpToElement.setAttribute('tabindex', 0);\n this.jumpToElement.focus();\n\n // If the AI drawer is opened on a small screen, we need to trap the focus tab within the AI drawer.\n if (isSmall()) {\n FocusLock.trapFocus(this.aiDrawerElement);\n this.aiDrawerElement.setAttribute('aria-modal', 'true');\n this.aiDrawerElement.setAttribute('role', 'dialog');\n this.isDrawerFocusLocked = true;\n }\n }\n\n /**\n * Close the AI drawer.\n */\n closeAIDrawer() {\n // Untrap focus if it was locked.\n if (this.isDrawerFocusLocked) {\n FocusLock.untrapFocus();\n this.aiDrawerElement.removeAttribute('aria-modal');\n this.aiDrawerElement.setAttribute('role', 'region');\n }\n\n this.aiDrawerElement.classList.remove('show');\n this.aiDrawerElement.setAttribute('tabindex', -1);\n this.aiDrawerBodyElement.removeAttribute('aria-live');\n if (this.pageElement.classList.contains('show-drawer-right') && this.aiDrawerBodyElement.dataset.removepadding === '1') {\n this.removePadding();\n }\n this.jumpToElement.setAttribute('tabindex', -1);\n\n // We can enforce a focus-visible state on the focus element using element.focus({focusVisible: true}).\n // Unfortunately, this feature isn't supported in all browsers, only Firefox provides support for it.\n // Therefore, we will apply the active class to the action element and set focus on it.\n // This action will make the action element appear focused.\n // When the action element loses focus,\n // we will remove the active class at {@see registerEventListeners()}\n this.actionElement.classList.add('active');\n this.actionElement.focus();\n }\n\n /**\n * Toggle the AI drawer.\n */\n toggleAIDrawer() {\n if (this.isAIDrawerOpen()) {\n this.closeAIDrawer();\n } else {\n this.openAIDrawer();\n }\n }\n\n /**\n * Add padding to the page to make space for the AI drawer.\n */\n addPadding() {\n this.pageElement.classList.add('show-drawer-right');\n this.aiDrawerBodyElement.dataset.removepadding = '1';\n }\n\n /**\n * Remove padding from the page.\n */\n removePadding() {\n this.pageElement.classList.remove('show-drawer-right');\n this.aiDrawerBodyElement.dataset.removepadding = '0';\n }\n\n /**\n * Get important params related to the action.\n * @param {string} action The action to use.\n * @returns {object} The params to use for the action.\n */\n async getParamsForAction(action) {\n let params = {};\n\n switch (action) {\n case 'summarise_text':\n params.method = 'aiplacement_courseassist_summarise_text';\n params.heading = await getString('aisummary', 'aiplacement_courseassist');\n params.copylabel = await getString('copyaisummary', 'aiplacement_courseassist');\n params.regeneratelabel = await getString('regenerateaisummary', 'aiplacement_courseassist');\n break;\n\n case 'explain_text':\n params.method = 'aiplacement_courseassist_explain_text';\n params.heading = await getString('aiexplain', 'aiplacement_courseassist');\n params.copylabel = await getString('copyaiexplanation', 'aiplacement_courseassist');\n params.regeneratelabel = await getString('regenerateaiexplanation', 'aiplacement_courseassist');\n break;\n }\n\n return params;\n }\n\n /**\n * Check if the policy is accepted.\n * @return {bool} True if the policy is accepted, false otherwise.\n */\n async isPolicyAccepted() {\n return await Policy.getPolicyStatus(this.userId);\n }\n\n /**\n * Accept the policy.\n * @return {Promise}\n */\n acceptPolicy() {\n return Policy.acceptPolicy();\n }\n\n /**\n * Check if the AI drawer has already generated content for a particular action.\n * @param {string} action The action to check.\n * @return {boolean} True if the AI drawer has generated content, false otherwise.\n */\n hasGeneratedContent(action) {\n return this.responses.has(action);\n }\n\n /**\n * Display the policy.\n */\n displayPolicy() {\n Templates.render('core_ai/policyblock', {}).then((html) => {\n this.aiDrawerBodyElement.innerHTML = html;\n this.registerPolicyEventListeners();\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the loading spinner.\n */\n displayLoading() {\n Templates.render('aiplacement_courseassist/loading', {}).then((html) => {\n this.addResponseToStack('loading', html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerLoadingEventListeners();\n return;\n }).then(() => {\n this.removeResponseFromStack('loading');\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the action result in the AI drawer.\n * @param {string} action The action to display.\n */\n async displayAction(action) {\n if (this.hasGeneratedContent(action)) {\n // Scroll to generated content.\n const existingReponse = document.querySelector('[data-action-performed=\"' + action + '\"]');\n if (existingReponse) {\n this.aiDrawerBodyElement.scrollTop = existingReponse.offsetTop;\n }\n } else {\n // Capture page content before any drawer UI changes. The drawer lives inside [role=\"main\"].\n const prompttext = this.getTextContent();\n this.aiDrawerBodyElement.innerHTML = '';\n const params = await this.getParamsForAction(action);\n this.displayLoading();\n const request = {\n methodname: params.method,\n args: {\n contextid: this.contextId,\n prompttext: prompttext,\n }\n };\n try {\n const responseObj = await Ajax.call([request])[0];\n if (responseObj.error) {\n this.displayError(responseObj.error, responseObj.errormessage);\n return;\n } else {\n if (!this.isRequestCancelled()) {\n // Perform replacements on the generated context to ensure it is formatted correctly.\n const generatedContent = AIHelper.formatResponse(responseObj.generatedcontent);\n this.displayResponse(generatedContent, action);\n return;\n } else {\n this.aiDrawerBodyElement.dataset.cancelled = '0';\n }\n }\n } catch (error) {\n window.console.log(error);\n this.displayError();\n }\n }\n }\n\n /**\n * Add the HTML response to the response stack.\n * The stack will be used to display all responses in the AI drawer.\n * @param {String} action The action key.\n * @param {String} html The HTML to store.\n */\n addResponseToStack(action, html) {\n this.responses.set(action, html);\n }\n\n /**\n * Remove a stored response, allowing for a regenerated one.\n * @param {String} action The action key.\n */\n removeResponseFromStack(action) {\n if (this.responses.has(action)) {\n this.responses.delete(action);\n }\n }\n\n /**\n * Return a stack of HTML responses.\n * @return {String} HTML responses.\n */\n getResponseStack() {\n let stack = '';\n // Reverse to get newest first.\n const responses = [...this.responses.values()].reverse();\n for (const response of responses) {\n stack += response;\n }\n return stack;\n }\n\n /**\n * Display the responses.\n * @param {String} content The content to display.\n * @param {String} action The action used.\n */\n async displayResponse(content, action) {\n const params = await this.getParamsForAction(action);\n const args = {\n content: content,\n heading: params.heading,\n action: action,\n copylabel: params.copylabel,\n regeneratelabel: params.regeneratelabel,\n };\n Templates.render('aiplacement_courseassist/response', args).then((html) => {\n this.addResponseToStack(action, html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerResponseEventListeners();\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the error.\n *\n * @param {String} error The error name to display.\n * @param {String} errorMessage The error message to display.\n */\n async displayError(error = '', errorMessage = '') {\n if (!error) {\n // Get the default error message.\n error = await getString('error:defaultname', 'core_ai');\n errorMessage = await getString('error:defaultmessage', 'core_ai');\n }\n Templates.render('aiplacement_courseassist/error', {'error': error, 'errorMessage': errorMessage}).then((html) => {\n this.addResponseToStack('error', html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerErrorEventListeners();\n return;\n }).then(() => {\n this.removeResponseFromStack('error');\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Get the text content of the main region for use as an AI prompt.\n * @return {String} The text content.\n */\n getTextContent() {\n const mainRegion = document.querySelector(Selectors.ELEMENTS.MAIN_REGION);\n if (!mainRegion) {\n return '';\n }\n\n // The drawer is rendered inside [role=\"main\"]. Temporarily hide AI placement UI so\n // innerText reflects only visible page content, matching live DOM behaviour.\n const aiElements = mainRegion.querySelectorAll(\n `${Selectors.ELEMENTS.AIDRAWER}, ${Selectors.ELEMENTS.RESPONSE}, ${Selectors.ELEMENTS.COURSE_ASSIST_CONTROLS}`\n );\n const previousDisplay = [];\n aiElements.forEach((element) => {\n previousDisplay.push(element.style.display);\n element.style.display = 'none';\n });\n\n try {\n const rawText = mainRegion.innerText || mainRegion.textContent || '';\n return this.normalizePromptText(rawText);\n } finally {\n aiElements.forEach((element, index) => {\n element.style.display = previousDisplay[index];\n });\n }\n }\n\n /**\n * Collapse redundant whitespace from extracted page text.\n * @param {String} text Raw text from the main region.\n * @return {String} Normalized prompt text.\n */\n normalizePromptText(text) {\n return text\n .replace(/\\r\\n/g, '\\n')\n .split('\\n')\n .map((line) => line.replace(/\\s+/g, ' ').trim())\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n }\n};\n\nexport default AICourseAssist;\n"],"names":["constructor","userId","contextId","aiDrawerElement","document","querySelector","Selectors","ELEMENTS","AIDRAWER","aiDrawerBodyElement","AIDRAWER_BODY","pageElement","PAGE","jumpToElement","JUMPTO","actionElement","ACTION","aiDrawerCloseElement","this","AIDRAWER_CLOSE","lastAction","responses","Map","isDrawerFocusLocked","registerEventListeners","addEventListener","async","e","target","closest","ACTIONS","SUMMARY","preventDefault","openAIDrawer","focus","isPolicyAccepted","displayPolicy","displayAction","EXPLAIN","closeAIDrawer","isAIDrawerOpen","key","DrawerEvents","DRAWER_SHOWN","classList","remove","registerPolicyEventListeners","acceptAction","ACCEPT","declineAction","DECLINE","length","acceptPolicy","then","catch","Notification","exception","registerErrorEventListeners","retryAction","RETRY","registerResponseEventListeners","querySelectorAll","REGENERATE","forEach","regenerateAction","responseElement","RESPONSE","actionPerformed","getAttribute","removeResponseFromStack","registerLoadingEventListeners","cancelAction","CANCEL","setRequestCancelled","toggleAIDrawer","getResponseStack","innerHTML","contains","isRequestCancelled","dataset","cancelled","MessageDrawerHelper","hide","add","setAttribute","addPadding","FocusLock","trapFocus","untrapFocus","removeAttribute","removepadding","removePadding","action","params","method","heading","copylabel","regeneratelabel","Policy","getPolicyStatus","hasGeneratedContent","has","render","html","displayLoading","addResponseToStack","existingReponse","scrollTop","offsetTop","prompttext","getTextContent","getParamsForAction","request","methodname","args","contextid","responseObj","Ajax","call","error","displayError","errormessage","generatedContent","AIHelper","formatResponse","generatedcontent","displayResponse","window","console","log","set","delete","stack","values","reverse","response","content","errorMessage","mainRegion","MAIN_REGION","aiElements","COURSE_ASSIST_CONTROLS","previousDisplay","element","push","style","display","rawText","innerText","textContent","normalizePromptText","index","text","replace","split","map","line","trim","join"],"mappings":"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"} \ No newline at end of file diff --git a/public/ai/placement/courseassist/amd/build/selectors.min.js b/public/ai/placement/courseassist/amd/build/selectors.min.js index 4715eb32ce9fe..a496216b70b4d 100644 --- a/public/ai/placement/courseassist/amd/build/selectors.min.js +++ b/public/ai/placement/courseassist/amd/build/selectors.min.js @@ -1,3 +1,3 @@ -define("aiplacement_courseassist/selectors",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;return _exports.default={ELEMENTS:{AIDRAWER:"#ai-drawer",AIDRAWER_BODY:"#ai-drawer .ai-drawer-body",PAGE:"#page",MAIN_REGION:'[role="main"]',AIDRAWER_CLOSE:"#ai-drawer-close",RESPONSE:".course-assist-response",JUMPTO:'.course-assist-controls [data-region="jumpto"]',ACTION:'.course-assist-controls [data-input-type="action"]'},ACTIONS:{SUMMARY:'.course-assist-controls [data-action="summarise_text"]',EXPLAIN:'.course-assist-controls [data-action="explain_text"]',RETRY:'.course-assist-controls [data-action="retry"]',DECLINE:'.ai-policy-block [data-action="decline"]',ACCEPT:'.ai-policy-block [data-action="accept"]',REGENERATE:'.course-assist-controls [data-action="regenerate"]',CANCEL:'.course-assist-controls [data-action="cancel"]'}},_exports.default})); +define("aiplacement_courseassist/selectors",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;var _default={ELEMENTS:{AIDRAWER:"#ai-drawer",AIDRAWER_BODY:"#ai-drawer .ai-drawer-body",PAGE:"#page",MAIN_REGION:'[role="main"]',AIDRAWER_CLOSE:"#ai-drawer-close",RESPONSE:".course-assist-response",COURSE_ASSIST_CONTROLS:".course-assist-controls",JUMPTO:"".concat(".course-assist-controls",' [data-region="jumpto"]'),ACTION:"".concat(".course-assist-controls",' [data-input-type="action"]')},ACTIONS:{SUMMARY:"".concat(".course-assist-controls",' [data-action="summarise_text"]'),EXPLAIN:"".concat(".course-assist-controls",' [data-action="explain_text"]'),RETRY:"".concat(".course-assist-controls",' [data-action="retry"]'),DECLINE:'.ai-policy-block [data-action="decline"]',ACCEPT:'.ai-policy-block [data-action="accept"]',REGENERATE:"".concat(".course-assist-controls",' [data-action="regenerate"]'),CANCEL:"".concat(".course-assist-controls",' [data-action="cancel"]')}};return _exports.default=_default,_exports.default})); //# sourceMappingURL=selectors.min.js.map \ No newline at end of file diff --git a/public/ai/placement/courseassist/amd/build/selectors.min.js.map b/public/ai/placement/courseassist/amd/build/selectors.min.js.map index 008adcf575532..0e1841a788e09 100644 --- a/public/ai/placement/courseassist/amd/build/selectors.min.js.map +++ b/public/ai/placement/courseassist/amd/build/selectors.min.js.map @@ -1 +1 @@ -{"version":3,"file":"selectors.min.js","sources":["../src/selectors.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 * Define all of the selectors we will be using on the AI Course assistant.\n *\n * @module aiplacement_courseassist/selectors\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default {\n ELEMENTS: {\n AIDRAWER: '#ai-drawer',\n AIDRAWER_BODY: '#ai-drawer .ai-drawer-body',\n PAGE: '#page',\n MAIN_REGION: '[role=\"main\"]',\n AIDRAWER_CLOSE: '#ai-drawer-close',\n RESPONSE: '.course-assist-response',\n JUMPTO: '.course-assist-controls [data-region=\"jumpto\"]',\n ACTION: '.course-assist-controls [data-input-type=\"action\"]',\n },\n ACTIONS: {\n SUMMARY: '.course-assist-controls [data-action=\"summarise_text\"]',\n EXPLAIN: '.course-assist-controls [data-action=\"explain_text\"]',\n RETRY: '.course-assist-controls [data-action=\"retry\"]',\n DECLINE: '.ai-policy-block [data-action=\"decline\"]',\n ACCEPT: '.ai-policy-block [data-action=\"accept\"]',\n REGENERATE: '.course-assist-controls [data-action=\"regenerate\"]',\n CANCEL: '.course-assist-controls [data-action=\"cancel\"]',\n }\n};\n"],"names":["ELEMENTS","AIDRAWER","AIDRAWER_BODY","PAGE","MAIN_REGION","AIDRAWER_CLOSE","RESPONSE","JUMPTO","ACTION","ACTIONS","SUMMARY","EXPLAIN","RETRY","DECLINE","ACCEPT","REGENERATE","CANCEL"],"mappings":"oLAsBe,CACXA,SAAU,CACNC,SAAU,aACVC,cAAe,6BACfC,KAAM,QACNC,YAAa,gBACbC,eAAgB,mBAChBC,SAAU,0BACVC,OAAQ,iDACRC,OAAQ,sDAEZC,QAAS,CACLC,QAAS,yDACTC,QAAS,uDACTC,MAAO,gDACPC,QAAS,2CACTC,OAAQ,0CACRC,WAAY,qDACZC,OAAQ"} \ No newline at end of file +{"version":3,"file":"selectors.min.js","sources":["../src/selectors.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 * Define all of the selectors we will be using on the AI Course assistant.\n *\n * @module aiplacement_courseassist/selectors\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nconst courseAssistControls = '.course-assist-controls';\n\nexport default {\n ELEMENTS: {\n AIDRAWER: '#ai-drawer',\n AIDRAWER_BODY: '#ai-drawer .ai-drawer-body',\n PAGE: '#page',\n MAIN_REGION: '[role=\"main\"]',\n AIDRAWER_CLOSE: '#ai-drawer-close',\n RESPONSE: '.course-assist-response',\n COURSE_ASSIST_CONTROLS: courseAssistControls,\n JUMPTO: `${courseAssistControls} [data-region=\"jumpto\"]`,\n ACTION: `${courseAssistControls} [data-input-type=\"action\"]`,\n },\n ACTIONS: {\n SUMMARY: `${courseAssistControls} [data-action=\"summarise_text\"]`,\n EXPLAIN: `${courseAssistControls} [data-action=\"explain_text\"]`,\n RETRY: `${courseAssistControls} [data-action=\"retry\"]`,\n DECLINE: '.ai-policy-block [data-action=\"decline\"]',\n ACCEPT: '.ai-policy-block [data-action=\"accept\"]',\n REGENERATE: `${courseAssistControls} [data-action=\"regenerate\"]`,\n CANCEL: `${courseAssistControls} [data-action=\"cancel\"]`,\n }\n};\n"],"names":["ELEMENTS","AIDRAWER","AIDRAWER_BODY","PAGE","MAIN_REGION","AIDRAWER_CLOSE","RESPONSE","COURSE_ASSIST_CONTROLS","JUMPTO","ACTION","ACTIONS","SUMMARY","EXPLAIN","RETRY","DECLINE","ACCEPT","REGENERATE","CANCEL"],"mappings":"yKAyBe,CACXA,SAAU,CACNC,SAAU,aACVC,cAAe,6BACfC,KAAM,QACNC,YAAa,gBACbC,eAAgB,mBAChBC,SAAU,0BACVC,uBAVqB,0BAWrBC,iBAXqB,qDAYrBC,iBAZqB,0DAczBC,QAAS,CACLC,kBAfqB,6DAgBrBC,kBAhBqB,2DAiBrBC,gBAjBqB,oDAkBrBC,QAAS,2CACTC,OAAQ,0CACRC,qBApBqB,yDAqBrBC,iBArBqB"} \ No newline at end of file diff --git a/public/ai/placement/courseassist/amd/src/placement.js b/public/ai/placement/courseassist/amd/src/placement.js index 6ca8463f2eeca..6ef551799cf83 100644 --- a/public/ai/placement/courseassist/amd/src/placement.js +++ b/public/ai/placement/courseassist/amd/src/placement.js @@ -332,11 +332,15 @@ const AICourseAssist = class { case 'summarise_text': params.method = 'aiplacement_courseassist_summarise_text'; params.heading = await getString('aisummary', 'aiplacement_courseassist'); + params.copylabel = await getString('copyaisummary', 'aiplacement_courseassist'); + params.regeneratelabel = await getString('regenerateaisummary', 'aiplacement_courseassist'); break; case 'explain_text': params.method = 'aiplacement_courseassist_explain_text'; params.heading = await getString('aiexplain', 'aiplacement_courseassist'); + params.copylabel = await getString('copyaiexplanation', 'aiplacement_courseassist'); + params.regeneratelabel = await getString('regenerateaiexplanation', 'aiplacement_courseassist'); break; } @@ -407,16 +411,16 @@ const AICourseAssist = class { this.aiDrawerBodyElement.scrollTop = existingReponse.offsetTop; } } else { - // Display loading spinner. - this.displayLoading(); - // Clear the drawer to prevent including the previously generated response in the new response prompt. + // Capture page content before any drawer UI changes. The drawer lives inside [role="main"]. + const prompttext = this.getTextContent(); this.aiDrawerBodyElement.innerHTML = ''; const params = await this.getParamsForAction(action); + this.displayLoading(); const request = { methodname: params.method, args: { contextid: this.contextId, - prompttext: this.getTextContent(), + prompttext: prompttext, } }; try { @@ -486,6 +490,8 @@ const AICourseAssist = class { content: content, heading: params.heading, action: action, + copylabel: params.copylabel, + regeneratelabel: params.regeneratelabel, }; Templates.render('aiplacement_courseassist/response', args).then((html) => { this.addResponseToStack(action, html); @@ -521,12 +527,49 @@ const AICourseAssist = class { } /** - * Get the text content of the main region. + * Get the text content of the main region for use as an AI prompt. * @return {String} The text content. */ getTextContent() { const mainRegion = document.querySelector(Selectors.ELEMENTS.MAIN_REGION); - return mainRegion.innerText || mainRegion.textContent; + if (!mainRegion) { + return ''; + } + + // The drawer is rendered inside [role="main"]. Temporarily hide AI placement UI so + // innerText reflects only visible page content, matching live DOM behaviour. + const aiElements = mainRegion.querySelectorAll( + `${Selectors.ELEMENTS.AIDRAWER}, ${Selectors.ELEMENTS.RESPONSE}, ${Selectors.ELEMENTS.COURSE_ASSIST_CONTROLS}` + ); + const previousDisplay = []; + aiElements.forEach((element) => { + previousDisplay.push(element.style.display); + element.style.display = 'none'; + }); + + try { + const rawText = mainRegion.innerText || mainRegion.textContent || ''; + return this.normalizePromptText(rawText); + } finally { + aiElements.forEach((element, index) => { + element.style.display = previousDisplay[index]; + }); + } + } + + /** + * Collapse redundant whitespace from extracted page text. + * @param {String} text Raw text from the main region. + * @return {String} Normalized prompt text. + */ + normalizePromptText(text) { + return text + .replace(/\r\n/g, '\n') + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); } }; diff --git a/public/ai/placement/courseassist/amd/src/selectors.js b/public/ai/placement/courseassist/amd/src/selectors.js index 41afc20e55ec4..0b6505bdad24a 100644 --- a/public/ai/placement/courseassist/amd/src/selectors.js +++ b/public/ai/placement/courseassist/amd/src/selectors.js @@ -20,6 +20,9 @@ * @copyright 2024 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ + +const courseAssistControls = '.course-assist-controls'; + export default { ELEMENTS: { AIDRAWER: '#ai-drawer', @@ -28,16 +31,17 @@ export default { MAIN_REGION: '[role="main"]', AIDRAWER_CLOSE: '#ai-drawer-close', RESPONSE: '.course-assist-response', - JUMPTO: '.course-assist-controls [data-region="jumpto"]', - ACTION: '.course-assist-controls [data-input-type="action"]', + COURSE_ASSIST_CONTROLS: courseAssistControls, + JUMPTO: `${courseAssistControls} [data-region="jumpto"]`, + ACTION: `${courseAssistControls} [data-input-type="action"]`, }, ACTIONS: { - SUMMARY: '.course-assist-controls [data-action="summarise_text"]', - EXPLAIN: '.course-assist-controls [data-action="explain_text"]', - RETRY: '.course-assist-controls [data-action="retry"]', + SUMMARY: `${courseAssistControls} [data-action="summarise_text"]`, + EXPLAIN: `${courseAssistControls} [data-action="explain_text"]`, + RETRY: `${courseAssistControls} [data-action="retry"]`, DECLINE: '.ai-policy-block [data-action="decline"]', ACCEPT: '.ai-policy-block [data-action="accept"]', - REGENERATE: '.course-assist-controls [data-action="regenerate"]', - CANCEL: '.course-assist-controls [data-action="cancel"]', + REGENERATE: `${courseAssistControls} [data-action="regenerate"]`, + CANCEL: `${courseAssistControls} [data-action="cancel"]`, } }; diff --git a/public/ai/placement/courseassist/classes/external/explain_text.php b/public/ai/placement/courseassist/classes/external/explain_text.php index 36532fe998534..a47a2a5336684 100644 --- a/public/ai/placement/courseassist/classes/external/explain_text.php +++ b/public/ai/placement/courseassist/classes/external/explain_text.php @@ -72,12 +72,13 @@ public static function execute( 'contextid' => $contextid, 'prompttext' => $prompttext, ]); + // Context validation and permission check. - // Get the context from the passed in ID. $context = \context::instance_by_id($contextid); + self::validate_context($context); // Check the user has permission to use the AI service. - self::validate_context($context); + require_capability('aiplacement/courseassist:explain_text', $context); // Check if AI Placement course assist is available. if (!utils::is_course_assist_available()) { diff --git a/public/ai/placement/courseassist/classes/external/summarise_text.php b/public/ai/placement/courseassist/classes/external/summarise_text.php index da1d82c655733..9355f8147f758 100644 --- a/public/ai/placement/courseassist/classes/external/summarise_text.php +++ b/public/ai/placement/courseassist/classes/external/summarise_text.php @@ -72,12 +72,13 @@ public static function execute( 'contextid' => $contextid, 'prompttext' => $prompttext, ]); + // Context validation and permission check. - // Get the context from the passed in ID. $context = \context::instance_by_id($contextid); + self::validate_context($context); // Check the user has permission to use the AI service. - self::validate_context($context); + require_capability('aiplacement/courseassist:summarise_text', $context); // Check if AI Placement course assist is available. if (!utils::is_course_assist_available()) { diff --git a/public/ai/placement/courseassist/classes/placement.php b/public/ai/placement/courseassist/classes/placement.php index 37655b441a36f..64664d4e5b609 100644 --- a/public/ai/placement/courseassist/classes/placement.php +++ b/public/ai/placement/courseassist/classes/placement.php @@ -16,6 +16,7 @@ namespace aiplacement_courseassist; + /** * Class placement. * @@ -33,4 +34,13 @@ public static function get_action_list(): array { ]; } + #[\Override] + public static function is_available_in_context(\context $context): bool { + return in_array($context->contextlevel, [CONTEXT_COURSE, CONTEXT_MODULE]); + } + + #[\Override] + public static function get_actions_available(\context $context, bool $checkcontext = true): array { + return utils::get_actions_available($context, $checkcontext); + } } diff --git a/public/ai/placement/courseassist/lang/en/aiplacement_courseassist.php b/public/ai/placement/courseassist/lang/en/aiplacement_courseassist.php index 1a0ffc9c2ac84..d24aeaf9e06fa 100644 --- a/public/ai/placement/courseassist/lang/en/aiplacement_courseassist.php +++ b/public/ai/placement/courseassist/lang/en/aiplacement_courseassist.php @@ -29,6 +29,8 @@ $string['courseassist:explain_text'] = 'Explain text'; $string['courseassist:summarise_text'] = 'Summarise text'; $string['copy'] = 'Copy'; +$string['copyaiexplanation'] = 'Copy AI-generated explanation'; +$string['copyaisummary'] = 'Copy AI-generated summary'; $string['explain'] = 'Explain'; $string['explain_tooltips'] = 'Create an AI-generated explanation of the page content'; $string['generatefailtitle'] = 'Something went wrong'; @@ -37,6 +39,8 @@ $string['pluginname'] = 'Course assistance placement'; $string['privacy:metadata'] = 'The Course assistance placement plugin does not store any personal data.'; $string['regenerate'] = 'Regenerate'; +$string['regenerateaiexplanation'] = 'Regenerate AI-generated explanation'; +$string['regenerateaisummary'] = 'Regenerate AI-generated summary'; $string['summarise'] = 'Summarise'; $string['summarise_tooltips'] = 'Create an AI-generated summary of the page content'; diff --git a/public/ai/placement/courseassist/templates/response.mustache b/public/ai/placement/courseassist/templates/response.mustache index cd421dee9d0c8..c3e1902c0717c 100644 --- a/public/ai/placement/courseassist/templates/response.mustache +++ b/public/ai/placement/courseassist/templates/response.mustache @@ -23,12 +23,16 @@ * content - Content to display * heading - The heading to display * action - The action being performed + * copylabel - Accessibility label for the copy button + * regeneratelabel - Accessibility label for the regenerate button Example context (json): { "content": "

Content to display

", "heading": "AI Explain", - "action": "explain_text" + "action": "explain_text", + "copylabel": "Copy AI-generated explanation", + "regeneratelabel": "Regenerate AI-generated explanation" } }}
@@ -52,11 +56,11 @@
- - diff --git a/public/ai/placement/courseassist/tests/behat/course_assist_features.feature b/public/ai/placement/courseassist/tests/behat/course_assist_features.feature index 521e1d740742f..4fb823b156868 100644 --- a/public/ai/placement/courseassist/tests/behat/course_assist_features.feature +++ b/public/ai/placement/courseassist/tests/behat/course_assist_features.feature @@ -91,6 +91,11 @@ Feature: AI course assist features And I am on the "PageName1" "page activity" page logged in as teacher1 Then "AI features" "button" should not exist + Scenario: AI tools can be enabled while creating a course + Given I log in as "admin" + When I navigate to "Courses > Add a new course" in site administration + Then I should see "Allow AI tools for this course" + Scenario: AI features are not available when AI tools is disabled at module level Given I am on the "PageName1" "page activity editing" page logged in as teacher1 When I set the following fields to these values: diff --git a/public/ai/placement/courseassist/tests/utils_test.php b/public/ai/placement/courseassist/tests/utils_test.php index e28e9e3117d4a..f48809441bc5b 100644 --- a/public/ai/placement/courseassist/tests/utils_test.php +++ b/public/ai/placement/courseassist/tests/utils_test.php @@ -99,6 +99,16 @@ public function test_is_course_assist_available(): void { $this->assertFalse(utils::is_course_assist_available()); } + /** + * Test the contexts where the placement is available. + * + * @covers \aiplacement_courseassist\placement::is_available_in_context + */ + public function test_is_available_in_context(): void { + $this->assertTrue(placement::is_available_in_context($this->context)); + $this->assertFalse(placement::is_available_in_context(\context_system::instance())); + } + /** * Test get_actions_available method. * diff --git a/public/ai/placement/editor/classes/external/generate_image.php b/public/ai/placement/editor/classes/external/generate_image.php index cf1d3512a9575..c835f0860d001 100644 --- a/public/ai/placement/editor/classes/external/generate_image.php +++ b/public/ai/placement/editor/classes/external/generate_image.php @@ -115,8 +115,11 @@ public static function execute( // Check the user has permission to use the AI service. self::validate_context($context); - if (!utils::is_html_editor_placement_action_available($context, 'generate_text', - \core_ai\aiactions\generate_image::class)) { + if (!utils::is_html_editor_placement_action_available( + $context, + 'generate_image', + \core_ai\aiactions\generate_image::class, + )) { throw new \moodle_exception('noeditor', 'aiplacement_editor'); } diff --git a/public/ai/placement/editor/classes/external/generate_text.php b/public/ai/placement/editor/classes/external/generate_text.php index 95d97f087b383..56c626026f9e1 100644 --- a/public/ai/placement/editor/classes/external/generate_text.php +++ b/public/ai/placement/editor/classes/external/generate_text.php @@ -128,7 +128,7 @@ public static function execute_returns(): external_function_parameters { VALUE_REQUIRED, ), 'generatedcontent' => new external_value( - PARAM_TEXT, + PARAM_RAW, 'The text generated by AI.', VALUE_DEFAULT, ), diff --git a/public/ai/placement/editor/classes/placement.php b/public/ai/placement/editor/classes/placement.php index ba39a45a9ad90..93c66a0c7398b 100644 --- a/public/ai/placement/editor/classes/placement.php +++ b/public/ai/placement/editor/classes/placement.php @@ -16,6 +16,7 @@ namespace aiplacement_editor; + /** * Class placement. * @@ -35,4 +36,14 @@ public static function get_action_list(): array { \core_ai\aiactions\generate_image::class, ]; } + + #[\Override] + public static function is_available_in_context(\context $context): bool { + return true; + } + + #[\Override] + public static function get_actions_available(\context $context, bool $checkcontext = true): array { + return utils::get_actions_available($context, $checkcontext); + } } diff --git a/public/ai/placement/editor/tests/utils_test.php b/public/ai/placement/editor/tests/utils_test.php index c57d82bb04a2e..4ad9c2b29194d 100644 --- a/public/ai/placement/editor/tests/utils_test.php +++ b/public/ai/placement/editor/tests/utils_test.php @@ -135,6 +135,16 @@ public function test_is_html_editor_placement_action_available(): void { )); } + /** + * Test the contexts where the placement is available. + * + * @covers \aiplacement_editor\placement::is_available_in_context + */ + public function test_is_available_in_context(): void { + $this->assertTrue(placement::is_available_in_context($this->context)); + $this->assertTrue(placement::is_available_in_context(\context_system::instance())); + } + /** * Test get_actions_available method. * diff --git a/public/ai/placement/editor/version.php b/public/ai/placement/editor/version.php index fe8243f96b2ed..29569a3ec1758 100644 --- a/public/ai/placement/editor/version.php +++ b/public/ai/placement/editor/version.php @@ -25,6 +25,6 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'aiplacement_editor'; -$plugin->version = 2025100600; +$plugin->version = 2025100601; $plugin->requires = 2025092600; $plugin->maturity = MATURITY_STABLE; diff --git a/public/ai/provider/azureai/classes/form/action_generate_image_form.php b/public/ai/provider/azureai/classes/form/action_generate_image_form.php index 4e34a3bcc5944..f65c04c16c8d2 100644 --- a/public/ai/provider/azureai/classes/form/action_generate_image_form.php +++ b/public/ai/provider/azureai/classes/form/action_generate_image_form.php @@ -42,7 +42,7 @@ protected function definition() { get_string("action:{$actionname}:deployment", 'aiprovider_azureai'), 'maxlength="255" size="20"', ); - $mform->setType('deployment', PARAM_ALPHANUMEXT); + $mform->setType('deployment', PARAM_TEXT); $mform->addRule('deployment', null, 'required', null, 'client'); $mform->setDefault('deployment', $actionconfig['deployment'] ?? ''); $mform->addHelpButton('deployment', "action:{$actionname}:deployment", 'aiprovider_azureai'); @@ -54,7 +54,7 @@ protected function definition() { get_string("action:{$actionname}:apiversion", 'aiprovider_azureai'), 'maxlength="255" size="30"', ); - $mform->setType('apiversion', PARAM_ALPHANUMEXT); + $mform->setType('apiversion', PARAM_TEXT); $mform->addRule('apiversion', null, 'required', null, 'client'); $mform->setDefault('apiversion', $actionconfig['apiversion'] ?? '2024-06-01'); diff --git a/public/ai/provider/azureai/classes/form/action_generate_text_form.php b/public/ai/provider/azureai/classes/form/action_generate_text_form.php index cc2636d56378e..c7b5ffce61dfb 100644 --- a/public/ai/provider/azureai/classes/form/action_generate_text_form.php +++ b/public/ai/provider/azureai/classes/form/action_generate_text_form.php @@ -42,7 +42,7 @@ protected function definition() { get_string("action:{$actionname}:deployment", 'aiprovider_azureai'), 'maxlength="255" size="20"', ); - $mform->setType('deployment', PARAM_ALPHANUMEXT); + $mform->setType('deployment', PARAM_TEXT); $mform->addRule('deployment', null, 'required', null, 'client'); $mform->setDefault('deployment', $actionconfig['deployment'] ?? ''); $mform->addHelpButton('deployment', "action:{$actionname}:deployment", 'aiprovider_azureai'); @@ -54,7 +54,7 @@ protected function definition() { get_string("action:{$actionname}:apiversion", 'aiprovider_azureai'), 'maxlength="255" size="30"', ); - $mform->setType('apiversion', PARAM_ALPHANUMEXT); + $mform->setType('apiversion', PARAM_TEXT); $mform->addRule('apiversion', null, 'required', null, 'client'); $mform->setDefault('apiversion', $actionconfig['apiversion'] ?? '2024-06-01'); diff --git a/public/ai/provider/openai/classes/process_generate_image.php b/public/ai/provider/openai/classes/process_generate_image.php index 91ea125fd31cd..55a5ab3cc6d82 100644 --- a/public/ai/provider/openai/classes/process_generate_image.php +++ b/public/ai/provider/openai/classes/process_generate_image.php @@ -139,7 +139,7 @@ protected function create_request_object(string $userid): RequestInterface { headers: [ 'Content-Type' => 'application/json', ], - body: json_encode($requestobj), + body: json_encode($requestobj, JSON_UNESCAPED_SLASHES), ); } diff --git a/public/ai/provider/openai/classes/process_generate_text.php b/public/ai/provider/openai/classes/process_generate_text.php index d21647d734d08..8376cf8e30a2d 100644 --- a/public/ai/provider/openai/classes/process_generate_text.php +++ b/public/ai/provider/openai/classes/process_generate_text.php @@ -69,7 +69,7 @@ protected function create_request_object(string $userid): RequestInterface { headers: [ 'Content-Type' => 'application/json', ], - body: json_encode($requestobj), + body: json_encode($requestobj, JSON_UNESCAPED_SLASHES), ); } diff --git a/public/ai/provider/openai/tests/process_generate_image_test.php b/public/ai/provider/openai/tests/process_generate_image_test.php index 8a8cf96c3e615..d553285ff26b1 100644 --- a/public/ai/provider/openai/tests/process_generate_image_test.php +++ b/public/ai/provider/openai/tests/process_generate_image_test.php @@ -150,7 +150,7 @@ public function test_create_request_object_with_model_settings(): void { $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\generate_image::class, actionconfig: [ - 'model' => 'my-custom-gpt', + 'model' => 'my/custom-gpt', 'modelextraparams' => '{"temperature": 0.5,"max_completion_tokens": 100}', ], ); @@ -160,11 +160,13 @@ public function test_create_request_object_with_model_settings(): void { $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); - $body = (object) json_decode($request->getBody()->getContents()); + $rawbody = $request->getBody()->getContents(); + $body = (object) json_decode($rawbody); - $this->assertEquals('my-custom-gpt', $body->model); + $this->assertEquals('my/custom-gpt', $body->model); $this->assertEquals('0.5', $body->temperature); $this->assertEquals('100', $body->max_completion_tokens); + $this->assertStringNotContainsString('\/', $rawbody); // Slashes must not be escaped. } /** diff --git a/public/ai/provider/openai/tests/process_generate_text_test.php b/public/ai/provider/openai/tests/process_generate_text_test.php index 79d163a407c92..ad460098d1ec4 100644 --- a/public/ai/provider/openai/tests/process_generate_text_test.php +++ b/public/ai/provider/openai/tests/process_generate_text_test.php @@ -120,7 +120,7 @@ public function test_create_request_object_with_model_settings(): void { $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\generate_text::class, actionconfig: [ - 'model' => 'my-custom-gpt', + 'model' => 'my/custom-gpt', 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), 'modelextraparams' => '{"temperature": 0.5,"max_completion_tokens": 100}', ], @@ -131,11 +131,13 @@ public function test_create_request_object_with_model_settings(): void { $method = new \ReflectionMethod($processor, 'create_request_object'); $request = $method->invoke($processor, 1); - $body = (object) json_decode($request->getBody()->getContents()); + $rawbody = $request->getBody()->getContents(); + $body = (object) json_decode($rawbody); - $this->assertEquals('my-custom-gpt', $body->model); + $this->assertEquals('my/custom-gpt', $body->model); $this->assertEquals('0.5', $body->temperature); $this->assertEquals('100', $body->max_completion_tokens); + $this->assertStringNotContainsString('\/', $rawbody); // Slashes must not be escaped. } /** diff --git a/public/ai/tests/aiactions/responses/response_explain_text_test.php b/public/ai/tests/aiactions/responses/response_explain_text_test.php index 41e6b855b3203..5e6dbc70f0930 100644 --- a/public/ai/tests/aiactions/responses/response_explain_text_test.php +++ b/public/ai/tests/aiactions/responses/response_explain_text_test.php @@ -75,4 +75,17 @@ public function test_set_response_data(): void { $this->assertEquals($body['prompttokens'], $actionresponse->get_response_data()['prompttokens']); $this->assertEquals($body['completiontokens'], $actionresponse->get_response_data()['completiontokens']); } + + /** + * Test that reasoning tags are stripped from generated content. + */ + public function test_set_response_data_strips_reasoning_tags(): void { + $actionresponse = new response_explain_text(success: true); + $actionresponse->set_response_data([ + 'generatedcontent' => 'Internal reasoning.The actual response.', + 'finishreason' => 'stop', + ]); + + $this->assertEquals('The actual response.', $actionresponse->get_response_data()['generatedcontent']); + } } diff --git a/public/ai/tests/aiactions/responses/response_generate_text_test.php b/public/ai/tests/aiactions/responses/response_generate_text_test.php index 84267a43dd90f..0ca9fd031665a 100644 --- a/public/ai/tests/aiactions/responses/response_generate_text_test.php +++ b/public/ai/tests/aiactions/responses/response_generate_text_test.php @@ -75,4 +75,17 @@ public function test_set_response_data(): void { $this->assertEquals($body['prompttokens'], $actionresponse->get_response_data()['prompttokens']); $this->assertEquals($body['completiontokens'], $actionresponse->get_response_data()['completiontokens']); } + + /** + * Test that reasoning tags are stripped from generated content. + */ + public function test_set_response_data_strips_reasoning_tags(): void { + $actionresponse = new response_generate_text(success: true); + $actionresponse->set_response_data([ + 'generatedcontent' => 'Internal reasoning.The actual response.', + 'finishreason' => 'stop', + ]); + + $this->assertEquals('The actual response.', $actionresponse->get_response_data()['generatedcontent']); + } } diff --git a/public/ai/tests/aiactions/responses/response_summarise_text_test.php b/public/ai/tests/aiactions/responses/response_summarise_text_test.php index 93a1205be4b74..3c850343c20a1 100644 --- a/public/ai/tests/aiactions/responses/response_summarise_text_test.php +++ b/public/ai/tests/aiactions/responses/response_summarise_text_test.php @@ -75,4 +75,17 @@ public function test_set_response_data(): void { $this->assertEquals($body['prompttokens'], $actionresponse->get_response_data()['prompttokens']); $this->assertEquals($body['completiontokens'], $actionresponse->get_response_data()['completiontokens']); } + + /** + * Test that reasoning tags are stripped from generated content. + */ + public function test_set_response_data_strips_reasoning_tags(): void { + $actionresponse = new response_summarise_text(success: true); + $actionresponse->set_response_data([ + 'generatedcontent' => 'Internal reasoning.The actual response.', + 'finishreason' => 'stop', + ]); + + $this->assertEquals('The actual response.', $actionresponse->get_response_data()['generatedcontent']); + } } diff --git a/public/ai/tests/manager_test.php b/public/ai/tests/manager_test.php index f5e2346ad3047..ab6e76e6b7e6e 100644 --- a/public/ai/tests/manager_test.php +++ b/public/ai/tests/manager_test.php @@ -31,6 +31,29 @@ * @covers \core_ai\manager */ final class manager_test extends \advanced_testcase { + /** + * Test the default placement context API. + * + * @covers \core_ai\placement::is_available_in_context + * @covers \core_ai\placement::get_actions_available + */ + public function test_default_placement_context_api(): void { + $placement = new class extends placement { + /** + * Get the action list. + * + * @return array + */ + public static function get_action_list(): array { + return []; + } + }; + + $context = \context_system::instance(); + $this->assertFalse($placement::is_available_in_context($context)); + $this->assertEmpty($placement::get_actions_available($context)); + } + /** * Test get_ai_plugin_classname. */ @@ -70,6 +93,71 @@ public function test_get_supported_actions(): void { ], $actions); } + /** + * Test get placements available in a context. + */ + public function test_get_placements_available_in_context(): void { + $this->resetAfterTest(); + + set_config('enabled', 1, 'aiplacement_courseassist'); + set_config('enabled', 1, 'aiplacement_editor'); + \core_plugin_manager::reset_caches(); + $course = self::getDataGenerator()->create_course(); + $placements = manager::get_placements_available_in_context(\context_course::instance($course->id)); + $this->assertArrayHasKey('aiplacement_courseassist', $placements); + $this->assertArrayHasKey('aiplacement_editor', $placements); + $placements = manager::get_placements_available_in_context(\context_system::instance()); + $this->assertArrayNotHasKey('aiplacement_courseassist', $placements); + $this->assertArrayHasKey('aiplacement_editor', $placements); + + unset_config('version', 'aiplacement_courseassist'); + \core_plugin_manager::reset_caches(); + $placements = manager::get_placements_available_in_context(\context_course::instance($course->id)); + $this->assertArrayNotHasKey('aiplacement_courseassist', $placements); + $this->assertArrayHasKey('aiplacement_editor', $placements); + $this->assertEmpty(manager::get_placement_actions_available(\context_system::instance(), false)); + } + + /** + * Test getting enabled placements without a concrete context. + */ + public function test_get_enabled_placements(): void { + $this->resetAfterTest(); + + set_config('enabled', 1, 'aiplacement_courseassist'); + set_config('enabled', 1, 'aiplacement_editor'); + \core_plugin_manager::reset_caches(); + + $placements = manager::get_enabled_placements(); + $this->assertArrayHasKey('aiplacement_courseassist', $placements); + $this->assertArrayHasKey('aiplacement_editor', $placements); + + set_config('enabled', 0, 'aiplacement_courseassist'); + \core_plugin_manager::reset_caches(); + + $placements = manager::get_enabled_placements(); + $this->assertArrayNotHasKey('aiplacement_courseassist', $placements); + $this->assertArrayHasKey('aiplacement_editor', $placements); + } + + /** + * Test placement actions are excluded when the editor placement is uninstalled. + */ + public function test_get_placement_actions_available_with_editor_uninstalled(): void { + $this->resetAfterTest(); + + set_config('enabled', 1, 'aiplacement_courseassist'); + set_config('enabled', 1, 'aiplacement_editor'); + unset_config('version', 'aiplacement_editor'); + \core_plugin_manager::reset_caches(); + + $course = self::getDataGenerator()->create_course(); + $placements = manager::get_placements_available_in_context(\context_course::instance($course->id)); + $this->assertArrayHasKey('aiplacement_courseassist', $placements); + $this->assertArrayNotHasKey('aiplacement_editor', $placements); + $this->assertEmpty(manager::get_placement_actions_available(\context_system::instance(), false)); + } + /** * Test create_provider_instance method. */ diff --git a/public/auth/oauth2/classes/api.php b/public/auth/oauth2/classes/api.php index c05768cedee68..35a38bc5a2eab 100644 --- a/public/auth/oauth2/classes/api.php +++ b/public/auth/oauth2/classes/api.php @@ -24,6 +24,8 @@ namespace auth_oauth2; use context_user; +use core\clock; +use core\di; use stdClass; use moodle_exception; use moodle_url; @@ -38,6 +40,11 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class api { + /** + * @var string Interval string of the expiration duration + * @see https://www.php.net/manual/en/dateinterval.construct.php + */ + public const CONFIRMTOKEN_EXPIRES = 'PT30M'; /** * Remove all linked logins that are using issuers that have been deleted. @@ -82,13 +89,20 @@ public static function get_linked_logins($userid = false) { * @return linked_login|false record if found and user exists, false otherwise. */ public static function match_username_to_user($username, $issuer) { - $params = [ + global $DB; + + $where = "issuerid = :issuerid + AND username = :username + AND (confirmtokenexpires = 0 OR confirmtokenexpires > :now)"; + + $record = $DB->get_record_select(linked_login::TABLE, $where, [ 'issuerid' => $issuer->get('id'), - 'username' => $username - ]; - $result = linked_login::get_record($params); + 'username' => $username, + 'now' => di::get(clock::class)->now()->getTimestamp(), + ]); - if ($result) { + if ($record) { + $result = new linked_login(0, $record); $user = \core_user::get_user($result->get('userid')); if (!empty($user) && !$user->deleted) { return $result; @@ -164,9 +178,11 @@ public static function send_confirm_link_login_email($userinfo, $issuer, $userid $record->email = $userinfo['email']; $record->confirmtoken = random_string(32); $expires = new \DateTime('NOW'); - $expires->add(new \DateInterval('PT30M')); + $expires->add(new \DateInterval(self::CONFIRMTOKEN_EXPIRES)); $record->confirmtokenexpires = $expires->getTimestamp(); + linked_login::delete_expired_pending($issuer, $userinfo['username'], $userid); + $linkedlogin = new linked_login(0, $record); $linkedlogin->create(); diff --git a/public/auth/oauth2/classes/linked_login.php b/public/auth/oauth2/classes/linked_login.php index 96be2aeea7591..7b4dcc529cdbf 100644 --- a/public/auth/oauth2/classes/linked_login.php +++ b/public/auth/oauth2/classes/linked_login.php @@ -25,7 +25,10 @@ defined('MOODLE_INTERNAL') || die(); +use core\clock; +use core\di; use core\persistent; +use dml_exception; /** * Class for loading/storing issuer from the DB @@ -110,4 +113,45 @@ public static function delete_orphaned($issuerid = false) { return $DB->execute($sql, $params); } + /** + * Delete expired confirmation tokens. + * + * @return void + * @throws dml_exception + */ + public static function delete_expired_confirmation_tokens(): void { + global $DB; + + $sql = " + DELETE FROM {" . self::TABLE . "} + WHERE confirmtokenexpires <> 0 AND confirmtokenexpires < :now"; + + $DB->execute($sql, ['now' => di::get(clock::class)->now()->getTimestamp()]); + } + + /** + * Delete an expired pending linked login record for a specific user, issuer, and username. + * + * @param \core\oauth2\issuer $issuer The issuer the pending record belongs to. + * @param string $username The external username of the pending record. + * @param int $userid The Moodle user ID the pending record belongs to. + * @return void + * @throws dml_exception + */ + public static function delete_expired_pending(\core\oauth2\issuer $issuer, string $username, int $userid): void { + global $DB; + + $where = "issuerid = :issuerid + AND username = :username + AND userid = :userid + AND confirmtokenexpires <> 0 + AND confirmtokenexpires < :now"; + + $DB->delete_records_select(static::TABLE, $where, [ + 'issuerid' => $issuer->get('id'), + 'username' => $username, + 'userid' => $userid, + 'now' => di::get(clock::class)->now()->getTimestamp(), + ]); + } } diff --git a/public/auth/oauth2/classes/task/delete_expired_confirmation_tokens.php b/public/auth/oauth2/classes/task/delete_expired_confirmation_tokens.php new file mode 100644 index 0000000000000..82c1e5eb60725 --- /dev/null +++ b/public/auth/oauth2/classes/task/delete_expired_confirmation_tokens.php @@ -0,0 +1,53 @@ +. + +namespace auth_oauth2\task; + +use auth_oauth2\linked_login; +use core\exception\coding_exception; +use core\task\scheduled_task; +use dml_exception; +use lang_string; + +/** + * Task to delete expired confirmation tokens. + * + * @package auth_oauth2 + * @copyright 2026 eDaktik GmbH {@link https://www.edaktik.at/} + * @author Christian Abila + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class delete_expired_confirmation_tokens extends scheduled_task { + /** + * Return the task's name. + * + * @return lang_string|string + * @throws coding_exception + */ + public function get_name(): lang_string|string { + return get_string('deleteexpiredconfirmtokens', 'auth_oauth2'); + } + + /** + * Execute the task + * + * @return void + * @throws dml_exception + */ + public function execute(): void { + linked_login::delete_expired_confirmation_tokens(); + } +} diff --git a/public/auth/oauth2/db/tasks.php b/public/auth/oauth2/db/tasks.php new file mode 100644 index 0000000000000..6ecdcc8ef829c --- /dev/null +++ b/public/auth/oauth2/db/tasks.php @@ -0,0 +1,38 @@ +. + +/** + * Tasks definition for auth_oauth2 + * + * @package auth_oauth2 + * @copyright 2026 eDaktik GmbH {@link https://www.edaktik.at/} + * @author Christian Abila + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$tasks = [ + [ + 'classname' => '\auth_oauth2\task\delete_expired_confirmation_tokens', + 'blocking' => 0, + 'minute' => 'R', + 'hour' => 'R', + 'day' => '*', + 'month' => '*', + 'dayofweek' => '*', + ], +]; diff --git a/public/auth/oauth2/lang/en/auth_oauth2.php b/public/auth/oauth2/lang/en/auth_oauth2.php index 4e942ac1e2b40..b1a9263526de8 100644 --- a/public/auth/oauth2/lang/en/auth_oauth2.php +++ b/public/auth/oauth2/lang/en/auth_oauth2.php @@ -61,6 +61,7 @@ $string['confirmlinkedloginemailsubject'] = '{$a}: linked login confirmation'; $string['createaccountswarning'] = 'This authentication plugin allows users to create accounts on your site. You may want to enable the setting "authpreventaccountcreation" if you use this plugin.'; $string['createnewlinkedlogin'] = 'Link a new account ({$a})'; +$string['deleteexpiredconfirmtokens'] = 'Delete expired confirmation tokens'; $string['emailconfirmlink'] = 'Link your accounts'; $string['emailconfirmlinksent'] = '

An existing account was found with this email address but it is not linked yet.

The accounts must be linked before you can log in.

diff --git a/public/auth/oauth2/tests/api_test.php b/public/auth/oauth2/tests/api_test.php index 67a167f3c227a..a1dd1b3c33559 100644 --- a/public/auth/oauth2/tests/api_test.php +++ b/public/auth/oauth2/tests/api_test.php @@ -172,6 +172,34 @@ public function test_linked_logins(): void { $this->assertEquals($newuser->id, $match->get('userid')); } + /** + * Test that match_username_to_user ignores expired pending tokens. + */ + public function test_match_username_to_user_ignores_expired_token(): void { + global $DB; + $this->resetAfterTest(); + $this->setAdminUser(); + + $issuer = \core\oauth2\api::create_standard_issuer('google'); + $user = $this->getDataGenerator()->create_user(); + + // Insert a linked login with an expired confirmation token. + $DB->insert_record(linked_login::TABLE, [ + 'timecreated' => time(), + 'timemodified' => 0, + 'usermodified' => 0, + 'userid' => $user->id, + 'issuerid' => $issuer->get('id'), + 'username' => 'banana', + 'email' => 'banana@example.com', + 'confirmtoken' => random_string(32), + 'confirmtokenexpires' => time() - 60, // Expired 1 minute ago. + ]); + + $match = \auth_oauth2\api::match_username_to_user('banana', $issuer); + $this->assertFalse($match); + } + /** * Test that we cannot deleted a linked login for another user */ @@ -289,4 +317,54 @@ public function test_email_greetings(): void { // Test greetings. $this->assertStringContainsString('Hi ' . $user->firstname, quoted_printable_decode($result[0]->body)); } + + /** + * Test that send_confirm_link_login_email succeeds when an expired pending record already exists. + */ + public function test_send_confirm_link_login_email_clears_expired_record(): void { + global $DB; + $this->resetAfterTest(); + $this->setAdminUser(); + + $issuer = \core\oauth2\api::create_standard_issuer('google'); + $user = $this->getDataGenerator()->create_user(); + + $userinfo = [ + 'username' => 'banana', + 'email' => 'banana@example.com', + ]; + + // Insert an expired pending record for the same user/issuer/username. + $DB->insert_record(linked_login::TABLE, [ + 'timecreated' => time(), + 'timemodified' => 0, + 'usermodified' => 0, + 'userid' => $user->id, + 'issuerid' => $issuer->get('id'), + 'username' => $userinfo['username'], + 'email' => $userinfo['email'], + 'confirmtoken' => random_string(32), + 'confirmtokenexpires' => time() - 60, // Expired 1 minute ago. + ]); + + $sink = $this->redirectEmails(); + \auth_oauth2\api::send_confirm_link_login_email($userinfo, $issuer, $user->id); + $sink->close(); + + // Expired record replaced; exactly one pending record now exists. + $this->assertEquals(1, $DB->count_records(linked_login::TABLE, [ + 'userid' => $user->id, + 'issuerid' => $issuer->get('id'), + 'username' => $userinfo['username'], + ])); + + // New record must have a fresh token and a future expiry. + $record = $DB->get_record(linked_login::TABLE, [ + 'userid' => $user->id, + 'issuerid' => $issuer->get('id'), + 'username' => $userinfo['username'], + ]); + $this->assertNotEmpty($record->confirmtoken); + $this->assertGreaterThan(time(), $record->confirmtokenexpires); + } } diff --git a/public/auth/oauth2/tests/linked_login_test.php b/public/auth/oauth2/tests/linked_login_test.php new file mode 100644 index 0000000000000..1eedc1d3df170 --- /dev/null +++ b/public/auth/oauth2/tests/linked_login_test.php @@ -0,0 +1,149 @@ +. + +namespace auth_oauth2; + +use advanced_testcase; +use core\clock; +use core\di; +use dml_exception; +use Generator; +use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Unit tests for the class \auth_oauth2\linked_login + * + * @package auth_oauth2 + * @copyright 2026 eDaktik GmbH {@link https://www.edaktik.at/} + * @author Christian Abila + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \auth_oauth2\linked_login + */ +#[CoversMethod(linked_login::class, 'delete_expired_confirmation_tokens')] +#[CoversMethod(linked_login::class, 'delete_expired_pending')] +final class linked_login_test extends advanced_testcase { + /** + * Expired confirmation tokens are deleted + * + * @param int $offset Seconds relative to now (negative = past, positive = future) + * @param int $expected + * @return void + * @throws dml_exception + */ + #[DataProvider('expirydate_provider')] + public function test_delete_expired_confirmation_tokens(int $offset, int $expected): void { + $this->resetAfterTest(); + global $DB, $USER; + + $confirmed = 0; + $expirydate = $offset === $confirmed ? $confirmed : di::get(clock::class)->now()->getTimestamp() + $offset; + + $DB->insert_record( + linked_login::TABLE, + [ + 'timecreated' => time(), + 'timemodified' => 0, + 'usermodified' => 0, + 'userid' => $USER->id, + 'issuerid' => 2, + 'email' => 'email@example.com', + 'confirmtokenexpires' => $expirydate, + ], + ); + + linked_login::delete_expired_confirmation_tokens(); + + $this->assertEquals($expected, $DB->count_records(linked_login::TABLE)); + } + + /** + * Expiry dates provider + * + * @return Generator + */ + public static function expirydate_provider(): Generator { + yield 'expired' => [ + 'offset' => -60, // 1 minute in the past. + 'expected' => 0, + ]; + yield 'not yet expired' => [ + 'offset' => 1740, // 29 minutes in the future. + 'expected' => 1, + ]; + yield 'confirmed' => [ + 'offset' => 0, + 'expected' => 1, + ]; + } + + /** + * delete_expired_pending removes only the expired record for the given user/issuer/username. + * + * @param int $offset Seconds relative to now (negative = past, positive = future, 0 = confirmed) + * @param int $expected Expected record count after deletion + * @return void + * @throws dml_exception + */ + #[DataProvider('delete_expired_pending_provider')] + public function test_delete_expired_pending(int $offset, int $expected): void { + $this->resetAfterTest(); + global $DB; + + $this->setAdminUser(); + $issuer = \core\oauth2\api::create_standard_issuer('google'); + $user = $this->getDataGenerator()->create_user(); + + $confirmed = 0; + $expirydate = $offset === $confirmed ? $confirmed : di::get(clock::class)->now()->getTimestamp() + $offset; + + $DB->insert_record(linked_login::TABLE, [ + 'timecreated' => time(), + 'timemodified' => 0, + 'usermodified' => 0, + 'userid' => $user->id, + 'issuerid' => $issuer->get('id'), + 'username' => 'banana', + 'email' => 'banana@example.com', + 'confirmtoken' => random_string(32), + 'confirmtokenexpires' => $expirydate, + ]); + + linked_login::delete_expired_pending($issuer, 'banana', $user->id); + + $this->assertEquals($expected, $DB->count_records(linked_login::TABLE)); + } + + /** + * Data provider for test_delete_expired_pending. + * + * @return Generator + */ + public static function delete_expired_pending_provider(): Generator { + yield 'expired record is deleted' => [ + 'offset' => -60, // 1 minute in the past. + 'expected' => 0, + ]; + yield 'not yet expired record is kept' => [ + 'offset' => 1740, // 29 minutes in the future. + 'expected' => 1, + ]; + yield 'confirmed record (expires = 0) is kept' => [ + 'offset' => 0, + 'expected' => 1, + ]; + } +} diff --git a/public/auth/oauth2/version.php b/public/auth/oauth2/version.php index 279d89d334ed0..04007b0955fbd 100644 --- a/public/auth/oauth2/version.php +++ b/public/auth/oauth2/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2025100600; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2025100601; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2025092600; // Requires this Moodle version. $plugin->component = 'auth_oauth2'; // Full name of the plugin (used for diagnostics). diff --git a/public/availability/classes/info.php b/public/availability/classes/info.php index a573374bfeaaa..8fa2ea0d7315f 100644 --- a/public/availability/classes/info.php +++ b/public/availability/classes/info.php @@ -208,15 +208,7 @@ public function is_available(&$information, $grabthelot = false, $userid = 0, $this->modinfo = null; return true; } else { - // If the item is marked as 'not visible' then we don't change the available - // flag (visible/available are treated distinctly), but we remove any - // availability info. If the item is hidden with the eye icon, it doesn't - // make sense to show 'Available from ' or similar, because even - // when that date arrives it will still not be available unless somebody - // toggles the eye icon. - if ($this->visible) { - $information = $tree->get_result_information($this, $result); - } + $information = $tree->get_result_information($this, $result); $this->modinfo = null; return false; diff --git a/public/backup/controller/restore_controller.class.php b/public/backup/controller/restore_controller.class.php index a4fefc426b7a3..b43603f4eb3f6 100644 --- a/public/backup/controller/restore_controller.class.php +++ b/public/backup/controller/restore_controller.class.php @@ -53,6 +53,9 @@ class restore_controller extends base_controller { /** @var restore_plan */ protected $plan; // Restore execution plan + /** @var array|null Fields provided in the CSV that should not be overwritten from the template course. */ + protected $skiptemplatefields = null; + /** * Immediate/delayed execution type. * @var integer @@ -88,9 +91,20 @@ class restore_controller extends base_controller { * @param \core\progress\base $progress Optional progress monitor * @param \stdClass $copydata Course copy data, required when in MODE_COPY * @param bool $releasesession Should release the session? backup::RELEASESESSION_YES or backup::RELEASESESSION_NO + * @param ?array $skiptemplatefields Course fields to exclude when restoring from a template course. */ - public function __construct($tempdir, $courseid, $interactive, $mode, $userid, $target, - ?\core\progress\base $progress = null, $releasesession = backup::RELEASESESSION_NO, ?\stdClass $copydata = null) { + public function __construct( + $tempdir, + $courseid, + $interactive, + $mode, + $userid, + $target, + ?\core\progress\base $progress = null, + $releasesession = backup::RELEASESESSION_NO, + ?\stdClass $copydata = null, + $skiptemplatefields = null + ) { if ($mode == backup::MODE_COPY && is_null($copydata)) { throw new restore_controller_exception('cannot_instantiate_missing_copydata'); @@ -113,6 +127,7 @@ public function __construct($tempdir, $courseid, $interactive, $mode, $userid, $ $this->samesite = false; $this->checksum = ''; $this->precheck = null; + $this->skiptemplatefields = $skiptemplatefields; // Apply current backup version and release if necessary backup_controller_dbops::apply_version_and_release(); @@ -341,6 +356,14 @@ public function get_executiontime() { return $this->executiontime; } + /** + * Returns fields that we want to skip importing + * @return array|null + */ + public function get_skiptemplatefields(): ?array { + return $this->skiptemplatefields; + } + /** * Returns the restore plan * @return restore_plan diff --git a/public/backup/moodle2/restore_course_task.class.php b/public/backup/moodle2/restore_course_task.class.php index d8a99b05edb9e..f8365b5a3fce3 100644 --- a/public/backup/moodle2/restore_course_task.class.php +++ b/public/backup/moodle2/restore_course_task.class.php @@ -66,9 +66,20 @@ public function build() { // Define the task contextid (the course one) $this->contextid = context_course::instance($this->get_courseid())->id; - // Executed conditionally if restoring to new course or if overwrite_conf setting is enabled - if ($this->get_target() == backup::TARGET_NEW_COURSE || $this->get_setting_value('overwrite_conf') == true) { - $this->add_step(new restore_course_structure_step('course_info', 'course.xml')); + // Fields that should be excluded when restoring the template. + $skiptemplatefields = $this->get_skiptemplatefields(); + + if ( + $this->get_target() == backup::TARGET_NEW_COURSE || + $this->get_setting_value('overwrite_conf') == true || + !is_null($skiptemplatefields) + ) { + $this->add_step(new restore_course_structure_step( + 'course_info', + 'course.xml', + null, + $skiptemplatefields + )); // Search reindexing (if enabled). if (\core_search\manager::is_indexing_enabled()) { diff --git a/public/backup/moodle2/restore_stepslib.php b/public/backup/moodle2/restore_stepslib.php index d1691ac3f461f..7ee1d236320f9 100644 --- a/public/backup/moodle2/restore_stepslib.php +++ b/public/backup/moodle2/restore_stepslib.php @@ -1047,6 +1047,17 @@ public function process_file($data) { $data = (object)$data; // handy + // Reject invalid contenthash values early to prevent path traversal. + if (!empty($data->contenthash) && !preg_match('/^[a-f0-9]{40}$/', $data->contenthash)) { + $filename = isset($data->filename) ? $data->filename : ''; + $this->log( + 'Skipping file with invalid contenthash during restore: ' . $filename, + backup::LOG_WARNING + ); + + return; + } + // load it if needed: // - it it is one of the annotated inforef files (course/section/activity/block) // - it is one "user", "group", "grouping", "grade", "question" or "qtype_xxxx" component file (that aren't sent to inforef ever) @@ -1682,9 +1693,12 @@ public function process_section($data) { $section->summaryformat = $data->summaryformat; $restorefiles = true; } - - // Don't update availability (I didn't see a useful way to define - // whether existing or new one should take precedence). + if (!$data->visible) { + $section->visible = $data->visible; + } + if (!empty($CFG->enableavailability) && empty($secrec->availability)) { + $section->availability = isset($data->availabilityjson) ? $data->availabilityjson : null; + } $DB->update_record('course_sections', $section); $newitemid = $secrec->id; @@ -1915,6 +1929,35 @@ class restore_course_structure_step extends restore_structure_step { */ protected $legacyallowedmodules = array(); + /** @var array|null Fields provided in the CSV that should not be overwritten from the template course. */ + protected $skiptemplatefields = []; + + /** + * Step constructor. + * @param string $name Step's name. + * @param string $filename Step's file name. + * @param restore_task|null $task Restore task. + * @param ?array $skiptemplatefields Course fields provided in the CSV that should not be overwritten by the template course. + * @throws restore_step_exception + */ + public function __construct($name, $filename, $task = null, $skiptemplatefields = []) { + parent::__construct($name, $filename, $task); + $this->skiptemplatefields = $skiptemplatefields; + } + + /** + * Check whether the template course field should be restored. + * + * Fields explicitly provided in the CSV should not be overwritten by values + * from the template course. + * + * @param string $field the course field name to check. + * @return bool + */ + protected function should_restore_template_field(string $field): bool { + return !in_array($field, $this->skiptemplatefields ?? []); + } + protected function define_structure() { $paths = []; @@ -1922,31 +1965,38 @@ protected function define_structure() { $course = new restore_path_element('course', '/course'); $paths[] = $course; $paths[] = new restore_path_element('category', '/course/category'); - $paths[] = new restore_path_element('tag', '/course/tags/tag'); - $paths[] = new restore_path_element('course_format_option', '/course/courseformatoptions/courseformatoption'); + if ($this->should_restore_template_field('tags')) { + $paths[] = new restore_path_element('tag', '/course/tags/tag'); + } + if ($this->should_restore_template_field('format')) { + $paths[] = new restore_path_element('course_format_option', '/course/courseformatoptions/courseformatoption'); + } $paths[] = new restore_path_element('allowed_module', '/course/allowed_modules/module'); // Custom fields. if ($this->get_setting_value('customfield')) { $paths[] = new restore_path_element('customfield', '/course/customfields/customfield'); } + if ($this->should_restore_template_field('format')) { + // Apply for 'format' plugins optional paths at course level. + $this->add_plugin_structure('format', $course); + } - // Apply for 'format' plugins optional paths at course level - $this->add_plugin_structure('format', $course); - - // Apply for 'theme' plugins optional paths at course level - $this->add_plugin_structure('theme', $course); + if ($this->should_restore_template_field('theme')) { + // Apply for 'theme' plugins optional paths at course level. + $this->add_plugin_structure('theme', $course); + } - // Apply for 'report' plugins optional paths at course level + // Apply for 'report' plugins optional paths at course level. $this->add_plugin_structure('report', $course); - // Apply for 'course report' plugins optional paths at course level + // Apply for 'course report' plugins optional paths at course level. $this->add_plugin_structure('coursereport', $course); - // Apply for plagiarism plugins optional paths at course level + // Apply for plagiarism plugins optional paths at course level. $this->add_plugin_structure('plagiarism', $course); - // Apply for local plugins optional paths at course level + // Apply for local plugins optional paths at course level. $this->add_plugin_structure('local', $course); // Apply for admin tool plugins optional paths at course level. @@ -2074,7 +2124,33 @@ public function process_course($data) { $data->activitytype = 'scorm'; } - // Course record ready, update it + // Remove fields explicitly provided via CSV upload so template values do not overwrite them. + foreach ($this->skiptemplatefields ?? [] as $field) { + // Keep the CSV-provided format instead of the template format. + // The format cannot be unset because it is required by the restore process. + if ($field == 'format') { + $data->format = $DB->get_field('course', 'format', ['id' => $this->get_courseid()]); + + // Activity type only applies to the single activity format. + if ($data->format != 'singleactivity') { + unset($data->activitytype); + } + + continue; + } + + if (!isset($data->{$field})) { + continue; + } + + // Some fields have dependent properties that must be removed alongside them. + if ($field == 'summary' && isset($data->summaryformat)) { + unset($data->summaryformat); + } + + unset($data->{$field}); + } + // Course record ready, update it. $DB->update_record('course', $data); // Apply any course format options that may be saved against the course @@ -2152,7 +2228,9 @@ protected function after_execute() { global $DB; // Add course related files, without itemid to match - $this->add_related_files('course', 'summary', null); + if ($this->should_restore_template_field('summary')) { + $this->add_related_files('course', 'summary', null); + } $this->add_related_files('course', 'overviewfiles', null); // Deal with legacy allowed modules. diff --git a/public/backup/moodle2/tests/restore_stepslib_test.php b/public/backup/moodle2/tests/restore_stepslib_test.php index cc76ebf79173d..ca28b8e01be12 100644 --- a/public/backup/moodle2/tests/restore_stepslib_test.php +++ b/public/backup/moodle2/tests/restore_stepslib_test.php @@ -200,4 +200,56 @@ public function test_restore_hook(): void { $rc->execute_plan(); $rc->destroy(); } + + /** + * Data provider for contenthash values - invalid hashes are skipped, valid hashes proceed to processing. + * + * @return array + */ + public static function contenthash_provider(): array { + return [ + 'Invalid - path traversal' => ['../../../../../../../../../../../../etc/passwd', false], + 'Invalid - uppercase hex' => ['DA39A3EE5E6B4B0D3255BFEF95601890AFD80709', false], + 'Invalid - too short' => ['da39a3ee5e6b4b0d3255bfef95601890afd807', false], + 'Invalid - non-hex chars' => ['zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz', false], + 'Valid - lowercase sha1' => ['da39a3ee5e6b4b0d3255bfef95601890afd80709', true], + 'Empty - directory entry' => ['', true], + ]; + } + + /** + * Test contenthash validation when restoring files. + * + * - Invalid contenthash values are rejected with a warning logged and processing stopped. + * - Valid contenthash values allow processing to continue. + * + * @param string $hash The contenthash value to validate. + * @param bool $isvalid Whether the hash is expected to pass validation. + * @dataProvider contenthash_provider + * @covers \restore_load_included_files::process_file + */ + public function test_process_file_contenthash_validation(string $hash, bool $isvalid): void { + $step = $this->getMockBuilder(\restore_load_included_files::class) + ->setConstructorArgs(['test', null]) + ->onlyMethods(['log']) + ->getMock(); + + if ($isvalid) { + // Valid hash: validation is skipped — no warning log should be emitted. + // Processing may throw due to the missing restore context; absorb it since + // only the log() assertion matters here. + $step->expects($this->never())->method('log'); + try { + $step->process_file(['contenthash' => $hash]); + } catch (\Throwable $e) { + // Absorb any exception caused by missing restore context after validation passes. + } + } else { + // Invalid hash: a LOG_WARNING must be emitted before the early return. + $step->expects($this->once()) + ->method('log') + ->with($this->stringContains('Skipping file with invalid contenthash during restore'), backup::LOG_WARNING); + $step->process_file(['contenthash' => $hash]); + } + } } diff --git a/public/backup/util/dbops/backup_plan_dbops.class.php b/public/backup/util/dbops/backup_plan_dbops.class.php index 4dae3e5d56370..6d15b05606b9f 100644 --- a/public/backup/util/dbops/backup_plan_dbops.class.php +++ b/public/backup/util/dbops/backup_plan_dbops.class.php @@ -241,6 +241,9 @@ public static function get_default_backup_filename($format, $type, $id, $users, } $shortname = str_replace(' ', '_', $shortname); $shortname = core_text::strtolower(trim(clean_filename($shortname), '_')); + // Truncate by bytes to keep total filename within the 255-byte OS limit. + // Use 170 bytes as a conservative budget for the shortname. + $shortname = core_text::str_max_bytes($shortname, 170); } // The name will always contain the ID, but we append the course short name if requested. diff --git a/public/backup/util/dbops/tests/backup_plan_dbops_test.php b/public/backup/util/dbops/tests/backup_plan_dbops_test.php new file mode 100644 index 0000000000000..0bf568056ee54 --- /dev/null +++ b/public/backup/util/dbops/tests/backup_plan_dbops_test.php @@ -0,0 +1,125 @@ +. + +namespace core_backup; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); + +/** + * Tests for backup_plan_dbops. + * + * @package core_backup + * @category test + * @copyright 2026 Muhammad Arnaldo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \backup_plan_dbops::get_default_backup_filename + */ +final class backup_plan_dbops_test extends \advanced_testcase { + /** + * Test that get_default_backup_filename returns a valid filename within the OS 255-byte limit. + * + * @param string $shortname The course shortname. + * @dataProvider get_default_backup_filename_provider + */ + public function test_get_default_backup_filename(string $shortname): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(['shortname' => $shortname]); + + $filename = \backup_plan_dbops::get_default_backup_filename( + \backup::FORMAT_MOODLE, + \backup::TYPE_1COURSE, + $course->id, + true, + false, + ); + + $this->assertLessThanOrEqual(255, strlen($filename)); + $this->assertTrue(mb_check_encoding($filename, 'UTF-8')); + } + + /** + * Data provider for test_get_default_backup_filename. + * + * @return array + */ + public static function get_default_backup_filename_provider(): array { + // 255 ASCII chars = 255 bytes = maximum shortname length allowed by the DB column. + $ascii255 = str_repeat('a', 255); + + // 255 Cyrillic chars = 510 bytes (each Cyrillic char is 2 bytes in UTF-8). + $cyrillic255 = str_repeat('и', 255); + + // 255 CJK chars = 765 bytes (each CJK char is 3 bytes in UTF-8). + $cjk255 = str_repeat('中', 255); + + // 1 ASCII byte + 254 Cyrillic = 255 chars = 509 bytes. The odd byte shifts the Cyrillic block + // so a byte-based substr() splits mid-character, producing invalid UTF-8. + $oddalignedcyrillic = 'a' . str_repeat('и', 254); + + return [ + 'short ascii shortname' => ['CS101'], + '255 ascii chars - max shortname length' => [$ascii255], + '255 cyrillic chars' => [$cyrillic255], + '255 cjk chars' => [$cjk255], + 'odd-aligned ascii+cyrillic' => [$oddalignedcyrillic], + 'shortname with spaces' => ['My Course Name Here'], + ]; + } + + /** + * Test that a short ASCII shortname is preserved and not truncated. + */ + public function test_get_default_backup_filename_preserves_short_name(): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(['shortname' => 'CS101']); + + $filename = \backup_plan_dbops::get_default_backup_filename( + \backup::FORMAT_MOODLE, + \backup::TYPE_1COURSE, + $course->id, + true, + false, + ); + + $this->assertStringContainsString('cs101', strtolower($filename)); + } + + /** + * Test that when useidonly is true, the shortname does not appear in the filename. + */ + public function test_get_default_backup_filename_useidonly_excludes_shortname(): void { + $this->resetAfterTest(); + + $shortname = 'UniqueShortname123'; + $course = $this->getDataGenerator()->create_course(['shortname' => $shortname]); + + $filename = \backup_plan_dbops::get_default_backup_filename( + \backup::FORMAT_MOODLE, + \backup::TYPE_1COURSE, + $course->id, + true, + false, + true, + ); + + $this->assertStringNotContainsString(strtolower($shortname), $filename); + } +} diff --git a/public/backup/util/loggers/file_logger.class.php b/public/backup/util/loggers/file_logger.class.php index 97ae661339a8f..797ffa0e29fb5 100644 --- a/public/backup/util/loggers/file_logger.class.php +++ b/public/backup/util/loggers/file_logger.class.php @@ -66,6 +66,13 @@ public function __sleep() { } public function __wakeup() { + // If the stored path no longer exists, reconstruct it using the current backup temp dir. + if (!empty($this->fullpath) && !file_exists(dirname($this->fullpath))) { + $filename = basename($this->fullpath); + $backuptempdir = make_backup_temp_directory(''); + $this->fullpath = $backuptempdir . '/' . $filename; + } + if ($this->level > backup::LOG_NONE) { // Only create the file if we are going to log something if (! $this->fhandle = fopen($this->fullpath, 'a')) { throw new base_logger_exception('error_opening_file', $this->fullpath); diff --git a/public/backup/util/plan/restore_plan.class.php b/public/backup/util/plan/restore_plan.class.php index 71f2c7a59f738..c353eae498a2d 100644 --- a/public/backup/util/plan/restore_plan.class.php +++ b/public/backup/util/plan/restore_plan.class.php @@ -95,6 +95,14 @@ public function get_logger() { return $this->controller->get_logger(); } + /** + * Returns fields that we want to skip importing + * @return array|null + */ + public function get_skiptemplatefields(): ?array { + return $this->controller->get_skiptemplatefields(); + } + /** * Gets the progress reporter, which can be used to report progress within * the backup or restore process. diff --git a/public/backup/util/plan/restore_task.class.php b/public/backup/util/plan/restore_task.class.php index cfddcdf20ecbf..9a7b27ee58caa 100644 --- a/public/backup/util/plan/restore_task.class.php +++ b/public/backup/util/plan/restore_task.class.php @@ -95,6 +95,14 @@ public function get_old_system_contextid() { return $this->plan->get_info()->original_system_contextid; } + /** + * Returns fields that we want to skip importing. + * @return array|null + */ + public function get_skiptemplatefields(): ?array { + return $this->plan->get_skiptemplatefields(); + } + /** * Given a commment area, return the itemname that contains the itemid mappings * diff --git a/public/backup/util/ui/tests/behat/backup_xapistate.feature b/public/backup/util/ui/tests/behat/backup_xapistate.feature index 5751a2c2cf984..213a64b427c93 100644 --- a/public/backup/util/ui/tests/behat/backup_xapistate.feature +++ b/public/backup/util/ui/tests/behat/backup_xapistate.feature @@ -23,14 +23,14 @@ Feature: Backup xAPI states | enableasyncbackup | 0 | # Save state for the student user. And I am on the "Awesome H5P package" "h5pactivity activity" page logged in as student1 - And I switch to "h5p-player" class iframe - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-player" iframe is interactable and switch to it + And I wait until "h5p-iframe" iframe is interactable and switch to it And I set the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" to "Narnia" And I switch to the main frame And I am on the "Course 1" course page And I am on the "Awesome H5P package" "h5pactivity activity" page - And I switch to "h5p-player" class iframe - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-player" iframe is interactable and switch to it + And I wait until "h5p-iframe" iframe is interactable and switch to it And the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" matches value "Narnia" And I log out @@ -47,8 +47,8 @@ Feature: Backup xAPI states # Login as student and confirm xAPI state has been restored. When I am on the "Course 2" course page logged in as student1 And I click on "Awesome H5P package" "link" in the "region-main" "region" - And I switch to "h5p-player" class iframe - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-player" iframe is interactable and switch to it + And I wait until "h5p-iframe" iframe is interactable and switch to it Then the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" matches value "Narnia" Scenario: Content state is not restored when user data is not included in the backup @@ -67,8 +67,8 @@ Feature: Backup xAPI states # Login as student and confirm xAPI state hasn't been restored. And I am on the "Course 2" course page logged in as student1 And I click on "Awesome H5P package" "link" in the "region-main" "region" - And I switch to "h5p-player" class iframe - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-player" iframe is interactable and switch to it + And I wait until "h5p-iframe" iframe is interactable and switch to it Then the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" does not match value "Narnia" Scenario: Content state is not restored when user data is included in the backup but xAPI state is not restored @@ -83,8 +83,8 @@ Feature: Backup xAPI states # Login as student and confirm xAPI state hasn't been restored. When I am on the "Course 2" course page logged in as student1 And I click on "Awesome H5P package" "link" in the "region-main" "region" - And I switch to "h5p-player" class iframe - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-player" iframe is interactable and switch to it + And I wait until "h5p-iframe" iframe is interactable and switch to it Then the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" does not match value "Narnia" Scenario: Content state is not restored when it is not included explicitly in the backup @@ -100,6 +100,6 @@ Feature: Backup xAPI states # Login as student and confirm xAPI state hasn't been restored. And I am on the "Course 2" course page logged in as student1 And I click on "Awesome H5P package" "link" in the "region-main" "region" - And I switch to "h5p-player" class iframe - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-player" iframe is interactable and switch to it + And I wait until "h5p-iframe" iframe is interactable and switch to it Then the field with xpath "//input[contains(@aria-label,\"Blank input 1 of 4\")]" does not match value "Narnia" diff --git a/public/backup/util/ui/tests/behat/restore_moodle2_courses.feature b/public/backup/util/ui/tests/behat/restore_moodle2_courses.feature index 479cc58e874b6..2746fedc73480 100644 --- a/public/backup/util/ui/tests/behat/restore_moodle2_courses.feature +++ b/public/backup/util/ui/tests/behat/restore_moodle2_courses.feature @@ -128,7 +128,8 @@ Feature: Restore Moodle 2 course backups And the field "Course layout" matches value "Show one section per page" And the field "Course short name" matches value "C1_1" And I press "Cancel" - And section "3" should be visible + And section "2" should be visible + And section "3" should be hidden And section "7" should be hidden And section "15" should be visible And I should see "Section 15" @@ -150,7 +151,8 @@ Feature: Restore Moodle 2 course backups And the field "Course short name" matches value "C2" And the field "Course layout" matches value "Show all sections on one page" And I press "Cancel" - And section "3" should be visible + And section "2" should be visible + And section "3" should be hidden And section "7" should be hidden And section "15" should be visible And I should see "Section 15" diff --git a/public/badges/classes/backpack_api.php b/public/badges/classes/backpack_api.php index 749c8c03680ec..615adfc98bb79 100644 --- a/public/badges/classes/backpack_api.php +++ b/public/badges/classes/backpack_api.php @@ -553,7 +553,7 @@ public function get_badges($collection, $expanded = false) { } // Now we can make requests. $badges = $this->curl_request('badges', $collection->entityid); - if (count($badges) == 0) { + if (empty($badges)) { return []; } $badges = $badges[0]; diff --git a/public/badges/classes/form/badge.php b/public/badges/classes/form/badge.php index 4a27ba583d501..766b4d501d68f 100644 --- a/public/badges/classes/form/badge.php +++ b/public/badges/classes/form/badge.php @@ -130,11 +130,8 @@ public function definition() { $mform->addGroup($issuancedetails, 'expirydategr', get_string('expirydate', 'badges'), [' '], false); $mform->setDefault('expiry', 0); $mform->setDefault('expiredate', strtotime('+1 year')); - $mform->disabledIf('expiredate[day]', 'expiry', 'neq', 1); - $mform->disabledIf('expiredate[month]', 'expiry', 'neq', 1); - $mform->disabledIf('expiredate[year]', 'expiry', 'neq', 1); - $mform->disabledIf('expireperiod[number]', 'expiry', 'neq', 2); - $mform->disabledIf('expireperiod[timeunit]', 'expiry', 'neq', 2); + $mform->disabledIf('expiredate', 'expiry', 'neq', 1); + $mform->disabledIf('expireperiod', 'expiry', 'neq', 2); $mform->addElement('hidden', 'action', $action); $mform->setType('action', PARAM_TEXT); diff --git a/public/badges/criteria/award_criteria.php b/public/badges/criteria/award_criteria.php index 6325c75df11dd..8e98a51494545 100644 --- a/public/badges/criteria/award_criteria.php +++ b/public/badges/criteria/award_criteria.php @@ -242,9 +242,6 @@ public function config_options(&$mform, $param) { $mform->addGroupRule('param_' . $prefix . $param['id'], array( 'grade_' . $param['id'] => array(array(get_string('err_numeric', 'form'), 'numeric', '', 'client')))); } - $mform->disabledIf('bydate_' . $param['id'] . '[day]', 'bydate_' . $param['id'] . '[enabled]', 'notchecked'); - $mform->disabledIf('bydate_' . $param['id'] . '[month]', 'bydate_' . $param['id'] . '[enabled]', 'notchecked'); - $mform->disabledIf('bydate_' . $param['id'] . '[year]', 'bydate_' . $param['id'] . '[enabled]', 'notchecked'); $mform->disabledIf('param_' . $prefix . $param['id'], $prefix . $param['id'], 'notchecked'); } diff --git a/public/badges/criteria/award_criteria_course.php b/public/badges/criteria/award_criteria_course.php index 3b0f268818575..51105093b75fe 100644 --- a/public/badges/criteria/award_criteria_course.php +++ b/public/badges/criteria/award_criteria_course.php @@ -149,10 +149,6 @@ public function get_options(&$mform) { $mform->setType('grade_' . $param['course'], PARAM_INT); $mform->addGroup($parameter, 'param_' . $param['course'], '', array(' '), false); - $mform->disabledIf('bydate_' . $param['course'] . '[day]', 'bydate_' . $param['course'] . '[enabled]', 'notchecked'); - $mform->disabledIf('bydate_' . $param['course'] . '[month]', 'bydate_' . $param['course'] . '[enabled]', 'notchecked'); - $mform->disabledIf('bydate_' . $param['course'] . '[year]', 'bydate_' . $param['course'] . '[enabled]', 'notchecked'); - // Set existing values. if (isset($param['bydate'])) { $mform->setDefault('bydate_' . $param['course'], $param['bydate']); diff --git a/public/blocks/blog_tags/tests/behat/blogtag.feature b/public/blocks/blog_tags/tests/behat/blogtag.feature index a6e7ba4b14105..21a856c0e25af 100644 --- a/public/blocks/blog_tags/tests/behat/blogtag.feature +++ b/public/blocks/blog_tags/tests/behat/blogtag.feature @@ -27,8 +27,7 @@ Feature: Adding blog tag block | unaddableblocks | | theme_boost| # TODO MDL-57120 site "Blogs" link not accessible without navigation block. And I add the "Navigation" block if not present - - And I navigate to course participants + And I am on the "Course 1" "enrolled users" page And I click on "Course blogs" "link" in the "Navigation" "block" And I follow "Blog about this Course" And I set the following fields to these values: @@ -36,10 +35,7 @@ Feature: Adding blog tag block | Blog entry body | Teacher blog post content | | Tags | Cats, dogs | And I press "Save changes" - And I log out - And I log in as "student1" - And I am on "Course 1" course homepage - And I navigate to course participants + And I am on the "Course 1" "enrolled users" page logged in as "student1" And I click on "Course blogs" "link" in the "Navigation" "block" And I follow "Blog about this Course" And I set the following fields to these values: diff --git a/public/blocks/myoverview/UPGRADING.md b/public/blocks/myoverview/UPGRADING.md new file mode 100644 index 0000000000000..6a4c4351cfeeb --- /dev/null +++ b/public/blocks/myoverview/UPGRADING.md @@ -0,0 +1,9 @@ +# block_myoverview Upgrade notes + +## 5.1.6 + +### Changed + +- For the correct display of title and context menus, fields like fullname are returned with numeric HTML entities (<) instead of named entities (<) and unencoded quotes. + + For more information see [MDL-79755](https://tracker.moodle.org/browse/MDL-79755) diff --git a/public/blocks/myoverview/templates/course-action-menu.mustache b/public/blocks/myoverview/templates/course-action-menu.mustache index 7a820cd08ced6..3f3f2da4ca5ec 100644 --- a/public/blocks/myoverview/templates/course-action-menu.mustache +++ b/public/blocks/myoverview/templates/course-action-menu.mustache @@ -30,8 +30,8 @@ data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> - - {{#str}} aria:courseactions, block_myoverview {{/str}} {{{fullname}}} + + {{#str}} aria:courseactions, block_myoverview {{/str}} {{fullname}} {{/checkbox}} {{/tools}} - +
\ No newline at end of file diff --git a/public/contentbank/tests/behat/copy_content.feature b/public/contentbank/tests/behat/copy_content.feature index 0d5b3ddb27e7d..ffe6cdd4954e2 100644 --- a/public/contentbank/tests/behat/copy_content.feature +++ b/public/contentbank/tests/behat/copy_content.feature @@ -37,7 +37,7 @@ Feature: Copy content from the content bank And I click on "Save changes" "button" Then I should see "Fill the blanks copy 1" And I click on "Edit" "link" - And I switch to "h5p-editor-iframe" class iframe + And I wait until "h5p-editor-iframe" iframe is interactable and switch to it Then the field "Title" matches value "Geography" Scenario: Users without the required capability cannot copy content diff --git a/public/contentbank/tests/behat/edit_content.feature b/public/contentbank/tests/behat/edit_content.feature index 76dd50cd07491..f027983b0bfed 100644 --- a/public/contentbank/tests/behat/edit_content.feature +++ b/public/contentbank/tests/behat/edit_content.feature @@ -54,7 +54,7 @@ Feature: Content bank use editor feature When I click on "Content bank" "link" And I click on "filltheblanks.h5p" "link" Then I click on "Edit" "link" - And I switch to "h5p-editor-iframe" class iframe + And I wait until "h5p-editor-iframe" iframe is interactable and switch to it And I switch to the main frame And I change viewport size to "800x1400" And I click on "Cancel" "button" @@ -69,7 +69,7 @@ Feature: Content bank use editor feature When I click on "Content bank" "link" in the "Navigation" "block" And I click on "[data-action=Add-content]" "css_element" Then I click on "Fill in the Blanks" "link" - And I switch to "h5p-editor-iframe" class iframe + And I wait until "h5p-editor-iframe" iframe is interactable and switch to it And I switch to the main frame And I click on "Cancel" "button" @@ -107,14 +107,14 @@ Feature: Content bank use editor feature And I click on "Content bank" "link" in the "Navigation" "block" And I click on "filltheblanks.h5p" "link" And I click on "Edit" "link" - And I switch to "h5p-editor-iframe" class iframe + And I wait until "h5p-editor-iframe" iframe is interactable and switch to it And the field "Title" matches value "Geography" And I set the field "Title" to "New title" And I switch to the main frame When I click on "Save" "button" And "filltheblanks.h5p" "heading" should exist And I click on "Edit" "link" - And I switch to "h5p-editor-iframe" class iframe + And I wait until "h5p-editor-iframe" iframe is interactable and switch to it Then the field "Title" matches value "New title" Scenario: Teachers can edit their own content in the content bank diff --git a/public/contentbank/tests/behat/restore_content.feature b/public/contentbank/tests/behat/restore_content.feature index 91ede4e3810d3..a09383bc10acd 100644 --- a/public/contentbank/tests/behat/restore_content.feature +++ b/public/contentbank/tests/behat/restore_content.feature @@ -33,6 +33,6 @@ Feature: Content bank contents are retained when course is restored And I expand "Site pages" node When I click on "Content bank" "link" And I click on "filltheblanks.h5p" "link" - And I switch to "h5p-player" class iframe - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-player" iframe is interactable and switch to it + And I wait until "h5p-iframe" iframe is interactable and switch to it Then I should see "Of which countries are Berlin, Washington, Beijing, Canberra and Brasilia the capitals?" diff --git a/public/course/classes/category.php b/public/course/classes/category.php index 83e7821178c83..3a6d05645e244 100644 --- a/public/course/classes/category.php +++ b/public/course/classes/category.php @@ -2063,6 +2063,9 @@ public function delete_full($showfeedback = true) { // Delete all events in the category. $DB->delete_records('event', array('categoryid' => $this->id)); + // Delete all event subscriptions in the category. + $DB->delete_records('event_subscriptions', ['categoryid' => $this->id]); + // Finally delete the category and it's context. $categoryrecord = $this->get_db_record(); $DB->delete_records('course_categories', array('id' => $this->id)); diff --git a/public/course/classes/cm_info.php b/public/course/classes/cm_info.php index f131dd1142098..226b6801e53b9 100644 --- a/public/course/classes/cm_info.php +++ b/public/course/classes/cm_info.php @@ -1599,7 +1599,7 @@ private function update_user_visible() { ($this->visibleoncoursepage || has_any_capability($capabilities, $this->get_context(), $userid)); // Activity that is not available, not hidden from course page and has availability // info is actually visible on the course page (with availability info and without a link). - if (!$this->uservisible && $this->visibleoncoursepage && $this->availableinfo) { + if ($this->visibleoncoursepage && $this->visible && $this->availableinfo) { $this->uservisibleoncoursepage = true; } } diff --git a/public/course/classes/task/course_delete_modules.php b/public/course/classes/task/course_delete_modules.php index 0ce504414ddfa..ceda8767be05e 100644 --- a/public/course/classes/task/course_delete_modules.php +++ b/public/course/classes/task/course_delete_modules.php @@ -89,11 +89,13 @@ public function execute() { } /** - * Sets attemptsavailable to false. + * Explicitly set attemptsavailable to true as their are valid + * reasons why a delete may fail intermittently and then work + * when allowed to retry. See MDL-88162 for more details. * * @return boolean */ public function retry_until_success(): bool { - return false; + return true; } } diff --git a/public/course/edit_form.php b/public/course/edit_form.php index b7c71b64ce86c..2923cba5926f6 100644 --- a/public/course/edit_form.php +++ b/public/course/edit_form.php @@ -415,11 +415,12 @@ function definition() { array('itemtype' => 'course', 'component' => 'core')); } - // Add AI tools section if AI placement (course or editor) is available and at least one provider is enabled. - $courseplacementenabled = aiplacement_courseassist\utils::is_course_assist_available(); - $editorplacementenabled = aiplacement_editor\utils::is_html_editor_placement_available(); - $providerenabled = \core\di::get(core_ai\manager::class)->get_provider_instances(['enabled' => 1]); - if (($courseplacementenabled || $editorplacementenabled) && $providerenabled) { + // Add AI tools section when an AI placement is available. + $aimanager = \core\di::get(core_ai\manager::class); + $providerenabled = $aimanager->get_provider_instances(['enabled' => 1]); + $placements = $coursecontext ? $aimanager::get_placements_available_in_context($coursecontext) : + $aimanager::get_enabled_placements(); + if ($placements && $providerenabled) { $mform->addElement('header', 'aitoolshdr', get_string('aitools', 'ai')); $mform->addElement('selectyesno', 'enableaitools', get_string('enableaitoolsincourse', 'ai')); $mform->setDefault('enableaitools', $course->enableaitools ?? 1); diff --git a/public/course/format/amd/build/local/courseeditor/mutations.min.js b/public/course/format/amd/build/local/courseeditor/mutations.min.js index 155e0de483681..28025045ab0f9 100644 --- a/public/course/format/amd/build/local/courseeditor/mutations.min.js +++ b/public/course/format/amd/build/local/courseeditor/mutations.min.js @@ -6,6 +6,6 @@ define("core_courseformat/local/courseeditor/mutations",["exports","core/ajax"," * @class core_courseformat/local/courseeditor/mutations * @copyright 2021 Ferran Recio * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */return _exports.default=class{async _callEditWebservice(action,courseId,ids,targetSectionId,targetCmId){const args={action:action,courseid:courseId,ids:ids};targetSectionId&&(args.targetsectionid=targetSectionId),targetCmId&&(args.targetcmid=targetCmId);let ajaxresult=await _ajax.default.call([{methodname:"core_courseformat_update_course",args:args}])[0];return JSON.parse(ajaxresult)}async _callAddModuleWebservice(courseId,modName,targetSectionNum,targetCmId){_log.default.debug("_callAddModuleWebservice() is deprecated. Use _callNewModuleWebservice() instead");const args={courseid:courseId,modname:modName,targetsectionnum:targetSectionNum};targetCmId&&(args.targetcmid=targetCmId);let ajaxresult=await _ajax.default.call([{methodname:"core_courseformat_create_module",args:args}])[0];return JSON.parse(ajaxresult)}async _callNewModuleWebservice(courseId,modName,targetSectionId,targetCmId){const args={courseid:courseId,modname:modName,targetsectionid:targetSectionId};targetCmId&&(args.targetcmid=targetCmId);let ajaxresult=await _ajax.default.call([{methodname:"core_courseformat_new_module",args:args}])[0];return JSON.parse(ajaxresult)}async _sectionBasicAction(stateManager,action,sectionIds,targetSectionId,targetCmId){const logEntry=this._getLoggerEntry(stateManager,action,sectionIds,{targetSectionId:targetSectionId,targetCmId:targetCmId,itemType:"section"}),course=stateManager.get("course");this.sectionLock(stateManager,sectionIds,!0);const updates=await this._callEditWebservice(action,course.id,sectionIds,targetSectionId,targetCmId);this.bulkReset(stateManager),stateManager.processUpdates(updates),this.sectionLock(stateManager,sectionIds,!1),stateManager.addLoggerEntry(await logEntry)}async _cmBasicAction(stateManager,action,cmIds,targetSectionId,targetCmId){const logEntry=this._getLoggerEntry(stateManager,action,cmIds,{targetSectionId:targetSectionId,targetCmId:targetCmId,itemType:"cm"}),course=stateManager.get("course");this.cmLock(stateManager,cmIds,!0);const updates=await this._callEditWebservice(action,course.id,cmIds,targetSectionId,targetCmId);this.bulkReset(stateManager),stateManager.processUpdates(updates),this.cmLock(stateManager,cmIds,!1),stateManager.addLoggerEntry(await logEntry)}async _getLoggerEntry(stateManager,action,itemIds){var _data$itemType,_data$component;let data=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{};isLoggerSet||(stateManager.setLogger(new _srlogger.default),isLoggerSet=!0);let feedbackParams={action:action,itemType:null!==(_data$itemType=data.itemType)&&void 0!==_data$itemType?_data$itemType:action.split("_")[0]},batch="";if(itemIds.length>1)feedbackParams.count=itemIds.length,batch="_batch";else if(1===itemIds.length){var _itemInfo$title;const itemInfo=stateManager.get(feedbackParams.itemType,itemIds[0]);feedbackParams.name=null!==(_itemInfo$title=itemInfo.title)&&void 0!==_itemInfo$title?_itemInfo$title:itemInfo.name}data.targetSectionId&&(feedbackParams.targetSectionName=stateManager.get("section",data.targetSectionId).title),data.targetCmId&&(feedbackParams.targetCmName=stateManager.get("cm",data.targetCmId).name),data.feedbackParams&&(feedbackParams={...feedbackParams,...data.feedbackParams});return{feedbackMessage:await(0,_str.getString)("".concat(action.toLowerCase(),"_feedback").concat(batch),null!==(_data$component=data.component)&&void 0!==_data$component?_data$component:"core_courseformat",feedbackParams)}}init(stateManager){stateManager.addUpdateTypes({prepareFields:this._prepareFields}),stateManager.setLogger(new _srlogger.default),isLoggerSet=!0}_prepareFields(stateManager,updateName,fields){return fields.locked=!1,fields}async sectionHide(stateManager,sectionIds){await this._sectionBasicAction(stateManager,"section_hide",sectionIds)}async sectionShow(stateManager,sectionIds){await this._sectionBasicAction(stateManager,"section_show",sectionIds)}async cmShow(stateManager,cmIds){await this._cmBasicAction(stateManager,"cm_show",cmIds)}async cmHide(stateManager,cmIds){await this._cmBasicAction(stateManager,"cm_hide",cmIds)}async cmStealth(stateManager,cmIds){await this._cmBasicAction(stateManager,"cm_stealth",cmIds)}async cmDuplicate(stateManager,cmIds,targetSectionId,targetCmId){const logEntry=this._getLoggerEntry(stateManager,"cm_duplicate",cmIds),course=stateManager.get("course"),sectionIds=new Set;targetSectionId?sectionIds.add(targetSectionId):cmIds.forEach((cmId=>{const cm=stateManager.get("cm",cmId);sectionIds.add(cm.sectionid)})),this.sectionLock(stateManager,Array.from(sectionIds),!0);const updates=await this._callEditWebservice("cm_duplicate",course.id,cmIds,targetSectionId,targetCmId);this.bulkReset(stateManager),stateManager.processUpdates(updates),this.sectionLock(stateManager,Array.from(sectionIds),!1),stateManager.addLoggerEntry(await logEntry)}async cmMove(stateManager,cmids,targetSectionId,targetCmId){if(!targetSectionId&&!targetCmId)throw new Error("Mutation cmMove requires targetSectionId or targetCmId");const course=stateManager.get("course");this.cmLock(stateManager,cmids,!0);const updates=await this._callEditWebservice("cm_move",course.id,cmids,targetSectionId,targetCmId);this.bulkReset(stateManager),stateManager.processUpdates(updates),this.cmLock(stateManager,cmids,!1)}async sectionMoveAfter(stateManager,sectionIds,targetSectionId){if(!targetSectionId)throw new Error("Mutation sectionMoveAfter requires targetSectionId");const course=stateManager.get("course");this.sectionLock(stateManager,sectionIds,!0);const updates=await this._callEditWebservice("section_move_after",course.id,sectionIds,targetSectionId);this.bulkReset(stateManager),stateManager.processUpdates(updates),this.sectionLock(stateManager,sectionIds,!1)}async addSection(stateManager,targetSectionId){targetSectionId||(targetSectionId=0);const course=stateManager.get("course"),updates=await this._callEditWebservice("section_add",course.id,[],targetSectionId);stateManager.processUpdates(updates);const logEntry=this._getLoggerEntry(stateManager,"section_add",[]);stateManager.addLoggerEntry(await logEntry)}async sectionDelete(stateManager,sectionIds){const course=stateManager.get("course"),logEntry=this._getLoggerEntry(stateManager,"section_delete",sectionIds),updates=await this._callEditWebservice("section_delete",course.id,sectionIds);this.bulkReset(stateManager),stateManager.processUpdates(updates),stateManager.addLoggerEntry(await logEntry)}async cmDelete(stateManager,cmIds){const course=stateManager.get("course"),logEntry=this._getLoggerEntry(stateManager,"cm_delete",cmIds);this.cmLock(stateManager,cmIds,!0);const updates=await this._callEditWebservice("cm_delete",course.id,cmIds);this.bulkReset(stateManager),this.cmLock(stateManager,cmIds,!1),stateManager.processUpdates(updates),stateManager.addLoggerEntry(await logEntry)}async addModule(stateManager,modName,targetSectionNum,targetCmId){if(_log.default.debug("addModule() is deprecated. Use newModule() instead"),!modName)throw new Error("Mutation addModule requires moduleName");if(!targetSectionNum)throw new Error("Mutation addModule requires targetSectionNum");targetCmId||(targetCmId=0);const course=stateManager.get("course"),updates=await this._callAddModuleWebservice(course.id,modName,targetSectionNum,targetCmId);stateManager.processUpdates(updates)}async newModule(stateManager,modName,targetSectionId,targetCmId){if(!modName)throw new Error("Mutation newModule requires moduleName");if(!targetSectionId)throw new Error("Mutation newModule requires targetSectionId");targetCmId||(targetCmId=0);const course=stateManager.get("course"),pluginname=await(0,_str.getString)("pluginname","".concat(modName.toLowerCase())),logEntry=this._getLoggerEntry(stateManager,"cm_add",[],{feedbackParams:{modname:pluginname}}),updates=await this._callNewModuleWebservice(course.id,modName,targetSectionId,targetCmId);stateManager.processUpdates(updates),stateManager.addLoggerEntry(await logEntry)}cmDrag(stateManager,cmIds,dragValue){this.setPageItem(stateManager),this._setElementsValue(stateManager,"cm",cmIds,"dragging",dragValue)}sectionDrag(stateManager,sectionIds,dragValue){this.setPageItem(stateManager),this._setElementsValue(stateManager,"section",sectionIds,"dragging",dragValue)}async cmCompletion(stateManager,cmIds,complete){const newState=complete?1:0,action=1==newState?"cm_complete":"cm_uncomplete",logEntry=this._getLoggerEntry(stateManager,action,cmIds);stateManager.setReadOnly(!1),cmIds.forEach((id=>{const element=stateManager.get("cm",id);element&&(element.isoverallcomplete=complete,element.completionstate=newState)})),stateManager.setReadOnly(!0),stateManager.addLoggerEntry(await logEntry)}async cmMoveRight(stateManager,cmIds){await this._cmBasicAction(stateManager,"cm_moveright",cmIds)}async cmMoveLeft(stateManager,cmIds){await this._cmBasicAction(stateManager,"cm_moveleft",cmIds)}async cmNoGroups(stateManager,cmIds){await this._cmBasicAction(stateManager,"cm_nogroups",cmIds)}async cmVisibleGroups(stateManager,cmIds){await this._cmBasicAction(stateManager,"cm_visiblegroups",cmIds)}async cmSeparateGroups(stateManager,cmIds){await this._cmBasicAction(stateManager,"cm_separategroups",cmIds)}cmLock(stateManager,cmIds,lockValue){this._setElementsValue(stateManager,"cm",cmIds,"locked",lockValue)}sectionLock(stateManager,sectionIds,lockValue){this._setElementsValue(stateManager,"section",sectionIds,"locked",lockValue)}_setElementsValue(stateManager,name,ids,fieldName,newValue){stateManager.setReadOnly(!1),ids.forEach((id=>{const element=stateManager.get(name,id);element&&(element[fieldName]=newValue)})),stateManager.setReadOnly(!0)}setPageItem(stateManager,type,id,isStatic){let newPageItem;if(void 0!==type&&(newPageItem=stateManager.get(type,id),!newPageItem))return;const course=stateManager.get("course");course.pageItem&&course.pageItem.type===type&&course.pageItem.id===id||(stateManager.setReadOnly(!1),course.pageItem=null,newPageItem&&(course.pageItem={id:id,type:type,sectionId:"section"==type?newPageItem.id:newPageItem.sectionid,isStatic:isStatic}),stateManager.setReadOnly(!0))}unlockAll(stateManager){const state=stateManager.state;stateManager.setReadOnly(!1),state.section.forEach((section=>{section.locked=!1})),state.cm.forEach((cm=>{cm.locked=!1})),stateManager.setReadOnly(!0)}async sectionIndexCollapsed(stateManager,sectionIds,collapsed){const affectedSections=this._updateStateSectionPreference(stateManager,"indexcollapsed",sectionIds,collapsed);if(!affectedSections)return;const course=stateManager.get("course");let actionName="section_index_collapsed";collapsed||(actionName="section_index_expanded"),await this._callEditWebservice(actionName,course.id,affectedSections)}async allSectionsIndexCollapsed(stateManager,collapsed){const sectionIds=stateManager.getIds("section");this.sectionIndexCollapsed(stateManager,sectionIds,collapsed)}async sectionContentCollapsed(stateManager,sectionIds,collapsed){const affectedSections=this._updateStateSectionPreference(stateManager,"contentcollapsed",sectionIds,collapsed);if(!affectedSections)return;const course=stateManager.get("course");let actionName="section_content_collapsed";collapsed||(actionName="section_content_expanded"),await this._callEditWebservice(actionName,course.id,affectedSections)}_updateStateSectionPreference(stateManager,preferenceName,sectionIds,preferenceValue){stateManager.setReadOnly(!1);const affectedSections=[];return sectionIds.forEach((sectionId=>{const section=stateManager.get("section",sectionId);if(void 0===section)return stateManager.setReadOnly(!0),null;const newValue=null!=preferenceValue?preferenceValue:section[preferenceName];section[preferenceName]!=newValue&&(section[preferenceName]=newValue,affectedSections.push(section.id))})),stateManager.setReadOnly(!0),affectedSections}bulkEnable(stateManager,enabled){const state=stateManager.state;stateManager.setReadOnly(!1),state.bulk.enabled=enabled,state.bulk.selectedType="",state.bulk.selection=[],stateManager.setReadOnly(!0)}bulkReset(stateManager){const state=stateManager.state;stateManager.setReadOnly(!1),state.bulk.selectedType="",state.bulk.selection=[],stateManager.setReadOnly(!0)}cmSelect(stateManager,cmIds){this._addIdsToSelection(stateManager,"cm",cmIds)}cmUnselect(stateManager,cmIds){this._removeIdsFromSelection(stateManager,"cm",cmIds)}sectionSelect(stateManager,sectionIds){this._addIdsToSelection(stateManager,"section",sectionIds)}sectionUnselect(stateManager,sectionIds){this._removeIdsFromSelection(stateManager,"section",sectionIds)}_addIdsToSelection(stateManager,typeName,ids){const bulk=stateManager.state.bulk;if(null==bulk||!bulk.enabled)throw new Error("Bulk is not enabled");if(""!==(null==bulk?void 0:bulk.selectedType)&&(null==bulk?void 0:bulk.selectedType)!==typeName)throw new Error("Cannot add ".concat(typeName," to the current selection"));ids=ids.map((value=>value.toString())),stateManager.setReadOnly(!1),bulk.selectedType=typeName;const newSelection=new Set([...bulk.selection,...ids]);bulk.selection=[...newSelection],stateManager.setReadOnly(!0)}_removeIdsFromSelection(stateManager,typeName,ids){const bulk=stateManager.state.bulk;if(null==bulk||!bulk.enabled)throw new Error("Bulk is not enabled");if(""!==(null==bulk?void 0:bulk.selectedType)&&(null==bulk?void 0:bulk.selectedType)!==typeName)throw new Error("Cannot remove ".concat(typeName," from the current selection"));ids=ids.map((value=>value.toString())),stateManager.setReadOnly(!1);const IdsToFilter=new Set(ids);bulk.selection=bulk.selection.filter((current=>!IdsToFilter.has(current))),0===bulk.selection.length&&(bulk.selectedType=""),stateManager.setReadOnly(!0)}async cmState(stateManager,cmids){this.cmLock(stateManager,cmids,!0);const course=stateManager.get("course"),updates=await this._callEditWebservice("cm_state",course.id,cmids);stateManager.processUpdates(updates),this.cmLock(stateManager,cmids,!1)}async sectionState(stateManager,sectionIds){this.sectionLock(stateManager,sectionIds,!0);const course=stateManager.get("course"),updates=await this._callEditWebservice("section_state",course.id,sectionIds);stateManager.processUpdates(updates),this.sectionLock(stateManager,sectionIds,!1)}async courseState(stateManager){const course=stateManager.get("course"),updates=await this._callEditWebservice("course_state",course.id);stateManager.processUpdates(updates)}},_exports.default})); + */return _exports.default=class{async _callEditWebservice(action,courseId,ids,targetSectionId,targetCmId){const args={action:action,courseid:courseId,ids:ids};targetSectionId&&(args.targetsectionid=targetSectionId),targetCmId&&(args.targetcmid=targetCmId);let ajaxresult=await _ajax.default.call([{methodname:"core_courseformat_update_course",args:args}])[0];return JSON.parse(ajaxresult)}async _callAddModuleWebservice(courseId,modName,targetSectionNum,targetCmId){_log.default.debug("_callAddModuleWebservice() is deprecated. Use _callNewModuleWebservice() instead");const args={courseid:courseId,modname:modName,targetsectionnum:targetSectionNum};targetCmId&&(args.targetcmid=targetCmId);let ajaxresult=await _ajax.default.call([{methodname:"core_courseformat_create_module",args:args}])[0];return JSON.parse(ajaxresult)}async _callNewModuleWebservice(courseId,modName,targetSectionId,targetCmId){const args={courseid:courseId,modname:modName,targetsectionid:targetSectionId};targetCmId&&(args.targetcmid=targetCmId);let ajaxresult=await _ajax.default.call([{methodname:"core_courseformat_new_module",args:args}])[0];return JSON.parse(ajaxresult)}async _sectionBasicAction(stateManager,action,sectionIds,targetSectionId,targetCmId){const logEntry=this._getLoggerEntry(stateManager,action,sectionIds,{targetSectionId:targetSectionId,targetCmId:targetCmId,itemType:"section"}),course=stateManager.get("course");this.sectionLock(stateManager,sectionIds,!0);const updates=await this._callEditWebservice(action,course.id,sectionIds,targetSectionId,targetCmId);this.bulkReset(stateManager),stateManager.processUpdates(updates),this.sectionLock(stateManager,sectionIds,!1),stateManager.addLoggerEntry(await logEntry)}async _cmBasicAction(stateManager,action,cmIds,targetSectionId,targetCmId){const logEntry=this._getLoggerEntry(stateManager,action,cmIds,{targetSectionId:targetSectionId,targetCmId:targetCmId,itemType:"cm"}),course=stateManager.get("course");this.cmLock(stateManager,cmIds,!0);const updates=await this._callEditWebservice(action,course.id,cmIds,targetSectionId,targetCmId);this.bulkReset(stateManager),stateManager.processUpdates(updates),this.cmLock(stateManager,cmIds,!1),stateManager.addLoggerEntry(await logEntry)}async _getLoggerEntry(stateManager,action,itemIds){var _data$itemType,_data$component;let data=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{};isLoggerSet||(stateManager.setLogger(new _srlogger.default),isLoggerSet=!0);let feedbackParams={action:action,itemType:null!==(_data$itemType=data.itemType)&&void 0!==_data$itemType?_data$itemType:action.split("_")[0]},batch="";if(itemIds.length>1)feedbackParams.count=itemIds.length,batch="_batch";else if(1===itemIds.length){var _itemInfo$title;const itemInfo=stateManager.get(feedbackParams.itemType,itemIds[0]);feedbackParams.name=null!==(_itemInfo$title=itemInfo.title)&&void 0!==_itemInfo$title?_itemInfo$title:itemInfo.name}if(data.targetSectionId)feedbackParams.targetSectionName=stateManager.get("section",data.targetSectionId).title;else if(data.targetCmId){const targetCm=stateManager.get("cm",data.targetCmId);feedbackParams.targetSectionName=stateManager.get("section",targetCm.sectionid).title}data.targetCmId&&(feedbackParams.targetCmName=stateManager.get("cm",data.targetCmId).name),data.feedbackParams&&(feedbackParams={...feedbackParams,...data.feedbackParams});return{feedbackMessage:await(0,_str.getString)("".concat(action.toLowerCase(),"_feedback").concat(batch),null!==(_data$component=data.component)&&void 0!==_data$component?_data$component:"core_courseformat",feedbackParams)}}init(stateManager){stateManager.addUpdateTypes({prepareFields:this._prepareFields}),stateManager.setLogger(new _srlogger.default),isLoggerSet=!0}_prepareFields(stateManager,updateName,fields){return fields.locked=!1,fields}async sectionHide(stateManager,sectionIds){await this._sectionBasicAction(stateManager,"section_hide",sectionIds)}async sectionShow(stateManager,sectionIds){await this._sectionBasicAction(stateManager,"section_show",sectionIds)}async cmShow(stateManager,cmIds){await this._cmBasicAction(stateManager,"cm_show",cmIds)}async cmHide(stateManager,cmIds){await this._cmBasicAction(stateManager,"cm_hide",cmIds)}async cmStealth(stateManager,cmIds){await this._cmBasicAction(stateManager,"cm_stealth",cmIds)}async cmDuplicate(stateManager,cmIds,targetSectionId,targetCmId){const logEntry=this._getLoggerEntry(stateManager,"cm_duplicate",cmIds),course=stateManager.get("course"),sectionIds=new Set;targetSectionId?sectionIds.add(targetSectionId):cmIds.forEach((cmId=>{const cm=stateManager.get("cm",cmId);sectionIds.add(cm.sectionid)})),this.sectionLock(stateManager,Array.from(sectionIds),!0);const updates=await this._callEditWebservice("cm_duplicate",course.id,cmIds,targetSectionId,targetCmId);this.bulkReset(stateManager),stateManager.processUpdates(updates),this.sectionLock(stateManager,Array.from(sectionIds),!1),stateManager.addLoggerEntry(await logEntry)}_getCmMoveFeedback(stateManager,cmids,targetSectionId,targetCmId){const cmlist=(targetCmId?stateManager.get("section",stateManager.get("cm",targetCmId).sectionid):stateManager.get("section",targetSectionId)).cmlist;let anchorCmId;for(let i=(targetCmId?cmlist.indexOf(targetCmId):cmlist.length)-1;i>=0;i--)if(!cmids.includes(cmlist[i])){anchorCmId=cmlist[i];break}return anchorCmId?["cm_move_after",{targetCmId:anchorCmId}]:targetCmId?["cm_move_before",{targetCmId:targetCmId}]:["cm_move",{targetSectionId:targetSectionId}]}async cmMove(stateManager,cmids,targetSectionId,targetCmId){if(!targetSectionId&&!targetCmId)throw new Error("Mutation cmMove requires targetSectionId or targetCmId");const course=stateManager.get("course");this.cmLock(stateManager,cmids,!0);const[moveAction,feedbackData]=this._getCmMoveFeedback(stateManager,cmids,targetSectionId,targetCmId),logEntry=this._getLoggerEntry(stateManager,moveAction,cmids,feedbackData),updates=await this._callEditWebservice("cm_move",course.id,cmids,targetSectionId,targetCmId);this.bulkReset(stateManager),stateManager.processUpdates(updates),this.cmLock(stateManager,cmids,!1),stateManager.addLoggerEntry(await logEntry)}async sectionMoveAfter(stateManager,sectionIds,targetSectionId){if(!targetSectionId)throw new Error("Mutation sectionMoveAfter requires targetSectionId");const course=stateManager.get("course");this.sectionLock(stateManager,sectionIds,!0);const logEntry=this._getLoggerEntry(stateManager,"section_move_after",sectionIds,{targetSectionId:targetSectionId}),updates=await this._callEditWebservice("section_move_after",course.id,sectionIds,targetSectionId);this.bulkReset(stateManager),stateManager.processUpdates(updates),this.sectionLock(stateManager,sectionIds,!1),stateManager.addLoggerEntry(await logEntry)}async addSection(stateManager,targetSectionId){targetSectionId||(targetSectionId=0);const course=stateManager.get("course"),updates=await this._callEditWebservice("section_add",course.id,[],targetSectionId);stateManager.processUpdates(updates);const logEntry=this._getLoggerEntry(stateManager,"section_add",[]);stateManager.addLoggerEntry(await logEntry)}async sectionDelete(stateManager,sectionIds){const course=stateManager.get("course"),logEntry=this._getLoggerEntry(stateManager,"section_delete",sectionIds),updates=await this._callEditWebservice("section_delete",course.id,sectionIds);this.bulkReset(stateManager),stateManager.processUpdates(updates),stateManager.addLoggerEntry(await logEntry)}async cmDelete(stateManager,cmIds){const course=stateManager.get("course"),logEntry=this._getLoggerEntry(stateManager,"cm_delete",cmIds);this.cmLock(stateManager,cmIds,!0);const updates=await this._callEditWebservice("cm_delete",course.id,cmIds);this.bulkReset(stateManager),this.cmLock(stateManager,cmIds,!1),stateManager.processUpdates(updates),stateManager.addLoggerEntry(await logEntry)}async addModule(stateManager,modName,targetSectionNum,targetCmId){if(_log.default.debug("addModule() is deprecated. Use newModule() instead"),!modName)throw new Error("Mutation addModule requires moduleName");if(!targetSectionNum)throw new Error("Mutation addModule requires targetSectionNum");targetCmId||(targetCmId=0);const course=stateManager.get("course"),updates=await this._callAddModuleWebservice(course.id,modName,targetSectionNum,targetCmId);stateManager.processUpdates(updates)}async newModule(stateManager,modName,targetSectionId,targetCmId){if(!modName)throw new Error("Mutation newModule requires moduleName");if(!targetSectionId)throw new Error("Mutation newModule requires targetSectionId");targetCmId||(targetCmId=0);const course=stateManager.get("course"),pluginname=await(0,_str.getString)("pluginname","".concat(modName.toLowerCase())),logEntry=this._getLoggerEntry(stateManager,"cm_add",[],{feedbackParams:{modname:pluginname}}),updates=await this._callNewModuleWebservice(course.id,modName,targetSectionId,targetCmId);stateManager.processUpdates(updates),stateManager.addLoggerEntry(await logEntry)}cmDrag(stateManager,cmIds,dragValue){this.setPageItem(stateManager),this._setElementsValue(stateManager,"cm",cmIds,"dragging",dragValue)}sectionDrag(stateManager,sectionIds,dragValue){this.setPageItem(stateManager),this._setElementsValue(stateManager,"section",sectionIds,"dragging",dragValue)}async cmCompletion(stateManager,cmIds,complete){const newState=complete?1:0,action=1==newState?"cm_complete":"cm_uncomplete",logEntry=this._getLoggerEntry(stateManager,action,cmIds);stateManager.setReadOnly(!1),cmIds.forEach((id=>{const element=stateManager.get("cm",id);element&&(element.isoverallcomplete=complete,element.completionstate=newState)})),stateManager.setReadOnly(!0),stateManager.addLoggerEntry(await logEntry)}async cmMoveRight(stateManager,cmIds){await this._cmBasicAction(stateManager,"cm_moveright",cmIds)}async cmMoveLeft(stateManager,cmIds){await this._cmBasicAction(stateManager,"cm_moveleft",cmIds)}async cmNoGroups(stateManager,cmIds){await this._cmBasicAction(stateManager,"cm_nogroups",cmIds)}async cmVisibleGroups(stateManager,cmIds){await this._cmBasicAction(stateManager,"cm_visiblegroups",cmIds)}async cmSeparateGroups(stateManager,cmIds){await this._cmBasicAction(stateManager,"cm_separategroups",cmIds)}cmLock(stateManager,cmIds,lockValue){this._setElementsValue(stateManager,"cm",cmIds,"locked",lockValue)}sectionLock(stateManager,sectionIds,lockValue){this._setElementsValue(stateManager,"section",sectionIds,"locked",lockValue)}_setElementsValue(stateManager,name,ids,fieldName,newValue){stateManager.setReadOnly(!1),ids.forEach((id=>{const element=stateManager.get(name,id);element&&(element[fieldName]=newValue)})),stateManager.setReadOnly(!0)}setPageItem(stateManager,type,id,isStatic){let newPageItem;if(void 0!==type&&(newPageItem=stateManager.get(type,id),!newPageItem))return;const course=stateManager.get("course");course.pageItem&&course.pageItem.type===type&&course.pageItem.id===id||(stateManager.setReadOnly(!1),course.pageItem=null,newPageItem&&(course.pageItem={id:id,type:type,sectionId:"section"==type?newPageItem.id:newPageItem.sectionid,isStatic:isStatic}),stateManager.setReadOnly(!0))}unlockAll(stateManager){const state=stateManager.state;stateManager.setReadOnly(!1),state.section.forEach((section=>{section.locked=!1})),state.cm.forEach((cm=>{cm.locked=!1})),stateManager.setReadOnly(!0)}async sectionIndexCollapsed(stateManager,sectionIds,collapsed){const affectedSections=this._updateStateSectionPreference(stateManager,"indexcollapsed",sectionIds,collapsed);if(!affectedSections)return;const course=stateManager.get("course");let actionName="section_index_collapsed";collapsed||(actionName="section_index_expanded"),await this._callEditWebservice(actionName,course.id,affectedSections)}async allSectionsIndexCollapsed(stateManager,collapsed){const sectionIds=stateManager.getIds("section");this.sectionIndexCollapsed(stateManager,sectionIds,collapsed)}async sectionContentCollapsed(stateManager,sectionIds,collapsed){const affectedSections=this._updateStateSectionPreference(stateManager,"contentcollapsed",sectionIds,collapsed);if(!affectedSections)return;const course=stateManager.get("course");let actionName="section_content_collapsed";collapsed||(actionName="section_content_expanded"),await this._callEditWebservice(actionName,course.id,affectedSections)}_updateStateSectionPreference(stateManager,preferenceName,sectionIds,preferenceValue){stateManager.setReadOnly(!1);const affectedSections=[];return sectionIds.forEach((sectionId=>{const section=stateManager.get("section",sectionId);if(void 0===section)return stateManager.setReadOnly(!0),null;const newValue=null!=preferenceValue?preferenceValue:section[preferenceName];section[preferenceName]!=newValue&&(section[preferenceName]=newValue,affectedSections.push(section.id))})),stateManager.setReadOnly(!0),affectedSections}bulkEnable(stateManager,enabled){const state=stateManager.state;stateManager.setReadOnly(!1),state.bulk.enabled=enabled,state.bulk.selectedType="",state.bulk.selection=[],stateManager.setReadOnly(!0)}bulkReset(stateManager){const state=stateManager.state;stateManager.setReadOnly(!1),state.bulk.selectedType="",state.bulk.selection=[],stateManager.setReadOnly(!0)}cmSelect(stateManager,cmIds){this._addIdsToSelection(stateManager,"cm",cmIds)}cmUnselect(stateManager,cmIds){this._removeIdsFromSelection(stateManager,"cm",cmIds)}sectionSelect(stateManager,sectionIds){this._addIdsToSelection(stateManager,"section",sectionIds)}sectionUnselect(stateManager,sectionIds){this._removeIdsFromSelection(stateManager,"section",sectionIds)}_addIdsToSelection(stateManager,typeName,ids){const bulk=stateManager.state.bulk;if(null==bulk||!bulk.enabled)throw new Error("Bulk is not enabled");if(""!==(null==bulk?void 0:bulk.selectedType)&&(null==bulk?void 0:bulk.selectedType)!==typeName)throw new Error("Cannot add ".concat(typeName," to the current selection"));ids=ids.map((value=>value.toString())),stateManager.setReadOnly(!1),bulk.selectedType=typeName;const newSelection=new Set([...bulk.selection,...ids]);bulk.selection=[...newSelection],stateManager.setReadOnly(!0)}_removeIdsFromSelection(stateManager,typeName,ids){const bulk=stateManager.state.bulk;if(null==bulk||!bulk.enabled)throw new Error("Bulk is not enabled");if(""!==(null==bulk?void 0:bulk.selectedType)&&(null==bulk?void 0:bulk.selectedType)!==typeName)throw new Error("Cannot remove ".concat(typeName," from the current selection"));ids=ids.map((value=>value.toString())),stateManager.setReadOnly(!1);const IdsToFilter=new Set(ids);bulk.selection=bulk.selection.filter((current=>!IdsToFilter.has(current))),0===bulk.selection.length&&(bulk.selectedType=""),stateManager.setReadOnly(!0)}async cmState(stateManager,cmids){this.cmLock(stateManager,cmids,!0);const course=stateManager.get("course"),updates=await this._callEditWebservice("cm_state",course.id,cmids);stateManager.processUpdates(updates),this.cmLock(stateManager,cmids,!1)}async sectionState(stateManager,sectionIds){this.sectionLock(stateManager,sectionIds,!0);const course=stateManager.get("course"),updates=await this._callEditWebservice("section_state",course.id,sectionIds);stateManager.processUpdates(updates),this.sectionLock(stateManager,sectionIds,!1)}async courseState(stateManager){const course=stateManager.get("course"),updates=await this._callEditWebservice("course_state",course.id);stateManager.processUpdates(updates)}},_exports.default})); //# sourceMappingURL=mutations.min.js.map \ No newline at end of file diff --git a/public/course/format/amd/build/local/courseeditor/mutations.min.js.map b/public/course/format/amd/build/local/courseeditor/mutations.min.js.map index 700b692e756a1..0cb318773246c 100644 --- a/public/course/format/amd/build/local/courseeditor/mutations.min.js.map +++ b/public/course/format/amd/build/local/courseeditor/mutations.min.js.map @@ -1 +1 @@ -{"version":3,"file":"mutations.min.js","sources":["../../../src/local/courseeditor/mutations.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\nimport ajax from 'core/ajax';\nimport {getString} from \"core/str\";\nimport log from 'core/log';\nimport SRLogger from \"core/local/reactive/srlogger\";\n\n/**\n * Flag to determine whether the screen reader-only logger has already been set, so we only need to set it once.\n *\n * @type {boolean}\n */\nlet isLoggerSet = false;\n\n/**\n * Default mutation manager\n *\n * @module core_courseformat/local/courseeditor/mutations\n * @class core_courseformat/local/courseeditor/mutations\n * @copyright 2021 Ferran Recio \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default class {\n\n // All course editor mutations for Moodle 4.0 will be located in this file.\n\n /**\n * Private method to call core_courseformat_update_course webservice.\n *\n * @method _callEditWebservice\n * @param {string} action\n * @param {number} courseId\n * @param {array} ids\n * @param {number} targetSectionId optional target section id (for moving actions)\n * @param {number} targetCmId optional target cm id (for moving actions)\n */\n async _callEditWebservice(action, courseId, ids, targetSectionId, targetCmId) {\n const args = {\n action,\n courseid: courseId,\n ids,\n };\n if (targetSectionId) {\n args.targetsectionid = targetSectionId;\n }\n if (targetCmId) {\n args.targetcmid = targetCmId;\n }\n let ajaxresult = await ajax.call([{\n methodname: 'core_courseformat_update_course',\n args,\n }])[0];\n return JSON.parse(ajaxresult);\n }\n\n /**\n * Private method to call core_courseformat_create_module webservice.\n *\n * @deprecated since Moodle 5.0 MDL-83469.\n * @todo MDL-83851 This will be deleted in Moodle 6.0.\n * @method _callEditWebservice\n * @param {number} courseId\n * @param {string} modName module name\n * @param {number} targetSectionNum target section number\n * @param {number} targetCmId optional target cm id\n */\n async _callAddModuleWebservice(courseId, modName, targetSectionNum, targetCmId) {\n log.debug('_callAddModuleWebservice() is deprecated. Use _callNewModuleWebservice() instead');\n const args = {\n courseid: courseId,\n modname: modName,\n targetsectionnum: targetSectionNum,\n };\n if (targetCmId) {\n args.targetcmid = targetCmId;\n }\n let ajaxresult = await ajax.call([{\n methodname: 'core_courseformat_create_module',\n args,\n }])[0];\n return JSON.parse(ajaxresult);\n }\n\n /**\n * Private method to call core_courseformat_new_module webservice.\n *\n * @method _callEditWebservice\n * @param {number} courseId\n * @param {string} modName module name\n * @param {number} targetSectionId target section number\n * @param {number} targetCmId optional target cm id\n */\n async _callNewModuleWebservice(courseId, modName, targetSectionId, targetCmId) {\n const args = {\n courseid: courseId,\n modname: modName,\n targetsectionid: targetSectionId,\n };\n if (targetCmId) {\n args.targetcmid = targetCmId;\n }\n let ajaxresult = await ajax.call([{\n methodname: 'core_courseformat_new_module',\n args,\n }])[0];\n return JSON.parse(ajaxresult);\n }\n\n /**\n * Execute a basic section state action.\n * @param {StateManager} stateManager the current state manager\n * @param {string} action the action name\n * @param {array} sectionIds the section ids\n * @param {number} targetSectionId optional target section id (for moving actions)\n * @param {number} targetCmId optional target cm id (for moving actions)\n */\n async _sectionBasicAction(stateManager, action, sectionIds, targetSectionId, targetCmId) {\n const logEntry = this._getLoggerEntry(stateManager, action, sectionIds, {\n targetSectionId,\n targetCmId,\n itemType: 'section',\n });\n const course = stateManager.get('course');\n this.sectionLock(stateManager, sectionIds, true);\n const updates = await this._callEditWebservice(\n action,\n course.id,\n sectionIds,\n targetSectionId,\n targetCmId\n );\n this.bulkReset(stateManager);\n stateManager.processUpdates(updates);\n this.sectionLock(stateManager, sectionIds, false);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Execute a basic course module state action.\n * @param {StateManager} stateManager the current state manager\n * @param {string} action the action name\n * @param {array} cmIds the cm ids\n * @param {number} targetSectionId optional target section id (for moving actions)\n * @param {number} targetCmId optional target cm id (for moving actions)\n */\n async _cmBasicAction(stateManager, action, cmIds, targetSectionId, targetCmId) {\n const logEntry = this._getLoggerEntry(stateManager, action, cmIds, {\n targetSectionId,\n targetCmId,\n itemType: 'cm',\n });\n const course = stateManager.get('course');\n this.cmLock(stateManager, cmIds, true);\n const updates = await this._callEditWebservice(\n action,\n course.id,\n cmIds,\n targetSectionId,\n targetCmId\n );\n this.bulkReset(stateManager);\n stateManager.processUpdates(updates);\n this.cmLock(stateManager, cmIds, false);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Get log entry for the current action.\n * @param {StateManager} stateManager the current state manager\n * @param {string} action the action name\n * @param {int[]|null} itemIds the element ids\n * @param {Object|undefined} data extra params for the log entry\n * @param {string|undefined} data.itemType the element type (will be taken from action if none)\n * @param {int|null|undefined} data.targetSectionId the target section id\n * @param {int|null|undefined} data.targetCmId the target cm id\n * @param {String|null|undefined} data.component optional component (for format plugins)\n * @param {Object|undefined} [data.feedbackParams] the params to build the feedback message\n * @return {Object} the log entry\n */\n async _getLoggerEntry(stateManager, action, itemIds, data = {}) {\n if (!isLoggerSet) {\n // In case the logger has not been set from init(), ensure we set the logger.\n stateManager.setLogger(new SRLogger());\n isLoggerSet = true;\n }\n let feedbackParams = {\n action,\n itemType: data.itemType ?? action.split('_')[0],\n };\n let batch = '';\n if (itemIds.length > 1) {\n feedbackParams.count = itemIds.length;\n batch = '_batch';\n } else if (itemIds.length === 1) {\n const itemInfo = stateManager.get(feedbackParams.itemType, itemIds[0]);\n feedbackParams.name = itemInfo.title ?? itemInfo.name;\n // Apply shortener for modules like label.\n }\n if (data.targetSectionId) {\n feedbackParams.targetSectionName = stateManager.get('section', data.targetSectionId).title;\n }\n if (data.targetCmId) {\n feedbackParams.targetCmName = stateManager.get('cm', data.targetCmId).name;\n }\n if (data.feedbackParams) {\n feedbackParams = {...feedbackParams, ...data.feedbackParams};\n }\n\n const message = await getString(\n `${action.toLowerCase()}_feedback${batch}`,\n data.component ?? 'core_courseformat',\n feedbackParams\n );\n\n return {\n feedbackMessage: message,\n };\n }\n\n /**\n * Mutation module initialize.\n *\n * The reactive instance will execute this method when addMutations or setMutation is invoked.\n *\n * @param {StateManager} stateManager the state manager\n */\n init(stateManager) {\n // Add a method to prepare the fields when some update is coming from the server.\n stateManager.addUpdateTypes({\n prepareFields: this._prepareFields,\n });\n // Use the screen reader-only logger (SRLogger) to handle the feedback messages from the mutations.\n stateManager.setLogger(new SRLogger());\n isLoggerSet = true;\n }\n\n /**\n * Add default values to state elements.\n *\n * This method is called every time a webservice returns a update state message.\n *\n * @param {Object} stateManager the state manager\n * @param {String} updateName the state element to update\n * @param {Object} fields the new data\n * @returns {Object} final fields data\n */\n _prepareFields(stateManager, updateName, fields) {\n // Any update should unlock the element.\n fields.locked = false;\n return fields;\n }\n\n /**\n * Hides sections.\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the list of section ids\n */\n async sectionHide(stateManager, sectionIds) {\n await this._sectionBasicAction(stateManager, 'section_hide', sectionIds);\n }\n\n /**\n * Show sections.\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the list of section ids\n */\n async sectionShow(stateManager, sectionIds) {\n await this._sectionBasicAction(stateManager, 'section_show', sectionIds);\n }\n\n /**\n * Show cms.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n async cmShow(stateManager, cmIds) {\n await this._cmBasicAction(stateManager, 'cm_show', cmIds);\n }\n\n /**\n * Hide cms.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n async cmHide(stateManager, cmIds) {\n await this._cmBasicAction(stateManager, 'cm_hide', cmIds);\n }\n\n /**\n * Stealth cms.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n async cmStealth(stateManager, cmIds) {\n await this._cmBasicAction(stateManager, 'cm_stealth', cmIds);\n }\n\n /**\n * Duplicate course modules\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of course modules ids\n * @param {number|undefined} targetSectionId the optional target sectionId\n * @param {number|undefined} targetCmId the target course module id\n */\n async cmDuplicate(stateManager, cmIds, targetSectionId, targetCmId) {\n const logEntry = this._getLoggerEntry(stateManager, 'cm_duplicate', cmIds);\n const course = stateManager.get('course');\n // Lock all target sections.\n const sectionIds = new Set();\n if (targetSectionId) {\n sectionIds.add(targetSectionId);\n } else {\n cmIds.forEach((cmId) => {\n const cm = stateManager.get('cm', cmId);\n sectionIds.add(cm.sectionid);\n });\n }\n this.sectionLock(stateManager, Array.from(sectionIds), true);\n\n const updates = await this._callEditWebservice('cm_duplicate', course.id, cmIds, targetSectionId, targetCmId);\n this.bulkReset(stateManager);\n stateManager.processUpdates(updates);\n\n this.sectionLock(stateManager, Array.from(sectionIds), false);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Move course modules to specific course location.\n *\n * Note that one of targetSectionId or targetCmId should be provided in order to identify the\n * new location:\n * - targetCmId: the activities will be located avobe the target cm. The targetSectionId\n * value will be ignored in this case.\n * - targetSectionId: the activities will be appended to the section. In this case\n * targetSectionId should not be present.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmids the list of cm ids to move\n * @param {number} targetSectionId the target section id\n * @param {number} targetCmId the target course module id\n */\n async cmMove(stateManager, cmids, targetSectionId, targetCmId) {\n if (!targetSectionId && !targetCmId) {\n throw new Error(`Mutation cmMove requires targetSectionId or targetCmId`);\n }\n const course = stateManager.get('course');\n this.cmLock(stateManager, cmids, true);\n const updates = await this._callEditWebservice('cm_move', course.id, cmids, targetSectionId, targetCmId);\n this.bulkReset(stateManager);\n stateManager.processUpdates(updates);\n this.cmLock(stateManager, cmids, false);\n }\n\n /**\n * Move course modules after a specific course location.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the list of section ids to move\n * @param {number} targetSectionId the target section id\n */\n async sectionMoveAfter(stateManager, sectionIds, targetSectionId) {\n if (!targetSectionId) {\n throw new Error(`Mutation sectionMoveAfter requires targetSectionId`);\n }\n const course = stateManager.get('course');\n this.sectionLock(stateManager, sectionIds, true);\n const updates = await this._callEditWebservice('section_move_after', course.id, sectionIds, targetSectionId);\n this.bulkReset(stateManager);\n stateManager.processUpdates(updates);\n this.sectionLock(stateManager, sectionIds, false);\n }\n\n /**\n * Add a new section to a specific course location.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {number} targetSectionId optional the target section id\n */\n async addSection(stateManager, targetSectionId) {\n if (!targetSectionId) {\n targetSectionId = 0;\n }\n const course = stateManager.get('course');\n const updates = await this._callEditWebservice('section_add', course.id, [], targetSectionId);\n stateManager.processUpdates(updates);\n const logEntry = this._getLoggerEntry(stateManager, 'section_add', []);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Delete sections.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the list of course modules ids\n */\n async sectionDelete(stateManager, sectionIds) {\n const course = stateManager.get('course');\n const logEntry = this._getLoggerEntry(stateManager, 'section_delete', sectionIds);\n const updates = await this._callEditWebservice('section_delete', course.id, sectionIds);\n this.bulkReset(stateManager);\n stateManager.processUpdates(updates);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Delete cms.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of section ids\n */\n async cmDelete(stateManager, cmIds) {\n const course = stateManager.get('course');\n const logEntry = this._getLoggerEntry(stateManager, 'cm_delete', cmIds);\n this.cmLock(stateManager, cmIds, true);\n const updates = await this._callEditWebservice('cm_delete', course.id, cmIds);\n this.bulkReset(stateManager);\n this.cmLock(stateManager, cmIds, false);\n stateManager.processUpdates(updates);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Add a new module to a specific course section.\n *\n * @deprecated since Moodle 5.0 MDL-83469.\n * @todo MDL-83851 This will be deleted in Moodle 6.0.\n * @param {StateManager} stateManager the current state manager\n * @param {string} modName the modulename to add\n * @param {number} targetSectionNum the target section number\n * @param {number} targetCmId optional the target cm id\n */\n async addModule(stateManager, modName, targetSectionNum, targetCmId) {\n log.debug('addModule() is deprecated. Use newModule() instead');\n if (!modName) {\n throw new Error(`Mutation addModule requires moduleName`);\n }\n if (!targetSectionNum) {\n throw new Error(`Mutation addModule requires targetSectionNum`);\n }\n if (!targetCmId) {\n targetCmId = 0;\n }\n const course = stateManager.get('course');\n const updates = await this._callAddModuleWebservice(course.id, modName, targetSectionNum, targetCmId);\n stateManager.processUpdates(updates);\n }\n\n /**\n * Add a new module to a specific course section.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {string} modName the modulename to add\n * @param {number} targetSectionId the target section id\n * @param {number} targetCmId optional the target cm id\n */\n async newModule(stateManager, modName, targetSectionId, targetCmId) {\n if (!modName) {\n throw new Error(`Mutation newModule requires moduleName`);\n }\n if (!targetSectionId) {\n throw new Error(`Mutation newModule requires targetSectionId`);\n }\n if (!targetCmId) {\n targetCmId = 0;\n }\n const course = stateManager.get('course');\n const pluginname = await getString(\n 'pluginname',\n `${modName.toLowerCase()}`,\n );\n const logEntry = this._getLoggerEntry(stateManager, 'cm_add', [], {\n feedbackParams: {\n 'modname': pluginname,\n },\n });\n const updates = await this._callNewModuleWebservice(course.id, modName, targetSectionId, targetCmId);\n stateManager.processUpdates(updates);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Mark or unmark course modules as dragging.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of course modules ids\n * @param {bool} dragValue the new dragging value\n */\n cmDrag(stateManager, cmIds, dragValue) {\n this.setPageItem(stateManager);\n this._setElementsValue(stateManager, 'cm', cmIds, 'dragging', dragValue);\n }\n\n /**\n * Mark or unmark course sections as dragging.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the list of section ids\n * @param {bool} dragValue the new dragging value\n */\n sectionDrag(stateManager, sectionIds, dragValue) {\n this.setPageItem(stateManager);\n this._setElementsValue(stateManager, 'section', sectionIds, 'dragging', dragValue);\n }\n\n /**\n * Mark or unmark course modules as complete.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of course modules ids\n * @param {bool} complete the new completion value\n */\n async cmCompletion(stateManager, cmIds, complete) {\n const newState = (complete) ? 1 : 0;\n const action = (newState == 1) ? 'cm_complete' : 'cm_uncomplete';\n const logEntry = this._getLoggerEntry(stateManager, action, cmIds);\n stateManager.setReadOnly(false);\n cmIds.forEach((id) => {\n const element = stateManager.get('cm', id);\n if (element) {\n element.isoverallcomplete = complete;\n element.completionstate = newState;\n }\n });\n stateManager.setReadOnly(true);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Move cms to the right: indent = 1.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n async cmMoveRight(stateManager, cmIds) {\n await this._cmBasicAction(stateManager, 'cm_moveright', cmIds);\n }\n\n /**\n * Move cms to the left: indent = 0.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n async cmMoveLeft(stateManager, cmIds) {\n await this._cmBasicAction(stateManager, 'cm_moveleft', cmIds);\n }\n\n /**\n * Set cms group mode to NOGROUPS.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n async cmNoGroups(stateManager, cmIds) {\n await this._cmBasicAction(stateManager, 'cm_nogroups', cmIds);\n }\n\n /**\n * Set cms group mode to VISIBLEGROUPS.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n async cmVisibleGroups(stateManager, cmIds) {\n await this._cmBasicAction(stateManager, 'cm_visiblegroups', cmIds);\n }\n\n /**\n * Set cms group mode to SEPARATEGROUPS.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n async cmSeparateGroups(stateManager, cmIds) {\n await this._cmBasicAction(stateManager, 'cm_separategroups', cmIds);\n }\n\n /**\n * Lock or unlock course modules.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of course modules ids\n * @param {bool} lockValue the new locked value\n */\n cmLock(stateManager, cmIds, lockValue) {\n this._setElementsValue(stateManager, 'cm', cmIds, 'locked', lockValue);\n }\n\n /**\n * Lock or unlock course sections.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the list of section ids\n * @param {bool} lockValue the new locked value\n */\n sectionLock(stateManager, sectionIds, lockValue) {\n this._setElementsValue(stateManager, 'section', sectionIds, 'locked', lockValue);\n }\n\n _setElementsValue(stateManager, name, ids, fieldName, newValue) {\n stateManager.setReadOnly(false);\n ids.forEach((id) => {\n const element = stateManager.get(name, id);\n if (element) {\n element[fieldName] = newValue;\n }\n });\n stateManager.setReadOnly(true);\n }\n\n /**\n * Set the page current item.\n *\n * Only one element of the course state can be the page item at a time.\n *\n * There are several actions that can alter the page current item. For example, when the user is in an activity\n * page, the page item is always the activity one. However, in a course page, when the user scrolls to an element,\n * this element get the page item.\n *\n * If the page item is static means that it is not meant to change. This is important because\n * static page items has some special logic. For example, if a cm is the static page item\n * and it is inside a collapsed section, the course index will expand the section to make it visible.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {String|undefined} type the element type (section or cm). Undefined will remove the current page item.\n * @param {Number|undefined} id the element id\n * @param {boolean|undefined} isStatic if the page item is static\n */\n setPageItem(stateManager, type, id, isStatic) {\n let newPageItem;\n if (type !== undefined) {\n newPageItem = stateManager.get(type, id);\n if (!newPageItem) {\n return;\n }\n }\n const course = stateManager.get('course');\n if (course.pageItem && course.pageItem.type === type && course.pageItem.id === id) {\n return;\n }\n stateManager.setReadOnly(false);\n // Remove the current page item.\n course.pageItem = null;\n // Save the new page item.\n if (newPageItem) {\n course.pageItem = {\n id,\n type,\n sectionId: (type == 'section') ? newPageItem.id : newPageItem.sectionid,\n isStatic,\n };\n }\n stateManager.setReadOnly(true);\n }\n\n /**\n * Unlock all course elements.\n *\n * @param {StateManager} stateManager the current state manager\n */\n unlockAll(stateManager) {\n const state = stateManager.state;\n stateManager.setReadOnly(false);\n state.section.forEach((section) => {\n section.locked = false;\n });\n state.cm.forEach((cm) => {\n cm.locked = false;\n });\n stateManager.setReadOnly(true);\n }\n\n /**\n * Update the course index collapsed attribute of some sections.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the affected section ids\n * @param {boolean} collapsed the new collapsed value\n */\n async sectionIndexCollapsed(stateManager, sectionIds, collapsed) {\n const affectedSections = this._updateStateSectionPreference(stateManager, 'indexcollapsed', sectionIds, collapsed);\n if (!affectedSections) {\n return;\n }\n const course = stateManager.get('course');\n let actionName = 'section_index_collapsed';\n if (!collapsed) {\n actionName = 'section_index_expanded';\n }\n await this._callEditWebservice(actionName, course.id, affectedSections);\n }\n\n /**\n * Update the course index collapsed attribute of all sections.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {boolean} collapsed the new collapsed value\n */\n async allSectionsIndexCollapsed(stateManager, collapsed) {\n const sectionIds = stateManager.getIds('section');\n this.sectionIndexCollapsed(stateManager, sectionIds, collapsed);\n }\n\n /**\n * Update the course content collapsed attribute of some sections.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the affected section ids\n * @param {boolean} collapsed the new collapsed value\n */\n async sectionContentCollapsed(stateManager, sectionIds, collapsed) {\n const affectedSections = this._updateStateSectionPreference(stateManager, 'contentcollapsed', sectionIds, collapsed);\n if (!affectedSections) {\n return;\n }\n const course = stateManager.get('course');\n let actionName = 'section_content_collapsed';\n if (!collapsed) {\n actionName = 'section_content_expanded';\n }\n await this._callEditWebservice(actionName, course.id, affectedSections);\n }\n\n /**\n * Private batch update for a section preference attribute.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {string} preferenceName the preference name\n * @param {array} sectionIds the affected section ids\n * @param {boolean} preferenceValue the new preferenceValue value\n * @return {Number[]|null} sections ids with the preference value true or null if no update is required\n */\n _updateStateSectionPreference(stateManager, preferenceName, sectionIds, preferenceValue) {\n stateManager.setReadOnly(false);\n const affectedSections = [];\n // Check if we need to update preferences.\n sectionIds.forEach(sectionId => {\n const section = stateManager.get('section', sectionId);\n if (section === undefined) {\n stateManager.setReadOnly(true);\n return null;\n }\n const newValue = preferenceValue ?? section[preferenceName];\n if (section[preferenceName] != newValue) {\n section[preferenceName] = newValue;\n affectedSections.push(section.id);\n }\n });\n stateManager.setReadOnly(true);\n return affectedSections;\n }\n\n /**\n * Enable/disable bulk editing.\n *\n * Note: reenabling the bulk will clean the current selection.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {Boolean} enabled the new bulk state.\n */\n bulkEnable(stateManager, enabled) {\n const state = stateManager.state;\n stateManager.setReadOnly(false);\n state.bulk.enabled = enabled;\n state.bulk.selectedType = '';\n state.bulk.selection = [];\n stateManager.setReadOnly(true);\n }\n\n /**\n * Reset the current selection.\n * @param {StateManager} stateManager the current state manager\n */\n bulkReset(stateManager) {\n const state = stateManager.state;\n stateManager.setReadOnly(false);\n state.bulk.selectedType = '';\n state.bulk.selection = [];\n stateManager.setReadOnly(true);\n }\n\n /**\n * Select a list of cms.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n cmSelect(stateManager, cmIds) {\n this._addIdsToSelection(stateManager, 'cm', cmIds);\n }\n\n /**\n * Unselect a list of cms.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n cmUnselect(stateManager, cmIds) {\n this._removeIdsFromSelection(stateManager, 'cm', cmIds);\n }\n\n /**\n * Select a list of sections.\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the list of cm ids\n */\n sectionSelect(stateManager, sectionIds) {\n this._addIdsToSelection(stateManager, 'section', sectionIds);\n }\n\n /**\n * Unselect a list of sections.\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the list of cm ids\n */\n sectionUnselect(stateManager, sectionIds) {\n this._removeIdsFromSelection(stateManager, 'section', sectionIds);\n }\n\n /**\n * Add some ids to the current bulk selection.\n * @param {StateManager} stateManager the current state manager\n * @param {String} typeName the type name (section/cm)\n * @param {array} ids the list of ids\n */\n _addIdsToSelection(stateManager, typeName, ids) {\n const bulk = stateManager.state.bulk;\n if (!bulk?.enabled) {\n throw new Error(`Bulk is not enabled`);\n }\n if (bulk?.selectedType !== \"\" && bulk?.selectedType !== typeName) {\n throw new Error(`Cannot add ${typeName} to the current selection`);\n }\n\n // Stored ids are strings for compatability with HTML data attributes.\n ids = ids.map(value => value.toString());\n\n stateManager.setReadOnly(false);\n bulk.selectedType = typeName;\n const newSelection = new Set([...bulk.selection, ...ids]);\n bulk.selection = [...newSelection];\n stateManager.setReadOnly(true);\n }\n\n /**\n * Remove some ids to the current bulk selection.\n *\n * The method resets the selection type if the current selection is empty.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {String} typeName the type name (section/cm)\n * @param {array} ids the list of ids\n */\n _removeIdsFromSelection(stateManager, typeName, ids) {\n const bulk = stateManager.state.bulk;\n if (!bulk?.enabled) {\n throw new Error(`Bulk is not enabled`);\n }\n if (bulk?.selectedType !== \"\" && bulk?.selectedType !== typeName) {\n throw new Error(`Cannot remove ${typeName} from the current selection`);\n }\n\n // Stored ids are strings for compatability with HTML data attributes.\n ids = ids.map(value => value.toString());\n\n stateManager.setReadOnly(false);\n const IdsToFilter = new Set(ids);\n bulk.selection = bulk.selection.filter(current => !IdsToFilter.has(current));\n if (bulk.selection.length === 0) {\n bulk.selectedType = '';\n }\n stateManager.setReadOnly(true);\n }\n\n /**\n * Get updated state data related to some cm ids.\n *\n * @method cmState\n * @param {StateManager} stateManager the current state\n * @param {array} cmids the list of cm ids to update\n */\n async cmState(stateManager, cmids) {\n this.cmLock(stateManager, cmids, true);\n const course = stateManager.get('course');\n const updates = await this._callEditWebservice('cm_state', course.id, cmids);\n stateManager.processUpdates(updates);\n this.cmLock(stateManager, cmids, false);\n }\n\n /**\n * Get updated state data related to some section ids.\n *\n * @method sectionState\n * @param {StateManager} stateManager the current state\n * @param {array} sectionIds the list of section ids to update\n */\n async sectionState(stateManager, sectionIds) {\n this.sectionLock(stateManager, sectionIds, true);\n const course = stateManager.get('course');\n const updates = await this._callEditWebservice('section_state', course.id, sectionIds);\n stateManager.processUpdates(updates);\n this.sectionLock(stateManager, sectionIds, false);\n }\n\n /**\n * Get the full updated state data of the course.\n *\n * @param {StateManager} stateManager the current state\n */\n async courseState(stateManager) {\n const course = stateManager.get('course');\n const updates = await this._callEditWebservice('course_state', course.id);\n stateManager.processUpdates(updates);\n }\n\n}\n"],"names":["isLoggerSet","action","courseId","ids","targetSectionId","targetCmId","args","courseid","targetsectionid","targetcmid","ajaxresult","ajax","call","methodname","JSON","parse","modName","targetSectionNum","debug","modname","targetsectionnum","stateManager","sectionIds","logEntry","this","_getLoggerEntry","itemType","course","get","sectionLock","updates","_callEditWebservice","id","bulkReset","processUpdates","addLoggerEntry","cmIds","cmLock","itemIds","data","setLogger","SRLogger","feedbackParams","split","batch","length","count","itemInfo","name","title","targetSectionName","targetCmName","feedbackMessage","toLowerCase","component","init","addUpdateTypes","prepareFields","_prepareFields","updateName","fields","locked","_sectionBasicAction","_cmBasicAction","Set","add","forEach","cmId","cm","sectionid","Array","from","cmids","Error","_callAddModuleWebservice","pluginname","_callNewModuleWebservice","cmDrag","dragValue","setPageItem","_setElementsValue","sectionDrag","complete","newState","setReadOnly","element","isoverallcomplete","completionstate","lockValue","fieldName","newValue","type","isStatic","newPageItem","undefined","pageItem","sectionId","unlockAll","state","section","collapsed","affectedSections","_updateStateSectionPreference","actionName","getIds","sectionIndexCollapsed","preferenceName","preferenceValue","push","bulkEnable","enabled","bulk","selectedType","selection","cmSelect","_addIdsToSelection","cmUnselect","_removeIdsFromSelection","sectionSelect","sectionUnselect","typeName","map","value","toString","newSelection","IdsToFilter","filter","current","has"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"mutations.min.js","sources":["../../../src/local/courseeditor/mutations.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\nimport ajax from 'core/ajax';\nimport {getString} from \"core/str\";\nimport log from 'core/log';\nimport SRLogger from \"core/local/reactive/srlogger\";\n\n/**\n * Flag to determine whether the screen reader-only logger has already been set, so we only need to set it once.\n *\n * @type {boolean}\n */\nlet isLoggerSet = false;\n\n/**\n * Default mutation manager\n *\n * @module core_courseformat/local/courseeditor/mutations\n * @class core_courseformat/local/courseeditor/mutations\n * @copyright 2021 Ferran Recio \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default class {\n\n // All course editor mutations for Moodle 4.0 will be located in this file.\n\n /**\n * Private method to call core_courseformat_update_course webservice.\n *\n * @method _callEditWebservice\n * @param {string} action\n * @param {number} courseId\n * @param {array} ids\n * @param {number} targetSectionId optional target section id (for moving actions)\n * @param {number} targetCmId optional target cm id (for moving actions)\n */\n async _callEditWebservice(action, courseId, ids, targetSectionId, targetCmId) {\n const args = {\n action,\n courseid: courseId,\n ids,\n };\n if (targetSectionId) {\n args.targetsectionid = targetSectionId;\n }\n if (targetCmId) {\n args.targetcmid = targetCmId;\n }\n let ajaxresult = await ajax.call([{\n methodname: 'core_courseformat_update_course',\n args,\n }])[0];\n return JSON.parse(ajaxresult);\n }\n\n /**\n * Private method to call core_courseformat_create_module webservice.\n *\n * @deprecated since Moodle 5.0 MDL-83469.\n * @todo MDL-83851 This will be deleted in Moodle 6.0.\n * @method _callEditWebservice\n * @param {number} courseId\n * @param {string} modName module name\n * @param {number} targetSectionNum target section number\n * @param {number} targetCmId optional target cm id\n */\n async _callAddModuleWebservice(courseId, modName, targetSectionNum, targetCmId) {\n log.debug('_callAddModuleWebservice() is deprecated. Use _callNewModuleWebservice() instead');\n const args = {\n courseid: courseId,\n modname: modName,\n targetsectionnum: targetSectionNum,\n };\n if (targetCmId) {\n args.targetcmid = targetCmId;\n }\n let ajaxresult = await ajax.call([{\n methodname: 'core_courseformat_create_module',\n args,\n }])[0];\n return JSON.parse(ajaxresult);\n }\n\n /**\n * Private method to call core_courseformat_new_module webservice.\n *\n * @method _callEditWebservice\n * @param {number} courseId\n * @param {string} modName module name\n * @param {number} targetSectionId target section number\n * @param {number} targetCmId optional target cm id\n */\n async _callNewModuleWebservice(courseId, modName, targetSectionId, targetCmId) {\n const args = {\n courseid: courseId,\n modname: modName,\n targetsectionid: targetSectionId,\n };\n if (targetCmId) {\n args.targetcmid = targetCmId;\n }\n let ajaxresult = await ajax.call([{\n methodname: 'core_courseformat_new_module',\n args,\n }])[0];\n return JSON.parse(ajaxresult);\n }\n\n /**\n * Execute a basic section state action.\n * @param {StateManager} stateManager the current state manager\n * @param {string} action the action name\n * @param {array} sectionIds the section ids\n * @param {number} targetSectionId optional target section id (for moving actions)\n * @param {number} targetCmId optional target cm id (for moving actions)\n */\n async _sectionBasicAction(stateManager, action, sectionIds, targetSectionId, targetCmId) {\n const logEntry = this._getLoggerEntry(stateManager, action, sectionIds, {\n targetSectionId,\n targetCmId,\n itemType: 'section',\n });\n const course = stateManager.get('course');\n this.sectionLock(stateManager, sectionIds, true);\n const updates = await this._callEditWebservice(\n action,\n course.id,\n sectionIds,\n targetSectionId,\n targetCmId\n );\n this.bulkReset(stateManager);\n stateManager.processUpdates(updates);\n this.sectionLock(stateManager, sectionIds, false);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Execute a basic course module state action.\n * @param {StateManager} stateManager the current state manager\n * @param {string} action the action name\n * @param {array} cmIds the cm ids\n * @param {number} targetSectionId optional target section id (for moving actions)\n * @param {number} targetCmId optional target cm id (for moving actions)\n */\n async _cmBasicAction(stateManager, action, cmIds, targetSectionId, targetCmId) {\n const logEntry = this._getLoggerEntry(stateManager, action, cmIds, {\n targetSectionId,\n targetCmId,\n itemType: 'cm',\n });\n const course = stateManager.get('course');\n this.cmLock(stateManager, cmIds, true);\n const updates = await this._callEditWebservice(\n action,\n course.id,\n cmIds,\n targetSectionId,\n targetCmId\n );\n this.bulkReset(stateManager);\n stateManager.processUpdates(updates);\n this.cmLock(stateManager, cmIds, false);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Get log entry for the current action.\n * @param {StateManager} stateManager the current state manager\n * @param {string} action the action name\n * @param {int[]|null} itemIds the element ids\n * @param {Object|undefined} data extra params for the log entry\n * @param {string|undefined} data.itemType the element type (will be taken from action if none)\n * @param {int|null|undefined} data.targetSectionId the target section id\n * @param {int|null|undefined} data.targetCmId the target cm id\n * @param {String|null|undefined} data.component optional component (for format plugins)\n * @param {Object|undefined} [data.feedbackParams] the params to build the feedback message\n * @return {Object} the log entry\n */\n async _getLoggerEntry(stateManager, action, itemIds, data = {}) {\n if (!isLoggerSet) {\n // In case the logger has not been set from init(), ensure we set the logger.\n stateManager.setLogger(new SRLogger());\n isLoggerSet = true;\n }\n let feedbackParams = {\n action,\n itemType: data.itemType ?? action.split('_')[0],\n };\n let batch = '';\n if (itemIds.length > 1) {\n feedbackParams.count = itemIds.length;\n batch = '_batch';\n } else if (itemIds.length === 1) {\n const itemInfo = stateManager.get(feedbackParams.itemType, itemIds[0]);\n feedbackParams.name = itemInfo.title ?? itemInfo.name;\n // Apply shortener for modules like label.\n }\n if (data.targetSectionId) {\n feedbackParams.targetSectionName = stateManager.get('section', data.targetSectionId).title;\n } else if (data.targetCmId) {\n // The target section can also be derived from the target cm when only the latter is provided\n // (e.g. dropping an activity right above another one).\n const targetCm = stateManager.get('cm', data.targetCmId);\n feedbackParams.targetSectionName = stateManager.get('section', targetCm.sectionid).title;\n }\n if (data.targetCmId) {\n feedbackParams.targetCmName = stateManager.get('cm', data.targetCmId).name;\n }\n if (data.feedbackParams) {\n feedbackParams = {...feedbackParams, ...data.feedbackParams};\n }\n\n const message = await getString(\n `${action.toLowerCase()}_feedback${batch}`,\n data.component ?? 'core_courseformat',\n feedbackParams\n );\n\n return {\n feedbackMessage: message,\n };\n }\n\n /**\n * Mutation module initialize.\n *\n * The reactive instance will execute this method when addMutations or setMutation is invoked.\n *\n * @param {StateManager} stateManager the state manager\n */\n init(stateManager) {\n // Add a method to prepare the fields when some update is coming from the server.\n stateManager.addUpdateTypes({\n prepareFields: this._prepareFields,\n });\n // Use the screen reader-only logger (SRLogger) to handle the feedback messages from the mutations.\n stateManager.setLogger(new SRLogger());\n isLoggerSet = true;\n }\n\n /**\n * Add default values to state elements.\n *\n * This method is called every time a webservice returns a update state message.\n *\n * @param {Object} stateManager the state manager\n * @param {String} updateName the state element to update\n * @param {Object} fields the new data\n * @returns {Object} final fields data\n */\n _prepareFields(stateManager, updateName, fields) {\n // Any update should unlock the element.\n fields.locked = false;\n return fields;\n }\n\n /**\n * Hides sections.\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the list of section ids\n */\n async sectionHide(stateManager, sectionIds) {\n await this._sectionBasicAction(stateManager, 'section_hide', sectionIds);\n }\n\n /**\n * Show sections.\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the list of section ids\n */\n async sectionShow(stateManager, sectionIds) {\n await this._sectionBasicAction(stateManager, 'section_show', sectionIds);\n }\n\n /**\n * Show cms.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n async cmShow(stateManager, cmIds) {\n await this._cmBasicAction(stateManager, 'cm_show', cmIds);\n }\n\n /**\n * Hide cms.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n async cmHide(stateManager, cmIds) {\n await this._cmBasicAction(stateManager, 'cm_hide', cmIds);\n }\n\n /**\n * Stealth cms.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n async cmStealth(stateManager, cmIds) {\n await this._cmBasicAction(stateManager, 'cm_stealth', cmIds);\n }\n\n /**\n * Duplicate course modules\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of course modules ids\n * @param {number|undefined} targetSectionId the optional target sectionId\n * @param {number|undefined} targetCmId the target course module id\n */\n async cmDuplicate(stateManager, cmIds, targetSectionId, targetCmId) {\n const logEntry = this._getLoggerEntry(stateManager, 'cm_duplicate', cmIds);\n const course = stateManager.get('course');\n // Lock all target sections.\n const sectionIds = new Set();\n if (targetSectionId) {\n sectionIds.add(targetSectionId);\n } else {\n cmIds.forEach((cmId) => {\n const cm = stateManager.get('cm', cmId);\n sectionIds.add(cm.sectionid);\n });\n }\n this.sectionLock(stateManager, Array.from(sectionIds), true);\n\n const updates = await this._callEditWebservice('cm_duplicate', course.id, cmIds, targetSectionId, targetCmId);\n this.bulkReset(stateManager);\n stateManager.processUpdates(updates);\n\n this.sectionLock(stateManager, Array.from(sectionIds), false);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Build the action and feedback data describing where a cmMove will place the activities, in\n * terms a screen reader user can act on.\n *\n * The activities are always inserted immediately before targetCmId (or appended to the end of\n * targetSectionId if no targetCmId is given, see stateactions::cm_move()). The \"Move activity\"\n * dialogue presents this to the user as \"Move after: \", so we report it the same way:\n * as \"moved after \", using whichever activity currently sits immediately before the\n * insertion point (skipping over any activities that are themselves being moved). Only when\n * nothing precedes the insertion point (i.e. the activities land at the very top of the\n * section) do we fall back to naming the activity they now precede.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmids the list of cm ids being moved\n * @param {number} targetSectionId the target section id\n * @param {number} targetCmId the target course module id\n * @return {array} a [action, feedbackData] pair to pass to _getLoggerEntry\n */\n _getCmMoveFeedback(stateManager, cmids, targetSectionId, targetCmId) {\n const targetSection = targetCmId\n ? stateManager.get('section', stateManager.get('cm', targetCmId).sectionid)\n : stateManager.get('section', targetSectionId);\n const cmlist = targetSection.cmlist;\n const insertionIndex = targetCmId ? cmlist.indexOf(targetCmId) : cmlist.length;\n\n let anchorCmId;\n for (let i = insertionIndex - 1; i >= 0; i--) {\n if (!cmids.includes(cmlist[i])) {\n anchorCmId = cmlist[i];\n break;\n }\n }\n\n if (anchorCmId) {\n return ['cm_move_after', {targetCmId: anchorCmId}];\n }\n if (targetCmId) {\n return ['cm_move_before', {targetCmId}];\n }\n return ['cm_move', {targetSectionId}];\n }\n\n /**\n * Move course modules to specific course location.\n *\n * Note that one of targetSectionId or targetCmId should be provided in order to identify the\n * new location:\n * - targetCmId: the activities will be located avobe the target cm. The targetSectionId\n * value will be ignored in this case.\n * - targetSectionId: the activities will be appended to the section. In this case\n * targetSectionId should not be present.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmids the list of cm ids to move\n * @param {number} targetSectionId the target section id\n * @param {number} targetCmId the target course module id\n */\n async cmMove(stateManager, cmids, targetSectionId, targetCmId) {\n if (!targetSectionId && !targetCmId) {\n throw new Error(`Mutation cmMove requires targetSectionId or targetCmId`);\n }\n const course = stateManager.get('course');\n this.cmLock(stateManager, cmids, true);\n const [moveAction, feedbackData] = this._getCmMoveFeedback(stateManager, cmids, targetSectionId, targetCmId);\n const logEntry = this._getLoggerEntry(stateManager, moveAction, cmids, feedbackData);\n const updates = await this._callEditWebservice('cm_move', course.id, cmids, targetSectionId, targetCmId);\n this.bulkReset(stateManager);\n stateManager.processUpdates(updates);\n this.cmLock(stateManager, cmids, false);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Move course modules after a specific course location.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the list of section ids to move\n * @param {number} targetSectionId the target section id\n */\n async sectionMoveAfter(stateManager, sectionIds, targetSectionId) {\n if (!targetSectionId) {\n throw new Error(`Mutation sectionMoveAfter requires targetSectionId`);\n }\n const course = stateManager.get('course');\n this.sectionLock(stateManager, sectionIds, true);\n const logEntry = this._getLoggerEntry(stateManager, 'section_move_after', sectionIds, {targetSectionId});\n const updates = await this._callEditWebservice('section_move_after', course.id, sectionIds, targetSectionId);\n this.bulkReset(stateManager);\n stateManager.processUpdates(updates);\n this.sectionLock(stateManager, sectionIds, false);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Add a new section to a specific course location.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {number} targetSectionId optional the target section id\n */\n async addSection(stateManager, targetSectionId) {\n if (!targetSectionId) {\n targetSectionId = 0;\n }\n const course = stateManager.get('course');\n const updates = await this._callEditWebservice('section_add', course.id, [], targetSectionId);\n stateManager.processUpdates(updates);\n const logEntry = this._getLoggerEntry(stateManager, 'section_add', []);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Delete sections.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the list of course modules ids\n */\n async sectionDelete(stateManager, sectionIds) {\n const course = stateManager.get('course');\n const logEntry = this._getLoggerEntry(stateManager, 'section_delete', sectionIds);\n const updates = await this._callEditWebservice('section_delete', course.id, sectionIds);\n this.bulkReset(stateManager);\n stateManager.processUpdates(updates);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Delete cms.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of section ids\n */\n async cmDelete(stateManager, cmIds) {\n const course = stateManager.get('course');\n const logEntry = this._getLoggerEntry(stateManager, 'cm_delete', cmIds);\n this.cmLock(stateManager, cmIds, true);\n const updates = await this._callEditWebservice('cm_delete', course.id, cmIds);\n this.bulkReset(stateManager);\n this.cmLock(stateManager, cmIds, false);\n stateManager.processUpdates(updates);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Add a new module to a specific course section.\n *\n * @deprecated since Moodle 5.0 MDL-83469.\n * @todo MDL-83851 This will be deleted in Moodle 6.0.\n * @param {StateManager} stateManager the current state manager\n * @param {string} modName the modulename to add\n * @param {number} targetSectionNum the target section number\n * @param {number} targetCmId optional the target cm id\n */\n async addModule(stateManager, modName, targetSectionNum, targetCmId) {\n log.debug('addModule() is deprecated. Use newModule() instead');\n if (!modName) {\n throw new Error(`Mutation addModule requires moduleName`);\n }\n if (!targetSectionNum) {\n throw new Error(`Mutation addModule requires targetSectionNum`);\n }\n if (!targetCmId) {\n targetCmId = 0;\n }\n const course = stateManager.get('course');\n const updates = await this._callAddModuleWebservice(course.id, modName, targetSectionNum, targetCmId);\n stateManager.processUpdates(updates);\n }\n\n /**\n * Add a new module to a specific course section.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {string} modName the modulename to add\n * @param {number} targetSectionId the target section id\n * @param {number} targetCmId optional the target cm id\n */\n async newModule(stateManager, modName, targetSectionId, targetCmId) {\n if (!modName) {\n throw new Error(`Mutation newModule requires moduleName`);\n }\n if (!targetSectionId) {\n throw new Error(`Mutation newModule requires targetSectionId`);\n }\n if (!targetCmId) {\n targetCmId = 0;\n }\n const course = stateManager.get('course');\n const pluginname = await getString(\n 'pluginname',\n `${modName.toLowerCase()}`,\n );\n const logEntry = this._getLoggerEntry(stateManager, 'cm_add', [], {\n feedbackParams: {\n 'modname': pluginname,\n },\n });\n const updates = await this._callNewModuleWebservice(course.id, modName, targetSectionId, targetCmId);\n stateManager.processUpdates(updates);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Mark or unmark course modules as dragging.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of course modules ids\n * @param {bool} dragValue the new dragging value\n */\n cmDrag(stateManager, cmIds, dragValue) {\n this.setPageItem(stateManager);\n this._setElementsValue(stateManager, 'cm', cmIds, 'dragging', dragValue);\n }\n\n /**\n * Mark or unmark course sections as dragging.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the list of section ids\n * @param {bool} dragValue the new dragging value\n */\n sectionDrag(stateManager, sectionIds, dragValue) {\n this.setPageItem(stateManager);\n this._setElementsValue(stateManager, 'section', sectionIds, 'dragging', dragValue);\n }\n\n /**\n * Mark or unmark course modules as complete.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of course modules ids\n * @param {bool} complete the new completion value\n */\n async cmCompletion(stateManager, cmIds, complete) {\n const newState = (complete) ? 1 : 0;\n const action = (newState == 1) ? 'cm_complete' : 'cm_uncomplete';\n const logEntry = this._getLoggerEntry(stateManager, action, cmIds);\n stateManager.setReadOnly(false);\n cmIds.forEach((id) => {\n const element = stateManager.get('cm', id);\n if (element) {\n element.isoverallcomplete = complete;\n element.completionstate = newState;\n }\n });\n stateManager.setReadOnly(true);\n stateManager.addLoggerEntry(await logEntry);\n }\n\n /**\n * Move cms to the right: indent = 1.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n async cmMoveRight(stateManager, cmIds) {\n await this._cmBasicAction(stateManager, 'cm_moveright', cmIds);\n }\n\n /**\n * Move cms to the left: indent = 0.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n async cmMoveLeft(stateManager, cmIds) {\n await this._cmBasicAction(stateManager, 'cm_moveleft', cmIds);\n }\n\n /**\n * Set cms group mode to NOGROUPS.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n async cmNoGroups(stateManager, cmIds) {\n await this._cmBasicAction(stateManager, 'cm_nogroups', cmIds);\n }\n\n /**\n * Set cms group mode to VISIBLEGROUPS.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n async cmVisibleGroups(stateManager, cmIds) {\n await this._cmBasicAction(stateManager, 'cm_visiblegroups', cmIds);\n }\n\n /**\n * Set cms group mode to SEPARATEGROUPS.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n async cmSeparateGroups(stateManager, cmIds) {\n await this._cmBasicAction(stateManager, 'cm_separategroups', cmIds);\n }\n\n /**\n * Lock or unlock course modules.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of course modules ids\n * @param {bool} lockValue the new locked value\n */\n cmLock(stateManager, cmIds, lockValue) {\n this._setElementsValue(stateManager, 'cm', cmIds, 'locked', lockValue);\n }\n\n /**\n * Lock or unlock course sections.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the list of section ids\n * @param {bool} lockValue the new locked value\n */\n sectionLock(stateManager, sectionIds, lockValue) {\n this._setElementsValue(stateManager, 'section', sectionIds, 'locked', lockValue);\n }\n\n _setElementsValue(stateManager, name, ids, fieldName, newValue) {\n stateManager.setReadOnly(false);\n ids.forEach((id) => {\n const element = stateManager.get(name, id);\n if (element) {\n element[fieldName] = newValue;\n }\n });\n stateManager.setReadOnly(true);\n }\n\n /**\n * Set the page current item.\n *\n * Only one element of the course state can be the page item at a time.\n *\n * There are several actions that can alter the page current item. For example, when the user is in an activity\n * page, the page item is always the activity one. However, in a course page, when the user scrolls to an element,\n * this element get the page item.\n *\n * If the page item is static means that it is not meant to change. This is important because\n * static page items has some special logic. For example, if a cm is the static page item\n * and it is inside a collapsed section, the course index will expand the section to make it visible.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {String|undefined} type the element type (section or cm). Undefined will remove the current page item.\n * @param {Number|undefined} id the element id\n * @param {boolean|undefined} isStatic if the page item is static\n */\n setPageItem(stateManager, type, id, isStatic) {\n let newPageItem;\n if (type !== undefined) {\n newPageItem = stateManager.get(type, id);\n if (!newPageItem) {\n return;\n }\n }\n const course = stateManager.get('course');\n if (course.pageItem && course.pageItem.type === type && course.pageItem.id === id) {\n return;\n }\n stateManager.setReadOnly(false);\n // Remove the current page item.\n course.pageItem = null;\n // Save the new page item.\n if (newPageItem) {\n course.pageItem = {\n id,\n type,\n sectionId: (type == 'section') ? newPageItem.id : newPageItem.sectionid,\n isStatic,\n };\n }\n stateManager.setReadOnly(true);\n }\n\n /**\n * Unlock all course elements.\n *\n * @param {StateManager} stateManager the current state manager\n */\n unlockAll(stateManager) {\n const state = stateManager.state;\n stateManager.setReadOnly(false);\n state.section.forEach((section) => {\n section.locked = false;\n });\n state.cm.forEach((cm) => {\n cm.locked = false;\n });\n stateManager.setReadOnly(true);\n }\n\n /**\n * Update the course index collapsed attribute of some sections.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the affected section ids\n * @param {boolean} collapsed the new collapsed value\n */\n async sectionIndexCollapsed(stateManager, sectionIds, collapsed) {\n const affectedSections = this._updateStateSectionPreference(stateManager, 'indexcollapsed', sectionIds, collapsed);\n if (!affectedSections) {\n return;\n }\n const course = stateManager.get('course');\n let actionName = 'section_index_collapsed';\n if (!collapsed) {\n actionName = 'section_index_expanded';\n }\n await this._callEditWebservice(actionName, course.id, affectedSections);\n }\n\n /**\n * Update the course index collapsed attribute of all sections.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {boolean} collapsed the new collapsed value\n */\n async allSectionsIndexCollapsed(stateManager, collapsed) {\n const sectionIds = stateManager.getIds('section');\n this.sectionIndexCollapsed(stateManager, sectionIds, collapsed);\n }\n\n /**\n * Update the course content collapsed attribute of some sections.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the affected section ids\n * @param {boolean} collapsed the new collapsed value\n */\n async sectionContentCollapsed(stateManager, sectionIds, collapsed) {\n const affectedSections = this._updateStateSectionPreference(stateManager, 'contentcollapsed', sectionIds, collapsed);\n if (!affectedSections) {\n return;\n }\n const course = stateManager.get('course');\n let actionName = 'section_content_collapsed';\n if (!collapsed) {\n actionName = 'section_content_expanded';\n }\n await this._callEditWebservice(actionName, course.id, affectedSections);\n }\n\n /**\n * Private batch update for a section preference attribute.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {string} preferenceName the preference name\n * @param {array} sectionIds the affected section ids\n * @param {boolean} preferenceValue the new preferenceValue value\n * @return {Number[]|null} sections ids with the preference value true or null if no update is required\n */\n _updateStateSectionPreference(stateManager, preferenceName, sectionIds, preferenceValue) {\n stateManager.setReadOnly(false);\n const affectedSections = [];\n // Check if we need to update preferences.\n sectionIds.forEach(sectionId => {\n const section = stateManager.get('section', sectionId);\n if (section === undefined) {\n stateManager.setReadOnly(true);\n return null;\n }\n const newValue = preferenceValue ?? section[preferenceName];\n if (section[preferenceName] != newValue) {\n section[preferenceName] = newValue;\n affectedSections.push(section.id);\n }\n });\n stateManager.setReadOnly(true);\n return affectedSections;\n }\n\n /**\n * Enable/disable bulk editing.\n *\n * Note: reenabling the bulk will clean the current selection.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {Boolean} enabled the new bulk state.\n */\n bulkEnable(stateManager, enabled) {\n const state = stateManager.state;\n stateManager.setReadOnly(false);\n state.bulk.enabled = enabled;\n state.bulk.selectedType = '';\n state.bulk.selection = [];\n stateManager.setReadOnly(true);\n }\n\n /**\n * Reset the current selection.\n * @param {StateManager} stateManager the current state manager\n */\n bulkReset(stateManager) {\n const state = stateManager.state;\n stateManager.setReadOnly(false);\n state.bulk.selectedType = '';\n state.bulk.selection = [];\n stateManager.setReadOnly(true);\n }\n\n /**\n * Select a list of cms.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n cmSelect(stateManager, cmIds) {\n this._addIdsToSelection(stateManager, 'cm', cmIds);\n }\n\n /**\n * Unselect a list of cms.\n * @param {StateManager} stateManager the current state manager\n * @param {array} cmIds the list of cm ids\n */\n cmUnselect(stateManager, cmIds) {\n this._removeIdsFromSelection(stateManager, 'cm', cmIds);\n }\n\n /**\n * Select a list of sections.\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the list of cm ids\n */\n sectionSelect(stateManager, sectionIds) {\n this._addIdsToSelection(stateManager, 'section', sectionIds);\n }\n\n /**\n * Unselect a list of sections.\n * @param {StateManager} stateManager the current state manager\n * @param {array} sectionIds the list of cm ids\n */\n sectionUnselect(stateManager, sectionIds) {\n this._removeIdsFromSelection(stateManager, 'section', sectionIds);\n }\n\n /**\n * Add some ids to the current bulk selection.\n * @param {StateManager} stateManager the current state manager\n * @param {String} typeName the type name (section/cm)\n * @param {array} ids the list of ids\n */\n _addIdsToSelection(stateManager, typeName, ids) {\n const bulk = stateManager.state.bulk;\n if (!bulk?.enabled) {\n throw new Error(`Bulk is not enabled`);\n }\n if (bulk?.selectedType !== \"\" && bulk?.selectedType !== typeName) {\n throw new Error(`Cannot add ${typeName} to the current selection`);\n }\n\n // Stored ids are strings for compatability with HTML data attributes.\n ids = ids.map(value => value.toString());\n\n stateManager.setReadOnly(false);\n bulk.selectedType = typeName;\n const newSelection = new Set([...bulk.selection, ...ids]);\n bulk.selection = [...newSelection];\n stateManager.setReadOnly(true);\n }\n\n /**\n * Remove some ids to the current bulk selection.\n *\n * The method resets the selection type if the current selection is empty.\n *\n * @param {StateManager} stateManager the current state manager\n * @param {String} typeName the type name (section/cm)\n * @param {array} ids the list of ids\n */\n _removeIdsFromSelection(stateManager, typeName, ids) {\n const bulk = stateManager.state.bulk;\n if (!bulk?.enabled) {\n throw new Error(`Bulk is not enabled`);\n }\n if (bulk?.selectedType !== \"\" && bulk?.selectedType !== typeName) {\n throw new Error(`Cannot remove ${typeName} from the current selection`);\n }\n\n // Stored ids are strings for compatability with HTML data attributes.\n ids = ids.map(value => value.toString());\n\n stateManager.setReadOnly(false);\n const IdsToFilter = new Set(ids);\n bulk.selection = bulk.selection.filter(current => !IdsToFilter.has(current));\n if (bulk.selection.length === 0) {\n bulk.selectedType = '';\n }\n stateManager.setReadOnly(true);\n }\n\n /**\n * Get updated state data related to some cm ids.\n *\n * @method cmState\n * @param {StateManager} stateManager the current state\n * @param {array} cmids the list of cm ids to update\n */\n async cmState(stateManager, cmids) {\n this.cmLock(stateManager, cmids, true);\n const course = stateManager.get('course');\n const updates = await this._callEditWebservice('cm_state', course.id, cmids);\n stateManager.processUpdates(updates);\n this.cmLock(stateManager, cmids, false);\n }\n\n /**\n * Get updated state data related to some section ids.\n *\n * @method sectionState\n * @param {StateManager} stateManager the current state\n * @param {array} sectionIds the list of section ids to update\n */\n async sectionState(stateManager, sectionIds) {\n this.sectionLock(stateManager, sectionIds, true);\n const course = stateManager.get('course');\n const updates = await this._callEditWebservice('section_state', course.id, sectionIds);\n stateManager.processUpdates(updates);\n this.sectionLock(stateManager, sectionIds, false);\n }\n\n /**\n * Get the full updated state data of the course.\n *\n * @param {StateManager} stateManager the current state\n */\n async courseState(stateManager) {\n const course = stateManager.get('course');\n const updates = await this._callEditWebservice('course_state', course.id);\n stateManager.processUpdates(updates);\n }\n\n}\n"],"names":["isLoggerSet","action","courseId","ids","targetSectionId","targetCmId","args","courseid","targetsectionid","targetcmid","ajaxresult","ajax","call","methodname","JSON","parse","modName","targetSectionNum","debug","modname","targetsectionnum","stateManager","sectionIds","logEntry","this","_getLoggerEntry","itemType","course","get","sectionLock","updates","_callEditWebservice","id","bulkReset","processUpdates","addLoggerEntry","cmIds","cmLock","itemIds","data","setLogger","SRLogger","feedbackParams","split","batch","length","count","itemInfo","name","title","targetSectionName","targetCm","sectionid","targetCmName","feedbackMessage","toLowerCase","component","init","addUpdateTypes","prepareFields","_prepareFields","updateName","fields","locked","_sectionBasicAction","_cmBasicAction","Set","add","forEach","cmId","cm","Array","from","_getCmMoveFeedback","cmids","cmlist","anchorCmId","i","indexOf","includes","Error","moveAction","feedbackData","_callAddModuleWebservice","pluginname","_callNewModuleWebservice","cmDrag","dragValue","setPageItem","_setElementsValue","sectionDrag","complete","newState","setReadOnly","element","isoverallcomplete","completionstate","lockValue","fieldName","newValue","type","isStatic","newPageItem","undefined","pageItem","sectionId","unlockAll","state","section","collapsed","affectedSections","_updateStateSectionPreference","actionName","getIds","sectionIndexCollapsed","preferenceName","preferenceValue","push","bulkEnable","enabled","bulk","selectedType","selection","cmSelect","_addIdsToSelection","cmUnselect","_removeIdsFromSelection","sectionSelect","sectionUnselect","typeName","map","value","toString","newSelection","IdsToFilter","filter","current","has"],"mappings":"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"} \ No newline at end of file diff --git a/public/course/format/amd/src/local/courseeditor/mutations.js b/public/course/format/amd/src/local/courseeditor/mutations.js index b2f4a62035ec5..bd01820903fec 100644 --- a/public/course/format/amd/src/local/courseeditor/mutations.js +++ b/public/course/format/amd/src/local/courseeditor/mutations.js @@ -211,6 +211,11 @@ export default class { } if (data.targetSectionId) { feedbackParams.targetSectionName = stateManager.get('section', data.targetSectionId).title; + } else if (data.targetCmId) { + // The target section can also be derived from the target cm when only the latter is provided + // (e.g. dropping an activity right above another one). + const targetCm = stateManager.get('cm', data.targetCmId); + feedbackParams.targetSectionName = stateManager.get('section', targetCm.sectionid).title; } if (data.targetCmId) { feedbackParams.targetCmName = stateManager.get('cm', data.targetCmId).name; @@ -338,6 +343,48 @@ export default class { stateManager.addLoggerEntry(await logEntry); } + /** + * Build the action and feedback data describing where a cmMove will place the activities, in + * terms a screen reader user can act on. + * + * The activities are always inserted immediately before targetCmId (or appended to the end of + * targetSectionId if no targetCmId is given, see stateactions::cm_move()). The "Move activity" + * dialogue presents this to the user as "Move after: ", so we report it the same way: + * as "moved after ", using whichever activity currently sits immediately before the + * insertion point (skipping over any activities that are themselves being moved). Only when + * nothing precedes the insertion point (i.e. the activities land at the very top of the + * section) do we fall back to naming the activity they now precede. + * + * @param {StateManager} stateManager the current state manager + * @param {array} cmids the list of cm ids being moved + * @param {number} targetSectionId the target section id + * @param {number} targetCmId the target course module id + * @return {array} a [action, feedbackData] pair to pass to _getLoggerEntry + */ + _getCmMoveFeedback(stateManager, cmids, targetSectionId, targetCmId) { + const targetSection = targetCmId + ? stateManager.get('section', stateManager.get('cm', targetCmId).sectionid) + : stateManager.get('section', targetSectionId); + const cmlist = targetSection.cmlist; + const insertionIndex = targetCmId ? cmlist.indexOf(targetCmId) : cmlist.length; + + let anchorCmId; + for (let i = insertionIndex - 1; i >= 0; i--) { + if (!cmids.includes(cmlist[i])) { + anchorCmId = cmlist[i]; + break; + } + } + + if (anchorCmId) { + return ['cm_move_after', {targetCmId: anchorCmId}]; + } + if (targetCmId) { + return ['cm_move_before', {targetCmId}]; + } + return ['cm_move', {targetSectionId}]; + } + /** * Move course modules to specific course location. * @@ -359,10 +406,13 @@ export default class { } const course = stateManager.get('course'); this.cmLock(stateManager, cmids, true); + const [moveAction, feedbackData] = this._getCmMoveFeedback(stateManager, cmids, targetSectionId, targetCmId); + const logEntry = this._getLoggerEntry(stateManager, moveAction, cmids, feedbackData); const updates = await this._callEditWebservice('cm_move', course.id, cmids, targetSectionId, targetCmId); this.bulkReset(stateManager); stateManager.processUpdates(updates); this.cmLock(stateManager, cmids, false); + stateManager.addLoggerEntry(await logEntry); } /** @@ -378,10 +428,12 @@ export default class { } const course = stateManager.get('course'); this.sectionLock(stateManager, sectionIds, true); + const logEntry = this._getLoggerEntry(stateManager, 'section_move_after', sectionIds, {targetSectionId}); const updates = await this._callEditWebservice('section_move_after', course.id, sectionIds, targetSectionId); this.bulkReset(stateManager); stateManager.processUpdates(updates); this.sectionLock(stateManager, sectionIds, false); + stateManager.addLoggerEntry(await logEntry); } /** diff --git a/public/course/format/classes/base.php b/public/course/format/classes/base.php index 081c6767e8575..a2d8ddbc7779c 100644 --- a/public/course/format/classes/base.php +++ b/public/course/format/classes/base.php @@ -1716,8 +1716,8 @@ public function is_section_visible(section_info $section): bool { // but there is some available info text which explains the reason & should display, // OR it is hidden but the course has a setting to display hidden sections as unavailable. return $section->uservisible || - ($section->visible && !$section->available && !empty($section->availableinfo)) || - (!$section->visible && !$hidesections); + ($section->visible || !$hidesections) + && ($section->available || !empty($section->availableinfo)); } /** diff --git a/public/course/format/classes/output/local/courseupdate.php b/public/course/format/classes/output/local/courseupdate.php index e5d932f527180..220147bf130af 100644 --- a/public/course/format/classes/output/local/courseupdate.php +++ b/public/course/format/classes/output/local/courseupdate.php @@ -184,7 +184,7 @@ protected function cm_delete_confirmation_dialog( 'core_courseformat', (object) [ 'type' => get_string('pluginname', 'mod_' . $cm->modname), - 'name' => $cm->name, + 'name' => $cm->get_formatted_name(), ], ); } else { diff --git a/public/course/format/classes/output/local/state/cm.php b/public/course/format/classes/output/local/state/cm.php index b83995a899c45..1534436f14c42 100644 --- a/public/course/format/classes/output/local/state/cm.php +++ b/public/course/format/classes/output/local/state/cm.php @@ -95,7 +95,10 @@ public function export_for_template(renderer_base $output): stdClass { // Check the user access type to this cm. $info = new info_module($cm); - $data->accessvisible = ($data->visible && $info->is_available_for_all()); + $information = ''; + $data->accessvisible = $data->visible && ( + ($info->is_available_for_all() || $info->is_available($information, true, $USER->id)) + ); // Add url if the activity is compatible. $url = $cm->url; diff --git a/public/course/format/classes/stateactions.php b/public/course/format/classes/stateactions.php index 65acef08f0a42..d1a91be536ab1 100644 --- a/public/course/format/classes/stateactions.php +++ b/public/course/format/classes/stateactions.php @@ -1022,7 +1022,7 @@ public function section_state( foreach ($modinfo->sections[$sectioninfo->section] as $modnumber) { $mod = $modinfo->cms[$modnumber]; - if ($mod->is_visible_on_course_page()) { + if ($mod->is_visible_on_course_page() && $mod->is_of_type_that_can_display()) { $cmids[$mod->id] = true; } } diff --git a/public/course/format/templates/local/content/sectionnavigation.mustache b/public/course/format/templates/local/content/sectionnavigation.mustache index 22e5ea47e428c..e2d92684bd7a3 100644 --- a/public/course/format/templates/local/content/sectionnavigation.mustache +++ b/public/course/format/templates/local/content/sectionnavigation.mustache @@ -42,7 +42,7 @@ diff --git a/public/course/format/templates/local/content/sectionselector.mustache b/public/course/format/templates/local/content/sectionselector.mustache index 7874ea5b3b736..62465dc5c8924 100644 --- a/public/course/format/templates/local/content/sectionselector.mustache +++ b/public/course/format/templates/local/content/sectionselector.mustache @@ -46,7 +46,7 @@ diff --git a/public/course/format/tests/activityoverviewbase_test.php b/public/course/format/tests/activityoverviewbase_test.php index a5f42142c68c4..b347d02dfb174 100644 --- a/public/course/format/tests/activityoverviewbase_test.php +++ b/public/course/format/tests/activityoverviewbase_test.php @@ -16,6 +16,11 @@ namespace core_courseformat; +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . '/completionlib.php'); + /** * Tests for course * @@ -29,7 +34,6 @@ final class activityoverviewbase_test extends \advanced_testcase { #[\Override()] public static function setUpBeforeClass(): void { global $CFG; - require_once($CFG->libdir . '/completionlib.php'); require_once($CFG->dirroot . '/course/format/tests/fixtures/fake_activityoverview.php'); require_once($CFG->libdir . '/gradelib.php'); parent::setUpBeforeClass(); diff --git a/public/course/format/tests/behat/activity_move.feature b/public/course/format/tests/behat/activity_move.feature index d6503e523ea84..97f884a2e14e1 100644 --- a/public/course/format/tests/behat/activity_move.feature +++ b/public/course/format/tests/behat/activity_move.feature @@ -38,7 +38,8 @@ Feature: Activities can be moved between sections When I open "Test forum name" actions menu And I click on "Move" "link" in the "Test forum name" activity And I click on "Section 3" "link" in the "Move activity" "dialogue" - Then I should see "Test forum name" in the "Section 3" "section" + Then "Test forum name moved before Third forum name." "text" should exist in the ".toast-wrapper" "css_element" + And I should see "Test forum name" in the "Section 3" "section" @javascript Scenario: The teacher can reorder activities in the same section using the activity action menu @@ -46,7 +47,8 @@ Feature: Activities can be moved between sections When I open "Test forum name" actions menu And I click on "Move" "link" in the "Test forum name" activity And I click on "Second forum name" "link" in the "Move activity" "dialogue" - Then I should see "Test forum name" in the "Section 1" "section" + Then "Test forum name moved after Second forum name." "text" should exist in the ".toast-wrapper" "css_element" + And I should see "Test forum name" in the "Section 1" "section" And "Second forum name" "activity" should appear before "Test forum name" "activity" @javascript @@ -55,6 +57,7 @@ Feature: Activities can be moved between sections And I click on "Move" "link" in the "Test forum name" activity And I click on "Expand" "link" in the "movemodalsection3" "region" And I click on "Third forum name" "link" in the "Move activity" "dialogue" - Then I should see "Test forum name" in the "Section 3" "section" + Then "Test forum name moved after Third forum name." "text" should exist in the ".toast-wrapper" "css_element" + And I should see "Test forum name" in the "Section 3" "section" And "Third forum name" "activity" should appear before "Test forum name" "activity" And "Test forum name" "activity" should appear before "Fourth forum name" "activity" diff --git a/public/course/format/tests/behat/section_move.feature b/public/course/format/tests/behat/section_move.feature index 0d6232dc4dc6a..604fc0331c4bc 100644 --- a/public/course/format/tests/behat/section_move.feature +++ b/public/course/format/tests/behat/section_move.feature @@ -30,7 +30,8 @@ Feature: Sections can be moved When I open section "1" edit menu And I click on "Move" "link" in the "Section 1" "section" And I click on "Section 3" "link" in the "Move section" "dialogue" - Then "Section 1" "section" should appear after "Section 3" "section" + Then "Course section Section 1 moved after course section Section 3." "text" should exist in the ".toast-wrapper" "css_element" + And "Section 1" "section" should appear after "Section 3" "section" And I should see "Test forum name" in the "Section 1" "section" And I should see "Second forum name" in the "Section 3" "section" diff --git a/public/course/lib.php b/public/course/lib.php index f7ccab536a129..600dd2da1b520 100644 --- a/public/course/lib.php +++ b/public/course/lib.php @@ -1045,7 +1045,38 @@ function course_module_bulk_update_calendar_events($modulename, $courseid = 0) { foreach ($instances as $instance) { if ($cm = get_coursemodule_from_instance($modulename, $instance->id, $instance->course)) { - course_module_calendar_event_update_process($instance, $cm); + // Optional check for modules mid-delete. + if (!empty($cm->deletioninprogress)) { + continue; + } + try { + // Validate the cm is present in course modinfo, not just in mdl_course_modules. + get_fast_modinfo($instance->course)->get_cm($cm->id); + + course_module_calendar_event_update_process($instance, $cm); + } catch (Exception $e) { + $errorcode = $e->errorcode ?? ''; + if ($errorcode === 'invalidrecord') { + debugging( + get_string('calendareventskipformissingcourse', 'error', $instance->course), + DEBUG_DEVELOPER + ); + continue; + } + if ($errorcode === 'invalidcoursemoduleid' || $errorcode === 'invalidmoduleid') { + $a = new stdClass(); + $a->modulename = $modulename; + $a->instance = $instance->id; + $a->course = $instance->course; + $a->cm = $cm->id; + debugging( + get_string('calendareventskipforbrokencoursemodule', 'error', $a), + DEBUG_DEVELOPER + ); + continue; + } + throw $e; + } } } return true; diff --git a/public/course/modlib.php b/public/course/modlib.php index d77f05b592244..fdfc4d3537a42 100644 --- a/public/course/modlib.php +++ b/public/course/modlib.php @@ -531,19 +531,8 @@ function set_moduleinfo_defaults($moduleinfo) { } $enabledaiactions = []; - // Get and check for enabled AI actions in the course placement. - $aiactions = aiplacement_courseassist\utils::get_actions_available($PAGE->context, false); - foreach ($aiactions as $action) { - $value = 0; - $actionname = "action-" . $action['action']; - if (!empty($moduleinfo->{$actionname})) { - $value = 1; - } - $enabledaiactions[$action['action']] = $value; - } - - // Get and check for enabled AI actions in the editor placement. - $aiactions = aiplacement_editor\utils::get_actions_available($PAGE->context, false); + // Get and check for enabled AI actions in enabled placements. + $aiactions = \core_ai\manager::get_placement_actions_available($PAGE->context, false); foreach ($aiactions as $action) { $value = 0; $actionname = "action-" . $action['action']; diff --git a/public/course/moodleform_mod.php b/public/course/moodleform_mod.php index 76356b99692d0..2bad11de10a4b 100644 --- a/public/course/moodleform_mod.php +++ b/public/course/moodleform_mod.php @@ -676,18 +676,9 @@ protected function standard_coursemodule_elements() { $this->plugin_extend_coursemodule_standard_elements(); + $aimanager = \core\di::get(core_ai\manager::class); $availableactions = []; - // Get available actions for the AI course placement. - if (aiplacement_courseassist\utils::is_course_assist_available()) { - $aicourseplacementactions = aiplacement_courseassist\utils::get_actions_available($this->get_context(), false); - $availableactions = array_merge($availableactions, $aicourseplacementactions); - } - - // Get available actions for the AI editor placement. - if (aiplacement_editor\utils::is_html_editor_placement_available()) { - $aieditorplacementactions = aiplacement_editor\utils::get_actions_available($this->get_context(), false); - $availableactions = array_merge($availableactions, $aieditorplacementactions); - } + $availableactions = $aimanager::get_placement_actions_available($this->get_context(), false); // Current set of enabled AI actions. $enabledaiactions = ($this->_cm && $this->_cm->enabledaiactions) @@ -695,7 +686,6 @@ protected function standard_coursemodule_elements() { : true; // Show AI tools in activity settings if AI course assist is available. - $aimanager = \core\di::get(core_ai\manager::class); if (!empty($availableactions) && $aimanager->get_provider_instances(['enabled' => 1])) { $mform->addElement('header', 'aitoolshdr', get_string('aitools', 'ai')); diff --git a/public/course/renderer.php b/public/course/renderer.php index a53c21d898ba8..47f0a417c39b5 100644 --- a/public/course/renderer.php +++ b/public/course/renderer.php @@ -323,7 +323,7 @@ public function course_section_cm_unavailable_error_message(cm_info $cm) { if ($cm->uservisible) { return null; } - if (!$cm->availableinfo) { + if (!$cm->visible || !$cm->availableinfo) { return get_string('activityiscurrentlyhidden'); } diff --git a/public/course/tests/behat/course_creation.feature b/public/course/tests/behat/course_creation.feature index 8deb2a58b3519..a12784601c6c9 100644 --- a/public/course/tests/behat/course_creation.feature +++ b/public/course/tests/behat/course_creation.feature @@ -122,7 +122,7 @@ Feature: Managers can create courses | Course full name | My first course | | Course short name | myfirstcourse | And I press "Save and display" - And I navigate to course participants + And I am on the "myfirstcourse" "enrolled users" page Then I should not see "Teacher" And I should see "Nothing to display" And the following config values are set as admin: @@ -132,6 +132,6 @@ Feature: Managers can create courses | Course full name | My second course | | Course short name | mysecondcourse | And I press "Save and display" - And I navigate to course participants + And I am on the "mysecondcourse" "enrolled users" page And I should see "Teacher" And I should not see "Nothing to display" diff --git a/public/course/tests/behat/course_request.feature b/public/course/tests/behat/course_request.feature index 480b0db706e0c..e94f077dfaf8c 100644 --- a/public/course/tests/behat/course_request.feature +++ b/public/course/tests/behat/course_request.feature @@ -46,11 +46,7 @@ Feature: Users can request and approve courses And I should see "There are no courses pending approval" And I press "Back to course listing" And I should see "My new course" - And I log out - And I log in as "user1" - And I am on course index - And I follow "My new course" - And I navigate to course participants + And I am on the "My new course" "enrolled users" page logged in as "user1" And I should see "Teacher" in the "User 1" "table_row" And I log out diff --git a/public/course/tests/behat/rename_roles.feature b/public/course/tests/behat/rename_roles.feature index c935bcb6b34e7..e88889b5600d1 100644 --- a/public/course/tests/behat/rename_roles.feature +++ b/public/course/tests/behat/rename_roles.feature @@ -27,7 +27,7 @@ Feature: Rename roles within a course And I follow "Switch role to..." in the user menu Then "Tutor" "button" should exist And "Learner" "button" should exist - And I navigate to course participants + And I am on the "Course 1" "enrolled users" page And I set the field "type" in the "Filter 1" "fieldset" to "Roles" And I open the autocomplete suggestions list in the "Filter 1" "fieldset" And I should see "Learner (Student)" in the ".form-autocomplete-suggestions" "css_element" @@ -49,7 +49,7 @@ Feature: Rename roles within a course And I should see "Teacher" And "Student" "button" should exist And "Learner" "button" should not exist - And I navigate to course participants + And I am on the "Course 1" "enrolled users" page And I set the field "type" in the "Filter 1" "fieldset" to "Roles" And I open the autocomplete suggestions list in the "Filter 1" "fieldset" And I should see "Non-editing teacher" in the ".form-autocomplete-suggestions" "css_element" diff --git a/public/course/tests/behat/reset_course.feature b/public/course/tests/behat/reset_course.feature index e202c965e7c3f..90ea3fb3d247e 100644 --- a/public/course/tests/behat/reset_course.feature +++ b/public/course/tests/behat/reset_course.feature @@ -68,7 +68,7 @@ Feature: Reset course # Check that you're redirected to the course page. And I should not see "Reset course" # Check the course has been reset. - And I navigate to course participants + And I am on the "Course 1" "enrolled users" page And I should see "Teacher 1" And I should not see "Student 1" And I am on the "Test assignment name" "assign activity" page diff --git a/public/course/tests/behat/restrict_available_activities.feature b/public/course/tests/behat/restrict_available_activities.feature index 46589f587a4ce..91e8011679c55 100644 --- a/public/course/tests/behat/restrict_available_activities.feature +++ b/public/course/tests/behat/restrict_available_activities.feature @@ -27,7 +27,7 @@ Feature: Restrict activities availability Then I should see "Test glossary name" And I should see "Test assign name" - @javascript @skip_chrome_zerosize + @javascript Scenario Outline: Activities can not be added when the admin restricts the permissions Given the following "role capability" exists: | role | editingteacher | diff --git a/public/course/tests/behat/role_renaming.feature b/public/course/tests/behat/role_renaming.feature index 4fb8b58a98339..ed7a3179c0377 100644 --- a/public/course/tests/behat/role_renaming.feature +++ b/public/course/tests/behat/role_renaming.feature @@ -23,13 +23,10 @@ Feature: Rename roles in a course | Your word for 'Teacher' | Lecturer | | Your word for 'Student' | Learner | And I press "Save" - And I navigate to course participants + And I am on the "Course 1" "enrolled users" page Then I should see "Lecturer (Teacher)" in the "Teacher 1" "table_row" And I should see "Learner (Student)" in the "Student 1" "table_row" - And I log out - And I log in as "student1" - And I am on "Course 1" course homepage - And I navigate to course participants + And I am on the "Course 1" "enrolled users" page logged in as "student1" And I should see "Lecturer" in the "Teacher 1" "table_row" And I should see "Learner" in the "Student 1" "table_row" And I should not see "Lecturer (Teacher)" in the "Teacher 1" "table_row" @@ -39,7 +36,5 @@ Feature: Rename roles in a course Given the following "role capability" exists: | role | editingteacher | | moodle/course:renameroles | inherit | - When I log in as "teacher1" - And I am on "Course 1" course homepage - And I navigate to course participants + When I am on the "Course 1" "enrolled users" page logged in as "teacher1" Then I should not see "Role renaming" diff --git a/public/course/tests/behat/section_visibility.feature b/public/course/tests/behat/section_visibility.feature index dae2f92d02465..975819ac0abce 100644 --- a/public/course/tests/behat/section_visibility.feature +++ b/public/course/tests/behat/section_visibility.feature @@ -104,3 +104,128 @@ Feature: Show/hide course sections And I click on "Section 3" "link" in the "region-main" "region" And I should not see "Section 2" in the "region-main" "region" And I should see "Section 1" in the "region-main" "region" + + @javascript + Scenario Outline: Check if students can see sections: hidden and fully restricted + # Set visibility status: fully hidden or partly hidden + Given I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Course layout | Show all sections on one page | + | Hidden sections | | + And I press "Save and display" + And I am on "Course 1" course homepage with editing mode on + And I hide section "2" + # Set availability status: fully restricted + And I edit the section "2" + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the following fields to these values: + | direction | | + | x[year] | 2000 | + And I click on "Item name displayed with access restriction information if student doesn't meet this condition • Click to hide" "link" + And I press "Save changes" + And I log out + # Check if students can see the section + When I am on the "Course 1" course page logged in as student1 + Then I see "Section 2" in the "region-main" "region" + And I see "Test hidden forum 22 name" in the "region-main" "region" + + # For these tests, visibility is hidden, and unavailable sections are fully restricted. + Examples: + | hiddensectionssetting | availability | seename | seecontent | + | Hide completely | until | should not | should not | + | Show section names only | until | should not | should not | + + @javascript + Scenario Outline: Check if students can see sections: hidden and not fully restricted + # Set visibility status: fully hidden or partly hidden + Given I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Course layout | Show all sections on one page | + | Hidden sections | | + And I press "Save and display" + And I am on "Course 1" course homepage with editing mode on + And I hide section "2" + # Set availability status: partly restricted or unrestricted + And I edit the section "2" + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the following fields to these values: + | direction | | + | x[year] | 2000 | + And I press "Save changes" + And I log out + # Check if students can see the section + When I am on the "Course 1" course page logged in as student1 + Then I see "Section 2" in the "region-main" "region" + And I see "Test hidden forum 22 name" in the "region-main" "region" + + # For these tests, visibility is hidden, and unavailable sections are partly restricted. + Examples: + | hiddensectionssetting | availability | seename | seecontent | + | Hide completely | until | should not | should not | + | Hide completely | from | should not | should not | + | Show section names only | until | should | should not | + | Show section names only | from | should | should not | + + @javascript + Scenario Outline: Check if students can see sections: shown and fully restricted + # Set visibility status: shown + Given I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Course layout | Show all sections on one page | + | Hidden sections | | + And I press "Save and display" + And I am on "Course 1" course homepage with editing mode on + # Set availability status: fully restricted + And I edit the section "2" + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the following fields to these values: + | direction | | + | x[year] | 2000 | + And I click on "Item name displayed with access restriction information if student doesn't meet this condition • Click to hide" "link" + And I press "Save changes" + And I log out + # Check if students can see the section + When I am on the "Course 1" course page logged in as student1 + Then I see "Section 2" in the "region-main" "region" + And I see "Test hidden forum 22 name" in the "region-main" "region" + + # For this test, visibility is shown, and unavailable sections are fully restricted. + Examples: + | hiddensectionssetting | availability | seename | seecontent | + | Show section names only | until | should not | should not | + + @javascript + Scenario Outline: Check if students can see sections: shown and not fully restricted + # Set visibility status: shown + Given I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Course layout | Show all sections on one page | + | Hidden sections | | + And I press "Save and display" + And I am on "Course 1" course homepage with editing mode on + # Set availability status: partly restricted or unrestricted + And I edit the section "2" + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the following fields to these values: + | direction | | + | x[year] | 2000 | + And I press "Save changes" + And I log out + # Check if students can see the section + When I am on the "Course 1" course page logged in as student1 + Then I see "Section 2" in the "region-main" "region" + And I see "Test hidden forum 22 name" in the "region-main" "region" + + # For these tests, visibility is shown, and unavailable sections are partly restricted. + Examples: + | hiddensectionssetting | availability | seename | seecontent | + | Show section names only | until | should | should not | + | Show section names only | from | should | should | diff --git a/public/course/tests/category_test.php b/public/course/tests/category_test.php index 67aab5c232d08..f630fa6432b61 100644 --- a/public/course/tests/category_test.php +++ b/public/course/tests/category_test.php @@ -317,6 +317,8 @@ public function test_delete(): void { $this->assertEquals($category4->id, $DB->get_field('course', 'category', array('id' => $course3->id))); $this->assertEquals($category3->id, $DB->get_field('course', 'category', array('id' => $course1->id))); + $DB->insert_record('event_subscriptions', ['url' => 'invalid', 'categoryid' => $category3->id]); + // Delete category 3 completely. $this->assertFalse($category3->can_delete_full()); // No luck! // Add necessary capabilities. @@ -335,6 +337,8 @@ public function test_delete(): void { $this->assertEquals(1, $DB->get_field_sql('SELECT count(*) FROM {course} WHERE id <> ?', array(SITEID))); $this->assertEquals(array('id' => $course4->id, 'category' => $category1->id), (array)$DB->get_record_sql('SELECT id, category from {course} where id <> ?', array(SITEID))); + + $this->assertEquals(0, $DB->count_records('event_subscriptions', ['categoryid' => $category3->id])); } public function test_get_children(): void { diff --git a/public/course/tests/courselib_test.php b/public/course/tests/courselib_test.php index b29e0a91e28ef..8f5a3710f1a8c 100644 --- a/public/course/tests/courselib_test.php +++ b/public/course/tests/courselib_test.php @@ -4393,6 +4393,7 @@ public function test_course_module_update_calendar_events(): void { * Test the higher level checks for updating calendar events for a module. */ public function test_course_module_bulk_update_calendar_events(): void { + global $DB; $this->resetAfterTest(); $this->setAdminUser(); @@ -4401,7 +4402,7 @@ public function test_course_module_bulk_update_calendar_events(): void { $course = $this->getDataGenerator()->create_course(['enablecompletion' => COMPLETION_ENABLED]); $course2 = $this->getDataGenerator()->create_course(['enablecompletion' => COMPLETION_ENABLED]); - $assign = $this->getDataGenerator()->create_module('assign', [ + $this->getDataGenerator()->create_module('assign', [ 'course' => $course, 'completionexpected' => $completionexpected, 'duedate' => $duedate @@ -4415,6 +4416,77 @@ public function test_course_module_bulk_update_calendar_events(): void { $this->assertTrue(course_module_bulk_update_calendar_events('assign')); // Update the assign instances for this course. $this->assertTrue(course_module_bulk_update_calendar_events('assign', $course->id)); + + // Success even the course has been deleted. + $DB->delete_records('course', ['id' => $course->id]); + $debuggingmessage = get_string('calendareventskipformissingcourse', 'error', $course->id); + // Update all assign instances. + $this->assertTrue(course_module_bulk_update_calendar_events('assign')); + $this->assertDebuggingCalled($debuggingmessage, DEBUG_DEVELOPER); + $this->resetDebugging(); + // Update the assign instances for this course. + $this->assertTrue(course_module_bulk_update_calendar_events('assign', $course->id)); + $this->assertDebuggingCalled($debuggingmessage, DEBUG_DEVELOPER); + $this->resetDebugging(); + $course3 = $this->getDataGenerator()->create_course(['enablecompletion' => COMPLETION_ENABLED]); + $brokenassign = $this->getDataGenerator()->create_module('assign', [ + 'course' => $course3, + 'completionexpected' => $completionexpected, + 'duedate' => $duedate, + ]); + + // Corrupt the course structure for one assign instance while keeping + // mdl_course_modules and mdl_assign valid. This reproduces the case where + // get_coursemodule_from_instance() succeeds, but modinfo cannot resolve the cm. + $cm = get_coursemodule_from_instance('assign', $brokenassign->id, $course3->id, false, MUST_EXIST); + $this->assertNotEmpty($cm); + + $section = $DB->get_record('course_sections', ['id' => $cm->section], '*', MUST_EXIST); + $sequence = array_filter( + explode(',', (string)$section->sequence), + static function (string $id): bool { + return !empty($id); + } + ); + $sequence = array_values(array_filter( + $sequence, + static function (string $id) use ($cm): bool { + return (int)$id !== (int)$cm->id; + } + )); + + $DB->set_field('course_sections', 'sequence', implode(',', $sequence), ['id' => $section->id]); + rebuild_course_cache($course3->id, true); + + // Sanity check: DB lookup still works, but modinfo lookup now fails. + $cmcheck = get_coursemodule_from_instance('assign', $brokenassign->id, $course3->id, false, MUST_EXIST); + $this->assertEquals($cm->id, $cmcheck->id); + + try { + get_fast_modinfo($course3->id)->get_cm($cm->id); + $this->fail('Expected get_cm() to fail for a broken course structure.'); + } catch (\moodle_exception $e) { + $this->assertNotEmpty($e->getMessage()); + } + // Update all assign instances. The broken instance should be skipped, not fatal. + $a = new stdClass(); + $a->modulename = "assign"; + $a->instance = $brokenassign->id; + $a->course = $course3->id; + $a->cm = $cm->id; + $brokenmessage = get_string('calendareventskipforbrokencoursemodule', 'error', $a); + $this->assertTrue(course_module_bulk_update_calendar_events('assign')); + // With module name 'assign' it will include the missing course above so 2 different messages are expected. + $getdebuggingmessage = fn(stdClass $debugging): array => [$debugging->message, $debugging->level]; + $debuggings = array_map($getdebuggingmessage, $this->getDebuggingMessages()); + $this->resetDebugging(); + + $this->assertCount(2, $debuggings); + $this->assertEqualsCanonicalizing([[$debuggingmessage, DEBUG_DEVELOPER], [$brokenmessage, DEBUG_DEVELOPER]], $debuggings); + + // Update the broken course only. It should also skip cleanly. + $this->assertTrue(course_module_bulk_update_calendar_events('assign', $course3->id)); + $this->assertDebuggingCalled($brokenmessage, DEBUG_DEVELOPER); } /** diff --git a/public/course/tests/modlib_test.php b/public/course/tests/modlib_test.php index 7e802021ba363..75f2d2b8996d0 100644 --- a/public/course/tests/modlib_test.php +++ b/public/course/tests/modlib_test.php @@ -16,8 +16,12 @@ namespace core_course; +use core_ai\ai_test_trait; use core_courseformat\formatactions; +defined('MOODLE_INTERNAL') || die(); +require_once(__DIR__ . '/../../ai/tests/ai_test_trait.php'); + /** * Module lib related unit tests * @@ -27,6 +31,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class modlib_test extends \advanced_testcase { + use ai_test_trait; + /** * Setup to ensure that fixtures are loaded. */ @@ -38,6 +44,83 @@ public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); } + /** + * Test module defaults when an AI placement is uninstalled. + * + * A provider and action are configured and enabled for the placement so the assertion + * demonstrates that the uninstalled placement itself is excluded from discovery, rather + * than the action list being empty for an unrelated reason such as a missing provider. + * + * @param string $placement The placement plugin component. + * @param string $actionname The AI action to configure and expect for the placement. + * @dataProvider provider_uninstalled_ai_placements + * @covers ::set_moduleinfo_defaults + */ + public function test_set_moduleinfo_defaults_with_uninstalled_ai_placement( + string $placement, + string $actionname, + ): void { + global $PAGE; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = self::getDataGenerator()->create_course(); + $PAGE->set_context(\context_course::instance($course->id)); + + $this->create_ai_provider([$actionname], \aiprovider_openai\provider::class); + set_config('enabled', 1, $placement); + set_config($actionname, 1, $placement); + + $moduleinfo = (object) [ + 'course' => $course->id, + 'modulename' => 'forum', + ]; + + // Confirm the action is discovered while the placement is installed and enabled. + $installed = set_moduleinfo_defaults(clone $moduleinfo); + $this->assertObjectHasProperty('enabledaiactions', $installed); + $this->assertArrayHasKey($actionname, json_decode($installed->enabledaiactions, true)); + + // Simulate the placement plugin being uninstalled. + unset_config('version', $placement); + \core_plugin_manager::reset_caches(); + + $uninstalled = set_moduleinfo_defaults(clone $moduleinfo); + $this->assertObjectNotHasProperty('enabledaiactions', $uninstalled); + } + + /** + * Data provider for {@see test_set_moduleinfo_defaults_with_uninstalled_ai_placement()}. + * + * @return array + */ + public static function provider_uninstalled_ai_placements(): array { + return [ + 'course assistance placement' => ['aiplacement_courseassist', 'explain_text'], + 'editor placement' => ['aiplacement_editor', 'generate_text'], + ]; + } + + /** + * Test course code has no direct dependency on AI placement plugins. + * + * @coversNothing + */ + public function test_course_code_has_no_ai_placement_dependencies(): void { + global $CFG; + + $files = [ + '/course/edit_form.php', + '/course/modlib.php', + '/course/moodleform_mod.php', + ]; + + foreach ($files as $file) { + $this->assertStringNotContainsString('aiplacement_', file_get_contents($CFG->dirroot . $file)); + } + } + /** * Test prepare_new_moduleinfo_data */ diff --git a/public/customfield/amd/build/form.min.js b/public/customfield/amd/build/form.min.js index bae1ff17a5990..b4f589f7e1df3 100644 --- a/public/customfield/amd/build/form.min.js +++ b/public/customfield/amd/build/form.min.js @@ -1,10 +1,10 @@ -define("core_customfield/form",["exports","core/inplace_editable","core/ajax","core/str","core_form/modalform","core/notification","core/pending","core/sortable_list","core/templates","jquery"],(function(_exports,_inplace_editable,_ajax,_str,_modalform,_notification,_pending,_sortable_list,_templates,_jquery){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} +define("core_customfield/form",["exports","core/inplace_editable","core/ajax","core/str","core_form/modalform","core/toast","core/notification","core/pending","core/sortable_list","core/templates","jquery"],(function(_exports,_inplace_editable,_ajax,_str,_modalform,_toast,_notification,_pending,_sortable_list,_templates,_jquery){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} /** * Custom Field interaction management for Moodle. * * @module core_customfield/form * @copyright 2018 Toni Barbera * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_modalform=_interopRequireDefault(_modalform),_notification=_interopRequireDefault(_notification),_pending=_interopRequireDefault(_pending),_sortable_list=_interopRequireDefault(_sortable_list),_templates=_interopRequireDefault(_templates),_jquery=_interopRequireDefault(_jquery);const confirmDelete=(id,type,component,area,itemid)=>{const pendingPromise=new _pending.default("core_customfield/form:confirmDelete");(0,_str.getStrings)([{key:"confirm"},{key:"confirmdelete"+type,component:"core_customfield"},{key:"yes"},{key:"no"}]).then((strings=>_notification.default.confirm(strings[0],strings[1],strings[2],strings[3],(function(){const pendingDeletePromise=new _pending.default("core_customfield/form:confirmDelete");(0,_ajax.call)([{methodname:"field"===type?"core_customfield_delete_field":"core_customfield_delete_category",args:{id:id}},{methodname:"core_customfield_reload_template",args:{component:component,area:area,itemid:itemid}}])[1].then((response=>_templates.default.render("core_customfield/list",response))).then(((html,js)=>_templates.default.replaceNode((0,_jquery.default)('[data-region="list-page"]'),html,js))).then(pendingDeletePromise.resolve).catch(_notification.default.exception)})))).then(pendingPromise.resolve).catch(_notification.default.exception)},getCategoryNameFor=nodeElement=>nodeElement.closest("[data-category-id]").attr("data-category-name");_exports.init=()=>{const rootNode=document.querySelector("#customfield_catlist"),component=rootNode.dataset.component,area=rootNode.dataset.area,itemid=rootNode.dataset.itemid;rootNode.addEventListener("click",(e=>{const roleHolder=e.target.closest("[data-role]");if(roleHolder)return"deletefield"===roleHolder.dataset.role?(e.preventDefault(),void confirmDelete(roleHolder.dataset.id,"field",component,area,itemid)):"deletecategory"===roleHolder.dataset.role?(e.preventDefault(),void confirmDelete(roleHolder.dataset.id,"category",component,area,itemid)):"addnewcategory"===roleHolder.dataset.role?(e.preventDefault(),void((component,area,itemid)=>{const pendingPromise=new _pending.default("core_customfield/form:createNewCategory");(0,_ajax.call)([{methodname:"core_customfield_create_category",args:{component:component,area:area,itemid:itemid}},{methodname:"core_customfield_reload_template",args:{component:component,area:area,itemid:itemid}}])[1].then((response=>_templates.default.render("core_customfield/list",response))).then(((html,js)=>_templates.default.replaceNode((0,_jquery.default)('[data-region="list-page"]'),html,js))).then((()=>pendingPromise.resolve())).catch(_notification.default.exception)})(component,area,itemid)):"addfield"===roleHolder.dataset.role?(e.preventDefault(),void((element,component,area,itemid)=>{const pendingPromise=new _pending.default("core_customfield/form:createNewField"),returnFocus=element.closest(".action-menu").querySelector(".dropdown-toggle"),form=new _modalform.default({formClass:"core_customfield\\field_config_form",args:{categoryid:element.getAttribute("data-categoryid"),type:element.getAttribute("data-type")},modalConfig:{title:(0,_str.getString)("addingnewcustomfield","core_customfield",element.getAttribute("data-typename"))},returnFocus:returnFocus});form.addEventListener(form.events.FORM_SUBMITTED,(()=>{const pendingCreatedPromise=new _pending.default("core_customfield/form:createdNewField");(0,_ajax.call)([{methodname:"core_customfield_reload_template",args:{component:component,area:area,itemid:itemid}}])[0].then((response=>_templates.default.render("core_customfield/list",response))).then(((html,js)=>_templates.default.replaceNode((0,_jquery.default)('[data-region="list-page"]'),html,js))).then((()=>pendingCreatedPromise.resolve())).catch((()=>window.location.reload()))})),form.show(),pendingPromise.resolve()})(roleHolder,component,area,itemid)):"editfield"===roleHolder.dataset.role?(e.preventDefault(),void((element,component,area,itemid)=>{const pendingPromise=new _pending.default("core_customfield/form:editField"),form=new _modalform.default({formClass:"core_customfield\\field_config_form",args:{id:element.getAttribute("data-id")},modalConfig:{title:(0,_str.getString)("editingfield","core_customfield",element.getAttribute("data-name"))},returnFocus:element});form.addEventListener(form.events.FORM_SUBMITTED,(()=>{const pendingCreatedPromise=new _pending.default("core_customfield/form:createdNewField");(0,_ajax.call)([{methodname:"core_customfield_reload_template",args:{component:component,area:area,itemid:itemid}}])[0].then((response=>_templates.default.render("core_customfield/list",response))).then(((html,js)=>_templates.default.replaceNode((0,_jquery.default)('[data-region="list-page"]'),html,js))).then((()=>pendingCreatedPromise.resolve())).catch((()=>window.location.reload()))})),form.show(),pendingPromise.resolve()})(roleHolder,component,area,itemid)):void 0})),(rootNode=>{new _sortable_list.default("#customfield_catlist .categorieslist",{moveHandlerSelector:".movecategory [data-drag-type=move]"}).getElementName=nodeElement=>Promise.resolve(getCategoryNameFor(nodeElement)),(0,_jquery.default)("[data-category-id]").on(_sortable_list.default.EVENTS.DROP,((evt,info)=>{if(info.positionChanged){const pendingPromise=new _pending.default("core_customfield/form:categoryid:on:sortablelist-drop");(0,_ajax.call)([{methodname:"core_customfield_move_category",args:{id:info.element.data("category-id"),beforeid:info.targetNextElement.data("category-id")}}])[0].then(pendingPromise.resolve).catch(_notification.default.exception)}evt.stopPropagation()})),new _sortable_list.default("#customfield_catlist .fieldslist tbody",{moveHandlerSelector:".movefield [data-drag-type=move]"}).getDestinationName=(parentElement,afterElement)=>afterElement.length?afterElement.attr("data-field-name")?(0,_str.getString)("afterfield","customfield",afterElement.attr("data-field-name")):Promise.resolve(""):(0,_str.getString)("totopofcategory","customfield",getCategoryNameFor(parentElement)),(0,_jquery.default)("[data-field-name]").on(_sortable_list.default.EVENTS.DROP,((evt,info)=>{if(info.positionChanged){const pendingPromise=new _pending.default("core_customfield/form:fieldname:on:sortablelist-drop");(0,_ajax.call)([{methodname:"core_customfield_move_field",args:{id:info.element.data("field-id"),beforeid:info.targetNextElement.data("field-id"),categoryid:Number(info.targetList.closest("[data-category-id]").attr("data-category-id"))}}])[0].then(pendingPromise.resolve).catch(_notification.default.exception)}evt.stopPropagation()})),(0,_jquery.default)("[data-field-name]").on(_sortable_list.default.EVENTS.DRAG,(evt=>{var pendingPromise=new _pending.default("core_customfield/form:fieldname:on:sortablelist-drag");evt.stopPropagation(),_templates.default.render("core_customfield/nofields",{}).then((html=>{rootNode.querySelectorAll(".categorieslist > *").forEach((category=>{const fields=category.querySelectorAll(".field:not(.sortable-list-is-dragged)"),noFields=category.querySelector(".nofields");fields.length||noFields?fields.length&&noFields&&noFields.remove():category.querySelector("tbody").innerHTML=html}))})).then(pendingPromise.resolve).catch(_notification.default.exception)})),(0,_jquery.default)("[data-category-id], [data-field-name]").on(_sortable_list.default.EVENTS.DRAGSTART,((evt,info)=>{setTimeout((()=>{(0,_jquery.default)(".sortable-list-is-dragged").width(info.element.width())}),501)}))})(rootNode)}})); + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_modalform=_interopRequireDefault(_modalform),_notification=_interopRequireDefault(_notification),_pending=_interopRequireDefault(_pending),_sortable_list=_interopRequireDefault(_sortable_list),_templates=_interopRequireDefault(_templates),_jquery=_interopRequireDefault(_jquery);const confirmDelete=(id,type,component,area,itemid)=>{const pendingPromise=new _pending.default("core_customfield/form:confirmDelete");(0,_str.getStrings)([{key:"confirm"},{key:"confirmdelete"+type,component:"core_customfield"},{key:"yes"},{key:"no"}]).then((strings=>_notification.default.confirm(strings[0],strings[1],strings[2],strings[3],(function(){const pendingDeletePromise=new _pending.default("core_customfield/form:confirmDelete");(0,_ajax.call)([{methodname:"field"===type?"core_customfield_delete_field":"core_customfield_delete_category",args:{id:id}},{methodname:"core_customfield_reload_template",args:{component:component,area:area,itemid:itemid}}])[1].then((response=>_templates.default.render("core_customfield/list",response))).then(((html,js)=>_templates.default.replaceNode((0,_jquery.default)('[data-region="list-page"]'),html,js))).then(pendingDeletePromise.resolve).catch(_notification.default.exception)})))).then(pendingPromise.resolve).catch(_notification.default.exception)},getCategoryNameFor=nodeElement=>nodeElement.closest("[data-category-id]").attr("data-category-name");_exports.init=()=>{const rootNode=document.querySelector("#customfield_catlist"),component=rootNode.dataset.component,area=rootNode.dataset.area,itemid=rootNode.dataset.itemid;rootNode.addEventListener("click",(e=>{const roleHolder=e.target.closest("[data-role]");if(roleHolder)return"deletefield"===roleHolder.dataset.role?(e.preventDefault(),void confirmDelete(roleHolder.dataset.id,"field",component,area,itemid)):"deletecategory"===roleHolder.dataset.role?(e.preventDefault(),void confirmDelete(roleHolder.dataset.id,"category",component,area,itemid)):"addnewcategory"===roleHolder.dataset.role?(e.preventDefault(),void((component,area,itemid)=>{const pendingPromise=new _pending.default("core_customfield/form:createNewCategory");(0,_ajax.call)([{methodname:"core_customfield_create_category",args:{component:component,area:area,itemid:itemid}},{methodname:"core_customfield_reload_template",args:{component:component,area:area,itemid:itemid}}])[1].then((response=>_templates.default.render("core_customfield/list",response))).then(((html,js)=>_templates.default.replaceNode((0,_jquery.default)('[data-region="list-page"]'),html,js))).then((()=>(0,_toast.add)((0,_str.getString)("categoryadded","core_customfield"),{type:"success"}))).then((()=>pendingPromise.resolve())).catch(_notification.default.exception)})(component,area,itemid)):"addfield"===roleHolder.dataset.role?(e.preventDefault(),void((element,component,area,itemid)=>{const pendingPromise=new _pending.default("core_customfield/form:createNewField"),returnFocus=element.closest(".action-menu").querySelector(".dropdown-toggle"),form=new _modalform.default({formClass:"core_customfield\\field_config_form",args:{categoryid:element.getAttribute("data-categoryid"),type:element.getAttribute("data-type")},modalConfig:{title:(0,_str.getString)("addingnewcustomfield","core_customfield",element.getAttribute("data-typename"))},returnFocus:returnFocus});form.addEventListener(form.events.FORM_SUBMITTED,(()=>{const pendingCreatedPromise=new _pending.default("core_customfield/form:createdNewField");(0,_ajax.call)([{methodname:"core_customfield_reload_template",args:{component:component,area:area,itemid:itemid}}])[0].then((response=>_templates.default.render("core_customfield/list",response))).then(((html,js)=>_templates.default.replaceNode((0,_jquery.default)('[data-region="list-page"]'),html,js))).then((()=>pendingCreatedPromise.resolve())).catch((()=>window.location.reload()))})),form.show(),pendingPromise.resolve()})(roleHolder,component,area,itemid)):"editfield"===roleHolder.dataset.role?(e.preventDefault(),void((element,component,area,itemid)=>{const pendingPromise=new _pending.default("core_customfield/form:editField"),form=new _modalform.default({formClass:"core_customfield\\field_config_form",args:{id:element.getAttribute("data-id")},modalConfig:{title:(0,_str.getString)("editingfield","core_customfield",element.getAttribute("data-name"))},returnFocus:element});form.addEventListener(form.events.FORM_SUBMITTED,(()=>{const pendingCreatedPromise=new _pending.default("core_customfield/form:createdNewField");(0,_ajax.call)([{methodname:"core_customfield_reload_template",args:{component:component,area:area,itemid:itemid}}])[0].then((response=>_templates.default.render("core_customfield/list",response))).then(((html,js)=>_templates.default.replaceNode((0,_jquery.default)('[data-region="list-page"]'),html,js))).then((()=>pendingCreatedPromise.resolve())).catch((()=>window.location.reload()))})),form.show(),pendingPromise.resolve()})(roleHolder,component,area,itemid)):void 0})),(rootNode=>{new _sortable_list.default("#customfield_catlist .categorieslist",{moveHandlerSelector:".movecategory [data-drag-type=move]"}).getElementName=nodeElement=>Promise.resolve(getCategoryNameFor(nodeElement)),(0,_jquery.default)("[data-category-id]").on(_sortable_list.default.EVENTS.DROP,((evt,info)=>{if(info.positionChanged){const pendingPromise=new _pending.default("core_customfield/form:categoryid:on:sortablelist-drop");(0,_ajax.call)([{methodname:"core_customfield_move_category",args:{id:info.element.data("category-id"),beforeid:info.targetNextElement.data("category-id")}}])[0].then(pendingPromise.resolve).catch(_notification.default.exception)}evt.stopPropagation()})),new _sortable_list.default("#customfield_catlist .fieldslist tbody",{moveHandlerSelector:".movefield [data-drag-type=move]"}).getDestinationName=(parentElement,afterElement)=>afterElement.length?afterElement.attr("data-field-name")?(0,_str.getString)("afterfield","customfield",afterElement.attr("data-field-name")):Promise.resolve(""):(0,_str.getString)("totopofcategory","customfield",getCategoryNameFor(parentElement)),(0,_jquery.default)("[data-field-name]").on(_sortable_list.default.EVENTS.DROP,((evt,info)=>{if(info.positionChanged){const pendingPromise=new _pending.default("core_customfield/form:fieldname:on:sortablelist-drop");(0,_ajax.call)([{methodname:"core_customfield_move_field",args:{id:info.element.data("field-id"),beforeid:info.targetNextElement.data("field-id"),categoryid:Number(info.targetList.closest("[data-category-id]").attr("data-category-id"))}}])[0].then(pendingPromise.resolve).catch(_notification.default.exception)}evt.stopPropagation()})),(0,_jquery.default)("[data-field-name]").on(_sortable_list.default.EVENTS.DRAG,(evt=>{var pendingPromise=new _pending.default("core_customfield/form:fieldname:on:sortablelist-drag");evt.stopPropagation(),_templates.default.render("core_customfield/nofields",{}).then((html=>{rootNode.querySelectorAll(".categorieslist > *").forEach((category=>{const fields=category.querySelectorAll(".field:not(.sortable-list-is-dragged)"),noFields=category.querySelector(".nofields");fields.length||noFields?fields.length&&noFields&&noFields.remove():category.querySelector("tbody").innerHTML=html}))})).then(pendingPromise.resolve).catch(_notification.default.exception)})),(0,_jquery.default)("[data-category-id], [data-field-name]").on(_sortable_list.default.EVENTS.DRAGSTART,((evt,info)=>{setTimeout((()=>{(0,_jquery.default)(".sortable-list-is-dragged").width(info.element.width())}),501)}))})(rootNode)}})); //# sourceMappingURL=form.min.js.map \ No newline at end of file diff --git a/public/customfield/amd/build/form.min.js.map b/public/customfield/amd/build/form.min.js.map index 1d5b299f8c5ce..3762d15d08843 100644 --- a/public/customfield/amd/build/form.min.js.map +++ b/public/customfield/amd/build/form.min.js.map @@ -1 +1 @@ -{"version":3,"file":"form.min.js","sources":["../src/form.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 * Custom Field interaction management for Moodle.\n *\n * @module core_customfield/form\n * @copyright 2018 Toni Barbera\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport 'core/inplace_editable';\nimport {call as fetchMany} from 'core/ajax';\nimport {\n getString,\n getStrings,\n} from 'core/str';\nimport ModalForm from 'core_form/modalform';\nimport Notification from 'core/notification';\nimport Pending from 'core/pending';\nimport SortableList from 'core/sortable_list';\nimport Templates from 'core/templates';\nimport jQuery from 'jquery';\n\n/**\n * Display confirmation dialogue\n *\n * @param {Number} id\n * @param {String} type\n * @param {String} component\n * @param {String} area\n * @param {Number} itemid\n */\nconst confirmDelete = (id, type, component, area, itemid) => {\n const pendingPromise = new Pending('core_customfield/form:confirmDelete');\n\n getStrings([\n {'key': 'confirm'},\n {'key': 'confirmdelete' + type, component: 'core_customfield'},\n {'key': 'yes'},\n {'key': 'no'},\n ])\n .then(strings => {\n return Notification.confirm(strings[0], strings[1], strings[2], strings[3], function() {\n const pendingDeletePromise = new Pending('core_customfield/form:confirmDelete');\n fetchMany([\n {\n methodname: (type === 'field') ? 'core_customfield_delete_field' : 'core_customfield_delete_category',\n args: {id},\n },\n {methodname: 'core_customfield_reload_template', args: {component, area, itemid}}\n ])[1]\n .then(response => Templates.render('core_customfield/list', response))\n .then((html, js) => Templates.replaceNode(jQuery('[data-region=\"list-page\"]'), html, js))\n .then(pendingDeletePromise.resolve)\n .catch(Notification.exception);\n });\n })\n .then(pendingPromise.resolve)\n .catch(Notification.exception);\n};\n\n\n/**\n * Creates a new custom fields category with default name and updates the list\n *\n * @param {String} component\n * @param {String} area\n * @param {Number} itemid\n */\nconst createNewCategory = (component, area, itemid) => {\n const pendingPromise = new Pending('core_customfield/form:createNewCategory');\n const promises = fetchMany([\n {methodname: 'core_customfield_create_category', args: {component, area, itemid}},\n {methodname: 'core_customfield_reload_template', args: {component, area, itemid}}\n ]);\n\n promises[1].then(response => Templates.render('core_customfield/list', response))\n .then((html, js) => Templates.replaceNode(jQuery('[data-region=\"list-page\"]'), html, js))\n .then(() => pendingPromise.resolve())\n .catch(Notification.exception);\n};\n\n/**\n * Create new custom field\n *\n * @param {HTMLElement} element\n * @param {String} component\n * @param {String} area\n * @param {Number} itemid\n */\nconst createNewField = (element, component, area, itemid) => {\n const pendingPromise = new Pending('core_customfield/form:createNewField');\n\n const returnFocus = element.closest(\".action-menu\").querySelector(\".dropdown-toggle\");\n const form = new ModalForm({\n formClass: \"core_customfield\\\\field_config_form\",\n args: {\n categoryid: element.getAttribute('data-categoryid'),\n type: element.getAttribute('data-type'),\n },\n modalConfig: {\n title: getString('addingnewcustomfield', 'core_customfield', element.getAttribute('data-typename')),\n },\n returnFocus,\n });\n\n form.addEventListener(form.events.FORM_SUBMITTED, () => {\n const pendingCreatedPromise = new Pending('core_customfield/form:createdNewField');\n const promises = fetchMany([\n {methodname: 'core_customfield_reload_template', args: {component: component, area: area, itemid: itemid}}\n ]);\n\n promises[0].then(response => Templates.render('core_customfield/list', response))\n .then((html, js) => Templates.replaceNode(jQuery('[data-region=\"list-page\"]'), html, js))\n .then(() => pendingCreatedPromise.resolve())\n .catch(() => window.location.reload());\n });\n\n form.show();\n\n pendingPromise.resolve();\n};\n\n/**\n * Edit custom field\n *\n * @param {HTMLElement} element\n * @param {String} component\n * @param {String} area\n * @param {Number} itemid\n */\nconst editField = (element, component, area, itemid) => {\n const pendingPromise = new Pending('core_customfield/form:editField');\n\n const form = new ModalForm({\n formClass: \"core_customfield\\\\field_config_form\",\n args: {\n id: element.getAttribute('data-id'),\n },\n modalConfig: {\n title: getString('editingfield', 'core_customfield', element.getAttribute('data-name')),\n },\n returnFocus: element,\n });\n\n form.addEventListener(form.events.FORM_SUBMITTED, () => {\n const pendingCreatedPromise = new Pending('core_customfield/form:createdNewField');\n const promises = fetchMany([\n {methodname: 'core_customfield_reload_template', args: {component: component, area: area, itemid: itemid}}\n ]);\n\n promises[0].then(response => Templates.render('core_customfield/list', response))\n .then((html, js) => Templates.replaceNode(jQuery('[data-region=\"list-page\"]'), html, js))\n .then(() => pendingCreatedPromise.resolve())\n .catch(() => window.location.reload());\n });\n\n form.show();\n\n pendingPromise.resolve();\n};\n\n/**\n * Fetch the category name from an inplace editable, given a child node of that field.\n *\n * @param {NodeElement} nodeElement\n * @returns {String}\n */\nconst getCategoryNameFor = nodeElement => nodeElement\n .closest('[data-category-id]')\n .attr('data-category-name');\n\nconst setupSortableLists = rootNode => {\n // Sort category.\n const sortCat = new SortableList(\n '#customfield_catlist .categorieslist',\n {\n moveHandlerSelector: '.movecategory [data-drag-type=move]',\n }\n );\n sortCat.getElementName = nodeElement => Promise.resolve(getCategoryNameFor(nodeElement));\n\n // Note: The sortable list currently uses jQuery events.\n jQuery('[data-category-id]').on(SortableList.EVENTS.DROP, (evt, info) => {\n if (info.positionChanged) {\n const pendingPromise = new Pending('core_customfield/form:categoryid:on:sortablelist-drop');\n fetchMany([{\n methodname: 'core_customfield_move_category',\n args: {\n id: info.element.data('category-id'),\n beforeid: info.targetNextElement.data('category-id')\n }\n\n }])[0]\n .then(pendingPromise.resolve)\n .catch(Notification.exception);\n }\n evt.stopPropagation(); // Important for nested lists to prevent multiple targets.\n });\n\n // Sort fields.\n var sort = new SortableList(\n '#customfield_catlist .fieldslist tbody',\n {\n moveHandlerSelector: '.movefield [data-drag-type=move]',\n }\n );\n\n sort.getDestinationName = (parentElement, afterElement) => {\n if (!afterElement.length) {\n return getString('totopofcategory', 'customfield', getCategoryNameFor(parentElement));\n } else if (afterElement.attr('data-field-name')) {\n return getString('afterfield', 'customfield', afterElement.attr('data-field-name'));\n } else {\n return Promise.resolve('');\n }\n };\n\n jQuery('[data-field-name]').on(SortableList.EVENTS.DROP, (evt, info) => {\n if (info.positionChanged) {\n const pendingPromise = new Pending('core_customfield/form:fieldname:on:sortablelist-drop');\n fetchMany([{\n methodname: 'core_customfield_move_field',\n args: {\n id: info.element.data('field-id'),\n beforeid: info.targetNextElement.data('field-id'),\n categoryid: Number(info.targetList.closest('[data-category-id]').attr('data-category-id'))\n },\n }])[0]\n .then(pendingPromise.resolve)\n .catch(Notification.exception);\n }\n evt.stopPropagation(); // Important for nested lists to prevent multiple targets.\n });\n\n jQuery('[data-field-name]').on(SortableList.EVENTS.DRAG, evt => {\n var pendingPromise = new Pending('core_customfield/form:fieldname:on:sortablelist-drag');\n\n evt.stopPropagation(); // Important for nested lists to prevent multiple targets.\n\n // Refreshing fields tables.\n Templates.render('core_customfield/nofields', {})\n .then(html => {\n rootNode.querySelectorAll('.categorieslist > *')\n .forEach(category => {\n const fields = category.querySelectorAll('.field:not(.sortable-list-is-dragged)');\n const noFields = category.querySelector('.nofields');\n\n if (!fields.length && !noFields) {\n category.querySelector('tbody').innerHTML = html;\n } else if (fields.length && noFields) {\n noFields.remove();\n }\n });\n return;\n })\n .then(pendingPromise.resolve)\n .catch(Notification.exception);\n });\n\n jQuery('[data-category-id], [data-field-name]').on(SortableList.EVENTS.DRAGSTART, (evt, info) => {\n setTimeout(() => {\n jQuery('.sortable-list-is-dragged').width(info.element.width());\n }, 501);\n });\n};\n\n/**\n * Initialise the custom fields manager.\n */\nexport const init = () => {\n const rootNode = document.querySelector('#customfield_catlist');\n\n const component = rootNode.dataset.component;\n const area = rootNode.dataset.area;\n const itemid = rootNode.dataset.itemid;\n\n rootNode.addEventListener('click', e => {\n const roleHolder = e.target.closest('[data-role]');\n if (!roleHolder) {\n return;\n }\n\n if (roleHolder.dataset.role === 'deletefield') {\n e.preventDefault();\n\n confirmDelete(roleHolder.dataset.id, 'field', component, area, itemid);\n return;\n }\n\n if (roleHolder.dataset.role === 'deletecategory') {\n e.preventDefault();\n\n confirmDelete(roleHolder.dataset.id, 'category', component, area, itemid);\n return;\n }\n\n if (roleHolder.dataset.role === 'addnewcategory') {\n e.preventDefault();\n createNewCategory(component, area, itemid);\n\n return;\n }\n\n if (roleHolder.dataset.role === 'addfield') {\n e.preventDefault();\n createNewField(roleHolder, component, area, itemid);\n\n return;\n }\n\n if (roleHolder.dataset.role === 'editfield') {\n e.preventDefault();\n editField(roleHolder, component, area, itemid);\n\n return;\n }\n });\n\n setupSortableLists(rootNode, component, area, itemid);\n};\n"],"names":["confirmDelete","id","type","component","area","itemid","pendingPromise","Pending","then","strings","Notification","confirm","pendingDeletePromise","methodname","args","response","Templates","render","html","js","replaceNode","resolve","catch","exception","getCategoryNameFor","nodeElement","closest","attr","rootNode","document","querySelector","dataset","addEventListener","e","roleHolder","target","role","preventDefault","createNewCategory","element","returnFocus","form","ModalForm","formClass","categoryid","getAttribute","modalConfig","title","events","FORM_SUBMITTED","pendingCreatedPromise","window","location","reload","show","createNewField","editField","SortableList","moveHandlerSelector","getElementName","Promise","on","EVENTS","DROP","evt","info","positionChanged","data","beforeid","targetNextElement","stopPropagation","getDestinationName","parentElement","afterElement","length","Number","targetList","DRAG","querySelectorAll","forEach","category","fields","noFields","remove","innerHTML","DRAGSTART","setTimeout","width","setupSortableLists"],"mappings":";;;;;;;gXA6CMA,cAAgB,CAACC,GAAIC,KAAMC,UAAWC,KAAMC,gBACxCC,eAAiB,IAAIC,iBAAQ,2DAExB,CACP,KAAQ,WACR,KAAQ,gBAAkBL,KAAMC,UAAW,oBAC3C,KAAQ,OACR,KAAQ,QAEXK,MAAKC,SACKC,sBAAaC,QAAQF,QAAQ,GAAIA,QAAQ,GAAIA,QAAQ,GAAIA,QAAQ,IAAI,iBAClEG,qBAAuB,IAAIL,iBAAQ,sDAC/B,CACN,CACIM,WAAsB,UAATX,KAAoB,gCAAkC,mCACnEY,KAAM,CAACb,GAAAA,KAEX,CAACY,WAAY,mCAAoCC,KAAM,CAACX,UAAAA,UAAWC,KAAAA,KAAMC,OAAAA,WAC1E,GACFG,MAAKO,UAAYC,mBAAUC,OAAO,wBAAyBF,YAC3DP,MAAK,CAACU,KAAMC,KAAOH,mBAAUI,aAAY,mBAAO,6BAA8BF,KAAMC,MACpFX,KAAKI,qBAAqBS,SAC1BC,MAAMZ,sBAAaa,gBAG3Bf,KAAKF,eAAee,SACpBC,MAAMZ,sBAAaa,YA8GlBC,mBAAqBC,aAAeA,YACrCC,QAAQ,sBACRC,KAAK,oCAoGU,WACVC,SAAWC,SAASC,cAAc,wBAElC3B,UAAYyB,SAASG,QAAQ5B,UAC7BC,KAAOwB,SAASG,QAAQ3B,KACxBC,OAASuB,SAASG,QAAQ1B,OAEhCuB,SAASI,iBAAiB,SAASC,UACzBC,WAAaD,EAAEE,OAAOT,QAAQ,kBAC/BQ,iBAI2B,gBAA5BA,WAAWH,QAAQK,MACnBH,EAAEI,sBAEFrC,cAAckC,WAAWH,QAAQ9B,GAAI,QAASE,UAAWC,KAAMC,SAInC,mBAA5B6B,WAAWH,QAAQK,MACnBH,EAAEI,sBAEFrC,cAAckC,WAAWH,QAAQ9B,GAAI,WAAYE,UAAWC,KAAMC,SAItC,mBAA5B6B,WAAWH,QAAQK,MACnBH,EAAEI,qBArOY,EAAClC,UAAWC,KAAMC,gBAClCC,eAAiB,IAAIC,iBAAQ,4CAClB,cAAU,CACvB,CAACM,WAAY,mCAAoCC,KAAM,CAACX,UAAAA,UAAWC,KAAAA,KAAMC,OAAAA,SACzE,CAACQ,WAAY,mCAAoCC,KAAM,CAACX,UAAAA,UAAWC,KAAAA,KAAMC,OAAAA,WAGpE,GAAGG,MAAKO,UAAYC,mBAAUC,OAAO,wBAAyBF,YACtEP,MAAK,CAACU,KAAMC,KAAOH,mBAAUI,aAAY,mBAAO,6BAA8BF,KAAMC,MACpFX,MAAK,IAAMF,eAAee,YAC1BC,MAAMZ,sBAAaa,YA4NZe,CAAkBnC,UAAWC,KAAMC,SAKP,aAA5B6B,WAAWH,QAAQK,MACnBH,EAAEI,qBAvNS,EAACE,QAASpC,UAAWC,KAAMC,gBACxCC,eAAiB,IAAIC,iBAAQ,wCAE7BiC,YAAcD,QAAQb,QAAQ,gBAAgBI,cAAc,oBAC5DW,KAAO,IAAIC,mBAAU,CACvBC,UAAW,sCACX7B,KAAM,CACF8B,WAAYL,QAAQM,aAAa,mBACjC3C,KAAMqC,QAAQM,aAAa,cAE/BC,YAAa,CACTC,OAAO,kBAAU,uBAAwB,mBAAoBR,QAAQM,aAAa,mBAEtFL,YAAAA,cAGJC,KAAKT,iBAAiBS,KAAKO,OAAOC,gBAAgB,WACxCC,sBAAwB,IAAI3C,iBAAQ,0CACzB,cAAU,CACvB,CAACM,WAAY,mCAAoCC,KAAM,CAACX,UAAWA,UAAWC,KAAMA,KAAMC,OAAQA,WAG7F,GAAGG,MAAKO,UAAYC,mBAAUC,OAAO,wBAAyBF,YACtEP,MAAK,CAACU,KAAMC,KAAOH,mBAAUI,aAAY,mBAAO,6BAA8BF,KAAMC,MACpFX,MAAK,IAAM0C,sBAAsB7B,YACjCC,OAAM,IAAM6B,OAAOC,SAASC,cAGjCZ,KAAKa,OAELhD,eAAee,WA0LPkC,CAAerB,WAAY/B,UAAWC,KAAMC,SAKhB,cAA5B6B,WAAWH,QAAQK,MACnBH,EAAEI,qBArLI,EAACE,QAASpC,UAAWC,KAAMC,gBACnCC,eAAiB,IAAIC,iBAAQ,mCAE7BkC,KAAO,IAAIC,mBAAU,CACvBC,UAAW,sCACX7B,KAAM,CACFb,GAAIsC,QAAQM,aAAa,YAE7BC,YAAa,CACTC,OAAO,kBAAU,eAAgB,mBAAoBR,QAAQM,aAAa,eAE9EL,YAAaD,UAGjBE,KAAKT,iBAAiBS,KAAKO,OAAOC,gBAAgB,WACxCC,sBAAwB,IAAI3C,iBAAQ,0CACzB,cAAU,CACvB,CAACM,WAAY,mCAAoCC,KAAM,CAACX,UAAWA,UAAWC,KAAMA,KAAMC,OAAQA,WAG7F,GAAGG,MAAKO,UAAYC,mBAAUC,OAAO,wBAAyBF,YACtEP,MAAK,CAACU,KAAMC,KAAOH,mBAAUI,aAAY,mBAAO,6BAA8BF,KAAMC,MACpFX,MAAK,IAAM0C,sBAAsB7B,YACjCC,OAAM,IAAM6B,OAAOC,SAASC,cAGjCZ,KAAKa,OAELhD,eAAee,WA0JPmC,CAAUtB,WAAY/B,UAAWC,KAAMC,mBA7IxBuB,CAAAA,WAEP,IAAI6B,uBAChB,uCACA,CACIC,oBAAqB,wCAGrBC,eAAiBlC,aAAemC,QAAQvC,QAAQG,mBAAmBC,kCAGpE,sBAAsBoC,GAAGJ,uBAAaK,OAAOC,MAAM,CAACC,IAAKC,WACxDA,KAAKC,gBAAiB,OAChB5D,eAAiB,IAAIC,iBAAQ,wEACzB,CAAC,CACPM,WAAY,iCACZC,KAAM,CACFb,GAAIgE,KAAK1B,QAAQ4B,KAAK,eACtBC,SAAUH,KAAKI,kBAAkBF,KAAK,mBAG1C,GACH3D,KAAKF,eAAee,SACpBC,MAAMZ,sBAAaa,WAExByC,IAAIM,qBAIG,IAAIb,uBACX,yCACA,CACIC,oBAAqB,qCAIxBa,mBAAqB,CAACC,cAAeC,eACjCA,aAAaC,OAEPD,aAAa9C,KAAK,oBAClB,kBAAU,aAAc,cAAe8C,aAAa9C,KAAK,oBAEzDiC,QAAQvC,QAAQ,KAJhB,kBAAU,kBAAmB,cAAeG,mBAAmBgD,oCAQvE,qBAAqBX,GAAGJ,uBAAaK,OAAOC,MAAM,CAACC,IAAKC,WACvDA,KAAKC,gBAAiB,OAChB5D,eAAiB,IAAIC,iBAAQ,uEACzB,CAAC,CACPM,WAAY,8BACZC,KAAM,CACFb,GAAIgE,KAAK1B,QAAQ4B,KAAK,YACtBC,SAAUH,KAAKI,kBAAkBF,KAAK,YACtCvB,WAAY+B,OAAOV,KAAKW,WAAWlD,QAAQ,sBAAsBC,KAAK,yBAE1E,GACHnB,KAAKF,eAAee,SACpBC,MAAMZ,sBAAaa,WAExByC,IAAIM,yCAGD,qBAAqBT,GAAGJ,uBAAaK,OAAOe,MAAMb,UACjD1D,eAAiB,IAAIC,iBAAQ,wDAEjCyD,IAAIM,qCAGMrD,OAAO,4BAA6B,IAC7CT,MAAKU,OACFU,SAASkD,iBAAiB,uBACzBC,SAAQC,iBACCC,OAASD,SAASF,iBAAiB,yCACnCI,SAAWF,SAASlD,cAAc,aAEnCmD,OAAOP,QAAWQ,SAEZD,OAAOP,QAAUQ,UACxBA,SAASC,SAFTH,SAASlD,cAAc,SAASsD,UAAYlE,WAOvDV,KAAKF,eAAee,SACpBC,MAAMZ,sBAAaa,kCAGjB,yCAAyCsC,GAAGJ,uBAAaK,OAAOuB,WAAW,CAACrB,IAAKC,QACpFqB,YAAW,yBACA,6BAA6BC,MAAMtB,KAAK1B,QAAQgD,WACxD,SAwDPC,CAAmB5D"} \ No newline at end of file +{"version":3,"file":"form.min.js","sources":["../src/form.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 * Custom Field interaction management for Moodle.\n *\n * @module core_customfield/form\n * @copyright 2018 Toni Barbera\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport 'core/inplace_editable';\nimport {call as fetchMany} from 'core/ajax';\nimport {\n getString,\n getStrings,\n} from 'core/str';\nimport ModalForm from 'core_form/modalform';\nimport {add as addToast} from 'core/toast';\nimport Notification from 'core/notification';\nimport Pending from 'core/pending';\nimport SortableList from 'core/sortable_list';\nimport Templates from 'core/templates';\nimport jQuery from 'jquery';\n\n/**\n * Display confirmation dialogue\n *\n * @param {Number} id\n * @param {String} type\n * @param {String} component\n * @param {String} area\n * @param {Number} itemid\n */\nconst confirmDelete = (id, type, component, area, itemid) => {\n const pendingPromise = new Pending('core_customfield/form:confirmDelete');\n\n getStrings([\n {'key': 'confirm'},\n {'key': 'confirmdelete' + type, component: 'core_customfield'},\n {'key': 'yes'},\n {'key': 'no'},\n ])\n .then(strings => {\n return Notification.confirm(strings[0], strings[1], strings[2], strings[3], function() {\n const pendingDeletePromise = new Pending('core_customfield/form:confirmDelete');\n fetchMany([\n {\n methodname: (type === 'field') ? 'core_customfield_delete_field' : 'core_customfield_delete_category',\n args: {id},\n },\n {methodname: 'core_customfield_reload_template', args: {component, area, itemid}}\n ])[1]\n .then(response => Templates.render('core_customfield/list', response))\n .then((html, js) => Templates.replaceNode(jQuery('[data-region=\"list-page\"]'), html, js))\n .then(pendingDeletePromise.resolve)\n .catch(Notification.exception);\n });\n })\n .then(pendingPromise.resolve)\n .catch(Notification.exception);\n};\n\n\n/**\n * Creates a new custom fields category with default name and updates the list\n *\n * @param {String} component\n * @param {String} area\n * @param {Number} itemid\n */\nconst createNewCategory = (component, area, itemid) => {\n const pendingPromise = new Pending('core_customfield/form:createNewCategory');\n const promises = fetchMany([\n {methodname: 'core_customfield_create_category', args: {component, area, itemid}},\n {methodname: 'core_customfield_reload_template', args: {component, area, itemid}}\n ]);\n\n promises[1].then(response => Templates.render('core_customfield/list', response))\n .then((html, js) => Templates.replaceNode(jQuery('[data-region=\"list-page\"]'), html, js))\n .then(() => addToast(getString('categoryadded', 'core_customfield'), {type: 'success'}))\n .then(() => pendingPromise.resolve())\n .catch(Notification.exception);\n};\n\n/**\n * Create new custom field\n *\n * @param {HTMLElement} element\n * @param {String} component\n * @param {String} area\n * @param {Number} itemid\n */\nconst createNewField = (element, component, area, itemid) => {\n const pendingPromise = new Pending('core_customfield/form:createNewField');\n\n const returnFocus = element.closest(\".action-menu\").querySelector(\".dropdown-toggle\");\n const form = new ModalForm({\n formClass: \"core_customfield\\\\field_config_form\",\n args: {\n categoryid: element.getAttribute('data-categoryid'),\n type: element.getAttribute('data-type'),\n },\n modalConfig: {\n title: getString('addingnewcustomfield', 'core_customfield', element.getAttribute('data-typename')),\n },\n returnFocus,\n });\n\n form.addEventListener(form.events.FORM_SUBMITTED, () => {\n const pendingCreatedPromise = new Pending('core_customfield/form:createdNewField');\n const promises = fetchMany([\n {methodname: 'core_customfield_reload_template', args: {component: component, area: area, itemid: itemid}}\n ]);\n\n promises[0].then(response => Templates.render('core_customfield/list', response))\n .then((html, js) => Templates.replaceNode(jQuery('[data-region=\"list-page\"]'), html, js))\n .then(() => pendingCreatedPromise.resolve())\n .catch(() => window.location.reload());\n });\n\n form.show();\n\n pendingPromise.resolve();\n};\n\n/**\n * Edit custom field\n *\n * @param {HTMLElement} element\n * @param {String} component\n * @param {String} area\n * @param {Number} itemid\n */\nconst editField = (element, component, area, itemid) => {\n const pendingPromise = new Pending('core_customfield/form:editField');\n\n const form = new ModalForm({\n formClass: \"core_customfield\\\\field_config_form\",\n args: {\n id: element.getAttribute('data-id'),\n },\n modalConfig: {\n title: getString('editingfield', 'core_customfield', element.getAttribute('data-name')),\n },\n returnFocus: element,\n });\n\n form.addEventListener(form.events.FORM_SUBMITTED, () => {\n const pendingCreatedPromise = new Pending('core_customfield/form:createdNewField');\n const promises = fetchMany([\n {methodname: 'core_customfield_reload_template', args: {component: component, area: area, itemid: itemid}}\n ]);\n\n promises[0].then(response => Templates.render('core_customfield/list', response))\n .then((html, js) => Templates.replaceNode(jQuery('[data-region=\"list-page\"]'), html, js))\n .then(() => pendingCreatedPromise.resolve())\n .catch(() => window.location.reload());\n });\n\n form.show();\n\n pendingPromise.resolve();\n};\n\n/**\n * Fetch the category name from an inplace editable, given a child node of that field.\n *\n * @param {NodeElement} nodeElement\n * @returns {String}\n */\nconst getCategoryNameFor = nodeElement => nodeElement\n .closest('[data-category-id]')\n .attr('data-category-name');\n\nconst setupSortableLists = rootNode => {\n // Sort category.\n const sortCat = new SortableList(\n '#customfield_catlist .categorieslist',\n {\n moveHandlerSelector: '.movecategory [data-drag-type=move]',\n }\n );\n sortCat.getElementName = nodeElement => Promise.resolve(getCategoryNameFor(nodeElement));\n\n // Note: The sortable list currently uses jQuery events.\n jQuery('[data-category-id]').on(SortableList.EVENTS.DROP, (evt, info) => {\n if (info.positionChanged) {\n const pendingPromise = new Pending('core_customfield/form:categoryid:on:sortablelist-drop');\n fetchMany([{\n methodname: 'core_customfield_move_category',\n args: {\n id: info.element.data('category-id'),\n beforeid: info.targetNextElement.data('category-id')\n }\n\n }])[0]\n .then(pendingPromise.resolve)\n .catch(Notification.exception);\n }\n evt.stopPropagation(); // Important for nested lists to prevent multiple targets.\n });\n\n // Sort fields.\n var sort = new SortableList(\n '#customfield_catlist .fieldslist tbody',\n {\n moveHandlerSelector: '.movefield [data-drag-type=move]',\n }\n );\n\n sort.getDestinationName = (parentElement, afterElement) => {\n if (!afterElement.length) {\n return getString('totopofcategory', 'customfield', getCategoryNameFor(parentElement));\n } else if (afterElement.attr('data-field-name')) {\n return getString('afterfield', 'customfield', afterElement.attr('data-field-name'));\n } else {\n // Empty string signals sortable_list to skip this destination.\n return Promise.resolve('');\n }\n };\n\n jQuery('[data-field-name]').on(SortableList.EVENTS.DROP, (evt, info) => {\n if (info.positionChanged) {\n const pendingPromise = new Pending('core_customfield/form:fieldname:on:sortablelist-drop');\n fetchMany([{\n methodname: 'core_customfield_move_field',\n args: {\n id: info.element.data('field-id'),\n beforeid: info.targetNextElement.data('field-id'),\n categoryid: Number(info.targetList.closest('[data-category-id]').attr('data-category-id'))\n },\n }])[0]\n .then(pendingPromise.resolve)\n .catch(Notification.exception);\n }\n evt.stopPropagation(); // Important for nested lists to prevent multiple targets.\n });\n\n jQuery('[data-field-name]').on(SortableList.EVENTS.DRAG, evt => {\n var pendingPromise = new Pending('core_customfield/form:fieldname:on:sortablelist-drag');\n\n evt.stopPropagation(); // Important for nested lists to prevent multiple targets.\n\n // Refreshing fields tables.\n Templates.render('core_customfield/nofields', {})\n .then(html => {\n rootNode.querySelectorAll('.categorieslist > *')\n .forEach(category => {\n const fields = category.querySelectorAll('.field:not(.sortable-list-is-dragged)');\n const noFields = category.querySelector('.nofields');\n\n if (!fields.length && !noFields) {\n category.querySelector('tbody').innerHTML = html;\n } else if (fields.length && noFields) {\n noFields.remove();\n }\n });\n return;\n })\n .then(pendingPromise.resolve)\n .catch(Notification.exception);\n });\n\n jQuery('[data-category-id], [data-field-name]').on(SortableList.EVENTS.DRAGSTART, (evt, info) => {\n setTimeout(() => {\n jQuery('.sortable-list-is-dragged').width(info.element.width());\n }, 501);\n });\n};\n\n/**\n * Initialise the custom fields manager.\n */\nexport const init = () => {\n const rootNode = document.querySelector('#customfield_catlist');\n\n const component = rootNode.dataset.component;\n const area = rootNode.dataset.area;\n const itemid = rootNode.dataset.itemid;\n\n rootNode.addEventListener('click', e => {\n const roleHolder = e.target.closest('[data-role]');\n if (!roleHolder) {\n return;\n }\n\n if (roleHolder.dataset.role === 'deletefield') {\n e.preventDefault();\n\n confirmDelete(roleHolder.dataset.id, 'field', component, area, itemid);\n return;\n }\n\n if (roleHolder.dataset.role === 'deletecategory') {\n e.preventDefault();\n\n confirmDelete(roleHolder.dataset.id, 'category', component, area, itemid);\n return;\n }\n\n if (roleHolder.dataset.role === 'addnewcategory') {\n e.preventDefault();\n createNewCategory(component, area, itemid);\n\n return;\n }\n\n if (roleHolder.dataset.role === 'addfield') {\n e.preventDefault();\n createNewField(roleHolder, component, area, itemid);\n\n return;\n }\n\n if (roleHolder.dataset.role === 'editfield') {\n e.preventDefault();\n editField(roleHolder, component, area, itemid);\n\n return;\n }\n });\n\n setupSortableLists(rootNode, component, area, itemid);\n};\n"],"names":["confirmDelete","id","type","component","area","itemid","pendingPromise","Pending","then","strings","Notification","confirm","pendingDeletePromise","methodname","args","response","Templates","render","html","js","replaceNode","resolve","catch","exception","getCategoryNameFor","nodeElement","closest","attr","rootNode","document","querySelector","dataset","addEventListener","e","roleHolder","target","role","preventDefault","createNewCategory","element","returnFocus","form","ModalForm","formClass","categoryid","getAttribute","modalConfig","title","events","FORM_SUBMITTED","pendingCreatedPromise","window","location","reload","show","createNewField","editField","SortableList","moveHandlerSelector","getElementName","Promise","on","EVENTS","DROP","evt","info","positionChanged","data","beforeid","targetNextElement","stopPropagation","getDestinationName","parentElement","afterElement","length","Number","targetList","DRAG","querySelectorAll","forEach","category","fields","noFields","remove","innerHTML","DRAGSTART","setTimeout","width","setupSortableLists"],"mappings":"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"} \ No newline at end of file diff --git a/public/customfield/amd/src/form.js b/public/customfield/amd/src/form.js index dbd461a4ca6f5..3bea1cc488cca 100644 --- a/public/customfield/amd/src/form.js +++ b/public/customfield/amd/src/form.js @@ -28,6 +28,7 @@ import { getStrings, } from 'core/str'; import ModalForm from 'core_form/modalform'; +import {add as addToast} from 'core/toast'; import Notification from 'core/notification'; import Pending from 'core/pending'; import SortableList from 'core/sortable_list'; @@ -89,6 +90,7 @@ const createNewCategory = (component, area, itemid) => { promises[1].then(response => Templates.render('core_customfield/list', response)) .then((html, js) => Templates.replaceNode(jQuery('[data-region="list-page"]'), html, js)) + .then(() => addToast(getString('categoryadded', 'core_customfield'), {type: 'success'})) .then(() => pendingPromise.resolve()) .catch(Notification.exception); }; @@ -225,6 +227,7 @@ const setupSortableLists = rootNode => { } else if (afterElement.attr('data-field-name')) { return getString('afterfield', 'customfield', afterElement.attr('data-field-name')); } else { + // Empty string signals sortable_list to skip this destination. return Promise.resolve(''); } }; diff --git a/public/customfield/externallib.php b/public/customfield/externallib.php index 2f5d8da28fa47..edc909168a91d 100644 --- a/public/customfield/externallib.php +++ b/public/customfield/externallib.php @@ -120,6 +120,7 @@ public static function reload_template_returns() { 'id' => new external_value(PARAM_INT, 'id'), 'name' => new external_value(PARAM_TEXT, 'name'), 'nameeditable' => new external_value(PARAM_RAW, 'inplace editable name'), + 'movetitle' => new external_value(PARAM_TEXT, 'accessible name for the category move handle'), 'addfieldmenu' => new external_value(PARAM_RAW, 'addfieldmenu'), 'canedit' => new external_value(PARAM_BOOL, 'can edit'), 'fields' => new external_multiple_structure( @@ -129,6 +130,7 @@ public static function reload_template_returns() { 'shortname' => new external_value(PARAM_NOTAGS, 'shortname'), 'type' => new external_value(PARAM_NOTAGS, 'type'), 'id' => new external_value(PARAM_INT, 'id'), + 'movetitle' => new external_value(PARAM_TEXT, 'accessible name for the field move handle'), ) ) , '', VALUE_OPTIONAL), diff --git a/public/customfield/field/number/classes/task/recalculate.php b/public/customfield/field/number/classes/task/recalculate.php index b0b55cb9af646..2151cf1fa7725 100644 --- a/public/customfield/field/number/classes/task/recalculate.php +++ b/public/customfield/field/number/classes/task/recalculate.php @@ -17,6 +17,7 @@ namespace customfield_number\task; use core\task\adhoc_task; +use core_customfield\customfield\shared_handler; use core_customfield\field_controller; use customfield_number\provider_base; @@ -78,10 +79,15 @@ public function execute() { */ protected function field_is_scheduled(field_controller $field): bool { $customdata = $this->get_custom_data(); - if (!empty($customdata->component) && $field->get_handler()->get_component() !== $customdata->component) { + $handler = $field->get_handler(); + // Shared custom fields can be enabled for any component/area, so they should not be filtered out. + if ($handler instanceof shared_handler) { + return true; + } + if (!empty($customdata->component) && $handler->get_component() !== $customdata->component) { return false; } - if (!empty($customdata->area) && $field->get_handler()->get_area() !== $customdata->area) { + if (!empty($customdata->area) && $handler->get_area() !== $customdata->area) { return false; } return true; diff --git a/public/customfield/field/number/tests/task/recalculate_test.php b/public/customfield/field/number/tests/task/recalculate_test.php new file mode 100644 index 0000000000000..188f5ec3c79f0 --- /dev/null +++ b/public/customfield/field/number/tests/task/recalculate_test.php @@ -0,0 +1,97 @@ +. + +declare(strict_types=1); + +namespace customfield_number\task; + +use core_customfield\api; +use core_customfield\external\toggle_shared_category; +use customfield_number\test_provider; + +/** + * Test the recalculate adhoc task. + * + * @package customfield_number + * @covers \customfield_number\task\recalculate + * @copyright 2026 Yerai Rodríguez + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class recalculate_test extends \advanced_testcase { + /** + * Test that schedule_for_fieldtype recalculates shared fields when scheduled with a specific component/area. + + * A shared custom field (handler core_customfield/shared) enabled for courses should be recalculated + * when the adhoc task is scheduled with component='core_course' and area='course'. + * + * @return void + */ + public function test_schedule_for_fieldtype_with_shared_field(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $this->load_fixture('customfield_number', 'test_provider.php'); + + $category = $this->getDataGenerator()->create_custom_field_category([ + 'component' => 'core_customfield', + 'area' => 'shared', + ]); + + toggle_shared_category::execute($category->get('id'), 'core_course', 'course', 0, true); + + $sharedfield = $this->getDataGenerator()->create_custom_field([ + 'categoryid' => $category->get('id'), + 'shortname' => 'seconds', + 'type' => 'number', + 'configdata' => [ + 'fieldtype' => test_provider::class, + ], + ]); + + $clock = $this->mock_clock_with_frozen(); + $fieldid = $sharedfield->get('id'); + $fields = [$fieldid => $sharedfield]; + + $course = $this->getDataGenerator()->create_course(); + $courseid = (int)$course->id; + + // Discard tasks and data created during setup to isolate the explicitly scheduled task below. + $DB->delete_records('task_adhoc'); + $DB->delete_records('customfield_data'); + + // Confirm current value is null. + $data = api::get_instance_fields_data($fields, $courseid, true, 'core_course', 'course'); + $this->assertNull($data[$fieldid]->get_value()); + + // Schedule recalculation with core_course/course component/area for a shared field. + recalculate::schedule_for_fieldtype( + fieldtype: test_provider::class, + component: 'core_course', + area: 'course', + ); + $this->run_all_adhoc_tasks(); + + // The shared field should have been recalculated for the course. + $data = api::get_instance_fields_data($fields, $courseid, true, 'core_course', 'course'); + $this->assertEquals($clock->time() % 3600, $data[$fieldid]->get_value()); + + // Data should not exist in the shared context. + $data = api::get_instance_fields_data($fields, $courseid, true, 'core_customfield', 'shared'); + $this->assertNull($data[$fieldid]->get_value()); + } +} diff --git a/public/customfield/templates/list.mustache b/public/customfield/templates/list.mustache index 5dd2a156cd83c..2eab67ee391f8 100644 --- a/public/customfield/templates/list.mustache +++ b/public/customfield/templates/list.mustache @@ -68,7 +68,7 @@
{{#usescategories}} - {{#str}}addnewcategory, core_customfield{{/str}} + {{/usescategories}}
diff --git a/public/customfield/tests/behat/edit_categories.feature b/public/customfield/tests/behat/edit_categories.feature index e8dbaf0d66310..132744745050c 100644 --- a/public/customfield/tests/behat/edit_categories.feature +++ b/public/customfield/tests/behat/edit_categories.feature @@ -10,9 +10,28 @@ Feature: Managers can manage categories for course custom fields And I press "Add a new category" And I wait until the page is ready Then I should see "Other fields" in the "#customfield_catlist" "css_element" + And "The category has been successfully added" "toast_message" should exist + And I wait until "The category has been successfully added" "toast_message" does not exist And I navigate to "Reports > Logs" in site administration And I press "Get these logs" + @accessibility + Scenario: Create categories for custom course fields using keyboard + Given I log in as "admin" + And I navigate to "Courses > Default settings > Course custom fields" in site administration + And the "region-main" "region" should meet accessibility standards with "best-practice" extra tests + And I press the tab key + And I click on "Skip to main content" "link" + And I press the tab key + And the focused element is "Add a new category" "button" + When I press the space key + Then I wait until "Other fields" "text" exists + And I press the tab key + And the focused element is "Add a new category" "button" + And I press enter + And I wait until "Other fields 1" "text" exists + And the "region-main" "region" should meet accessibility standards with "best-practice" extra tests + Scenario: Edit a category name for custom course fields Given the following "custom field categories" exist: | name | component | area | itemid | @@ -43,6 +62,7 @@ Feature: Managers can manage categories for course custom fields And I navigate to "Reports > Logs" in site administration And I press "Get these logs" + @accessibility Scenario: Move field in the course custom fields to another category Given the following "custom field categories" exist: | name | component | area | itemid | @@ -60,6 +80,7 @@ Feature: Managers can manage categories for course custom fields And "Field2" "text" should appear after "Category2" "text" And "Category3" "text" should appear after "Field2" "text" And I press "Move \"Field1\"" + And the page should meet accessibility standards And I follow "To the top of category Category2" And "Category2" "text" should appear after "Category1" "text" And "Field1" "text" should appear after "Category2" "text" diff --git a/public/customfield/tests/behat/shared_custom_fields.feature b/public/customfield/tests/behat/shared_custom_fields.feature index f62455d737e73..8d2a4794d06f4 100644 --- a/public/customfield/tests/behat/shared_custom_fields.feature +++ b/public/customfield/tests/behat/shared_custom_fields.feature @@ -10,11 +10,29 @@ Feature: Create shared categories and fields And I press "Add a new category" And I wait until the page is ready Then I should see "Other fields" in the "#customfield_catlist" "css_element" + And "The category has been successfully added" "toast_message" should exist And I click on "[data-role='deletecategory']" "css_element" And I click on "Yes" "button" in the "Confirm" "dialogue" And I wait until the page is ready And I wait until "Other fields" "text" does not exist + @accessibility + Scenario: Create categories for shared custom fields using keyboard + Given I log in as "admin" + And I navigate to "Custom fields > Shared custom fields" in site administration + And the "region-main" "region" should meet accessibility standards with "best-practice" extra tests + And I press the tab key + And I click on "Skip to main content" "link" + And I press the tab key + And the focused element is "Add a new category" "button" + When I press the space key + Then I wait until "Other fields" "text" exists + And I press the tab key + And the focused element is "Add a new category" "button" + And I press enter + And I wait until "Other fields 1" "text" exists + And the "region-main" "region" should meet accessibility standards with "best-practice" extra tests + Scenario: Shared custom field short name must be unique across all instance fields Given the following "custom field categories" exist: | name | component | area | itemid | diff --git a/public/enrol/cohort/tests/behat/enrolcohorts.feature b/public/enrol/cohort/tests/behat/enrolcohorts.feature index a68c63ca86aa9..996c94bb4e4cf 100644 --- a/public/enrol/cohort/tests/behat/enrolcohorts.feature +++ b/public/enrol/cohort/tests/behat/enrolcohorts.feature @@ -79,8 +79,7 @@ Feature: Cohort enrolment management And the "removeselect[]" select box should contain "Jane Doe (s4@example.com)" And the "removeselect[]" select box should not contain "John Smith (s2@example.com)" And I trigger cron - And I am on "Course 001" course homepage - And I navigate to course participants + And I am on the "Course 001" "enrolled users" page # Verifies students 1 and 4 are in the cohort and student 2 is not any more. And the following should exist in the "participants" table: | First name | Email address | Roles | Groups | @@ -123,13 +122,13 @@ Feature: Cohort enrolment management And I add "Cohort sync" enrolment method in "Course 001" with: | Cohort | Alpha1 | And I should see "Cohort sync (Alpha1 - Student)" - And I navigate to course participants + And I am on the "Course 001" "enrolled users" page And I should see "Student" in the "Sandra Cole" "table_row" And I am on the "Course 001" "enrolment methods" page And I click on "Edit" "link" in the "Alpha1" "table_row" And I set the field "Assign role" to "Non-editing teacher" And I click on "Save" "button" Then I should see "Cohort sync (Alpha1 - Non-editing teacher)" - And I navigate to course participants + And I am on the "Course 001" "enrolled users" page And I should see "Non-editing teacher" in the "Sandra Cole" "table_row" And I should not see "Student" in the "Sandra Cole" "table_row" diff --git a/public/enrol/cohort/tests/behat/unenrolactionsuspendonly.feature b/public/enrol/cohort/tests/behat/unenrolactionsuspendonly.feature index fae582e4aa8f9..049a69a699040 100644 --- a/public/enrol/cohort/tests/behat/unenrolactionsuspendonly.feature +++ b/public/enrol/cohort/tests/behat/unenrolactionsuspendonly.feature @@ -25,7 +25,7 @@ Feature: Unenrol action to disable course enrolment | user | course | role | timestart | | teacher001 | C001 | editingteacher | ##1 month ago## | - @javascript @skip_chrome_zerosize + @javascript Scenario: Removing the user from the cohort will suspend the enrolment but keep the role When I log in as "teacher001" And I am on the "Course 001" "enrolment methods" page @@ -50,14 +50,13 @@ Feature: Unenrol action to disable course enrolment And I set the field "Current users" to "Student 001 (student001@example.com)" And I wait "1" seconds And I press "Remove" - And I am on "Course 001" course homepage - And I navigate to course participants + And I am on the "Course 001" "enrolled users" page And I should see "Suspended" in the "Student 001" "table_row" And I should see "Active" in the "Student 002" "table_row" And I should see "Active" in the "Student 003" "table_row" And I should see "Active" in the "Student 004" "table_row" - @javascript @skip_chrome_zerosize + @javascript Scenario: Deleting non-empty cohort will suspend the enrolment but keep the role When I log in as "teacher001" And I am on the "Course 001" "enrolment methods" page @@ -79,8 +78,7 @@ Feature: Unenrol action to disable course enrolment And I navigate to "Users > Accounts > Cohorts" in site administration When I press "Delete" action in the "System cohort" report row And I click on "Delete" "button" in the "Delete selected" "dialogue" - And I am on "Course 001" course homepage - And I navigate to course participants + And I am on the "Course 001" "enrolled users" page And I should see "Suspended" in the "Student 001" "table_row" And I should see "Suspended" in the "Student 002" "table_row" And I should see "Suspended" in the "Student 003" "table_row" diff --git a/public/enrol/fee/classes/task/process_expirations.php b/public/enrol/fee/classes/task/process_expirations.php new file mode 100644 index 0000000000000..d51983d520e6b --- /dev/null +++ b/public/enrol/fee/classes/task/process_expirations.php @@ -0,0 +1,44 @@ +. + +namespace enrol_fee\task; + +/** + * Process expirations task. + * + * @package enrol_fee + * @copyright 2026 Andi Permana + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class process_expirations extends \core\task\scheduled_task { + /** + * Name for this task. + * + * @return string + */ + public function get_name() { + return get_string('processexpirationstask', 'enrol_fee'); + } + + /** + * Run task for processing expirations. + */ + public function execute() { + $enrol = enrol_get_plugin('fee'); + $trace = new \text_progress_trace(); + $enrol->process_expirations($trace); + } +} diff --git a/public/enrol/fee/db/tasks.php b/public/enrol/fee/db/tasks.php new file mode 100644 index 0000000000000..13cadc6d3c636 --- /dev/null +++ b/public/enrol/fee/db/tasks.php @@ -0,0 +1,38 @@ +. + +/** + * Task definition for enrol_fee. + * + * @package enrol_fee + * @copyright 2026 Andi Permana + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$tasks = [ + [ + 'classname' => '\enrol_fee\task\process_expirations', + 'blocking' => 0, + 'minute' => '*', + 'hour' => '*', + 'day' => '*', + 'month' => '*', + 'dayofweek' => '*', + 'disabled' => 0, + ], +]; diff --git a/public/enrol/fee/lang/en/enrol_fee.php b/public/enrol/fee/lang/en/enrol_fee.php index dc1b89aea520b..115235d7d3c4b 100644 --- a/public/enrol/fee/lang/en/enrol_fee.php +++ b/public/enrol/fee/lang/en/enrol_fee.php @@ -50,6 +50,7 @@ $string['pluginname'] = 'Enrolment on payment'; $string['pluginname_desc'] = 'The enrolment on payment enrolment method allows you to set up courses requiring a payment. If the fee for any course is set to zero, then students are not asked to pay for entry. There is a site-wide fee that you set here as a default for the whole site and then a course setting that you can set for each course individually. The course fee overrides the site fee.'; $string['privacy:metadata'] = 'The enrolment on payment enrolment plugin does not store any personal data.'; +$string['processexpirationstask'] = 'Process enrolment expirations'; $string['purchasedescription'] = 'Enrolment in course {$a}'; $string['sendpaymentbutton'] = 'Select payment type'; $string['status'] = 'Allow enrolment on payment enrolments'; diff --git a/public/enrol/fee/tests/fee_test.php b/public/enrol/fee/tests/fee_test.php new file mode 100644 index 0000000000000..cded4dec3f12c --- /dev/null +++ b/public/enrol/fee/tests/fee_test.php @@ -0,0 +1,214 @@ +. + +namespace enrol_fee; + +/** + * enrol_fee tests. + * + * @package enrol_fee + * @copyright 2026 Andi Permana + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \enrol_fee_plugin + */ +final class fee_test extends \advanced_testcase { + /** + * Enable the enrol_fee plugin. + */ + protected function enable_plugin(): void { + $enabled = enrol_get_plugins(true); + $enabled['fee'] = true; + set_config('enrol_plugins_enabled', implode(',', array_keys($enabled))); + } + + /** + * Disable the enrol_fee plugin. + */ + protected function disable_plugin(): void { + $enabled = enrol_get_plugins(true); + unset($enabled['fee']); + set_config('enrol_plugins_enabled', implode(',', array_keys($enabled))); + } + + /** + * Test basic plugin state and default config. + */ + public function test_basics(): void { + $this->assertFalse(enrol_is_enabled('fee')); + $plugin = enrol_get_plugin('fee'); + $this->assertInstanceOf('enrol_fee_plugin', $plugin); + $this->assertEquals(ENROL_EXT_REMOVED_SUSPENDNOROLES, get_config('enrol_fee', 'expiredaction')); + } + + /** + * Test that sync does not error when there is nothing to do. + */ + public function test_sync_nothing(): void { + $this->resetAfterTest(); + $this->enable_plugin(); + + $feeplugin = enrol_get_plugin('fee'); + $feeplugin->sync(new \null_progress_trace()); + } + + /** + * Test that process_expirations() correctly handles all expiry actions: + * - ENROL_EXT_REMOVED_KEEP: no changes to expired enrolments. + * - ENROL_EXT_REMOVED_SUSPENDNOROLES: expired active enrolments are suspended and roles removed. + * - ENROL_EXT_REMOVED_UNENROL: expired enrolments are fully removed from user_enrolments. + * + * @covers \enrol_fee_plugin::sync + */ + public function test_expired(): void { + global $DB; + $this->resetAfterTest(); + $this->enable_plugin(); + + /** @var \enrol_fee_plugin $feeplugin */ + $feeplugin = enrol_get_plugin('fee'); + $manualplugin = enrol_get_plugin('manual'); + $this->assertNotEmpty($manualplugin); + + $now = time(); + $trace = new \null_progress_trace(); + + // Prepare roles. + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->assertNotEmpty($studentrole); + $teacherrole = $DB->get_record('role', ['shortname' => 'teacher']); + $this->assertNotEmpty($teacherrole); + $managerrole = $DB->get_record('role', ['shortname' => 'manager']); + $this->assertNotEmpty($managerrole); + + // Prepare users. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); + $user4 = $this->getDataGenerator()->create_user(); + + // Prepare courses. + $course1 = $this->getDataGenerator()->create_course(); + $course2 = $this->getDataGenerator()->create_course(); + $context1 = \context_course::instance($course1->id); + $context2 = \context_course::instance($course2->id); + + // Add fee enrolment instances. + $instanceid1 = $feeplugin->add_instance($course1, [ + 'status' => ENROL_INSTANCE_ENABLED, + 'roleid' => $studentrole->id, + 'cost' => 10, + 'currency' => 'USD', + ]); + $instanceid2 = $feeplugin->add_instance($course2, [ + 'status' => ENROL_INSTANCE_ENABLED, + 'roleid' => $studentrole->id, + 'cost' => 10, + 'currency' => 'USD', + ]); + $instanceid2b = $feeplugin->add_instance($course2, [ + 'status' => ENROL_INSTANCE_ENABLED, + 'roleid' => $teacherrole->id, + 'cost' => 10, + 'currency' => 'USD', + ]); + + $instance1 = $DB->get_record('enrol', ['id' => $instanceid1], '*', MUST_EXIST); + $instance2 = $DB->get_record('enrol', ['id' => $instanceid2], '*', MUST_EXIST); + $instance2b = $DB->get_record('enrol', ['id' => $instanceid2b], '*', MUST_EXIST); + + // Enrol a user via manual in course2 to verify it is not affected by fee expiry sync. + $maninstance2 = $DB->get_record('enrol', ['courseid' => $course2->id, 'enrol' => 'manual'], '*', MUST_EXIST); + $manualplugin->enrol_user($maninstance2, $user1->id, $teacherrole->id); + + $this->assertEquals(1, $DB->count_records('user_enrolments')); + $this->assertEquals(1, $DB->count_records('role_assignments')); + + // Enrol users via fee: + // course1: user1 active (no end), user2 active (no end), user3 expired 60s ago. + $feeplugin->enrol_user($instance1, $user1->id, $studentrole->id); + $feeplugin->enrol_user($instance1, $user2->id, $studentrole->id); + $feeplugin->enrol_user($instance1, $user3->id, $studentrole->id, 0, $now - 60); + + // Course2: user1 no end, user2 expired 1h ago, user3 ends in 1h, user1 as teacher expired 60s ago. + $feeplugin->enrol_user($instance2, $user1->id, $studentrole->id, 0, 0); + $feeplugin->enrol_user($instance2, $user2->id, $studentrole->id, 0, $now - 60 * 60); + $feeplugin->enrol_user($instance2, $user3->id, $studentrole->id, 0, $now + 60 * 60); + $feeplugin->enrol_user($instance2b, $user1->id, $teacherrole->id, $now - 60 * 60 * 24 * 7, $now - 60); + $feeplugin->enrol_user($instance2b, $user4->id, $teacherrole->id); + + // Manually assign manager role to user3 in course1. + role_assign($managerrole->id, $user3->id, $context1->id); + + $this->assertEquals(9, $DB->count_records('user_enrolments')); + $this->assertEquals(9, $DB->count_records('role_assignments')); + $this->assertEquals(6, $DB->count_records('role_assignments', ['roleid' => $studentrole->id])); + $this->assertEquals(2, $DB->count_records('role_assignments', ['roleid' => $teacherrole->id])); + $this->assertEquals(1, $DB->count_records('role_assignments', ['roleid' => $managerrole->id])); + + // Test 1: ENROL_EXT_REMOVED_KEEP — nothing should change. + $feeplugin->set_config('expiredaction', ENROL_EXT_REMOVED_KEEP); + + $this->assertSame(0, $feeplugin->sync($trace)); + $this->assertEquals(9, $DB->count_records('user_enrolments')); + $this->assertEquals(9, $DB->count_records('role_assignments')); + + // Test 2: ENROL_EXT_REMOVED_SUSPENDNOROLES — suspend expired + remove roles. + $feeplugin->set_config('expiredaction', ENROL_EXT_REMOVED_SUSPENDNOROLES); + + $feeplugin->sync($trace); + // User_enrolments count stays the same (suspend, not unenrol). + $this->assertEquals(9, $DB->count_records('user_enrolments')); + // Roles removed for the 3 expired enrolments (user3/course1, user2/course2, user1-teacher/course2). + $this->assertEquals(6, $DB->count_records('role_assignments')); + $this->assertEquals(4, $DB->count_records('role_assignments', ['roleid' => $studentrole->id])); + $this->assertEquals(1, $DB->count_records('role_assignments', ['roleid' => $teacherrole->id])); + // Expired users should no longer have roles in their respective expired enrolment contexts. + $this->assertFalse($DB->record_exists('role_assignments', [ + 'contextid' => $context1->id, 'userid' => $user3->id, 'roleid' => $studentrole->id, + ])); + $this->assertFalse($DB->record_exists('role_assignments', [ + 'contextid' => $context2->id, 'userid' => $user2->id, 'roleid' => $studentrole->id, + ])); + $this->assertFalse($DB->record_exists('role_assignments', [ + 'contextid' => $context2->id, 'userid' => $user1->id, 'roleid' => $teacherrole->id, + ])); + // User1's student role in course2 (non-expired) must still be there. + $this->assertTrue($DB->record_exists('role_assignments', [ + 'contextid' => $context2->id, 'userid' => $user1->id, 'roleid' => $studentrole->id, + ])); + + // Test 3: ENROL_EXT_REMOVED_UNENROL — fully remove expired enrolments. + $feeplugin->set_config('expiredaction', ENROL_EXT_REMOVED_UNENROL); + + // Re-assign the roles that were stripped in test 2 so the count baseline is accurate. + role_assign($studentrole->id, $user3->id, $context1->id); + role_assign($studentrole->id, $user2->id, $context2->id); + role_assign($teacherrole->id, $user1->id, $context2->id); + $this->assertEquals(9, $DB->count_records('user_enrolments')); + $this->assertEquals(9, $DB->count_records('role_assignments')); + + $feeplugin->sync($trace); + // 3 expired enrolments removed: user3/instance1, user2/instance2, user1/instance2b. + $this->assertEquals(6, $DB->count_records('user_enrolments')); + $this->assertFalse($DB->record_exists('user_enrolments', ['enrolid' => $instance1->id, 'userid' => $user3->id])); + $this->assertFalse($DB->record_exists('user_enrolments', ['enrolid' => $instance2->id, 'userid' => $user2->id])); + $this->assertFalse($DB->record_exists('user_enrolments', ['enrolid' => $instance2b->id, 'userid' => $user1->id])); + // Roles cleaned up too. + $this->assertEquals(5, $DB->count_records('role_assignments')); + $this->assertEquals(4, $DB->count_records('role_assignments', ['roleid' => $studentrole->id])); + $this->assertEquals(1, $DB->count_records('role_assignments', ['roleid' => $teacherrole->id])); + } +} diff --git a/public/enrol/fee/version.php b/public/enrol/fee/version.php index b5a473d81ccee..a1f9d6bd36d6a 100644 --- a/public/enrol/fee/version.php +++ b/public/enrol/fee/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2025100600; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2025100601; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2025092600; // Requires this Moodle version. $plugin->component = 'enrol_fee'; // Full name of the plugin (used for diagnostics). diff --git a/public/enrol/imsenterprise/lib.php b/public/enrol/imsenterprise/lib.php index 6d1d174eba1b9..ac1c38fa05030 100644 --- a/public/enrol/imsenterprise/lib.php +++ b/public/enrol/imsenterprise/lib.php @@ -707,7 +707,14 @@ protected function process_membership_tag($tagcontents) { $einstance = $DB->get_record('enrol', array('id' => $enrolid)); } - $this->enrol_user($einstance, $memberstoreobj->userid, $moodleroleid, $timeframe->begin, $timeframe->end); + $this->enrol_user( + $einstance, + $memberstoreobj->userid, + $moodleroleid, + $timeframe->begin, + $timeframe->end, + ENROL_USER_ACTIVE, + ); $this->log_line("Enrolled user #$memberstoreobj->userid ($member->idnumber) " ."to role $member->roletype in course $memberstoreobj->course"); diff --git a/public/enrol/imsenterprise/tests/imsenterprise_unenrol_test.php b/public/enrol/imsenterprise/tests/imsenterprise_unenrol_test.php index 21ed5948ebbbe..d0a00eca1ffe0 100644 --- a/public/enrol/imsenterprise/tests/imsenterprise_unenrol_test.php +++ b/public/enrol/imsenterprise/tests/imsenterprise_unenrol_test.php @@ -520,6 +520,106 @@ public function test_disable_enrolments_only(): void { ['userid' => $dbuser->id, 'id' => $dbrole->id])); } + /** + * Re-enroling a user with a suspended enrolment re-actives that enrolment. + */ + public function test_reenable_suspended_enrolment(): void { + + global $DB; + + $this->imsplugin->set_config('imsunenrol', 1); + $this->imsplugin->set_config('unenrolaction', ENROL_EXT_REMOVED_SUSPEND); + + $courses = $this->generate_test_course_records(1); + $users = $this->generate_test_user_records(1); + + // Add a new enrolment for the same user via IMS file. + $coursemembership = $this->link_users_with_courses( + $users, + $courses, + // Role types: 01=Learner, 02=Instructor, 03=Content Dev, 04=Member, 05=Manager, 06=Mentor, 07=Admin, 08=TA. + // Role statuses: 0=Inactive, 1=Active. + // Role recstatus: 1=Add, 2=Update, 3=Delete. + // Format of matrix elements: ::. + [ + ['01:1:1'], // Course 1. + ] + ); + + $this->set_xml_file($users, $courses, $coursemembership); + $this->imsplugin->cron(); + + // Capture DB ids. + $dbuser = $DB->get_record('user', ['idnumber' => $users[0]->idnumber], '*', MUST_EXIST); + + $dbenrolment = $DB->get_record( + 'user_enrolments', + [ + 'userid' => $dbuser->id, + 'status' => ENROL_USER_ACTIVE, + ], + '*', + MUST_EXIST, + ); + + // Unenrol the user, check that the enrolment is suspended. + $coursemembership = $this->link_users_with_courses( + $users, + $courses, + // Role types: 01=Learner, 02=Instructor, 03=Content Dev, 04=Member, 05=Manager, 06=Mentor, 07=Admin, 08=TA. + // Role statuses: 0=Inactive, 1=Active. + // Role recstatus: 1=Add, 2=Update, 3=Delete. + // Format of matrix elements: ::. + [ + ['01:0:3'], // Course 1. + ] + ); + + $this->set_xml_file($users, $courses, $coursemembership); + $this->imsplugin->cron(); + + $this->assertEquals( + 1, + $DB->count_records( + 'user_enrolments', + [ + 'userid' => $dbuser->id, + 'id' => $dbenrolment->id, + 'status' => ENROL_USER_SUSPENDED, + ] + ), + ); + + // Re-import the original enrolment. + $coursemembership = $this->link_users_with_courses( + $users, + $courses, + // Role types: 01=Learner, 02=Instructor, 03=Content Dev, 04=Member, 05=Manager, 06=Mentor, 07=Admin, 08=TA. + // Role statuses: 0=Inactive, 1=Active. + // Role recstatus: 1=Add, 2=Update, 3=Delete. + // Format of matrix elements: ::. + [ + ['01:1:1'], // Course 1. + ] + ); + + $this->set_xml_file($users, $courses, $coursemembership); + $this->imsplugin->cron(); + + // The user's original enrolment should be active again. + $this->assertEquals( + 1, + $DB->count_records( + 'user_enrolments', + [ + 'userid' => $dbuser->id, + 'id' => $dbenrolment->id, + 'status' => ENROL_USER_ACTIVE, + ], + ), + ); + } + /** * Enrolments are disabled but retained) and roles removed */ diff --git a/public/enrol/manual/ajax.php b/public/enrol/manual/ajax.php index da2ac8133ed06..f46f0e0fdd9fe 100644 --- a/public/enrol/manual/ajax.php +++ b/public/enrol/manual/ajax.php @@ -98,7 +98,7 @@ $startdateselect = optional_param_array('startdateselect', [], PARAM_INT); $recovergrades = optional_param('recovergrades', 0, PARAM_INT); $timeend = optional_param_array('timeend', [], PARAM_INT); - $group = optional_param('group', null, PARAM_INT); + $groupid = optional_param('group', 0, PARAM_INT); if (empty($roleid)) { $roleid = null; @@ -154,7 +154,7 @@ $mform = new enrol_manual_enrol_users_form(null, (object)["context" => $context]); $userenroldata = [ - 'group' => $group, + 'group' => $groupid, 'startdate' => $timestart, 'timeend' => $timeend, ]; @@ -177,8 +177,11 @@ if ($plugin->allow_enrol($instance) && has_capability('enrol/'.$plugin->get_name().':enrol', $context)) { foreach ($users as $user) { $plugin->enrol_user($instance, $user->id, $roleid, $timestart, $timeend, null, $recovergrades); - if ($group && has_capability('moodle/course:managegroups', $context)) { - groups_add_member($group, $user->id); + if ($groupid) { + $group = $DB->get_record('groups', ['id' => $groupid, 'courseid' => $course->id]); + if ($group && has_capability('moodle/course:managegroups', $context)) { + groups_add_member($group, $user->id); + } } } $outcome->count += count($users); diff --git a/public/enrol/manual/manage.php b/public/enrol/manual/manage.php index 809a28b3fd6c1..0461d9fa96bff 100644 --- a/public/enrol/manual/manage.php +++ b/public/enrol/manual/manage.php @@ -61,6 +61,8 @@ if (!$enrol_manual = enrol_get_plugin('manual')) { throw new coding_exception('Can not instantiate enrol_manual'); +} else if (!enrol_is_enabled('manual')) { + throw new moodle_exception('plugindisabled', 'core_enrol', '', get_string('pluginname', 'enrol_manual')); } $url = new moodle_url('/enrol/manual/manage.php', ['enrolid' => $instance->id]); diff --git a/public/enrol/manual/tests/behat/quickenrolment.feature b/public/enrol/manual/tests/behat/quickenrolment.feature index 55a624bc82ae6..8a2558749828b 100644 --- a/public/enrol/manual/tests/behat/quickenrolment.feature +++ b/public/enrol/manual/tests/behat/quickenrolment.feature @@ -116,13 +116,11 @@ Feature: Teacher can search and enrol users one by one into the course And the following "course enrolments" exist: | user | course | role | timestart | | teacher001 | C001 | editingteacher | ##1 month ago## | - And I log in as "teacher001" - And I am on "Course 001" course homepage + And I am on the "Course 001" "enrolled users" page logged in as "teacher001" @javascript Scenario: Teacher can search and enrol one particular student - Given I navigate to course participants - And I press "Enrol users" + Given I press "Enrol users" When I set the field "Select users" to "student001" And I should see "Student 001" And I click on "Enrol users" "button" in the "Enrol users" "dialogue" @@ -131,16 +129,14 @@ Feature: Teacher can search and enrol users one by one into the course @javascript Scenario: Searching for a non-existing user - Given I navigate to course participants - And I press "Enrol users" + Given I press "Enrol users" And I click on "Select users" "field" And I type "qwertyuiop" Then I should see "No suggestions" @javascript Scenario: If there are less than 100 matching users, all are displayed for selection - Given I navigate to course participants - And I press "Enrol users" + Given I press "Enrol users" When I click on "Select users" "field" And I type "example.com" Then "Student 099" "autocomplete_suggestions" should exist @@ -151,7 +147,6 @@ Feature: Teacher can search and enrol users one by one into the course | username | firstname | lastname | email | | student100 | Student | 100 | student100@example.com | | student101 | Student | 101 | student101@example.com | - And I navigate to course participants And I press "Enrol users" When I click on "Select users" "field" And I type "example.com" @@ -161,7 +156,6 @@ Feature: Teacher can search and enrol users one by one into the course Scenario: Changing the Maximum users per page setting affects the enrolment pop-up. Given the following config values are set as admin: | maxusersperpage | 5 | - And I navigate to course participants And I press "Enrol users" When I click on "Select users" "field" And I type "student00" @@ -176,8 +170,7 @@ Feature: Teacher can search and enrol users one by one into the course | student100 | Student | 100 | student100@example.com | 1234567892 | 1234567893 | ABC1 | ABC2 | CITY1 | GB | And the following config values are set as admin: | showuseridentity | idnumber,email,city,country,phone1,phone2,department,institution | - When I am on "Course 001" course homepage - Then I navigate to course participants + And I am on the "Course 001" "enrolled users" page And I press "Enrol users" And I click on "Select users" "field" And I type "student100@example.com" @@ -185,8 +178,7 @@ Feature: Teacher can search and enrol users one by one into the course # Remove identity field in setting User policies And the following config values are set as admin: | showuseridentity | idnumber,email,phone1,phone2,department,institution | - And I am on "Course 001" course homepage - And I navigate to course participants + And I am on the "Course 001" "enrolled users" page And I press "Enrol users" And I click on "Select users" "field" And I type "student100@example.com" @@ -196,7 +188,6 @@ Feature: Teacher can search and enrol users one by one into the course Scenario: Custom user profile fields work for search and display, if user has permission Given the following config values are set as admin: | showuseridentity | email,profile_field_customid | - And I navigate to course participants And I press "Enrol users" When I set the field "Select users" to "Q994" Then I should see "student001@example.com, Q994" @@ -214,13 +205,12 @@ Feature: Teacher can search and enrol users one by one into the course @javascript Scenario: Add participant to a group - Given I navigate to course participants When I press "Enrol users" # No group created yet. Then I should not see "Add to group" in the "Enrol users" "dialogue" And I should not see "Show groups" in the "Enrol users" "dialogue" And I click on "Cancel" "button" in the "Enrol users" "dialogue" - And I navigate to course participants + And I am on the "Course 001" "enrolled users" page And the following "groups" exist: | name | course | idnumber | | Group 1 | C001 | G1 | diff --git a/public/enrol/meta/tests/behat/enrol_meta.feature b/public/enrol/meta/tests/behat/enrol_meta.feature index 41c442418c92b..91f8e004183aa 100644 --- a/public/enrol/meta/tests/behat/enrol_meta.feature +++ b/public/enrol/meta/tests/behat/enrol_meta.feature @@ -105,7 +105,7 @@ Feature: Enrolments are synchronised with meta courses Scenario: Unenrol a user from the course participants page that was enrolled via course meta link. Given I add "Course meta link" enrolment method in "Course 3" with: | Link course | C4C4 | - And I navigate to course participants + And I am on the "Course 3" "enrolled users" page # Suspended users can be unenrolled. When I click on "//a[@data-action='unenrol']" "xpath_element" in the "student2" "table_row" And I click on "Unenrol" "button" in the "Unenrol" "dialogue" diff --git a/public/enrol/self/classes/form/enrol_form.php b/public/enrol/self/classes/form/enrol_form.php index ff605fc608c7b..6e7fe423de3b7 100644 --- a/public/enrol/self/classes/form/enrol_form.php +++ b/public/enrol/self/classes/form/enrol_form.php @@ -193,7 +193,8 @@ public function process_dynamic_submission() { require_once($CFG->dirroot . '/course/lib.php'); $destination = course_get_url($this->get_instance()->courseid); } - return $destination; + + return (string) $destination; } #[\Override] diff --git a/public/enrol/self/tests/behat/self_enrolment.feature b/public/enrol/self/tests/behat/self_enrolment.feature index 75c52f3cfef30..d75291c747711 100644 --- a/public/enrol/self/tests/behat/self_enrolment.feature +++ b/public/enrol/self/tests/behat/self_enrolment.feature @@ -93,8 +93,7 @@ Feature: Users can auto-enrol themself in courses where self enrolment is allowe And I set the following fields to these values: | Enrolment key | moodle_rules | And I click on "Enrol me" "button" in the "Test student enrolment" "dialogue" - And I am on the "Course 1" course page logged in as teacher1 - And I navigate to course participants + And I am on the "Course 1" "enrolled users" page logged in as teacher1 And the following should exist in the "participants" table: | First name | Email address | Roles | Groups | | Student 1 | student1@example.com | Student | Group 1 | @@ -110,10 +109,7 @@ Feature: Users can auto-enrol themself in courses where self enrolment is allowe And I am on "Course 1" course homepage And I press "Enrol me" And I should see "You are enrolled in the course" - And I log out - And I log in as "teacher1" - And I am on "Course 1" course homepage - And I navigate to course participants + And I am on the "Course 1" "enrolled users" page logged in as "teacher1" When I click on "//a[@data-action='editenrolment']" "xpath_element" in the "student1" "table_row" And I should see "Edit Student 1's enrolment" And I set the field "Status" to "Suspended" @@ -130,10 +126,7 @@ Feature: Users can auto-enrol themself in courses where self enrolment is allowe And I am on "Course 1" course homepage And I press "Enrol me" And I should see "You are enrolled in the course" - And I log out - And I log in as "teacher1" - And I am on "Course 1" course homepage - And I navigate to course participants + And I am on the "Course 1" "enrolled users" page logged in as "teacher1" When I click on "//a[@data-action='unenrol']" "xpath_element" in the "student1" "table_row" And I click on "Unenrol" "button" in the "Unenrol" "dialogue" Then I should not see "Student 1" in the "participants" "table" diff --git a/public/enrol/tests/behat/add_to_group.feature b/public/enrol/tests/behat/add_to_group.feature index 8ece9defe0f5a..2ccb4cd247468 100644 --- a/public/enrol/tests/behat/add_to_group.feature +++ b/public/enrol/tests/behat/add_to_group.feature @@ -22,7 +22,7 @@ Feature: Users can be added to multiple groups at once | teacher1 | C1 | editingteacher | | student1 | C1 | editingteacher | - @javascript @skip_chrome_zerosize + @javascript Scenario: Adding a user to multiple groups Given I log in as "teacher1" And I am on "Course 1" course homepage diff --git a/public/files/classes/redactor/services/exifremover_service.php b/public/files/classes/redactor/services/exifremover_service.php index c9c71a2efaa1e..9c8d87d00d5a6 100644 --- a/public/files/classes/redactor/services/exifremover_service.php +++ b/public/files/classes/redactor/services/exifremover_service.php @@ -102,6 +102,11 @@ public function redact_file_by_path( string $mimetype, string $filepath, ): ?string { + // Return if there is no EXIF data. + if (@exif_read_data($filepath, 'ANY_TAG') === false) { + return null; + } + if (!$this->is_mimetype_supported($mimetype)) { return null; } @@ -120,6 +125,13 @@ public function redact_file_by_content( string $mimetype, string $filecontent, ): ?string { + // Return if there is no EXIF data. + $filepath = make_request_directory() . '/input'; + file_put_contents($filepath, $filecontent); + if (@exif_read_data($filepath, 'ANY_TAG') === false) { + return null; + } + if (!$this->is_mimetype_supported($mimetype)) { return null; } diff --git a/public/files/renderer.php b/public/files/renderer.php index c37b22f6245b3..bc78ae50f3dd4 100644 --- a/public/files/renderer.php +++ b/public/files/renderer.php @@ -114,6 +114,7 @@ public function render_form_filemanager($fm) { array('unknownoriginal', 'repository'), array('confirmdeletefolder', 'repository'), array('confirmdeletefilewithhref', 'repository'), array('confirmrenamefolder', 'repository'), array('confirmrenamefile', 'repository'), array('newfolder', 'repository'), array('edit', 'moodle'), + ['edita', 'moodle'], ['originalextensionremove', 'repository'], array('aliaseschange', 'repository'), ['nofilesselected', 'repository'], ['confirmdeleteselectedfile', 'repository'], ['selectall', 'moodle'], ['deselectall', 'moodle'], diff --git a/public/files/tests/fixtures/redactor/nometadata.jpg b/public/files/tests/fixtures/redactor/nometadata.jpg new file mode 100644 index 0000000000000..15e05d1c32dba Binary files /dev/null and b/public/files/tests/fixtures/redactor/nometadata.jpg differ diff --git a/public/files/tests/redactor/services/exifremover_service_test.php b/public/files/tests/redactor/services/exifremover_service_test.php index 793cb4e7175e5..cb2b51be8c12a 100644 --- a/public/files/tests/redactor/services/exifremover_service_test.php +++ b/public/files/tests/redactor/services/exifremover_service_test.php @@ -44,7 +44,7 @@ public function test_exifremover_service_with_gd(): void { $this->resetAfterTest(true); // Ensure that the exif remover tool path is not set. - set_config('exifremovertoolpath', null, 'core_files'); + set_config('file_redactor_exifremovertoolpath', null); $sourcepath = self::get_fixture_path('core_files', 'redactor/dummy.jpg'); @@ -83,7 +83,7 @@ public function test_exifremover_service_flip_orientation_with_gd( $this->resetAfterTest(true); // Ensure that the exif remover tool path is not set. - set_config('exifremovertoolpath', null, 'core_files'); + set_config('file_redactor_exifremovertoolpath', null); // Flip the orientation. $service = new exifremover_service(); @@ -213,7 +213,7 @@ public function test_exifremover_service_is_mimetype_supported_generic(): void { // An unsupported mimetype will just return null. $sourcepath = self::get_fixture_path('core_files', 'redactor/dummy.jpg'); $this->assertNull($service->redact_file_by_path('application/binary', $sourcepath)); - $this->assertNull($service->redact_file_by_content('application/binary', $sourcepath)); + $this->assertNull($service->redact_file_by_content('application/binary', file_get_contents($sourcepath))); } /** @@ -269,7 +269,23 @@ public function test_exiftool_notfound_filename_unknown(): void { $service = new exifremover_service(); $this->expectException(\core\exception\moodle_exception::class); $this->expectExceptionMessage(get_string('redactor:exifremover:failedprocessgd', 'core_files')); - $service->redact_file_by_content('image/jpeg', 'content'); + + $class = new \ReflectionClass(exifremover_service::class); + $method = $class->getMethod('execute_gd_on_content'); + $method->invoke($service, 'content'); + } + + /** + * Tests redaction is skipped when EXIF data is absent. + */ + public function test_redaction_skipped_when_exif_data_absent(): void { + $this->resetAfterTest(true); + + $service = new exifremover_service(); + $sourcepath = self::get_fixture_path('core_files', 'redactor/nometadata.jpg'); + + $this->assertNull($service->redact_file_by_path('image/jpeg', $sourcepath)); + $this->assertNull($service->redact_file_by_content('image/jpeg', file_get_contents($sourcepath))); } /** diff --git a/public/filter/displayh5p/tests/behat/h5p_filter.feature b/public/filter/displayh5p/tests/behat/h5p_filter.feature index 93ece199a7d56..abc1fa81e4a36 100644 --- a/public/filter/displayh5p/tests/behat/h5p_filter.feature +++ b/public/filter/displayh5p/tests/behat/h5p_filter.feature @@ -24,7 +24,7 @@ Feature: Render H5P content using filters | activity | name | intro | introformat | course | content | contentformat | idnumber | | page | PageName1 | PageDesc1 | 1 | C1 |
Go for it
https://moodle.h5p.com/content/1290772960722742119/embed | 1 | 1 | When I am on the PageName1 "page activity" page logged in as teacher1 - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it Then I should see "Lorum ipsum" @javascript @@ -33,7 +33,7 @@ Feature: Render H5P content using filters | activity | name | intro | introformat | course | content | contentformat | idnumber | | page | PageName1 | PageDesc1 | 1 | C1 | https://moodle.h5p.com/content/1290772960722742119/embed | 1 | 1 | When I am on the PageName1 "page activity" page logged in as teacher1 - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it Then I should see "Lorum ipsum" Scenario: Add an external H5P content URL in a link with text. Shouldn't be rendered. @@ -52,17 +52,17 @@ Feature: Render H5P content using filters When I am on the PageName1 "page activity" page logged in as teacher1 And I should see "PageName1" in the "page-header" "region" # Switch to iframe created by filter - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it # Switch to iframe created by embed.php page - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it Then I should see "Lorum ipsum" And I switch to the main frame And I log out And I am on the PageName1 "page activity" page logged in as student1 # Switch to iframe created by filter - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it # Switch to iframe created by embed.php page - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it Then I should not see "you don't have access" And I should see "Lorum ipsum" @@ -74,7 +74,7 @@ Feature: Render H5P content using filters When I am on the PageName1 "page activity" page logged in as teacher1 And I should see "PageName1" in the "page-header" "region" # Switch to iframe created by filter - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it Then I should see "Note that the libraries may exist in the file you uploaded, but you're not allowed to upload new libraries." And I should see "missing-required-library" @@ -87,24 +87,24 @@ Feature: Render H5P content using filters When I am on the PageName1 "page activity" page logged in as teacher1 And I should see "PageName1" in the "page-header" "region" # Switch to iframe created by filter - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it # Libraries don't exist, so an error should be displayed. Then I should see "missing-required-library" And I switch to the main frame And I am on the PageName2 "page activity" page logged in as admin And I should see "PageName2" in the "page-header" "region" # Switch to iframe created by filter - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it # Switch to iframe created by embed.php page - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it # Libraries have been installed. Then I should see "Lorum ipsum" And I switch to the main frame And I am on the PageName1 "page activity" page logged in as teacher1 # Switch to iframe created by filter - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it # Switch to iframe created by embed.php page - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it Then I should not see "missing-required-library" And I should see "Lorum ipsum" @@ -121,7 +121,7 @@ Feature: Render H5P content using filters And I click on "Disable" "link" in the "Accordion" "table_row" And I am on the PageName1 "page activity" page logged in as admin And I should see "PageName1" in the "page-header" "region" - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it # Library is disabled, so an error should be displayed. Then I should see "This file can't be displayed because its content type is disabled." And I should not see "Lorum ipsum" @@ -131,9 +131,9 @@ Feature: Render H5P content using filters # Content should be deployed now that main library is enabled. And I am on the PageName1 "page activity" page # Switch to iframe created by filter. - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it # Switch to iframe created by embed.php page. - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it Then I should see "Lorum ipsum" And I should not see "This file can't be displayed because its content type is disabled." And I switch to the main frame @@ -141,7 +141,7 @@ Feature: Render H5P content using filters And I click on "Disable" "link" in the "Accordion" "table_row" # Library is disabled again, so an error should be displayed. And I am on the PageName1 "page activity" page - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it Then I should see "This file can't be displayed because its content type is disabled." And I should not see "Lorum ipsum" And I switch to the main frame diff --git a/public/filter/displayh5p/tests/behat/inline_editing_content.feature b/public/filter/displayh5p/tests/behat/inline_editing_content.feature index 69225e13f3102..d5aa7de63cba5 100644 --- a/public/filter/displayh5p/tests/behat/inline_editing_content.feature +++ b/public/filter/displayh5p/tests/behat/inline_editing_content.feature @@ -50,8 +50,8 @@ Feature: Inline editing H5P content anywhere And I click on "Select this file" "button" And I click on "Insert H5P" "button" in the "Insert H5P content" "dialogue" And I click on "Save and display" "button" - And I switch to "h5p-iframe" class iframe - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it + And I wait until "h5p-iframe" iframe is interactable and switch to it And I should see "Hello world!" And I switch to the main frame # The Edit button is only displayed when editing mode is on. @@ -94,8 +94,8 @@ Feature: Inline editing H5P content anywhere And I click on "Select this file" "button" And I click on "Insert H5P" "button" in the "Insert H5P content" "dialogue" And I click on "Save and display" "button" - And I switch to "h5p-iframe" class iframe - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it + And I wait until "h5p-iframe" iframe is interactable and switch to it And I should see "Hello world!" And I switch to the main frame # The Edit button is only displayed when editing mode is on. @@ -138,8 +138,8 @@ Feature: Inline editing H5P content anywhere And I click on "Select this file" "button" And I click on "Insert H5P" "button" in the "Insert H5P content" "dialogue" And I click on "Save and display" "button" - And I switch to "h5p-iframe" class iframe - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it + And I wait until "h5p-iframe" iframe is interactable and switch to it And I should see "Hello world!" And I switch to the main frame # The Edit button is only displayed when editing mode is on. @@ -227,8 +227,8 @@ Feature: Inline editing H5P content anywhere And I click on "Select this file" "button" And I click on "Insert H5P" "button" in the "Insert H5P content" "dialogue" And I click on "Save changes" "button" in the "Configure H5PTest block" "dialogue" - And I switch to "h5p-iframe" class iframe - And I switch to "h5p-iframe" class iframe + And I wait until "h5p-iframe" iframe is interactable and switch to it + And I wait until "h5p-iframe" iframe is interactable and switch to it And I should see "Hello world!" And I switch to the main frame # The Edit button is only displayed when editing mode is on. diff --git a/public/filter/manage.php b/public/filter/manage.php index 8fc78ff66eac5..0bb8adde83cae 100644 --- a/public/filter/manage.php +++ b/public/filter/manage.php @@ -163,7 +163,7 @@ $table->colclasses[] = 'leftalign'; } $table->id = 'frontpagefiltersettings'; - $table->attributes['class'] = 'admintable table generaltable table-hover'; + $table->attributes['class'] = 'admintable table generaltable table-striped table-hover mb-3'; $table->data = []; // Iterate through filters adding to display table. diff --git a/public/grade/classes/external/get_enrolled_users_for_selector.php b/public/grade/classes/external/get_enrolled_users_for_selector.php index 5c7bf55b47116..aa034deb5ed99 100644 --- a/public/grade/classes/external/get_enrolled_users_for_selector.php +++ b/public/grade/classes/external/get_enrolled_users_for_selector.php @@ -84,6 +84,11 @@ public static function execute(int $courseid, ?int $groupid = 0): array { require_capability('moodle/course:viewparticipants', $coursecontext); $course = $DB->get_record('course', ['id' => $params['courseid']]); + + if ($params['groupid'] && !groups_group_visible($params['groupid'], $course)) { + throw new \moodle_exception('cannotaccessgroup', 'core_grades'); + } + // Create a graded_users_iterator because it will properly check the groups etc. $defaultgradeshowactiveenrol = !empty($CFG->grade_report_showonlyactiveenrol); $showonlyactiveenrol = get_user_preferences('grade_report_showonlyactiveenrol', $defaultgradeshowactiveenrol); diff --git a/public/grade/classes/external/get_gradable_users.php b/public/grade/classes/external/get_gradable_users.php index a6efdc9bb752b..ead53a204f88a 100644 --- a/public/grade/classes/external/get_gradable_users.php +++ b/public/grade/classes/external/get_gradable_users.php @@ -86,9 +86,14 @@ public static function execute(int $courseid, ?int $groupid = 0, bool $onlyactiv $coursecontext = \context_course::instance($params['courseid']); parent::validate_context($coursecontext); - require_capability('moodle/course:viewparticipants', $coursecontext); + require_capability('moodle/site:viewuseridentity', $coursecontext); $course = $DB->get_record('course', ['id' => $params['courseid']]); + + if ($params['groupid'] && !groups_group_visible($params['groupid'], $course)) { + throw new \moodle_exception('cannotaccessgroup', 'core_grades'); + } + $onlyactive = $onlyactive || !has_capability('moodle/course:viewsuspendedusers', $coursecontext); $users = get_gradable_users($course->id, $params['groupid'], $onlyactive); diff --git a/public/grade/classes/penalty_container.php b/public/grade/classes/penalty_container.php index c545cd2c37e91..407a187599c78 100644 --- a/public/grade/classes/penalty_container.php +++ b/public/grade/classes/penalty_container.php @@ -104,10 +104,13 @@ public function get_grade_grade(): grade_grade { /** * Get the grade before penalties are applied. * - * @return float The grade before penalties are applied + * Returns the raw grade (the original source grade from the activity, before + * grade-item factors such as multiplier or offset are applied). + * + * @return float The raw grade before any penalty or grade-item adjustment */ public function get_grade_before_penalties(): float { - return $this->gradegrade->finalgrade; + return $this->gradegrade->rawgrade; } /** diff --git a/public/grade/classes/penalty_manager.php b/public/grade/classes/penalty_manager.php index c4c7fb904b80a..941d591cab2cd 100644 --- a/public/grade/classes/penalty_manager.php +++ b/public/grade/classes/penalty_manager.php @@ -231,14 +231,60 @@ private static function apply_penalty( // Update the grade if not in preview mode. if (!$previewonly) { - // Update the raw grade and store the deducted mark. - $gradeitem->update_raw_grade($userid, $container->get_grade_after_penalties(), 'gradepenalty'); - $gradeitem->update_deducted_mark($userid, $container->get_penalty()); + $oldfinalgrade = $grade->finalgrade; + + // Apply penalty to raw grade first, then apply grade-item factors to compute final grade. + // rawgrade is intentionally not updated - it must always hold the original unpenalised source grade. + $grade->deductedmark = $container->get_penalty(); + + $penalisedraw = $container->get_grade_after_penalties(); + $grade->finalgrade = self::apply_grade_item_factors($penalisedraw, $gradeitem, $grade); + + $grade->timemodified = time(); + $grade->update('gradepenalty'); + + if (grade_floats_different($grade->finalgrade, $oldfinalgrade)) { + \core\event\user_graded::create_from_grade($grade)->trigger(); + // Regrade parent category/course totals so the penalised finalgrade + // is preserved and updated through nested categories if present. + if (!$gradeitem->needsupdate && !grade_item::fetch_course_item($gradeitem->courseid)->needsupdate) { + $updateditem = grade_item::fetch([ + 'itemtype' => 'category', + 'iteminstance' => $gradeitem->categoryid, + 'courseid' => $gradeitem->courseid, + ]) ?: grade_item::fetch_course_item($gradeitem->courseid); + if (grade_regrade_final_grades($gradeitem->courseid, $userid, $updateditem) !== true) { + // Fast regrade failed; mark the parent (category/course) item for regrading. + $updateditem->force_regrading(); + } + } + } } return $container; } + /** + * Apply grade-item multfactor/plusfactor to a raw penalised grade, returning a value + * on the same scale as the gradebook finalgrade. + * + * @param float $rawgrade The penalised raw grade (before grade-item factors are applied). + * @param grade_item $gradeitem The grade item whose multfactor/plusfactor to apply. + * @param grade_grade|null $usergrade The user's grade_grade record, which carries the + * rawgrademin/rawgrademax stored at grading time. Falls back to gradeitem + * grademin/grademax when null or when the record has not yet been persisted. + * @return float|null The adjusted grade, or null when rawgrade is null. + */ + public static function apply_grade_item_factors( + float $rawgrade, + grade_item $gradeitem, + ?grade_grade $usergrade = null + ): ?float { + $rawmin = !empty($usergrade->id) ? $usergrade->rawgrademin : $gradeitem->grademin; + $rawmax = !empty($usergrade->id) ? $usergrade->rawgrademax : $gradeitem->grademax; + return $gradeitem->adjust_raw_grade($rawgrade, $rawmin, $rawmax); + } + /** * Returns the penalty indicator HTML code if a penalty is applied to the grade. * Otherwise, returns an empty string. diff --git a/public/grade/edit/tree/calculation.php b/public/grade/edit/tree/calculation.php index f1337c72c6bde..a7211cb147c90 100644 --- a/public/grade/edit/tree/calculation.php +++ b/public/grade/edit/tree/calculation.php @@ -83,6 +83,7 @@ redirect($returnurl); } elseif (!empty($section) AND $section='idnumbers' AND !empty($idnumbers)) { // Handle idnumbers separately (non-mform) + require_sesskey(); //first validate and store the new idnumbers foreach ($idnumbers as $giid => $value) { if ($gi = grade_item::fetch(array('id' => $giid))) { @@ -179,7 +180,7 @@ function get_grade_tree(&$gtree, $element, $current_itemid=null, $errors=null) { if ($type != 'category') { if (is_null($current_itemid) OR $grade_item->id != $current_itemid) { if ($idnumber) { - $name .= ": [[$idnumber]]"; + $name .= ": [[" . s($idnumber) . "]]"; } else { $closingdiv = ''; if (!empty($errors[$grade_item->id])) { diff --git a/public/grade/edit/tree/lib.php b/public/grade/edit/tree/lib.php index 9e825480caa03..3b8f9280c78ec 100644 --- a/public/grade/edit/tree/lib.php +++ b/public/grade/edit/tree/lib.php @@ -815,12 +815,20 @@ public function get_header_cell() { return $headercell; } + /** + * Return category cell content + * + * @param grade_category $category + * @param string $levelclass + * @param array $params Parameters required to build the category cell content (must contain 'name', 'level' and 'eid' keys) + */ public function get_category_cell($category, $levelclass, $params) { global $OUTPUT; - if (empty($params['name']) || empty($params['level'])) { + if (!array_key_exists('name', $params) || !array_key_exists('level', $params)) { throw new Exception('Array key (name or level) missing from 3rd param of grade_edit_tree_column_name::get_category_cell($category, $levelclass, $params)'); } + $visibilitytoggle = $OUTPUT->render_from_template('core_grades/grade_category_visibility_toggle', [ 'category' => $params['eid'] ]); diff --git a/public/grade/grading/form/guide/tests/behat/incomplete_marking_guide.feature b/public/grade/grading/form/guide/tests/behat/incomplete_marking_guide.feature new file mode 100644 index 0000000000000..593398f7eb0a7 --- /dev/null +++ b/public/grade/grading/form/guide/tests/behat/incomplete_marking_guide.feature @@ -0,0 +1,57 @@ +@grade @gradingform @gradingform_guide +Feature: Verify incomplete marking guides + In order to ensure teachers are not blocked from grading + As a teacher + I need to be presented with the simple grading interface if a marking guide is incomplete + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | topics | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "activities" exist: + | activity | course | idnumber | name | assignfeedback_comments_enabled | advancedgradingmethod_submissions | + | assign | C1 | assign1 | Assignment 1 | 1 | guide | + And the following "mod_assign > submissions" exist: + | assign | user | onlinetext | + | assign1 | student1 | Student submission text here | + + @javascript + Scenario: Grading an assignment with an incomplete marking guide + Given I am on the "Assignment 1" "assign activity" page logged in as "teacher1" + And I go to "Student 1" "Assignment 1" activity advanced grading page + And I should see "Grade out of 100" + And I should not see "Marking guide" + When I am on the "Course 1" course page + And I go to "Assignment 1" advanced grading definition page + And I set the following fields to these values: + | Name | My Marking Guide | + | Description | Guide description | + And I define the following marking guide: + | Criterion name | Description for students | Description for markers | Maximum score | + | Criterion 1 | Student desc | Marker desc | 50 | + And I press "Save as draft" + And I am on the "Assignment 1" "assign activity" page + And I go to "Student 1" "Assignment 1" activity advanced grading page + And I should see "Grade out of 100" + And I set the following fields to these values: + | Grade out of 100 | 25 | + | Feedback comments | Good start, but incomplete | + And I press "Save changes" + And I am on the "Course 1" course page + And I go to "Assignment 1" advanced grading definition page + And I press "Save marking guide and make it ready" + And I am on the "Assignment 1" "assign activity" page + And I go to "Student 1" "Assignment 1" activity advanced grading page + Then I should see "Criterion 1" + And I should not see "Grade out of 100" + And the following fields match these values: + | Feedback comments | Good start, but incomplete | + | Criterion 1 | | diff --git a/public/grade/import/xml/import.php b/public/grade/import/xml/import.php index 6a4f136700ed8..21162fa75feba 100644 --- a/public/grade/import/xml/import.php +++ b/public/grade/import/xml/import.php @@ -36,46 +36,17 @@ require_login($course); $context = context_course::instance($id); -require_capability('moodle/grade:import', $context); -require_capability('gradeimport/xml:view', $context); - - -// Large files are likely to take their time and memory. Let PHP know -// that we'll take longer, and that the process should be recycled soon -// to free up memory. -core_php_time_limit::raise(); -raise_memory_limit(MEMORY_EXTRA); - -$text = download_file_content($gradesurl); -if ($text === false) { - throw new \moodle_exception('cannotreadfile', 'error', - $CFG->wwwroot . '/grade/import/xml/index.php?id=' . $id, $gradesurl); +// Only reachable as the body of fetch.php (key login); reject direct access. MDL-84545. +if (!defined('USER_KEY_LOGIN')) { + throw new \moodle_exception('invalidaccess', 'error'); } -$error = ''; -$importcode = import_xml_grades($text, $course, $error); - -if ($importcode !== false) { - /// commit the code if we are up this far - - if (defined('USER_KEY_LOGIN')) { - if (grade_import_commit($id, $importcode, $feedback, false)) { - echo 'ok'; - die; - } else { - throw new \moodle_exception('cannotimportgrade'); // TODO: localize. - } - - } else { - print_grade_page_head($course->id, 'import', 'xml', get_string('importxml', 'grades')); - - grade_import_commit($id, $importcode, $feedback, true); - - echo $OUTPUT->footer(); - die; - } +require_capability('moodle/grade:import', $context); +require_capability('gradeimport/xml:view', $context); +if (gradeimport_xml_fetch_and_commit($course, $gradesurl, $feedback, false)) { + echo 'ok'; + die; } else { - throw new \moodle_exception('errorduringimport', 'gradeimport_xml', - $CFG->wwwroot . '/grade/import/xml/index.php?id=' . $id, $error); + throw new \moodle_exception('cannotimportgrade'); } diff --git a/public/grade/import/xml/index.php b/public/grade/import/xml/index.php index c6ee91a1eefae..b8c59fed5d13e 100644 --- a/public/grade/import/xml/index.php +++ b/public/grade/import/xml/index.php @@ -70,7 +70,17 @@ } } else if (empty($data->key)) { - redirect('import.php?id='.$id.'&feedback='.(int)($data->feedback).'&url='.urlencode($data->url)); + print_grade_page_head( + courseid: $COURSE->id, + active_type: 'import', + active_plugin: 'xml', + heading: get_string('importxml', 'grades'), + headerhelpidentifier: 'importxml', + headerhelpcomponent: 'gradeimport_xml' + ); + gradeimport_xml_fetch_and_commit($course, $data->url, $data->feedback, true); + echo $OUTPUT->footer(); + die; } else { if ($data->key == 1) { diff --git a/public/grade/import/xml/lib.php b/public/grade/import/xml/lib.php index aa2265c62b1a1..6981823925316 100644 --- a/public/grade/import/xml/lib.php +++ b/public/grade/import/xml/lib.php @@ -120,3 +120,44 @@ function import_xml_grades($text, $course, &$error) { } } +/** + * Download an XML grades file from a URL and import it into a course. + * + * @param stdClass $course target course + * @param string $url source URL of the XML grades file + * @param bool $feedback import feedback alongside grades + * @param bool $verbose whether grade_import_commit prints progress + * @return bool true if the grades were committed + */ +function gradeimport_xml_fetch_and_commit(stdClass $course, string $url, bool $feedback, bool $verbose): bool { + global $CFG; + require_once($CFG->libdir.'/filelib.php'); + + // Large files are likely to take their time and memory. Let PHP know that we'll + // take longer, and that the process should be recycled soon to free up memory. + core_php_time_limit::raise(); + raise_memory_limit(MEMORY_EXTRA); + + $text = download_file_content($url); + if ($text === false) { + throw new \moodle_exception( + 'cannotreadfile', + 'error', + $CFG->wwwroot . '/grade/import/xml/index.php?id=' . $course->id, + $url + ); + } + + $error = ''; + $importcode = import_xml_grades($text, $course, $error); + if ($importcode === false) { + throw new \moodle_exception( + 'errorduringimport', + 'gradeimport_xml', + $CFG->wwwroot . '/grade/import/xml/index.php?id=' . $course->id, + $error + ); + } + + return grade_import_commit($course->id, $importcode, $feedback, $verbose); +} diff --git a/public/grade/import/xml/tests/lib_test.php b/public/grade/import/xml/tests/lib_test.php new file mode 100644 index 0000000000000..ea9cda666bdb2 --- /dev/null +++ b/public/grade/import/xml/tests/lib_test.php @@ -0,0 +1,72 @@ +. + +namespace gradeimport_xml; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/grade/import/xml/lib.php'); + +/** + * Unit tests for XML grade import helper functions. + * + * @package gradeimport_xml + * @category test + * @copyright 2026 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class lib_test extends \advanced_testcase { + + /** + * Ensure inaccessible URL download throws a Moodle exception. + * + * @covers ::gradeimport_xml_fetch_and_commit + */ + public function test_grade_import_xml_throws_on_inaccessible_url(): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $missingurl = 'file://example.com/fake.xml'; + + try { + gradeimport_xml_fetch_and_commit($course, $missingurl, false, false); + $this->fail(); + } catch (\moodle_exception $e) { + $this->assertSame('cannotreadfile', $e->errorcode); + } + } + + /** + * Ensure invalid XML content throws import failure exception. + * + * @covers ::gradeimport_xml_fetch_and_commit + */ + public function test_grade_import_xml_throws_on_unexpected_xml_structure(): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + // Valid XML response over HTTP, but not in the expected grade import format. + $xmlurl = $this->getExternalTestFileUrl('/rsstest.xml'); + + try { + gradeimport_xml_fetch_and_commit($course, $xmlurl, false, false); + $this->fail(); + } catch (\moodle_exception $e) { + $this->assertSame('errorduringimport', $e->errorcode); + } + } +} diff --git a/public/grade/lib.php b/public/grade/lib.php index 1cf15f4773216..4608817c43b87 100644 --- a/public/grade/lib.php +++ b/public/grade/lib.php @@ -135,7 +135,7 @@ public function __construct($course, $grade_items=null, $groupid=0, * @return boolean success */ public function init() { - global $CFG, $DB; + global $CFG, $DB, $USER; $this->close(); @@ -150,18 +150,29 @@ public function init() { list($relatedctxsql, $relatedctxparams) = $DB->get_in_or_equal($coursecontext->get_parent_context_ids(true), SQL_PARAMS_NAMED, 'relatedctx'); list($gradebookroles_sql, $params) = $DB->get_in_or_equal(explode(',', $CFG->gradebookroles), SQL_PARAMS_NAMED, 'grbr'); - list($enrolledsql, $enrolledparams) = get_enrolled_sql($coursecontext, '', 0, $this->onlyactive); + list($enrolledsql, $enrolledparams) = get_enrolled_sql($coursecontext, '', $this->groupid, $this->onlyactive); $params = array_merge($params, $enrolledparams, $relatedctxparams); - if ($this->groupid) { - $groupsql = "INNER JOIN {groups_members} gm ON gm.userid = u.id"; - $groupwheresql = "AND gm.groupid = :groupid"; - // $params contents: gradebookroles - $params['groupid'] = $this->groupid; + if ( + empty($this->groupid) && + groups_get_course_groupmode($this->course) == SEPARATEGROUPS && + !has_capability('moodle/site:accessallgroups', $coursecontext) + ) { + $groups = groups_get_all_groups($this->course->id, $USER->id, 0, 'g.id'); + if (count($groups) > 0) { + [$groupmembersql, $groupmemberparams] = groups_get_members_ids_sql( + array_column($groups, 'id'), + $coursecontext, + ); + + $groupsql = "JOIN ({$groupmembersql}) jg ON jg.id = u.id"; + $params = array_merge($params, $groupmemberparams); + } else { + $groupsql = "JOIN (SELECT 0 AS id) jg ON jg.id = u.id"; + } } else { $groupsql = ""; - $groupwheresql = ""; } if (empty($this->sortfield1)) { @@ -213,7 +224,6 @@ public function init() { AND ra.contextid $relatedctxsql ) rainner ON rainner.userid = u.id WHERE u.deleted = 0 - $groupwheresql ORDER BY $order"; $this->users_rs = $DB->get_recordset_sql($users_sql, $params); @@ -242,7 +252,6 @@ public function init() { ) rainner ON rainner.userid = u.id WHERE u.deleted = 0 AND g.itemid $itemidsql - $groupwheresql ORDER BY $order, g.itemid ASC"; $this->grades_rs = $DB->get_recordset_sql($grades_sql, $params); } else { diff --git a/public/grade/penalty/view.php b/public/grade/penalty/view.php index 50de312620efb..2dfa7e0920abf 100644 --- a/public/grade/penalty/view.php +++ b/public/grade/penalty/view.php @@ -57,6 +57,12 @@ $PAGE->set_heading($course->fullname); $PAGE->activityheader->disable(); +// Ensure grade penalty container node exists (user can access at least one penalty type). +$penaltynode = $PAGE->settingsnav->find('gradepenalty', \navigation_node::TYPE_CONTAINER); +if ($penaltynode === false) { + throw new \core\exception\moodle_exception('gradepenaltynodeerror', 'core_grades'); +} + // Check if the recalculate button is clicked. if ($recalculate) { // Show message for user confirmation. @@ -91,12 +97,10 @@ // Penalty plugins. $haspenaltypluginnode = false; -if ($penaltynode = $PAGE->settingsnav->find('gradepenalty', \navigation_node::TYPE_CONTAINER)) { - foreach ($penaltynode->children as $child) { - if ($child->display) { - $haspenaltypluginnode = true; - break; - } +foreach ($penaltynode->children as $child) { + if ($child->display) { + $haspenaltypluginnode = true; + break; } } diff --git a/public/grade/report/grader/tests/behat/switch_views.feature b/public/grade/report/grader/tests/behat/switch_views.feature index d493e43a746d5..e463ad0e5c07a 100644 --- a/public/grade/report/grader/tests/behat/switch_views.feature +++ b/public/grade/report/grader/tests/behat/switch_views.feature @@ -69,7 +69,7 @@ Feature: We can change what we are viewing on the grader report | -1- | -2- | -3- | -4- | -5- | | Student 1 | student1@example.com | 80 | 90 | 30 | - @javascript @skip_chrome_zerosize + @javascript Scenario: Minimise the grader report containing hidden activities without the 'moodle/grade:viewhidden' capability Given I am on "Course 1" course homepage with editing mode on And I open "Test assignment name 2" actions menu diff --git a/public/grade/report/singleview/tests/behat/singleview.feature b/public/grade/report/singleview/tests/behat/singleview.feature index ad9bea8fcf16b..acbf12e0504ec 100644 --- a/public/grade/report/singleview/tests/behat/singleview.feature +++ b/public/grade/report/singleview/tests/behat/singleview.feature @@ -244,7 +244,7 @@ Feature: We can use Single view Scenario: Teacher does not see his last viewed user report if that user is no longer enrolled in the course. Given I navigate to "View > Single view" in the course gradebook And I click on "Gronya,Beecham" in the "Search users" search combo box - And I navigate to course participants + And I am on the "Course 1" "enrolled users" page And I click on "Unenrol" "icon" in the "Gronya,Beecham" "table_row" And I click on "Unenrol" "button" in the "Unenrol" "dialogue" When I am on the "Course 1" "grades > Single view > View" page diff --git a/public/grade/report/user/classes/report/user.php b/public/grade/report/user/classes/report/user.php index 76f592ac005e9..7b9420628d3cc 100644 --- a/public/grade/report/user/classes/report/user.php +++ b/public/grade/report/user/classes/report/user.php @@ -545,7 +545,7 @@ private function fill_table_recursive(array &$element) { if (!$cm->uservisible) { // If there is 'availableinfo' text then it is only greyed // out and not entirely hidden. - if (!$cm->availableinfo) { + if (!$cm->visible || !$cm->availableinfo) { $hide = true; } } diff --git a/public/grade/report/user/tests/behat/view_usereport.feature b/public/grade/report/user/tests/behat/view_usereport.feature index d4dbad98c7b41..a9f4a2a8b60a9 100644 --- a/public/grade/report/user/tests/behat/view_usereport.feature +++ b/public/grade/report/user/tests/behat/view_usereport.feature @@ -97,7 +97,7 @@ Feature: We can use the user report And I am on the "Course 1" "grades > User report > View" page logged in as "teacher1" And I click on "Student 1" in the "Search users" search combo box And I should see "Student 1" in the "region-main" "region" - And I navigate to course participants + And I am on the "Course 1" "enrolled users" page And I click on "Unenrol" "icon" in the "Student 1" "table_row" And I click on "Unenrol" "button" in the "Unenrol" "dialogue" And I am on "Course 1" course homepage diff --git a/public/grade/tests/behat/grade_calculated_weights.feature b/public/grade/tests/behat/grade_calculated_weights.feature index 60f149d71042c..15958dad83cb8 100644 --- a/public/grade/tests/behat/grade_calculated_weights.feature +++ b/public/grade/tests/behat/grade_calculated_weights.feature @@ -52,7 +52,7 @@ Feature: We can understand the gradebook user report And I click on "Move" "link" in the "Test assignment four" "table_row" And I click on "Move to here" "link" in the "//tbody//tr[position()=last()-3]" "xpath_element" - @javascript @skip_chrome_zerosize + @javascript Scenario: Mean of grades aggregation And I set the following settings for grade item "Course 1" of type "course" on "setup" page: | Aggregation | Mean of grades | @@ -69,7 +69,7 @@ Feature: We can understand the gradebook user report | Test assignment five | 33.33 % | 70.00 | 5.83 % | | Test assignment six | 33.33 % | 30.00 | 2.50 % | - @javascript @skip_chrome_zerosize + @javascript Scenario: Weighted mean of grades aggregation And I set the following settings for grade item "Course 1" of type "course" on "setup" page: | Aggregation | Weighted mean of grades | @@ -94,7 +94,7 @@ Feature: We can understand the gradebook user report | Test assignment five | 33.33 % | 70.00 | 4.67 % | | Test assignment six | 33.33 % | 30.00 | 2.00 % | - @javascript @skip_chrome_zerosize + @javascript Scenario: Simple weighted mean of grades aggregation And I set the following settings for grade item "Course 1" of type "course" on "setup" page: | Aggregation | Simple weighted mean of grades | @@ -115,7 +115,7 @@ Feature: We can understand the gradebook user report | Test assignment five | 33.33 % | 70.00 | 7.78 % | | Test assignment six | 33.33 % | 30.00 | 3.33 % | - @javascript @skip_chrome_zerosize + @javascript Scenario: Mean of grades (with extra credits) aggregation And I set the following settings for grade item "Course 1" of type "course" on "setup" page: | Aggregation | Mean of grades (with extra credits) | @@ -134,7 +134,7 @@ Feature: We can understand the gradebook user report | Test assignment five | 33.33 % | 70.00 | 7.78 % | | Test assignment six | 33.33 % | 30.00 | 3.33 % | - @javascript @skip_chrome_zerosize + @javascript Scenario: Median of grades aggregation And I set the following settings for grade item "Course 1" of type "course" on "setup" page: | Aggregation | Median of grades | @@ -151,7 +151,7 @@ Feature: We can understand the gradebook user report | Test assignment five | 33.33 % | 70.00 | 11.67 % | | Test assignment six | 33.33 % | 30.00 | 5.00 % | - @javascript @skip_chrome_zerosize + @javascript Scenario: Lowest grade aggregation And I set the following settings for grade item "Course 1" of type "course" on "setup" page: | Aggregation | Lowest grade | @@ -168,7 +168,7 @@ Feature: We can understand the gradebook user report | Test assignment five | 33.33 % | 70.00 | 0.00 % | | Test assignment six | 33.33 % | 30.00 | 0.00 % | - @javascript @skip_chrome_zerosize + @javascript Scenario: Highest grade aggregation And I set the following settings for grade item "Course 1" of type "course" on "setup" page: | Aggregation | Highest grade | @@ -185,7 +185,7 @@ Feature: We can understand the gradebook user report | Test assignment five | 33.33 % | 70.00 | 0.00 % | | Test assignment six | 33.33 % | 30.00 | 0.00 % | - @javascript @skip_chrome_zerosize + @javascript Scenario: Mode of grades aggregation And I set the following settings for grade item "Course 1" of type "course" on "setup" page: | Aggregation | Mode of grades | @@ -202,7 +202,7 @@ Feature: We can understand the gradebook user report | Test assignment five | 33.33 % | 70.00 | 0.00 % | | Test assignment six | 33.33 % | 30.00 | 0.00 % | - @javascript @skip_chrome_zerosize + @javascript Scenario: View user report with mixed aggregation methods And I change window size to "large" And I set the following settings for grade item "Course 1" of type "course" on "setup" page: @@ -226,7 +226,7 @@ Feature: We can understand the gradebook user report | Sub category total | 33.33 % | 36.67 | - | | Course total | - | 156.67 | - | - @javascript @skip_chrome_zerosize + @javascript Scenario: View user report with natural aggregation And I set the following settings for grade item "Test assignment three" of type "gradeitem" on "setup" page: | Extra credit | 1 | diff --git a/public/grade/tests/behat/grade_category_validation.feature b/public/grade/tests/behat/grade_category_validation.feature index fbbcd133b1ef8..77664d31d2fb1 100644 --- a/public/grade/tests/behat/grade_category_validation.feature +++ b/public/grade/tests/behat/grade_category_validation.feature @@ -29,6 +29,13 @@ Feature: Editing a grade item | Item 2 | C1 | Cat 1 | And I am on the "Course 1" "grades > gradebook setup" page logged in as "admin" + Scenario: Rename gradebook category + When I click on grade item menu "Cat 1" of type "category" on "setup" page + And I choose "Edit category" in the open action menu + And I set the field "Category name" in the "Edit category" "dialogue" to "0" + And I click on "Save" "button" in the "Edit category" "dialogue" + Then I should see "0" + Scenario: Being able to change the grade type, scale and maximum grade for a grade category when there are no overridden grades Given I click on grade item menu "Cat 1" of type "category" on "setup" page And I choose "Edit category" in the open action menu diff --git a/public/grade/tests/behat/grade_recovery_settings.feature b/public/grade/tests/behat/grade_recovery_settings.feature index a00c156093bb8..8a03d857e03ca 100644 --- a/public/grade/tests/behat/grade_recovery_settings.feature +++ b/public/grade/tests/behat/grade_recovery_settings.feature @@ -30,7 +30,7 @@ Feature: Admin can set Recover grades default setting # Confirm that assigned grade was saved And I am on the "Course 1" "grades > Grader report > View" page And I should see "60.00" in the "Student One" "table_row" - And I navigate to course participants + And I am on the "Course 1" "enrolled users" page And I click on "Unenrol" "icon" in the "Student One" "table_row" And I click on "Unenrol" "button" in the "Unenrol" "dialogue" And I press "Enrol users" diff --git a/public/grade/tests/external/get_gradable_users_test.php b/public/grade/tests/external/get_gradable_users_test.php index 772a4e84e9153..227e5fa5b4054 100644 --- a/public/grade/tests/external/get_gradable_users_test.php +++ b/public/grade/tests/external/get_gradable_users_test.php @@ -187,4 +187,53 @@ public static function execute_data(): array { ], ]; } + + /** + * Test access to get_gradable_users based on role capability. + */ + public function test_execute_access_by_user_type(): void { + global $DB; + + $this->resetAfterTest(); + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + + $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST); + $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher'], '*', MUST_EXIST); + $managerrole = $DB->get_record('role', ['shortname' => 'manager'], '*', MUST_EXIST); + + $student = $generator->create_user(['username' => 'student']); + $teacher = $generator->create_user(['username' => 'teacherwithcap']); + $manager = $generator->create_user(['username' => 'managerwithcap']); + + $generator->enrol_user($student->id, $course->id, $studentrole->id); + $generator->enrol_user($teacher->id, $course->id, $teacherrole->id); + $generator->enrol_user($manager->id, $course->id, $managerrole->id); + + $generator->create_module('assign', ['course' => $course->id]); + + // Teacher has the capability to call get_gradable_users. + $this->setUser($teacher); + $result = get_gradable_users::execute($course->id); + $result = external_api::clean_returnvalue(get_gradable_users::execute_returns(), $result); + $this->assertArrayHasKey('users', $result); + $this->assertArrayHasKey('warnings', $result); + + // Manager has the capability to call get_gradable_users. + $this->setUser($manager); + $result = get_gradable_users::execute($course->id); + $result = external_api::clean_returnvalue(get_gradable_users::execute_returns(), $result); + $this->assertArrayHasKey('users', $result); + $this->assertArrayHasKey('warnings', $result); + + // Student does not have the capability to call get_gradable_users. + try { + $this->setUser($student); + $result = get_gradable_users::execute($course->id); + $result = external_api::clean_returnvalue(get_gradable_users::execute_returns(), $result); + $this->fail('Users without moodle/site:viewuseridentity should not be able to access this service.'); + } catch (\required_capability_exception $e) { + $this->assertEquals('nopermissions', $e->errorcode); + } + } } diff --git a/public/grade/tests/penalty_manager_test.php b/public/grade/tests/penalty_manager_test.php index 6e11988a06933..05e30e795d733 100644 --- a/public/grade/tests/penalty_manager_test.php +++ b/public/grade/tests/penalty_manager_test.php @@ -17,6 +17,8 @@ namespace core_grades; use advanced_testcase; +use context_system; +use core\plugininfo\gradepenalty; use grade_item; /** @@ -103,4 +105,221 @@ public function test_apply_grade_penalty_to_user(): void { // No penalty by default. $this->assertEquals(90, $container->get_grade_after_penalties()); } + + /** + * Test penalty is deducted from raw grade before grade-item factors are applied. + * + * @covers \core_grades\penalty_manager::apply_grade_penalty_to_user + * @covers \core_grades\penalty_manager::apply_grade_item_factors + */ + public function test_penalty_applied_before_grade_factors(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + // Enable assign penalties and the due date penalty plugin. + penalty_manager::enable_module('assign'); + gradepenalty::enable_plugin('duedate', true); + + // Add a single penalty rule at system context: 10% penalty if overdue. + $DB->insert_record('gradepenalty_duedate_rule', (object)[ + 'contextid' => context_system::instance()->id, + 'overdueby' => 1, + 'penalty' => 10, + 'sortorder' => 1, + ]); + + $user = $this->getDataGenerator()->create_user(); + $course = $this->getDataGenerator()->create_course(); + $assign = $this->getDataGenerator()->create_module('assign', ['course' => $course->id, 'grade' => 200]); + + // Set grade factors to exercise adjustment logic. + grade_update( + source: 'mod/assign', + courseid: $course->id, + itemtype: 'mod', + itemmodule: 'assign', + iteminstance: $assign->id, + itemnumber: 0, + grades: ['userid' => $user->id, 'rawgrade' => 50], + itemdetails: ['multfactor' => 2.0, 'plusfactor' => 5.0], + ); + + $gradeitem = grade_item::fetch([ + 'courseid' => $course->id, + 'itemtype' => 'mod', + 'itemmodule' => 'assign', + 'iteminstance' => $assign->id, + 'itemnumber' => 0, + ]); + + // Before penalty: (50 * 2) + 5 = 105. + $before = $gradeitem->get_final($user->id); + $this->assertEquals(105.0, (float)$before->finalgrade); + + // One day late applies 10% of grademax (200) = 20 raw-grade points deduction. + penalty_manager::apply_grade_penalty_to_user($user->id, $gradeitem, DAYSECS + 1, 0); + // Apply the same penalty twice to confirm it doesn't accumulate. + penalty_manager::apply_grade_penalty_to_user($user->id, $gradeitem, DAYSECS + 1, 0); + + $after = $gradeitem->get_final($user->id); + // Raw: 50 - 20 = 30, then (30 * 2) + 5 = 65. + $this->assertEquals(65.0, (float)$after->finalgrade); + $this->assertEquals(20.0, (float)$after->deductedmark); + // Rawgrade should remain 50 (penalty stored separately in deductedmark). + $this->assertEquals(50.0, (float)$after->rawgrade); + } + + /** + * Test that due date change triggers recalculation of penalty. + * + * @covers \core_grades\penalty_manager::apply_grade_penalty_to_user + */ + public function test_apply_grade_penalty_with_due_date_extension(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + penalty_manager::enable_module('assign'); + gradepenalty::enable_plugin('duedate', true); + + $DB->insert_record('gradepenalty_duedate_rule', (object)[ + 'contextid' => context_system::instance()->id, + 'overdueby' => 1, + 'penalty' => 10, + 'sortorder' => 1, + ]); + + $user = $this->getDataGenerator()->create_user(); + $course = $this->getDataGenerator()->create_course(); + $assign = $this->getDataGenerator()->create_module('assign', ['course' => $course->id, 'grade' => 200]); + + grade_update( + source: 'mod/assign', + courseid: $course->id, + itemtype: 'mod', + itemmodule: 'assign', + iteminstance: $assign->id, + itemnumber: 0, + grades: ['userid' => $user->id, 'rawgrade' => 50], + itemdetails: ['multfactor' => 2.0, 'plusfactor' => 5.0], + ); + + $gradeitem = grade_item::fetch([ + 'courseid' => $course->id, + 'itemtype' => 'mod', + 'itemmodule' => 'assign', + 'iteminstance' => $assign->id, + 'itemnumber' => 0, + ]); + + // Initial state: on time, no penalty. + $before = $gradeitem->get_final($user->id); + $this->assertEquals(105.0, (float)$before->finalgrade); + $this->assertEquals(0.0, (float)$before->deductedmark); + + // Apply penalty when one day late. + $submissiondate = DAYSECS + 1; // Late by 1 day. + $duedate = 0; // Due at time 0. + penalty_manager::apply_grade_penalty_to_user($user->id, $gradeitem, $submissiondate, $duedate); + + $afterpenalty = $gradeitem->get_final($user->id); + // Raw: 50 - 20 = 30, then (30 * 2) + 5 = 65. + $this->assertEquals(65.0, (float)$afterpenalty->finalgrade); + $this->assertEquals(20.0, (float)$afterpenalty->deductedmark); + + // Now extend the due date so submission is on time. + $newdue = DAYSECS + 2; // New due date after submission. + penalty_manager::apply_grade_penalty_to_user($user->id, $gradeitem, $submissiondate, $newdue); + + $afterextension = $gradeitem->get_final($user->id); + // No penalty now: raw stays 50, so (50 * 2) + 5 = 105. + $this->assertEquals(105.0, (float)$afterextension->finalgrade); + $this->assertEquals(0.0, (float)$afterextension->deductedmark); + } + + /** + * Test that grade_item::regrade_final_grades() preserves a penalised grade. + * + * A full regrade must not overwrite a penalised finalgrade with the plain + * adjust_raw_grade(rawgrade) result. This regression test covers the fix in + * grade_item::regrade_final_grades() that checks deductedmark before recomputing. + * + * @covers \grade_item::regrade_final_grades + */ + public function test_full_regrade_preserves_penalised_finalgrade(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + penalty_manager::enable_module('assign'); + gradepenalty::enable_plugin('duedate', true); + + // 10% penalty rule: any overdue submission loses 10% of grademax. + $DB->insert_record('gradepenalty_duedate_rule', (object)[ + 'contextid' => context_system::instance()->id, + 'overdueby' => 1, + 'penalty' => 10, + 'sortorder' => 1, + ]); + + $user = $this->getDataGenerator()->create_user(); + $course = $this->getDataGenerator()->create_course(); + $assign = $this->getDataGenerator()->create_module('assign', ['course' => $course->id, 'grade' => 100]); + + // Grade item: multfactor=1.5, rawgrade=50 → unpenalised finalgrade = 50 * 1.5 = 75. + grade_update( + source: 'mod/assign', + courseid: $course->id, + itemtype: 'mod', + itemmodule: 'assign', + iteminstance: $assign->id, + itemnumber: 0, + grades: ['userid' => $user->id, 'rawgrade' => 50], + itemdetails: ['multfactor' => 1.5, 'plusfactor' => 0.0], + ); + + $gradeitem = grade_item::fetch([ + 'courseid' => $course->id, + 'itemtype' => 'mod', + 'itemmodule' => 'assign', + 'iteminstance' => $assign->id, + 'itemnumber' => 0, + ]); + $this->assertEqualsWithDelta(75.0, (float) $gradeitem->get_final($user->id)->finalgrade, 0.001); + + // Apply penalty: 1 day late -> 10% of grademax(100) = 10 raw points deducted. + // Penalised finalgrade = (50 − 10) * 1.5 = 60. + penalty_manager::apply_grade_penalty_to_user($user->id, $gradeitem, DAYSECS + 1, 0); + + $penalised = $gradeitem->get_final($user->id); + $this->assertEqualsWithDelta(60.0, (float) $penalised->finalgrade, 0.001); + $this->assertEqualsWithDelta(10.0, (float) $penalised->deductedmark, 0.001); + + // Simulate what happens when a course item (or category) triggers a full + // regrade of the grade item (e.g. on first page load when needsupdate=1). + $gradeitem->needsupdate = 1; + $DB->set_field('grade_items', 'needsupdate', 1, ['id' => $gradeitem->id]); + + // Before the fix, regrade_final_grades() would compute + // finalgrade = adjust_raw_grade(50) * 1.5 = 75, silently undoing the penalty. + $gradeitem->regrade_final_grades($user->id); + + $after = $gradeitem->get_final($user->id); + $this->assertEqualsWithDelta( + 60.0, + (float) $after->finalgrade, + 0.001, + 'Full regrade must not undo an existing penalty.' + ); + $this->assertEqualsWithDelta( + 10.0, + (float) $after->deductedmark, + 0.001, + 'deductedmark must be unchanged after a full regrade.' + ); + } } diff --git a/public/group/classes/output/index_page.php b/public/group/classes/output/index_page.php index cd2afa14b8c51..cff7e2b24776d 100644 --- a/public/group/classes/output/index_page.php +++ b/public/group/classes/output/index_page.php @@ -100,6 +100,8 @@ public function export_for_template(renderer_base $output) { // Variables that will be passed to the JS helper. $data->courseid = $this->courseid; $data->wwwroot = $CFG->wwwroot; + $data->sesskey = sesskey(); + // To be passed to the JS init script in the template. Encode as a JSON string. $data->undeletablegroups = json_encode($this->undeletablegroups); diff --git a/public/group/index.php b/public/group/index.php index 94bc4b13c25af..5f7455cc4a359 100644 --- a/public/group/index.php +++ b/public/group/index.php @@ -156,12 +156,14 @@ break; case 'enablemessaging': + require_sesskey(); set_groups_messaging($groupids, true); redirect($returnurl, get_string('messagingenabled', 'group', count($groupids)), null, \core\output\notification::NOTIFY_SUCCESS); break; case 'disablemessaging': + require_sesskey(); set_groups_messaging($groupids, false); redirect($returnurl, get_string('messagingdisabled', 'group', count($groupids)), null, \core\output\notification::NOTIFY_SUCCESS); diff --git a/public/group/templates/index.mustache b/public/group/templates/index.mustache index 0d55423071cc0..f200cdffa9f3d 100644 --- a/public/group/templates/index.mustache +++ b/public/group/templates/index.mustache @@ -27,6 +27,7 @@ Context variables required for this template: * courseid int The course ID. + * sesskey string The sesskey of the user. * selectedgroup string The initially selected group. * editgroupsettingsdisabled bool Whether to disable the "Edit group settings" button on load. * deletegroupdisabled bool Whether to disable the "Delete selected group" button on load. @@ -39,6 +40,7 @@ Example context (json): { "courseid": "1", + "sesskey": "abc123", "selectedgroup": "Group 1 (3)", "editgroupsettingsdisabled": false, "deletegroupdisabled": false, @@ -82,6 +84,7 @@
+
-
diff --git a/public/lib/editor/tiny/plugins/media/templates/embed/body/media_thumbnail_body.mustache b/public/lib/editor/tiny/plugins/media/templates/embed/body/media_thumbnail_body.mustache index 7a8b7a077ff8d..f66a642f252f9 100644 --- a/public/lib/editor/tiny/plugins/media/templates/embed/body/media_thumbnail_body.mustache +++ b/public/lib/editor/tiny/plugins/media/templates/embed/body/media_thumbnail_body.mustache @@ -29,8 +29,12 @@
- media thumbnail image
diff --git a/public/lib/editor/tiny/plugins/media/templates/image/body/insert_image_modal_details_body.mustache b/public/lib/editor/tiny/plugins/media/templates/image/body/insert_image_modal_details_body.mustache index 0ce9f036746f2..728064633bfdc 100644 --- a/public/lib/editor/tiny/plugins/media/templates/image/body/insert_image_modal_details_body.mustache +++ b/public/lib/editor/tiny/plugins/media/templates/image/body/insert_image_modal_details_body.mustache @@ -41,7 +41,7 @@
{{! Image description }}
- {{#str}} imagealternativetext, tiny_media {{/str}} +
{{#str}} imagealternativetext, tiny_media {{/str}}
{{! Character counter }} @@ -59,7 +59,7 @@ {{#alttexthelpicon}}{{> core/help_icon }}{{/alttexthelpicon}}

-
{{#str}} imagesize, tiny_media {{/str}}
+
{{#str}} imagesize, tiny_media {{/str}}
{{! Original size radio button }}
@@ -103,9 +103,13 @@ {{! Image preview }}
{{! Delete image icon }} -
- -
+ {{! Image placeholder }}
diff --git a/public/lib/editor/tiny/plugins/media/tests/behat/image.feature b/public/lib/editor/tiny/plugins/media/tests/behat/image.feature index 4aa0c37ce3270..3dcfb412959ab 100644 --- a/public/lib/editor/tiny/plugins/media/tests/behat/image.feature +++ b/public/lib/editor/tiny/plugins/media/tests/behat/image.feature @@ -35,7 +35,7 @@ Feature: Use the TinyMCE editor to upload an image # Note: This needs to be replaced with a label. Then ".tiny_image_preview" "css_element" should be visible - @_file_upload + @_file_upload @accessibility Scenario: Insert image to the TinyMCE editor Given I log in as "admin" And I open my profile in edit mode @@ -43,6 +43,7 @@ Feature: Use the TinyMCE editor to upload an image And I click on "Browse repositories" "button" in the "Insert image" "dialogue" And I upload "lib/editor/tiny/tests/behat/fixtures/moodle-logo.png" to the file picker for TinyMCE And I set the field "How would you describe this image to someone who cannot see it?" to "It's the Moodle" + And the "Image details" "dialogue" should meet accessibility standards And I click on "Save" "button" in the "Image details" "dialogue" When I select the "img" element in position "0" of the "Description" TinyMCE editor And I click on the "Image" button for the "Description" TinyMCE editor @@ -74,6 +75,22 @@ Feature: Use the TinyMCE editor to upload an image And I should see "Height" in the "Image details" "dialogue" And the field "Width" matches value "102" + @_file_upload + Scenario: Re-opening a resized image keeps the saved width and height + Given I log in as "admin" + And I open my profile in edit mode + And I click on the "Image" button for the "Description" TinyMCE editor + And I click on "Browse repositories" "button" in the "Insert image" "dialogue" + And I upload "lib/editor/tiny/tests/behat/fixtures/moodle-logo.png" to the file picker for TinyMCE + And I click on "Decorative image" "checkbox" + And I click on "Custom" "button" in the "Image details" "dialogue" + And I set the field "Width" to "650" + And I click on "Save" "button" in the "Image details" "dialogue" + When I select the "img" element in position "0" of the "Description" TinyMCE editor + And I click on the "Image" button for the "Description" TinyMCE editor + Then the field "Width" matches value "650" + And the field "Height" matches value "194" + @_file_upload Scenario: Set the alt text to the maximum and below the maximum length Given I log in as "admin" diff --git a/public/lib/editor/tiny/plugins/recordrtc/amd/build/base_recorder.min.js b/public/lib/editor/tiny/plugins/recordrtc/amd/build/base_recorder.min.js index 3299229ed830e..90062e5d2bc05 100644 --- a/public/lib/editor/tiny/plugins/recordrtc/amd/build/base_recorder.min.js +++ b/public/lib/editor/tiny/plugins/recordrtc/amd/build/base_recorder.min.js @@ -1,3 +1,3 @@ -define("tiny_recordrtc/base_recorder",["exports","core/str","./common","core/pending","./options","editor_tiny/uploader","core/toast","core/modal_events","core/templates","core/notification","core/prefetch","core/local/modal/alert"],(function(_exports,_str,_common,_pending,_options,_uploader,_toast,ModalEvents,Templates,_notification,_prefetch,_alert){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_pending=_interopRequireDefault(_pending),_uploader=_interopRequireDefault(_uploader),ModalEvents=_interopRequireWildcard(ModalEvents),Templates=_interopRequireWildcard(Templates),_alert=_interopRequireDefault(_alert);return _exports.default=class{constructor(editor,modal){_defineProperty(this,"stopRequested",!1),_defineProperty(this,"buttonTimer",null),_defineProperty(this,"pauseTime",null),_defineProperty(this,"startTime",null),this.ready=!1,this.checkAndWarnAboutBrowserCompatibility()&&(this.editor=editor,this.config=(0,_options.getData)(editor).params,this.modal=modal,this.modalRoot=modal.getRoot()[0],this.startStopButton=this.modalRoot.querySelector('button[data-action="startstop"]'),this.uploadButton=this.modalRoot.querySelector('button[data-action="upload"]'),this.pauseResumeButton=this.modalRoot.querySelector('button[data-action="pauseresume"]'),this.setRecordButtonState(!1),this.player=this.configurePlayer(),this.registerEventListeners(),this.ready=!0,this.captureUserMedia(),this.prefetchContent())}isReady(){return this.ready}configurePlayer(){throw new Error("configurePlayer() must be implemented in ".concat(this.constructor.name))}getSupportedTypes(){throw new Error("getSupportedTypes() must be implemented in ".concat(this.constructor.name))}getRecordingOptions(){throw new Error("getRecordingOptions() must be implemented in ".concat(this.constructor.name))}getFileName(prefix){throw new Error("getFileName() must be implemented in ".concat(this.constructor.name))}getMediaConstraints(){throw new Error("getMediaConstraints() must be implemented in ".concat(this.constructor.name))}playOnCapture(){return!1}getTimeLimit(){throw new Error("getTimeLimit() must be implemented in ".concat(this.constructor.name))}getEmbedTemplateName(){throw new Error("getEmbedTemplateName() must be implemented in ".concat(this.constructor.name))}static getModalClass(){throw new Error("getModalClass() must be implemented in ".concat(this.constructor.name))}getParsedRecordingOptions(){const compatTypes=this.getSupportedTypes().reduce(((result,type)=>(result.push(type),result.push(type.replace("=",":")),result)),[]).filter((type=>window.MediaRecorder.isTypeSupported(type))),options=this.getRecordingOptions();return 0!==compatTypes.length&&(options.mimeType=compatTypes[0]),window.console.info("Selected codec ".concat(options.mimeType," from ").concat(compatTypes.length," options."),compatTypes),options}async captureUserMedia(){try{const stream=await navigator.mediaDevices.getUserMedia(this.getMediaConstraints());this.handleCaptureSuccess(stream)}catch(error){this.handleCaptureFailure(error)}}prefetchContent(){(0,_prefetch.prefetchStrings)(_common.component,["uploading","recordagain_title","recordagain_desc","discard_title","discard_desc","confirm_yes","recordinguploaded","maxfilesizehit","maxfilesizehit_title","uploadfailed","pause","resume"]),(0,_prefetch.prefetchTemplates)([this.getEmbedTemplateName(),"tiny_recordrtc/timeremaining"])}async displayAlert(title,content){const pendingPromise=new _pending.default("core/confirm:alert"),modal=await _alert.default.create({title:title,body:content,removeOnClose:!0});return modal.show(),pendingPromise.resolve(),modal}handleCaptureSuccess(stream){this.player.srcObject=stream,this.playOnCapture()&&(this.player.muted=!0,this.player.play()),this.stream=stream,this.setupPlayerSource(),this.setRecordButtonState(!0)}setupPlayerSource(){this.player.srcObject||(this.player.srcObject=this.stream,this.player.muted=!0,this.player.play())}setRecordButtonState(enabled){this.startStopButton.disabled=!enabled}setRecordButtonVisibility(visible){this.getButtonContainer("start-stop").classList.toggle("hide",!visible)}setPauseButtonVisibility(visible){this.pauseResumeButton&&this.pauseResumeButton.classList.toggle("hidden",!visible)}setUploadButtonState(enabled){this.uploadButton.disabled=!enabled}setUploadButtonVisibility(visible){this.getButtonContainer("upload").classList.toggle("hide",!visible)}setPlayerState(state){var _this$getButtonContai;this.player.muted=!state,this.player.controls=state,null===(_this$getButtonContai=this.getButtonContainer("player"))||void 0===_this$getButtonContai||_this$getButtonContai.classList.toggle("hide",!state)}handleCaptureFailure(error){var subject="gum".concat(error.name.replace("Error","").toLowerCase());this.displayAlert((0,_str.getString)("".concat(subject,"_title"),_common.component),(0,_str.getString)(subject,_common.component))}close(){this.modal.hide()}registerEventListeners(){this.modalRoot.addEventListener("click",this.handleModalClick.bind(this)),this.modal.getRoot().on(ModalEvents.outsideClick,this.outsideClickHandler.bind(this)),this.modal.getRoot().on(ModalEvents.hidden,(()=>{this.cleanupStream(),this.requestRecordingStop()})),this.player.addEventListener("error",this.handlePlayerError.bind(this)),this.player.addEventListener("loadedmetadata",this.handlePlayerLoadedMetadata.bind(this))}handlePlayerError(){const error=this.player.error;if(error){const message="An error occurred: ".concat(error.message||"Unknown error",". Please try again.");(0,_toast.add)(message,{type:error}),this.setUploadButtonState(!1)}}handlePlayerLoadedMetadata(){isFinite(this.player.duration)&&(this.player.currentTime=.1)}async outsideClickHandler(event){if(this.isRecording()||this.isPaused())event.preventDefault();else if(this.hasData()){event.preventDefault();try{await(0,_notification.saveCancelPromise)(await(0,_str.getString)("discard_title",_common.component),await(0,_str.getString)("discard_desc",_common.component),await(0,_str.getString)("confirm_yes",_common.component)),this.modal.hide()}catch(error){}}}handleModalClick(event){const button=event.target.closest("button");if(button&&button.dataset.action){const action=button.dataset.action;"startstop"===action&&this.handleRecordingStartStopRequested(),"upload"===action&&this.uploadRecording(),"pauseresume"===action&&this.handleRecordingPauseResumeRequested()}}handleRecordingStartStopRequested(){this.isRecording()||this.isPaused()?this.requestRecordingStop():this.startRecording()}handleRecordingPauseResumeRequested(){this.isRecording()?this.mediaRecorder.pause():this.isPaused()&&this.mediaRecorder.resume()}async onMediaStopped(){this.blob=new Blob(this.data.chunks,{type:this.mediaRecorder.mimeType}),this.player.srcObject=null,this.player.src=URL.createObjectURL(this.blob),this.setRecordButtonTextFromString("recordagain"),this.setUploadButtonVisibility(!0),this.setPlayerState(!0),this.setUploadButtonState(!0),this.setPauseButtonVisibility(!1),"inactive"===this.mediaRecorder.state&&this.setPauseButtonTextFromString("pause")}async uploadRecording(){if(0===this.data.chunks.length)return void this.displayAlert("norecordingfound");const fileName=this.getFileName((1e3*Math.random()).toString().replace(".",""));try{this.setRecordButtonVisibility(!1),this.setUploadButtonState(!1);const fileURL=await(0,_uploader.default)(this.editor,"media",this.blob,fileName,(progress=>{this.setUploadButtonTextProgress(progress)}));this.insertMedia(fileURL),this.close(),(0,_toast.add)(await(0,_str.getString)("recordinguploaded",_common.component))}catch(error){this.setUploadButtonState(!0),(0,_toast.add)(await(0,_str.getString)("uploadfailed",_common.component,{error:error}),{type:"error"})}}getButtonContainer(purpose){return this.modalRoot.querySelector('[data-purpose="'.concat(purpose,'-container"]'))}static isBrowserCompatible(){return this.checkSecure()&&this.hasUserMedia()}static async display(editor){const ModalClass=this.getModalClass(),modal=await ModalClass.create({templateContext:{isallowedpausing:(0,_options.isPausingAllowed)(editor)},large:!0,removeOnClose:!0});return new this(editor,modal).isReady()&&modal.show(),modal}checkAndWarnAboutBrowserCompatibility(){return this.constructor.checkSecure()?!!this.constructor.hasUserMedia||((0,_str.getStrings)(["nowebrtc_title","nowebrtc"].map((key=>({key:key,component:_common.component})))).then((_ref2=>{let[title,message]=_ref2;return(0,_toast.add)(message,{title:title,type:"error"})})).catch(),!1):((0,_str.getStrings)(["insecurealert_title","insecurealert"].map((key=>({key:key,component:_common.component})))).then((_ref=>{let[title,message]=_ref;return(0,_toast.add)(message,{title:title,type:"error"})})).catch(),!1)}static hasUserMedia(){return navigator.mediaDevices&&window.MediaRecorder}static checkSecure(){return window.isSecureContext}async setStopRecordingButton(){const{html:html,js:js}=await Templates.renderForPromise("tiny_recordrtc/timeremaining",this.getTimeRemaining());Templates.replaceNodeContents(this.startStopButton,html,js),this.startButtonTimer()}updateRecordButtonTime(){const{remaining:remaining,minutes:minutes,seconds:seconds}=this.getTimeRemaining();remaining<0?this.requestRecordingStop():(this.startStopButton.querySelector('[data-type="minutes"]').textContent=minutes,this.startStopButton.querySelector('[data-type="seconds"]').textContent=seconds)}async setRecordButtonTextFromString(string){this.startStopButton.textContent=await(0,_str.getString)(string,_common.component)}async setPauseButtonTextFromString(string){this.pauseResumeButton&&(this.pauseResumeButton.textContent=await(0,_str.getString)(string,_common.component))}async setUploadButtonTextProgress(progress){this.uploadButton.textContent=await(0,_str.getString)("uploading",_common.component,{progress:Math.round(100*progress)/100})}async resetUploadButtonText(){this.uploadButton.textContent=await(0,_str.getString)("upload",_common.component)}clearButtonTimer(){this.buttonTimer&&clearInterval(this.buttonTimer),this.buttonTimer=null,this.pauseTime=null,this.startTime=null}pauseButtonTimer(){this.pauseTime=(new Date).getTime(),this.buttonTimer&&clearInterval(this.buttonTimer)}startButtonTimer(){if(null!==this.pauseTime){const pauseDuration=(new Date).getTime()-this.pauseTime;this.startTime+=pauseDuration,this.pauseTime=null}this.buttonTimer=setInterval(this.updateRecordButtonTime.bind(this),500)}getTimeRemaining(){let now=(new Date).getTime();null!==this.pauseTime&&(now=this.pauseTime);const remaining=Math.floor(this.getTimeLimit()-(now-this.startTime)/1e3),formatter=new Intl.NumberFormat(navigator.language,{minimumIntegerDigits:2}),seconds=formatter.format(remaining%60);return{remaining:remaining,minutes:formatter.format(Math.floor((remaining-seconds)/60)),seconds:seconds}}getMaxUploadSize(){return this.config.maxrecsize}requestRecordingStop(){this.mediaRecorder&&"inactive"!==this.mediaRecorder.state?(this.stopRequested=!0,this.isPaused()&&this.stopRecorder()):this.cleanupStream()}stopRecorder(){this.isPaused()&&(this.pauseTime=null),this.mediaRecorder.stop(),this.player.muted=!1}cleanupStream(){this.stream&&this.stream.getTracks().filter((track=>"ended"!==track.readyState)).forEach((track=>track.stop()))}handleStopped(){this.onMediaStopped(),this.clearButtonTimer()}handleStarted(){this.startTime=(new Date).getTime(),(0,_options.isPausingAllowed)(this.editor)&&!this.isPaused()&&this.setPauseButtonVisibility(!0),this.setStopRecordingButton()}handlePaused(){this.pauseButtonTimer(),this.setPauseButtonTextFromString("resume")}handleResume(){this.startButtonTimer(),this.setPauseButtonTextFromString("pause")}handleDataAvailable(event){if(this.isRecording()||this.isPaused()){const newSize=this.data.blobSize+event.data.size;newSize>=this.getMaxUploadSize()?(this.stopRecorder(),this.displayFileLimitHitMessage()):(this.data.chunks.push(event.data),this.data.blobSize=newSize,this.stopRequested&&this.stopRecorder())}}async displayFileLimitHitMessage(){(0,_toast.add)(await(0,_str.getString)("maxfilesizehit",_common.component),{title:await(0,_str.getString)("maxfilesizehit_title",_common.component),type:"error"})}isRecording(){var _this$mediaRecorder;return"recording"===(null===(_this$mediaRecorder=this.mediaRecorder)||void 0===_this$mediaRecorder?void 0:_this$mediaRecorder.state)}isPaused(){var _this$mediaRecorder2;return"paused"===(null===(_this$mediaRecorder2=this.mediaRecorder)||void 0===_this$mediaRecorder2?void 0:_this$mediaRecorder2.state)}hasData(){var _this$data;return!(null===(_this$data=this.data)||void 0===_this$data||!_this$data.blobSize)}async startRecording(){if(this.mediaRecorder){if((this.isRecording()||this.isPaused())&&this.mediaRecorder.stop(),this.hasData()){if(!await this.recordAgainConfirmation())return;this.setUploadButtonVisibility(!1),this.setPlayerState(!1),this.stream.active||await this.captureUserMedia()}this.mediaRecorder=null}this.mediaRecorder=new MediaRecorder(this.stream,this.getParsedRecordingOptions()),this.mediaRecorder.addEventListener("dataavailable",this.handleDataAvailable.bind(this)),this.mediaRecorder.addEventListener("stop",this.handleStopped.bind(this)),this.mediaRecorder.addEventListener("start",this.handleStarted.bind(this)),this.mediaRecorder.addEventListener("pause",this.handlePaused.bind(this)),this.mediaRecorder.addEventListener("resume",this.handleResume.bind(this)),this.data={chunks:[],blobSize:0},this.setupPlayerSource(),this.stopRequested=!1,this.mediaRecorder.start(50)}async recordAgainConfirmation(){try{return await(0,_notification.saveCancelPromise)(await(0,_str.getString)("recordagain_title",_common.component),await(0,_str.getString)("recordagain_desc",_common.component),await(0,_str.getString)("confirm_yes",_common.component)),!0}catch{return!1}}async insertMedia(source){const{html:html}=await Templates.renderForPromise(this.getEmbedTemplateName(),this.getEmbedTemplateContext({source:source}));this.editor.insertContent(html)}getEmbedTemplateContext(templateContext){return templateContext}},_exports.default})); +define("tiny_recordrtc/base_recorder",["exports","core/str","./common","core/pending","./options","editor_tiny/uploader","core/toast","core/modal_events","core/templates","core/notification","core/prefetch","core/local/modal/alert"],(function(_exports,_str,_common,_pending,_options,_uploader,_toast,ModalEvents,Templates,_notification,_prefetch,_alert){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_pending=_interopRequireDefault(_pending),_uploader=_interopRequireDefault(_uploader),ModalEvents=_interopRequireWildcard(ModalEvents),Templates=_interopRequireWildcard(Templates),_alert=_interopRequireDefault(_alert);return _exports.default=class{constructor(editor,modal){_defineProperty(this,"stopRequested",!1),_defineProperty(this,"buttonTimer",null),_defineProperty(this,"pauseTime",null),_defineProperty(this,"startTime",null),this.ready=!1,this.checkAndWarnAboutBrowserCompatibility()&&(this.editor=editor,this.config=(0,_options.getData)(editor).params,this.modal=modal,this.modalRoot=modal.getRoot()[0],this.startStopButton=this.modalRoot.querySelector('button[data-action="startstop"]'),this.uploadButton=this.modalRoot.querySelector('button[data-action="upload"]'),this.pauseResumeButton=this.modalRoot.querySelector('button[data-action="pauseresume"]'),this.setRecordButtonState(!1),this.player=this.configurePlayer(),this.registerEventListeners(),this.ready=!0,this.captureUserMedia(),this.prefetchContent())}isReady(){return this.ready}configurePlayer(){throw new Error("configurePlayer() must be implemented in ".concat(this.constructor.name))}getSupportedTypes(){throw new Error("getSupportedTypes() must be implemented in ".concat(this.constructor.name))}getRecordingOptions(){throw new Error("getRecordingOptions() must be implemented in ".concat(this.constructor.name))}getFileName(prefix){throw new Error("getFileName() must be implemented in ".concat(this.constructor.name))}getMediaConstraints(){throw new Error("getMediaConstraints() must be implemented in ".concat(this.constructor.name))}playOnCapture(){return!1}getTimeLimit(){throw new Error("getTimeLimit() must be implemented in ".concat(this.constructor.name))}getEmbedTemplateName(){throw new Error("getEmbedTemplateName() must be implemented in ".concat(this.constructor.name))}static getModalClass(){throw new Error("getModalClass() must be implemented in ".concat(this.constructor.name))}getParsedRecordingOptions(){const compatTypes=this.getSupportedTypes().reduce(((result,type)=>(result.push(type),result.push(type.replace("=",":")),result)),[]).filter((type=>window.MediaRecorder.isTypeSupported(type))),options=this.getRecordingOptions();return 0!==compatTypes.length&&(options.mimeType=compatTypes[0]),window.console.info("Selected codec ".concat(options.mimeType," from ").concat(compatTypes.length," options."),compatTypes),options}async captureUserMedia(){try{const stream=await navigator.mediaDevices.getUserMedia(this.getMediaConstraints());this.handleCaptureSuccess(stream)}catch(error){this.handleCaptureFailure(error)}}prefetchContent(){(0,_prefetch.prefetchStrings)(_common.component,["uploading","recordagain_title","recordagain_desc","discard_title","discard_desc","confirm_yes","recordinguploaded","maxfilesizehit","maxfilesizehit_title","uploadfailed","pause","resume"]),(0,_prefetch.prefetchTemplates)([this.getEmbedTemplateName(),"tiny_recordrtc/timeremaining"])}async displayAlert(title,content){const pendingPromise=new _pending.default("core/confirm:alert"),modal=await _alert.default.create({title:title,body:content,removeOnClose:!0});return modal.show(),pendingPromise.resolve(),modal}handleCaptureSuccess(stream){this.player.srcObject=stream,this.playOnCapture()&&(this.player.muted=!0,this.player.play()),this.stream=stream,this.setupPlayerSource(),this.setRecordButtonState(!0)}setupPlayerSource(){this.player.srcObject||(this.player.srcObject=this.stream,this.player.muted=!0,this.player.play())}setRecordButtonState(enabled){this.startStopButton.disabled=!enabled}setRecordButtonVisibility(visible){this.getButtonContainer("start-stop").classList.toggle("hide",!visible)}setPauseButtonVisibility(visible){this.pauseResumeButton&&this.pauseResumeButton.classList.toggle("hidden",!visible)}setUploadButtonState(enabled){this.uploadButton.disabled=!enabled}setUploadButtonVisibility(visible){this.getButtonContainer("upload").classList.toggle("hide",!visible)}setPlayerState(state){var _this$getButtonContai;this.player.muted=!state,this.player.controls=state,null===(_this$getButtonContai=this.getButtonContainer("player"))||void 0===_this$getButtonContai||_this$getButtonContai.classList.toggle("hide",!state)}handleCaptureFailure(error){var subject="gum".concat(error.name.replace("Error","").toLowerCase());this.displayAlert((0,_str.getString)("".concat(subject,"_title"),_common.component),(0,_str.getString)(subject,_common.component))}close(){this.modal.hide()}registerEventListeners(){this.modalRoot.addEventListener("click",this.handleModalClick.bind(this)),this.modal.getRoot().on(ModalEvents.outsideClick,this.outsideClickHandler.bind(this)),this.modal.getRoot().on(ModalEvents.hidden,(()=>{this.cleanupStream(),this.requestRecordingStop()})),this.player.addEventListener("error",this.handlePlayerError.bind(this)),this.player.addEventListener("loadedmetadata",this.handlePlayerLoadedMetadata.bind(this))}handlePlayerError(){const error=this.player.error;if(error){const message="An error occurred: ".concat(error.message||"Unknown error",". Please try again.");(0,_toast.add)(message,{type:error}),this.setUploadButtonState(!1)}}handlePlayerLoadedMetadata(){isFinite(this.player.duration)&&(this.player.currentTime=.1)}async outsideClickHandler(event){if(this.isRecording()||this.isPaused())event.preventDefault();else if(this.hasData()){event.preventDefault();try{await(0,_notification.saveCancelPromise)(await(0,_str.getString)("discard_title",_common.component),await(0,_str.getString)("discard_desc",_common.component),await(0,_str.getString)("confirm_yes",_common.component)),this.modal.hide()}catch(error){}}}handleModalClick(event){const button=event.target.closest("button");if(button&&button.dataset.action){const action=button.dataset.action;"startstop"===action&&this.handleRecordingStartStopRequested(),"upload"===action&&this.uploadRecording(),"pauseresume"===action&&this.handleRecordingPauseResumeRequested()}}handleRecordingStartStopRequested(){this.isRecording()||this.isPaused()?this.requestRecordingStop():this.startRecording()}handleRecordingPauseResumeRequested(){this.isRecording()?this.mediaRecorder.pause():this.isPaused()&&this.mediaRecorder.resume()}async onMediaStopped(){this.blob=new Blob(this.data.chunks,{type:this.mediaRecorder.mimeType}),this.player.srcObject=null,this.player.src=URL.createObjectURL(this.blob),this.setRecordButtonTextFromString("recordagain"),this.setUploadButtonVisibility(!0),this.setPlayerState(!0),this.setUploadButtonState(!0),this.setPauseButtonVisibility(!1),"inactive"===this.mediaRecorder.state&&this.setPauseButtonTextFromString("pause")}async uploadRecording(){if(0===this.data.chunks.length)return void this.displayAlert("norecordingfound");const fileName=this.getFileName((1e3*Math.random()).toString().replace(".",""));try{this.setRecordButtonVisibility(!1),this.setUploadButtonState(!1);const fileURL=await(0,_uploader.default)(this.editor,"media",this.blob,fileName,(progress=>{this.setUploadButtonTextProgress(progress)}));this.insertMedia(fileURL),this.close(),(0,_toast.add)(await(0,_str.getString)("recordinguploaded",_common.component))}catch(error){this.setUploadButtonState(!0),(0,_toast.add)(await(0,_str.getString)("uploadfailed",_common.component,{error:error}),{type:"error"})}}getButtonContainer(purpose){return this.modalRoot.querySelector('[data-purpose="'.concat(purpose,'-container"]'))}static isBrowserCompatible(){return this.checkSecure()&&this.hasUserMedia()}static async display(editor){const ModalClass=this.getModalClass(),modal=await ModalClass.create({templateContext:{isallowedpausing:(0,_options.isPausingAllowed)(editor)},large:!0,removeOnClose:!0});return new this(editor,modal).isReady()&&modal.show(),modal}checkAndWarnAboutBrowserCompatibility(){return this.constructor.checkSecure()?!!this.constructor.hasUserMedia||((0,_str.getStrings)(["nowebrtc_title","nowebrtc"].map((key=>({key:key,component:_common.component})))).then((_ref2=>{let[title,message]=_ref2;return(0,_toast.add)(message,{title:title,type:"error"})})).catch(),!1):((0,_str.getStrings)(["insecurealert_title","insecurealert"].map((key=>({key:key,component:_common.component})))).then((_ref=>{let[title,message]=_ref;return(0,_toast.add)(message,{title:title,type:"error"})})).catch(),!1)}static hasUserMedia(){return navigator.mediaDevices&&window.MediaRecorder}static checkSecure(){return window.isSecureContext}async setStopRecordingButton(){const{html:html,js:js}=await Templates.renderForPromise("tiny_recordrtc/timeremaining",this.getTimeRemaining());Templates.replaceNodeContents(this.startStopButton,html,js),this.startButtonTimer()}updateRecordButtonTime(){const{remaining:remaining,minutes:minutes,seconds:seconds}=this.getTimeRemaining();remaining<0?this.requestRecordingStop():(this.startStopButton.querySelector('[data-type="minutes"]').textContent=minutes,this.startStopButton.querySelector('[data-type="seconds"]').textContent=seconds)}async setRecordButtonTextFromString(string){this.startStopButton.textContent=await(0,_str.getString)(string,_common.component)}async setPauseButtonTextFromString(string){this.pauseResumeButton&&(this.pauseResumeButton.textContent=await(0,_str.getString)(string,_common.component))}async setUploadButtonTextProgress(progress){this.uploadButton.textContent=await(0,_str.getString)("uploading",_common.component,{progress:Math.round(100*progress)/100})}async resetUploadButtonText(){this.uploadButton.textContent=await(0,_str.getString)("upload",_common.component)}clearButtonTimer(){this.buttonTimer&&clearInterval(this.buttonTimer),this.buttonTimer=null,this.pauseTime=null,this.startTime=null}pauseButtonTimer(){this.pauseTime=(new Date).getTime(),this.buttonTimer&&clearInterval(this.buttonTimer)}startButtonTimer(){if(null!==this.pauseTime){const pauseDuration=(new Date).getTime()-this.pauseTime;this.startTime+=pauseDuration,this.pauseTime=null}this.buttonTimer=setInterval(this.updateRecordButtonTime.bind(this),500)}getTimeRemaining(){let now=(new Date).getTime();null!==this.pauseTime&&(now=this.pauseTime);const remaining=Math.floor(this.getTimeLimit()-(now-this.startTime)/1e3),formatter=new Intl.NumberFormat(navigator.language,{minimumIntegerDigits:2}),seconds=formatter.format(remaining%60);return{remaining:remaining,minutes:formatter.format(Math.floor((remaining-seconds)/60)),seconds:seconds}}getMaxUploadSize(){return this.config.maxrecsize}requestRecordingStop(){this.mediaRecorder&&"inactive"!==this.mediaRecorder.state?(this.stopRequested=!0,this.isPaused()&&this.stopRecorder()):this.cleanupStream()}stopRecorder(){this.isPaused()&&(this.pauseTime=null),this.mediaRecorder.stop(),this.player.muted=!1}cleanupStream(){this.stream&&this.stream.getTracks().filter((track=>"ended"!==track.readyState)).forEach((track=>track.stop()))}handleStopped(){this.onMediaStopped(),this.clearButtonTimer()}handleStarted(){this.startTime=(new Date).getTime(),(0,_options.isPausingAllowed)(this.editor)&&!this.isPaused()&&this.setPauseButtonVisibility(!0),this.setStopRecordingButton()}handlePaused(){this.pauseButtonTimer(),this.setPauseButtonTextFromString("resume")}handleResume(){this.startButtonTimer(),this.setPauseButtonTextFromString("pause")}handleDataAvailable(event){if(this.isRecording()||this.isPaused()){const newSize=this.data.blobSize+event.data.size;-1!==this.getMaxUploadSize()&&newSize>=this.getMaxUploadSize()?(this.stopRecorder(),this.displayFileLimitHitMessage()):(this.data.chunks.push(event.data),this.data.blobSize=newSize,this.stopRequested&&this.stopRecorder())}}async displayFileLimitHitMessage(){(0,_toast.add)(await(0,_str.getString)("maxfilesizehit",_common.component),{title:await(0,_str.getString)("maxfilesizehit_title",_common.component),type:"error"})}isRecording(){var _this$mediaRecorder;return"recording"===(null===(_this$mediaRecorder=this.mediaRecorder)||void 0===_this$mediaRecorder?void 0:_this$mediaRecorder.state)}isPaused(){var _this$mediaRecorder2;return"paused"===(null===(_this$mediaRecorder2=this.mediaRecorder)||void 0===_this$mediaRecorder2?void 0:_this$mediaRecorder2.state)}hasData(){var _this$data;return!(null===(_this$data=this.data)||void 0===_this$data||!_this$data.blobSize)}async startRecording(){if(this.mediaRecorder){if((this.isRecording()||this.isPaused())&&this.mediaRecorder.stop(),this.hasData()){if(!await this.recordAgainConfirmation())return;this.setUploadButtonVisibility(!1),this.setPlayerState(!1),this.stream.active||await this.captureUserMedia()}this.mediaRecorder=null}this.mediaRecorder=new MediaRecorder(this.stream,this.getParsedRecordingOptions()),this.mediaRecorder.addEventListener("dataavailable",this.handleDataAvailable.bind(this)),this.mediaRecorder.addEventListener("stop",this.handleStopped.bind(this)),this.mediaRecorder.addEventListener("start",this.handleStarted.bind(this)),this.mediaRecorder.addEventListener("pause",this.handlePaused.bind(this)),this.mediaRecorder.addEventListener("resume",this.handleResume.bind(this)),this.data={chunks:[],blobSize:0},this.setupPlayerSource(),this.stopRequested=!1,this.mediaRecorder.start(50)}async recordAgainConfirmation(){try{return await(0,_notification.saveCancelPromise)(await(0,_str.getString)("recordagain_title",_common.component),await(0,_str.getString)("recordagain_desc",_common.component),await(0,_str.getString)("confirm_yes",_common.component)),!0}catch{return!1}}async insertMedia(source){const{html:html}=await Templates.renderForPromise(this.getEmbedTemplateName(),this.getEmbedTemplateContext({source:source}));this.editor.insertContent(html)}getEmbedTemplateContext(templateContext){return templateContext}},_exports.default})); //# sourceMappingURL=base_recorder.min.js.map \ No newline at end of file diff --git a/public/lib/editor/tiny/plugins/recordrtc/amd/build/base_recorder.min.js.map b/public/lib/editor/tiny/plugins/recordrtc/amd/build/base_recorder.min.js.map index 5dc092f3ebbcd..d7929b47d2e54 100644 --- a/public/lib/editor/tiny/plugins/recordrtc/amd/build/base_recorder.min.js.map +++ b/public/lib/editor/tiny/plugins/recordrtc/amd/build/base_recorder.min.js.map @@ -1 +1 @@ -{"version":3,"file":"base_recorder.min.js","sources":["../src/base_recorder.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 * Tiny Record RTC type.\n *\n * @module tiny_recordrtc/base_recorder\n * @copyright 2022 Stevani Andolo \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {getString, getStrings} from 'core/str';\nimport {component} from './common';\nimport Pending from 'core/pending';\nimport {getData, isPausingAllowed} from './options';\nimport uploadFile from 'editor_tiny/uploader';\nimport {add as addToast} from 'core/toast';\nimport * as ModalEvents from 'core/modal_events';\nimport * as Templates from 'core/templates';\nimport {saveCancelPromise} from 'core/notification';\nimport {prefetchStrings, prefetchTemplates} from 'core/prefetch';\nimport AlertModal from 'core/local/modal/alert';\n\n/**\n * The RecordRTC base class for audio, video, and any other future types\n */\nexport default class {\n\n stopRequested = false;\n buttonTimer = null;\n pauseTime = null;\n startTime = null;\n\n /**\n * Constructor for the RecordRTC class\n *\n * @param {TinyMCE} editor The Editor to which the content will be inserted\n * @param {Modal} modal The Moodle Modal that contains the interface used for recording\n */\n constructor(editor, modal) {\n this.ready = false;\n\n if (!this.checkAndWarnAboutBrowserCompatibility()) {\n return;\n }\n\n this.editor = editor;\n this.config = getData(editor).params;\n this.modal = modal;\n this.modalRoot = modal.getRoot()[0];\n this.startStopButton = this.modalRoot.querySelector('button[data-action=\"startstop\"]');\n this.uploadButton = this.modalRoot.querySelector('button[data-action=\"upload\"]');\n this.pauseResumeButton = this.modalRoot.querySelector('button[data-action=\"pauseresume\"]');\n\n // Disable the record button untilt he stream is acquired.\n this.setRecordButtonState(false);\n\n this.player = this.configurePlayer();\n this.registerEventListeners();\n this.ready = true;\n\n this.captureUserMedia();\n this.prefetchContent();\n }\n\n /**\n * Check whether the browser is compatible.\n *\n * @returns {boolean}\n */\n isReady() {\n return this.ready;\n }\n\n // Disable eslint's valid-jsdoc rule as the following methods are abstract and mnust be overridden by the child class.\n\n /* eslint-disable valid-jsdoc, no-unused-vars */\n\n /**\n * Get the Player element for this type.\n *\n * @returns {HTMLElement} The player element, typically an audio or video tag.\n */\n configurePlayer() {\n throw new Error(`configurePlayer() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Get the list of supported mimetypes for this recorder.\n * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/isTypeSupported}\n *\n * @returns {string[]} The list of supported mimetypes.\n */\n getSupportedTypes() {\n throw new Error(`getSupportedTypes() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Get any recording options passed into the MediaRecorder.\n * Please note that the mimeType will be fetched from {@link getSupportedTypes()}.\n *\n * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/MediaRecorder#options}\n * @returns {Object}\n */\n getRecordingOptions() {\n throw new Error(`getRecordingOptions() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Get a filename for the generated file.\n *\n * Typically this function will take a prefix and add a type-specific suffix such as the extension to it.\n *\n * @param {string} prefix The prefix for the filename generated by the recorder.\n * @returns {string}\n */\n getFileName(prefix) {\n throw new Error(`getFileName() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Get a list of constraints as required by the getUserMedia() function.\n * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#constraints}\n *\n * @returns {Object}\n */\n getMediaConstraints() {\n throw new Error(`getMediaConstraints() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Whether to start playing the recording as it is captured.\n * @returns {boolean} Whether to start playing the recording as it is captured.\n */\n playOnCapture() {\n return false;\n }\n\n /**\n * Get the time limit for this recording type.\n *\n * @returns {number} The time limit in seconds.\n */\n getTimeLimit() {\n throw new Error(`getTimeLimit() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Get the name of the template used when embedding the URL in the editor content.\n *\n * @returns {string}\n */\n getEmbedTemplateName() {\n throw new Error(`getEmbedTemplateName() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Fetch the Class of the Modal to be displayed.\n *\n * @returns {Modal}\n */\n static getModalClass() {\n throw new Error(`getModalClass() must be implemented in ${this.constructor.name}`);\n }\n\n /* eslint-enable valid-jsdoc, no-unused-vars */\n\n /**\n * Get the options for the MediaRecorder.\n *\n * @returns {object} The options for the MediaRecorder instance.\n */\n getParsedRecordingOptions() {\n const requestedTypes = this.getSupportedTypes();\n const possibleTypes = requestedTypes.reduce((result, type) => {\n result.push(type);\n // Safari seems to use codecs: instead of codecs=.\n // It is safe to add both, so we do, but we want them to remain in order.\n result.push(type.replace('=', ':'));\n return result;\n }, []);\n\n const compatTypes = possibleTypes.filter((type) => window.MediaRecorder.isTypeSupported(type));\n\n const options = this.getRecordingOptions();\n if (compatTypes.length !== 0) {\n options.mimeType = compatTypes[0];\n }\n window.console.info(\n `Selected codec ${options.mimeType} from ${compatTypes.length} options.`,\n compatTypes,\n );\n\n return options;\n }\n\n /**\n * Start capturing the User Media and handle success or failure of the capture.\n */\n async captureUserMedia() {\n try {\n const stream = await navigator.mediaDevices.getUserMedia(this.getMediaConstraints());\n this.handleCaptureSuccess(stream);\n } catch (error) {\n this.handleCaptureFailure(error);\n }\n }\n\n /**\n * Prefetch some of the content that will be used in the UI.\n *\n * Note: not all of the strings used are pre-fetched.\n * Some of the strings will be fetched because their template is used.\n */\n prefetchContent() {\n prefetchStrings(component, [\n 'uploading',\n 'recordagain_title',\n 'recordagain_desc',\n 'discard_title',\n 'discard_desc',\n 'confirm_yes',\n 'recordinguploaded',\n 'maxfilesizehit',\n 'maxfilesizehit_title',\n 'uploadfailed',\n 'pause',\n 'resume',\n ]);\n\n prefetchTemplates([\n this.getEmbedTemplateName(),\n 'tiny_recordrtc/timeremaining',\n ]);\n }\n\n /**\n * Display an error message to the user.\n *\n * @param {Promise} title The error title\n * @param {Promise} content The error message\n * @returns {Promise}\n */\n async displayAlert(title, content) {\n const pendingPromise = new Pending('core/confirm:alert');\n const modal = await AlertModal.create({\n title: title,\n body: content,\n removeOnClose: true,\n });\n\n modal.show();\n pendingPromise.resolve();\n\n return modal;\n }\n\n /**\n * Handle successful capture of the User Media.\n *\n * @param {MediaStream} stream The stream as captured by the User Media.\n */\n handleCaptureSuccess(stream) {\n // Set audio player source to microphone stream.\n this.player.srcObject = stream;\n\n if (this.playOnCapture()) {\n // Mute audio, distracting while recording.\n this.player.muted = true;\n\n this.player.play();\n }\n\n this.stream = stream;\n this.setupPlayerSource();\n this.setRecordButtonState(true);\n }\n\n /**\n * Setup the player to use the stream as a source.\n */\n setupPlayerSource() {\n if (!this.player.srcObject) {\n this.player.srcObject = this.stream;\n\n // Mute audio, distracting while recording.\n this.player.muted = true;\n\n this.player.play();\n }\n }\n\n /**\n * Enable the record button.\n *\n * @param {boolean|null} enabled Set the button state\n */\n setRecordButtonState(enabled) {\n this.startStopButton.disabled = !enabled;\n }\n\n /**\n * Configure button visibility for the record button.\n *\n * @param {boolean} visible Set the visibility of the button.\n */\n setRecordButtonVisibility(visible) {\n const container = this.getButtonContainer('start-stop');\n container.classList.toggle('hide', !visible);\n }\n\n /**\n * Configure button visibility for the pause button.\n *\n * @param {boolean} visible Set the visibility of the button.\n */\n setPauseButtonVisibility(visible) {\n if (this.pauseResumeButton) {\n this.pauseResumeButton.classList.toggle('hidden', !visible);\n }\n }\n\n /**\n * Enable the upload button.\n *\n * @param {boolean|null} enabled Set the button state\n */\n setUploadButtonState(enabled) {\n this.uploadButton.disabled = !enabled;\n }\n\n /**\n * Configure button visibility for the upload button.\n *\n * @param {boolean} visible Set the visibility of the button.\n */\n setUploadButtonVisibility(visible) {\n const container = this.getButtonContainer('upload');\n container.classList.toggle('hide', !visible);\n }\n\n /**\n * Sets the state of the audio player, including visibility, muting, and controls.\n *\n * @param {boolean} state A boolean indicating the audio player state.\n */\n setPlayerState(state) {\n // Mute or unmute the audio player and show or hide controls.\n this.player.muted = !state;\n this.player.controls = state;\n // Toggle the 'hide' class on the player button container based on state.\n this.getButtonContainer('player')?.classList.toggle('hide', !state);\n }\n\n /**\n * Handle failure to capture the User Media.\n *\n * @param {Error} error\n */\n handleCaptureFailure(error) {\n // Changes 'CertainError' -> 'gumcertain' to match language string names.\n var subject = `gum${error.name.replace('Error', '').toLowerCase()}`;\n this.displayAlert(\n getString(`${subject}_title`, component),\n getString(subject, component)\n );\n }\n\n /**\n * Close the modal and stop recording.\n */\n close() {\n // Closing the modal will destroy it and remove it from the DOM.\n // It will also stop the recording via the hidden Modal Event.\n this.modal.hide();\n }\n\n /**\n * Register event listeners for the modal.\n */\n registerEventListeners() {\n this.modalRoot.addEventListener('click', this.handleModalClick.bind(this));\n this.modal.getRoot().on(ModalEvents.outsideClick, this.outsideClickHandler.bind(this));\n this.modal.getRoot().on(ModalEvents.hidden, () => {\n this.cleanupStream();\n this.requestRecordingStop();\n });\n this.player.addEventListener('error', this.handlePlayerError.bind(this));\n this.player.addEventListener('loadedmetadata', this.handlePlayerLoadedMetadata.bind(this));\n }\n\n /**\n * Handle the player `error` event.\n *\n * This event is called when the player throws an error.\n */\n handlePlayerError() {\n const error = this.player.error;\n if (error) {\n const message = `An error occurred: ${error.message || 'Unknown error'}. Please try again.`;\n addToast(message, {type: error});\n // Disable the upload button.\n this.setUploadButtonState(false);\n }\n }\n\n /**\n * Handles the event when the player's metadata has been loaded.\n */\n handlePlayerLoadedMetadata() {\n if (isFinite(this.player.duration)) {\n // Note: In Chrome, you need to seek to activate the error listener\n // if an issue arises after inserting the recorded audio into the player source.\n this.player.currentTime = 0.1;\n }\n }\n\n /**\n * Prevent the Modal from closing when recording is on process.\n *\n * @param {MouseEvent} event The click event\n */\n async outsideClickHandler(event) {\n if (this.isRecording() || this.isPaused()) {\n // The user is recording.\n // Do not distract with a confirmation, just prevent closing.\n event.preventDefault();\n } else if (this.hasData()) {\n // If there is a blobsize then there is data that may be lost.\n // Ask the user to confirm they want to close the modal.\n // We prevent default here, and then close the modal if they confirm.\n event.preventDefault();\n\n try {\n await saveCancelPromise(\n await getString(\"discard_title\", component),\n await getString(\"discard_desc\", component),\n await getString(\"confirm_yes\", component),\n );\n this.modal.hide();\n } catch (error) {\n // Do nothing, the modal will not close.\n }\n }\n }\n\n /**\n * Handle a click within the Modal.\n *\n * @param {MouseEvent} event The click event\n */\n handleModalClick(event) {\n const button = event.target.closest('button');\n if (button && button.dataset.action) {\n const action = button.dataset.action;\n if (action === 'startstop') {\n this.handleRecordingStartStopRequested();\n }\n\n if (action === 'upload') {\n this.uploadRecording();\n }\n\n if (action === 'pauseresume') {\n this.handleRecordingPauseResumeRequested();\n }\n }\n }\n\n /**\n * Handle the click event for the recording start/stop button.\n */\n handleRecordingStartStopRequested() {\n if (this.isRecording() || this.isPaused()) {\n this.requestRecordingStop();\n } else {\n this.startRecording();\n }\n }\n\n /**\n * Handle the click event for the recording pause/resume button.\n */\n handleRecordingPauseResumeRequested() {\n if (this.isRecording()) {\n // Pause recording.\n this.mediaRecorder.pause();\n } else if (this.isPaused()) {\n // Resume recording.\n this.mediaRecorder.resume();\n }\n }\n\n /**\n * Handle the media stream after it has finished.\n */\n async onMediaStopped() {\n // Set source of audio player.\n this.blob = new Blob(this.data.chunks, {\n type: this.mediaRecorder.mimeType\n });\n this.player.srcObject = null;\n this.player.src = URL.createObjectURL(this.blob);\n\n // Change the label to \"Record again\".\n this.setRecordButtonTextFromString('recordagain');\n\n // Show upload button.\n this.setUploadButtonVisibility(true);\n this.setPlayerState(true);\n this.setUploadButtonState(true);\n\n // Hide the pause button.\n this.setPauseButtonVisibility(false);\n if (this.mediaRecorder.state === 'inactive') {\n this.setPauseButtonTextFromString('pause');\n }\n }\n\n /**\n * Upload the recording and insert it into the editor content.\n */\n async uploadRecording() {\n // Trigger error if no recording has been made.\n if (this.data.chunks.length === 0) {\n this.displayAlert('norecordingfound');\n return;\n }\n\n const fileName = this.getFileName((Math.random() * 1000).toString().replace('.', ''));\n\n // Upload recording to server.\n try {\n // Once uploading starts, do not allow any further changes to the recording.\n this.setRecordButtonVisibility(false);\n\n // Disable the upload button.\n this.setUploadButtonState(false);\n\n // Upload the recording.\n const fileURL = await uploadFile(this.editor, 'media', this.blob, fileName, (progress) => {\n this.setUploadButtonTextProgress(progress);\n });\n this.insertMedia(fileURL);\n this.close();\n addToast(await getString('recordinguploaded', component));\n } catch (error) {\n // Show a toast and unhide the button.\n this.setUploadButtonState(true);\n\n addToast(await getString('uploadfailed', component, {error}), {\n type: 'error',\n });\n\n }\n }\n\n /**\n * Helper to get the container that a button is in.\n *\n * @param {string} purpose The button purpose\n * @returns {HTMLElement}\n */\n getButtonContainer(purpose) {\n return this.modalRoot.querySelector(`[data-purpose=\"${purpose}-container\"]`);\n }\n\n /**\n * Check whether the browser is compatible with capturing media.\n *\n * @returns {boolean}\n */\n static isBrowserCompatible() {\n return this.checkSecure() && this.hasUserMedia();\n }\n\n static async display(editor) {\n const ModalClass = this.getModalClass();\n const modal = await ModalClass.create({\n templateContext: {\n isallowedpausing: isPausingAllowed(editor),\n },\n large: true,\n removeOnClose: true,\n });\n\n // Set up the VideoRecorder.\n const recorder = new this(editor, modal);\n if (recorder.isReady()) {\n modal.show();\n }\n return modal;\n }\n\n /**\n * Check whether the browser is compatible with capturing media, and display a warning if not.\n *\n * @returns {boolean}\n */\n checkAndWarnAboutBrowserCompatibility() {\n if (!this.constructor.checkSecure()) {\n getStrings(['insecurealert_title', 'insecurealert'].map((key) => ({key, component})))\n .then(([title, message]) => addToast(message, {title, type: 'error'}))\n .catch();\n return false;\n }\n\n if (!this.constructor.hasUserMedia) {\n getStrings(['nowebrtc_title', 'nowebrtc'].map((key) => ({key, component})))\n .then(([title, message]) => addToast(message, {title, type: 'error'}))\n .catch();\n return false;\n }\n\n return true;\n }\n\n /**\n * Check whether the browser supports WebRTC.\n *\n * @returns {boolean}\n */\n static hasUserMedia() {\n return (navigator.mediaDevices && window.MediaRecorder);\n }\n\n /**\n * Check whether the hostname is either hosted over SSL, or from a valid localhost hostname.\n *\n * The UserMedia API can only be used in secure contexts as noted.\n * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#privacy_and_security}\n *\n * @returns {boolean} Whether the plugin can be loaded.\n */\n static checkSecure() {\n // Note: We can now use window.isSecureContext.\n // https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#feature_detection\n // https://developer.mozilla.org/en-US/docs/Web/API/isSecureContext\n return window.isSecureContext;\n }\n\n /**\n * Update the content of the stop recording button timer.\n */\n async setStopRecordingButton() {\n const {html, js} = await Templates.renderForPromise('tiny_recordrtc/timeremaining', this.getTimeRemaining());\n Templates.replaceNodeContents(this.startStopButton, html, js);\n this.startButtonTimer();\n }\n\n /**\n * Update the time on the stop recording button.\n */\n updateRecordButtonTime() {\n const {remaining, minutes, seconds} = this.getTimeRemaining();\n if (remaining < 0) {\n this.requestRecordingStop();\n } else {\n this.startStopButton.querySelector('[data-type=\"minutes\"]').textContent = minutes;\n this.startStopButton.querySelector('[data-type=\"seconds\"]').textContent = seconds;\n }\n }\n\n /**\n * Set the text of the record button using a language string.\n *\n * @param {string} string The string identifier\n */\n async setRecordButtonTextFromString(string) {\n this.startStopButton.textContent = await getString(string, component);\n }\n\n /**\n * Set the text of the pause button using a language string.\n *\n * @param {string} string The string identifier\n */\n async setPauseButtonTextFromString(string) {\n if (this.pauseResumeButton) {\n this.pauseResumeButton.textContent = await getString(string, component);\n }\n }\n\n /**\n * Set the upload button text progress.\n *\n * @param {number} progress The progress\n */\n async setUploadButtonTextProgress(progress) {\n this.uploadButton.textContent = await getString('uploading', component, {\n progress: Math.round(progress * 100) / 100,\n });\n }\n\n async resetUploadButtonText() {\n this.uploadButton.textContent = await getString('upload', component);\n }\n\n /**\n * Clear the timer for the stop recording button.\n */\n clearButtonTimer() {\n if (this.buttonTimer) {\n clearInterval(this.buttonTimer);\n }\n this.buttonTimer = null;\n this.pauseTime = null;\n this.startTime = null;\n }\n\n /**\n * Pause the timer for the stop recording button.\n */\n pauseButtonTimer() {\n // Stop the countdown timer.\n this.pauseTime = new Date().getTime(); // Store pause time.\n if (this.buttonTimer) {\n clearInterval(this.buttonTimer);\n }\n }\n\n /**\n * Start the timer for the start recording button.\n * If the recording was paused, the timer will resume from the pause time.\n */\n startButtonTimer() {\n if (this.pauseTime !== null) {\n // Resume from pause.\n const pauseDuration = new Date().getTime() - this.pauseTime;\n // Adjust start time by pause duration.\n this.startTime += pauseDuration;\n this.pauseTime = null;\n }\n this.buttonTimer = setInterval(this.updateRecordButtonTime.bind(this), 500);\n }\n\n /**\n * Get the time remaining for the recording.\n *\n * @returns {Object} The minutes and seconds remaining.\n */\n getTimeRemaining() {\n // All times are in milliseconds.\n let now = new Date().getTime();\n if (this.pauseTime !== null) {\n // If paused, use pauseTime instead of current time.\n now = this.pauseTime;\n }\n const remaining = Math.floor(this.getTimeLimit() - ((now - this.startTime) / 1000));\n\n const formatter = new Intl.NumberFormat(navigator.language, {minimumIntegerDigits: 2});\n const seconds = formatter.format(remaining % 60);\n const minutes = formatter.format(Math.floor((remaining - seconds) / 60));\n return {\n remaining,\n minutes,\n seconds,\n };\n }\n\n /**\n * Get the maximum file size that can be uploaded.\n *\n * @returns {number} The max byte size\n */\n getMaxUploadSize() {\n return this.config.maxrecsize;\n }\n\n /**\n * Stop the recording.\n * Please note that this should only stop the recording.\n * Anything related to processing the recording should be handled by the\n * mediaRecorder's stopped event handler which is processed after it has stopped.\n */\n requestRecordingStop() {\n if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {\n this.stopRequested = true;\n if (this.isPaused()) {\n this.stopRecorder();\n }\n } else {\n // There is no recording to stop, but the stream must still be cleaned up.\n this.cleanupStream();\n }\n }\n\n stopRecorder() {\n if (this.isPaused()) {\n this.pauseTime = null;\n }\n this.mediaRecorder.stop();\n\n // Unmute the player so that the audio is heard during playback.\n this.player.muted = false;\n }\n\n /**\n * Clean up the stream.\n *\n * This involves stopping any track which is still active.\n */\n cleanupStream() {\n if (this.stream) {\n this.stream.getTracks()\n .filter((track) => track.readyState !== 'ended')\n .forEach((track) => track.stop());\n }\n }\n\n /**\n * Handle the mediaRecorder `stop` event.\n */\n handleStopped() {\n // Handle the stream data.\n this.onMediaStopped();\n\n // Clear the button timer.\n this.clearButtonTimer();\n }\n\n /**\n * Handle the mediaRecorder `start` event.\n *\n * This event is called when the recording starts.\n */\n handleStarted() {\n this.startTime = new Date().getTime();\n if (isPausingAllowed(this.editor) && !this.isPaused()) {\n this.setPauseButtonVisibility(true);\n }\n this.setStopRecordingButton();\n }\n\n /**\n * Handle the mediaRecorder `pause` event.\n *\n * This event is called when the recording pauses.\n */\n handlePaused() {\n this.pauseButtonTimer();\n this.setPauseButtonTextFromString('resume');\n }\n\n /**\n * Handle the mediaRecorder `resume` event.\n *\n * This event is called when the recording resumes.\n */\n handleResume() {\n this.startButtonTimer();\n this.setPauseButtonTextFromString('pause');\n }\n\n /**\n * Handle the mediaRecorder `dataavailable` event.\n *\n * @param {Event} event\n */\n handleDataAvailable(event) {\n if (this.isRecording() || this.isPaused()) {\n const newSize = this.data.blobSize + event.data.size;\n // Recording stops when either the maximum upload size is reached, or the time limit expires.\n // The time limit is checked in the `updateButtonTime` function.\n if (newSize >= this.getMaxUploadSize()) {\n this.stopRecorder();\n this.displayFileLimitHitMessage();\n } else {\n // Push recording slice to array.\n this.data.chunks.push(event.data);\n\n // Size of all recorded data so far.\n this.data.blobSize = newSize;\n\n if (this.stopRequested) {\n this.stopRecorder();\n }\n }\n }\n }\n\n async displayFileLimitHitMessage() {\n addToast(await getString('maxfilesizehit', component), {\n title: await getString('maxfilesizehit_title', component),\n type: 'error',\n });\n }\n\n /**\n * Check whether the recording is in progress.\n *\n * @returns {boolean}\n */\n isRecording() {\n return this.mediaRecorder?.state === 'recording';\n }\n\n /**\n * Check whether the recording is paused.\n *\n * @returns {boolean}\n */\n isPaused() {\n return this.mediaRecorder?.state === 'paused';\n }\n\n /**\n * Whether any data has been recorded.\n *\n * @returns {boolean}\n */\n hasData() {\n return !!this.data?.blobSize;\n }\n\n /**\n * Start the recording\n */\n async startRecording() {\n if (this.mediaRecorder) {\n // Stop the existing recorder if it exists.\n if (this.isRecording() || this.isPaused()) {\n this.mediaRecorder.stop();\n }\n\n if (this.hasData()) {\n const resetRecording = await this.recordAgainConfirmation();\n if (!resetRecording) {\n // User cancelled at the confirmation to reset the data, so exit early.\n return;\n }\n this.setUploadButtonVisibility(false);\n this.setPlayerState(false);\n if (!this.stream.active) {\n await this.captureUserMedia();\n }\n }\n\n this.mediaRecorder = null;\n }\n\n // The options for the recording codecs and bitrates.\n this.mediaRecorder = new MediaRecorder(this.stream, this.getParsedRecordingOptions());\n\n this.mediaRecorder.addEventListener('dataavailable', this.handleDataAvailable.bind(this));\n this.mediaRecorder.addEventListener('stop', this.handleStopped.bind(this));\n this.mediaRecorder.addEventListener('start', this.handleStarted.bind(this));\n this.mediaRecorder.addEventListener('pause', this.handlePaused.bind(this));\n this.mediaRecorder.addEventListener('resume', this.handleResume.bind(this));\n\n this.data = {\n chunks: [],\n blobSize: 0\n };\n this.setupPlayerSource();\n this.stopRequested = false;\n\n // Capture in 50ms chunks.\n this.mediaRecorder.start(50);\n }\n\n /**\n * Confirm whether the user wants to reset the existing recoring.\n *\n * @returns {Promise} Whether the user confirmed the reset.\n */\n async recordAgainConfirmation() {\n try {\n await saveCancelPromise(\n await getString(\"recordagain_title\", component),\n await getString(\"recordagain_desc\", component),\n await getString(\"confirm_yes\", component)\n );\n return true;\n } catch {\n return false;\n }\n }\n\n /**\n * Insert the HTML to embed the recording into the editor content.\n *\n * @param {string} source The URL to view the media.\n */\n async insertMedia(source) {\n const {html} = await Templates.renderForPromise(\n this.getEmbedTemplateName(),\n this.getEmbedTemplateContext({\n source,\n })\n );\n this.editor.insertContent(html);\n }\n\n /**\n * Add or modify the template parameters for the specified type.\n *\n * @param {Object} templateContext The Tempalte context to use\n * @returns {Object} The finalised template context\n */\n getEmbedTemplateContext(templateContext) {\n return templateContext;\n }\n}\n"],"names":["constructor","editor","modal","ready","this","checkAndWarnAboutBrowserCompatibility","config","params","modalRoot","getRoot","startStopButton","querySelector","uploadButton","pauseResumeButton","setRecordButtonState","player","configurePlayer","registerEventListeners","captureUserMedia","prefetchContent","isReady","Error","name","getSupportedTypes","getRecordingOptions","getFileName","prefix","getMediaConstraints","playOnCapture","getTimeLimit","getEmbedTemplateName","getParsedRecordingOptions","compatTypes","reduce","result","type","push","replace","filter","window","MediaRecorder","isTypeSupported","options","length","mimeType","console","info","stream","navigator","mediaDevices","getUserMedia","handleCaptureSuccess","error","handleCaptureFailure","component","title","content","pendingPromise","Pending","AlertModal","create","body","removeOnClose","show","resolve","srcObject","muted","play","setupPlayerSource","enabled","disabled","setRecordButtonVisibility","visible","getButtonContainer","classList","toggle","setPauseButtonVisibility","setUploadButtonState","setUploadButtonVisibility","setPlayerState","state","controls","subject","toLowerCase","displayAlert","close","hide","addEventListener","handleModalClick","bind","on","ModalEvents","outsideClick","outsideClickHandler","hidden","cleanupStream","requestRecordingStop","handlePlayerError","handlePlayerLoadedMetadata","message","isFinite","duration","currentTime","event","isRecording","isPaused","preventDefault","hasData","button","target","closest","dataset","action","handleRecordingStartStopRequested","uploadRecording","handleRecordingPauseResumeRequested","startRecording","mediaRecorder","pause","resume","blob","Blob","data","chunks","src","URL","createObjectURL","setRecordButtonTextFromString","setPauseButtonTextFromString","fileName","Math","random","toString","fileURL","progress","setUploadButtonTextProgress","insertMedia","purpose","checkSecure","hasUserMedia","ModalClass","getModalClass","templateContext","isallowedpausing","large","map","key","then","_ref2","catch","_ref","isSecureContext","html","js","Templates","renderForPromise","getTimeRemaining","replaceNodeContents","startButtonTimer","updateRecordButtonTime","remaining","minutes","seconds","textContent","string","round","clearButtonTimer","buttonTimer","clearInterval","pauseTime","startTime","pauseButtonTimer","Date","getTime","pauseDuration","setInterval","now","floor","formatter","Intl","NumberFormat","language","minimumIntegerDigits","format","getMaxUploadSize","maxrecsize","stopRequested","stopRecorder","stop","getTracks","track","readyState","forEach","handleStopped","onMediaStopped","handleStarted","setStopRecordingButton","handlePaused","handleResume","handleDataAvailable","newSize","blobSize","size","displayFileLimitHitMessage","_this$data","recordAgainConfirmation","active","start","source","getEmbedTemplateContext","insertContent"],"mappings":"u1DAoDIA,YAAYC,OAAQC,6CAXJ,sCACF,uCACF,uCACA,WASHC,OAAQ,EAERC,KAAKC,+CAILJ,OAASA,YACTK,QAAS,oBAAQL,QAAQM,YACzBL,MAAQA,WACRM,UAAYN,MAAMO,UAAU,QAC5BC,gBAAkBN,KAAKI,UAAUG,cAAc,wCAC/CC,aAAeR,KAAKI,UAAUG,cAAc,qCAC5CE,kBAAoBT,KAAKI,UAAUG,cAAc,0CAGjDG,sBAAqB,QAErBC,OAASX,KAAKY,uBACdC,8BACAd,OAAQ,OAERe,wBACAC,mBAQTC,iBACWhB,KAAKD,MAYhBa,wBACU,IAAIK,yDAAkDjB,KAAKJ,YAAYsB,OASjFC,0BACU,IAAIF,2DAAoDjB,KAAKJ,YAAYsB,OAUnFE,4BACU,IAAIH,6DAAsDjB,KAAKJ,YAAYsB,OAWrFG,YAAYC,cACF,IAAIL,qDAA8CjB,KAAKJ,YAAYsB,OAS7EK,4BACU,IAAIN,6DAAsDjB,KAAKJ,YAAYsB,OAOrFM,uBACW,EAQXC,qBACU,IAAIR,sDAA+CjB,KAAKJ,YAAYsB,OAQ9EQ,6BACU,IAAIT,8DAAuDjB,KAAKJ,YAAYsB,oCAS5E,IAAID,uDAAgDjB,KAAKJ,YAAYsB,OAU/ES,kCAUUC,YATiB5B,KAAKmB,oBACSU,QAAO,CAACC,OAAQC,QACjDD,OAAOE,KAAKD,MAGZD,OAAOE,KAAKD,KAAKE,QAAQ,IAAK,MACvBH,SACR,IAE+BI,QAAQH,MAASI,OAAOC,cAAcC,gBAAgBN,QAElFO,QAAUtC,KAAKoB,6BACM,IAAvBQ,YAAYW,SACZD,QAAQE,SAAWZ,YAAY,IAEnCO,OAAOM,QAAQC,8BACOJ,QAAQE,0BAAiBZ,YAAYW,oBACvDX,aAGGU,2CAQGK,aAAeC,UAAUC,aAAaC,aAAa9C,KAAKuB,4BACzDwB,qBAAqBJ,QAC5B,MAAOK,YACAC,qBAAqBD,QAUlCjC,gDACoBmC,kBAAW,CACvB,YACA,oBACA,mBACA,gBACA,eACA,cACA,oBACA,iBACA,uBACA,eACA,QACA,2CAGc,CACdlD,KAAK0B,uBACL,oDAWWyB,MAAOC,eAChBC,eAAiB,IAAIC,iBAAQ,sBAC7BxD,YAAcyD,eAAWC,OAAO,CAClCL,MAAOA,MACPM,KAAML,QACNM,eAAe,WAGnB5D,MAAM6D,OACNN,eAAeO,UAER9D,MAQXiD,qBAAqBJ,aAEZhC,OAAOkD,UAAYlB,OAEpB3C,KAAKwB,uBAEAb,OAAOmD,OAAQ,OAEfnD,OAAOoD,aAGXpB,OAASA,YACTqB,yBACAtD,sBAAqB,GAM9BsD,oBACShE,KAAKW,OAAOkD,iBACRlD,OAAOkD,UAAY7D,KAAK2C,YAGxBhC,OAAOmD,OAAQ,OAEfnD,OAAOoD,QASpBrD,qBAAqBuD,cACZ3D,gBAAgB4D,UAAYD,QAQrCE,0BAA0BC,SACJpE,KAAKqE,mBAAmB,cAChCC,UAAUC,OAAO,QAASH,SAQxCI,yBAAyBJ,SACjBpE,KAAKS,wBACAA,kBAAkB6D,UAAUC,OAAO,UAAWH,SAS3DK,qBAAqBR,cACZzD,aAAa0D,UAAYD,QAQlCS,0BAA0BN,SACJpE,KAAKqE,mBAAmB,UAChCC,UAAUC,OAAO,QAASH,SAQxCO,eAAeC,sCAENjE,OAAOmD,OAASc,WAChBjE,OAAOkE,SAAWD,yCAElBP,mBAAmB,kEAAWC,UAAUC,OAAO,QAASK,OAQjE3B,qBAAqBD,WAEb8B,qBAAgB9B,MAAM9B,KAAKe,QAAQ,QAAS,IAAI8C,oBAC/CC,cACD,4BAAaF,kBAAiB5B,oBAC9B,kBAAU4B,QAAS5B,oBAO3B+B,aAGSnF,MAAMoF,OAMfrE,8BACST,UAAU+E,iBAAiB,QAASnF,KAAKoF,iBAAiBC,KAAKrF,YAC/DF,MAAMO,UAAUiF,GAAGC,YAAYC,aAAcxF,KAAKyF,oBAAoBJ,KAAKrF,YAC3EF,MAAMO,UAAUiF,GAAGC,YAAYG,QAAQ,UACnCC,qBACAC,+BAEJjF,OAAOwE,iBAAiB,QAASnF,KAAK6F,kBAAkBR,KAAKrF,YAC7DW,OAAOwE,iBAAiB,iBAAkBnF,KAAK8F,2BAA2BT,KAAKrF,OAQxF6F,0BACU7C,MAAQhD,KAAKW,OAAOqC,SACtBA,MAAO,OACD+C,qCAAgC/C,MAAM+C,SAAW,sDAC9CA,QAAS,CAAChE,KAAMiB,aAEpByB,sBAAqB,IAOlCqB,6BACQE,SAAShG,KAAKW,OAAOsF,iBAGhBtF,OAAOuF,YAAc,8BASRC,UAClBnG,KAAKoG,eAAiBpG,KAAKqG,WAG3BF,MAAMG,sBACH,GAAItG,KAAKuG,UAAW,CAIvBJ,MAAMG,2BAGI,yCACI,kBAAU,gBAAiBpD,yBAC3B,kBAAU,eAAgBA,yBAC1B,kBAAU,cAAeA,yBAE9BpD,MAAMoF,OACb,MAAOlC,UAWjBoC,iBAAiBe,aACPK,OAASL,MAAMM,OAAOC,QAAQ,aAChCF,QAAUA,OAAOG,QAAQC,OAAQ,OAC3BA,OAASJ,OAAOG,QAAQC,OACf,cAAXA,aACKC,oCAGM,WAAXD,aACKE,kBAGM,gBAAXF,aACKG,uCAQjBF,oCACQ7G,KAAKoG,eAAiBpG,KAAKqG,gBACtBT,4BAEAoB,iBAObD,sCACQ/G,KAAKoG,mBAEAa,cAAcC,QACZlH,KAAKqG,iBAEPY,cAAcE,qCASlBC,KAAO,IAAIC,KAAKrH,KAAKsH,KAAKC,OAAQ,CACnCxF,KAAM/B,KAAKiH,cAAczE,gBAExB7B,OAAOkD,UAAY,UACnBlD,OAAO6G,IAAMC,IAAIC,gBAAgB1H,KAAKoH,WAGtCO,8BAA8B,oBAG9BjD,2BAA0B,QAC1BC,gBAAe,QACfF,sBAAqB,QAGrBD,0BAAyB,GACG,aAA7BxE,KAAKiH,cAAcrC,YACdgD,6BAA6B,oCASN,IAA5B5H,KAAKsH,KAAKC,OAAOhF,wBACZyC,aAAa,0BAIhB6C,SAAW7H,KAAKqB,aAA6B,IAAhByG,KAAKC,UAAiBC,WAAW/F,QAAQ,IAAK,cAKxEkC,2BAA0B,QAG1BM,sBAAqB,SAGpBwD,cAAgB,qBAAWjI,KAAKH,OAAQ,QAASG,KAAKoH,KAAMS,UAAWK,gBACpEC,4BAA4BD,kBAEhCE,YAAYH,cACZhD,6BACU,kBAAU,oBAAqB/B,oBAChD,MAAOF,YAEAyB,sBAAqB,wBAEX,kBAAU,eAAgBvB,kBAAW,CAACF,MAAAA,QAAS,CAC1DjB,KAAM,WAYlBsC,mBAAmBgE,gBACRrI,KAAKI,UAAUG,uCAAgC8H,6DAS/CrI,KAAKsI,eAAiBtI,KAAKuI,oCAGjB1I,cACX2I,WAAaxI,KAAKyI,gBAClB3I,YAAc0I,WAAWhF,OAAO,CAClCkF,gBAAiB,CACbC,kBAAkB,6BAAiB9I,SAEvC+I,OAAO,EACPlF,eAAe,WAIF,IAAI1D,KAAKH,OAAQC,OACrBkB,WACTlB,MAAM6D,OAEH7D,MAQXG,+CACSD,KAAKJ,YAAY0I,gBAOjBtI,KAAKJ,YAAY2I,mCACP,CAAC,iBAAkB,YAAYM,KAAKC,OAAUA,IAAAA,IAAK5F,UAAAA,uBACzD6F,MAAKC,YAAE7F,MAAO4C,sBAAa,cAASA,QAAS,CAAC5C,MAAAA,MAAOpB,KAAM,aAC3DkH,SACE,wBAVI,CAAC,sBAAuB,iBAAiBJ,KAAKC,OAAUA,IAAAA,IAAK5F,UAAAA,uBACnE6F,MAAKG,WAAE/F,MAAO4C,qBAAa,cAASA,QAAS,CAAC5C,MAAAA,MAAOpB,KAAM,aAC3DkH,SACE,gCAmBHrG,UAAUC,cAAgBV,OAAOC,0CAelCD,OAAOgH,qDAORC,KAACA,KAADC,GAAOA,UAAYC,UAAUC,iBAAiB,+BAAgCvJ,KAAKwJ,oBACzFF,UAAUG,oBAAoBzJ,KAAKM,gBAAiB8I,KAAMC,SACrDK,mBAMTC,+BACUC,UAACA,UAADC,QAAYA,QAAZC,QAAqBA,SAAW9J,KAAKwJ,mBACvCI,UAAY,OACPhE,6BAEAtF,gBAAgBC,cAAc,yBAAyBwJ,YAAcF,aACrEvJ,gBAAgBC,cAAc,yBAAyBwJ,YAAcD,6CAS9CE,aAC3B1J,gBAAgByJ,kBAAoB,kBAAUC,OAAQ9G,sDAQ5B8G,QAC3BhK,KAAKS,yBACAA,kBAAkBsJ,kBAAoB,kBAAUC,OAAQ9G,sDASnCgF,eACzB1H,aAAauJ,kBAAoB,kBAAU,YAAa7G,kBAAW,CACpEgF,SAAUJ,KAAKmC,MAAiB,IAAX/B,UAAkB,yCAKtC1H,aAAauJ,kBAAoB,kBAAU,SAAU7G,mBAM9DgH,mBACQlK,KAAKmK,aACLC,cAAcpK,KAAKmK,kBAElBA,YAAc,UACdE,UAAY,UACZC,UAAY,KAMrBC,wBAESF,WAAY,IAAIG,MAAOC,UACxBzK,KAAKmK,aACLC,cAAcpK,KAAKmK,aAQ3BT,sBAC2B,OAAnB1J,KAAKqK,UAAoB,OAEnBK,eAAgB,IAAIF,MAAOC,UAAYzK,KAAKqK,eAE7CC,WAAaI,mBACbL,UAAY,UAEhBF,YAAcQ,YAAY3K,KAAK2J,uBAAuBtE,KAAKrF,MAAO,KAQ3EwJ,uBAEQoB,KAAM,IAAIJ,MAAOC,UACE,OAAnBzK,KAAKqK,YAELO,IAAM5K,KAAKqK,iBAETT,UAAY9B,KAAK+C,MAAM7K,KAAKyB,gBAAmBmJ,IAAM5K,KAAKsK,WAAa,KAEvEQ,UAAY,IAAIC,KAAKC,aAAapI,UAAUqI,SAAU,CAACC,qBAAsB,IAC7EpB,QAAUgB,UAAUK,OAAOvB,UAAY,UAEtC,CACHA,UAAAA,UACAC,QAHYiB,UAAUK,OAAOrD,KAAK+C,OAAOjB,UAAYE,SAAW,KAIhEA,QAAAA,SASRsB,0BACWpL,KAAKE,OAAOmL,WASvBzF,uBACQ5F,KAAKiH,eAA8C,aAA7BjH,KAAKiH,cAAcrC,YACpC0G,eAAgB,EACjBtL,KAAKqG,iBACAkF,qBAIJ5F,gBAIb4F,eACQvL,KAAKqG,kBACAgE,UAAY,WAEhBpD,cAAcuE,YAGd7K,OAAOmD,OAAQ,EAQxB6B,gBACQ3F,KAAK2C,aACAA,OAAO8I,YACPvJ,QAAQwJ,OAA+B,UAArBA,MAAMC,aACxBC,SAASF,OAAUA,MAAMF,SAOtCK,qBAESC,sBAGA5B,mBAQT6B,qBACSzB,WAAY,IAAIE,MAAOC,WACxB,6BAAiBzK,KAAKH,UAAYG,KAAKqG,iBAClC7B,0BAAyB,QAE7BwH,yBAQTC,oBACS1B,wBACA3C,6BAA6B,UAQtCsE,oBACSxC,wBACA9B,6BAA6B,SAQtCuE,oBAAoBhG,UACZnG,KAAKoG,eAAiBpG,KAAKqG,WAAY,OACjC+F,QAAUpM,KAAKsH,KAAK+E,SAAWlG,MAAMmB,KAAKgF,KAG5CF,SAAWpM,KAAKoL,yBACXG,oBACAgB,oCAGAjF,KAAKC,OAAOvF,KAAKmE,MAAMmB,WAGvBA,KAAK+E,SAAWD,QAEjBpM,KAAKsL,oBACAC,yEAOF,kBAAU,iBAAkBrI,mBAAY,CACnDC,YAAa,kBAAU,uBAAwBD,mBAC/CnB,KAAM,UASdqE,4CACyC,gDAAzBa,wEAAerC,OAQ/ByB,0CACyC,8CAAzBY,0EAAerC,OAQ/B2B,oDACavG,KAAKsH,6BAALkF,WAAWH,oCAOhBrM,KAAKiH,cAAe,KAEhBjH,KAAKoG,eAAiBpG,KAAKqG,kBACtBY,cAAcuE,OAGnBxL,KAAKuG,UAAW,WACavG,KAAKyM,sCAK7B/H,2BAA0B,QAC1BC,gBAAe,GACf3E,KAAK2C,OAAO+J,cACP1M,KAAKc,wBAIdmG,cAAgB,UAIpBA,cAAgB,IAAI7E,cAAcpC,KAAK2C,OAAQ3C,KAAK2B,kCAEpDsF,cAAc9B,iBAAiB,gBAAiBnF,KAAKmM,oBAAoB9G,KAAKrF,YAC9EiH,cAAc9B,iBAAiB,OAAQnF,KAAK6L,cAAcxG,KAAKrF,YAC/DiH,cAAc9B,iBAAiB,QAASnF,KAAK+L,cAAc1G,KAAKrF,YAChEiH,cAAc9B,iBAAiB,QAASnF,KAAKiM,aAAa5G,KAAKrF,YAC/DiH,cAAc9B,iBAAiB,SAAUnF,KAAKkM,aAAa7G,KAAKrF,YAEhEsH,KAAO,CACRC,OAAQ,GACR8E,SAAU,QAETrI,yBACAsH,eAAgB,OAGhBrE,cAAc0F,MAAM,qDAUf,yCACI,kBAAU,oBAAqBzJ,yBAC/B,kBAAU,mBAAoBA,yBAC9B,kBAAU,cAAeA,qBAE5B,EACT,aACS,qBASG0J,cACRxD,KAACA,YAAcE,UAAUC,iBAC3BvJ,KAAK0B,uBACL1B,KAAK6M,wBAAwB,CACzBD,OAAAA,eAGH/M,OAAOiN,cAAc1D,MAS9ByD,wBAAwBnE,wBACbA"} \ No newline at end of file +{"version":3,"file":"base_recorder.min.js","sources":["../src/base_recorder.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 * Tiny Record RTC type.\n *\n * @module tiny_recordrtc/base_recorder\n * @copyright 2022 Stevani Andolo \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {getString, getStrings} from 'core/str';\nimport {component} from './common';\nimport Pending from 'core/pending';\nimport {getData, isPausingAllowed} from './options';\nimport uploadFile from 'editor_tiny/uploader';\nimport {add as addToast} from 'core/toast';\nimport * as ModalEvents from 'core/modal_events';\nimport * as Templates from 'core/templates';\nimport {saveCancelPromise} from 'core/notification';\nimport {prefetchStrings, prefetchTemplates} from 'core/prefetch';\nimport AlertModal from 'core/local/modal/alert';\n\n/**\n * The RecordRTC base class for audio, video, and any other future types\n */\nexport default class {\n\n stopRequested = false;\n buttonTimer = null;\n pauseTime = null;\n startTime = null;\n\n /**\n * Constructor for the RecordRTC class\n *\n * @param {TinyMCE} editor The Editor to which the content will be inserted\n * @param {Modal} modal The Moodle Modal that contains the interface used for recording\n */\n constructor(editor, modal) {\n this.ready = false;\n\n if (!this.checkAndWarnAboutBrowserCompatibility()) {\n return;\n }\n\n this.editor = editor;\n this.config = getData(editor).params;\n this.modal = modal;\n this.modalRoot = modal.getRoot()[0];\n this.startStopButton = this.modalRoot.querySelector('button[data-action=\"startstop\"]');\n this.uploadButton = this.modalRoot.querySelector('button[data-action=\"upload\"]');\n this.pauseResumeButton = this.modalRoot.querySelector('button[data-action=\"pauseresume\"]');\n\n // Disable the record button untilt he stream is acquired.\n this.setRecordButtonState(false);\n\n this.player = this.configurePlayer();\n this.registerEventListeners();\n this.ready = true;\n\n this.captureUserMedia();\n this.prefetchContent();\n }\n\n /**\n * Check whether the browser is compatible.\n *\n * @returns {boolean}\n */\n isReady() {\n return this.ready;\n }\n\n // Disable eslint's valid-jsdoc rule as the following methods are abstract and mnust be overridden by the child class.\n\n /* eslint-disable valid-jsdoc, no-unused-vars */\n\n /**\n * Get the Player element for this type.\n *\n * @returns {HTMLElement} The player element, typically an audio or video tag.\n */\n configurePlayer() {\n throw new Error(`configurePlayer() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Get the list of supported mimetypes for this recorder.\n * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/isTypeSupported}\n *\n * @returns {string[]} The list of supported mimetypes.\n */\n getSupportedTypes() {\n throw new Error(`getSupportedTypes() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Get any recording options passed into the MediaRecorder.\n * Please note that the mimeType will be fetched from {@link getSupportedTypes()}.\n *\n * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/MediaRecorder#options}\n * @returns {Object}\n */\n getRecordingOptions() {\n throw new Error(`getRecordingOptions() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Get a filename for the generated file.\n *\n * Typically this function will take a prefix and add a type-specific suffix such as the extension to it.\n *\n * @param {string} prefix The prefix for the filename generated by the recorder.\n * @returns {string}\n */\n getFileName(prefix) {\n throw new Error(`getFileName() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Get a list of constraints as required by the getUserMedia() function.\n * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#constraints}\n *\n * @returns {Object}\n */\n getMediaConstraints() {\n throw new Error(`getMediaConstraints() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Whether to start playing the recording as it is captured.\n * @returns {boolean} Whether to start playing the recording as it is captured.\n */\n playOnCapture() {\n return false;\n }\n\n /**\n * Get the time limit for this recording type.\n *\n * @returns {number} The time limit in seconds.\n */\n getTimeLimit() {\n throw new Error(`getTimeLimit() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Get the name of the template used when embedding the URL in the editor content.\n *\n * @returns {string}\n */\n getEmbedTemplateName() {\n throw new Error(`getEmbedTemplateName() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Fetch the Class of the Modal to be displayed.\n *\n * @returns {Modal}\n */\n static getModalClass() {\n throw new Error(`getModalClass() must be implemented in ${this.constructor.name}`);\n }\n\n /* eslint-enable valid-jsdoc, no-unused-vars */\n\n /**\n * Get the options for the MediaRecorder.\n *\n * @returns {object} The options for the MediaRecorder instance.\n */\n getParsedRecordingOptions() {\n const requestedTypes = this.getSupportedTypes();\n const possibleTypes = requestedTypes.reduce((result, type) => {\n result.push(type);\n // Safari seems to use codecs: instead of codecs=.\n // It is safe to add both, so we do, but we want them to remain in order.\n result.push(type.replace('=', ':'));\n return result;\n }, []);\n\n const compatTypes = possibleTypes.filter((type) => window.MediaRecorder.isTypeSupported(type));\n\n const options = this.getRecordingOptions();\n if (compatTypes.length !== 0) {\n options.mimeType = compatTypes[0];\n }\n window.console.info(\n `Selected codec ${options.mimeType} from ${compatTypes.length} options.`,\n compatTypes,\n );\n\n return options;\n }\n\n /**\n * Start capturing the User Media and handle success or failure of the capture.\n */\n async captureUserMedia() {\n try {\n const stream = await navigator.mediaDevices.getUserMedia(this.getMediaConstraints());\n this.handleCaptureSuccess(stream);\n } catch (error) {\n this.handleCaptureFailure(error);\n }\n }\n\n /**\n * Prefetch some of the content that will be used in the UI.\n *\n * Note: not all of the strings used are pre-fetched.\n * Some of the strings will be fetched because their template is used.\n */\n prefetchContent() {\n prefetchStrings(component, [\n 'uploading',\n 'recordagain_title',\n 'recordagain_desc',\n 'discard_title',\n 'discard_desc',\n 'confirm_yes',\n 'recordinguploaded',\n 'maxfilesizehit',\n 'maxfilesizehit_title',\n 'uploadfailed',\n 'pause',\n 'resume',\n ]);\n\n prefetchTemplates([\n this.getEmbedTemplateName(),\n 'tiny_recordrtc/timeremaining',\n ]);\n }\n\n /**\n * Display an error message to the user.\n *\n * @param {Promise} title The error title\n * @param {Promise} content The error message\n * @returns {Promise}\n */\n async displayAlert(title, content) {\n const pendingPromise = new Pending('core/confirm:alert');\n const modal = await AlertModal.create({\n title: title,\n body: content,\n removeOnClose: true,\n });\n\n modal.show();\n pendingPromise.resolve();\n\n return modal;\n }\n\n /**\n * Handle successful capture of the User Media.\n *\n * @param {MediaStream} stream The stream as captured by the User Media.\n */\n handleCaptureSuccess(stream) {\n // Set audio player source to microphone stream.\n this.player.srcObject = stream;\n\n if (this.playOnCapture()) {\n // Mute audio, distracting while recording.\n this.player.muted = true;\n\n this.player.play();\n }\n\n this.stream = stream;\n this.setupPlayerSource();\n this.setRecordButtonState(true);\n }\n\n /**\n * Setup the player to use the stream as a source.\n */\n setupPlayerSource() {\n if (!this.player.srcObject) {\n this.player.srcObject = this.stream;\n\n // Mute audio, distracting while recording.\n this.player.muted = true;\n\n this.player.play();\n }\n }\n\n /**\n * Enable the record button.\n *\n * @param {boolean|null} enabled Set the button state\n */\n setRecordButtonState(enabled) {\n this.startStopButton.disabled = !enabled;\n }\n\n /**\n * Configure button visibility for the record button.\n *\n * @param {boolean} visible Set the visibility of the button.\n */\n setRecordButtonVisibility(visible) {\n const container = this.getButtonContainer('start-stop');\n container.classList.toggle('hide', !visible);\n }\n\n /**\n * Configure button visibility for the pause button.\n *\n * @param {boolean} visible Set the visibility of the button.\n */\n setPauseButtonVisibility(visible) {\n if (this.pauseResumeButton) {\n this.pauseResumeButton.classList.toggle('hidden', !visible);\n }\n }\n\n /**\n * Enable the upload button.\n *\n * @param {boolean|null} enabled Set the button state\n */\n setUploadButtonState(enabled) {\n this.uploadButton.disabled = !enabled;\n }\n\n /**\n * Configure button visibility for the upload button.\n *\n * @param {boolean} visible Set the visibility of the button.\n */\n setUploadButtonVisibility(visible) {\n const container = this.getButtonContainer('upload');\n container.classList.toggle('hide', !visible);\n }\n\n /**\n * Sets the state of the audio player, including visibility, muting, and controls.\n *\n * @param {boolean} state A boolean indicating the audio player state.\n */\n setPlayerState(state) {\n // Mute or unmute the audio player and show or hide controls.\n this.player.muted = !state;\n this.player.controls = state;\n // Toggle the 'hide' class on the player button container based on state.\n this.getButtonContainer('player')?.classList.toggle('hide', !state);\n }\n\n /**\n * Handle failure to capture the User Media.\n *\n * @param {Error} error\n */\n handleCaptureFailure(error) {\n // Changes 'CertainError' -> 'gumcertain' to match language string names.\n var subject = `gum${error.name.replace('Error', '').toLowerCase()}`;\n this.displayAlert(\n getString(`${subject}_title`, component),\n getString(subject, component)\n );\n }\n\n /**\n * Close the modal and stop recording.\n */\n close() {\n // Closing the modal will destroy it and remove it from the DOM.\n // It will also stop the recording via the hidden Modal Event.\n this.modal.hide();\n }\n\n /**\n * Register event listeners for the modal.\n */\n registerEventListeners() {\n this.modalRoot.addEventListener('click', this.handleModalClick.bind(this));\n this.modal.getRoot().on(ModalEvents.outsideClick, this.outsideClickHandler.bind(this));\n this.modal.getRoot().on(ModalEvents.hidden, () => {\n this.cleanupStream();\n this.requestRecordingStop();\n });\n this.player.addEventListener('error', this.handlePlayerError.bind(this));\n this.player.addEventListener('loadedmetadata', this.handlePlayerLoadedMetadata.bind(this));\n }\n\n /**\n * Handle the player `error` event.\n *\n * This event is called when the player throws an error.\n */\n handlePlayerError() {\n const error = this.player.error;\n if (error) {\n const message = `An error occurred: ${error.message || 'Unknown error'}. Please try again.`;\n addToast(message, {type: error});\n // Disable the upload button.\n this.setUploadButtonState(false);\n }\n }\n\n /**\n * Handles the event when the player's metadata has been loaded.\n */\n handlePlayerLoadedMetadata() {\n if (isFinite(this.player.duration)) {\n // Note: In Chrome, you need to seek to activate the error listener\n // if an issue arises after inserting the recorded audio into the player source.\n this.player.currentTime = 0.1;\n }\n }\n\n /**\n * Prevent the Modal from closing when recording is on process.\n *\n * @param {MouseEvent} event The click event\n */\n async outsideClickHandler(event) {\n if (this.isRecording() || this.isPaused()) {\n // The user is recording.\n // Do not distract with a confirmation, just prevent closing.\n event.preventDefault();\n } else if (this.hasData()) {\n // If there is a blobsize then there is data that may be lost.\n // Ask the user to confirm they want to close the modal.\n // We prevent default here, and then close the modal if they confirm.\n event.preventDefault();\n\n try {\n await saveCancelPromise(\n await getString(\"discard_title\", component),\n await getString(\"discard_desc\", component),\n await getString(\"confirm_yes\", component),\n );\n this.modal.hide();\n } catch (error) {\n // Do nothing, the modal will not close.\n }\n }\n }\n\n /**\n * Handle a click within the Modal.\n *\n * @param {MouseEvent} event The click event\n */\n handleModalClick(event) {\n const button = event.target.closest('button');\n if (button && button.dataset.action) {\n const action = button.dataset.action;\n if (action === 'startstop') {\n this.handleRecordingStartStopRequested();\n }\n\n if (action === 'upload') {\n this.uploadRecording();\n }\n\n if (action === 'pauseresume') {\n this.handleRecordingPauseResumeRequested();\n }\n }\n }\n\n /**\n * Handle the click event for the recording start/stop button.\n */\n handleRecordingStartStopRequested() {\n if (this.isRecording() || this.isPaused()) {\n this.requestRecordingStop();\n } else {\n this.startRecording();\n }\n }\n\n /**\n * Handle the click event for the recording pause/resume button.\n */\n handleRecordingPauseResumeRequested() {\n if (this.isRecording()) {\n // Pause recording.\n this.mediaRecorder.pause();\n } else if (this.isPaused()) {\n // Resume recording.\n this.mediaRecorder.resume();\n }\n }\n\n /**\n * Handle the media stream after it has finished.\n */\n async onMediaStopped() {\n // Set source of audio player.\n this.blob = new Blob(this.data.chunks, {\n type: this.mediaRecorder.mimeType\n });\n this.player.srcObject = null;\n this.player.src = URL.createObjectURL(this.blob);\n\n // Change the label to \"Record again\".\n this.setRecordButtonTextFromString('recordagain');\n\n // Show upload button.\n this.setUploadButtonVisibility(true);\n this.setPlayerState(true);\n this.setUploadButtonState(true);\n\n // Hide the pause button.\n this.setPauseButtonVisibility(false);\n if (this.mediaRecorder.state === 'inactive') {\n this.setPauseButtonTextFromString('pause');\n }\n }\n\n /**\n * Upload the recording and insert it into the editor content.\n */\n async uploadRecording() {\n // Trigger error if no recording has been made.\n if (this.data.chunks.length === 0) {\n this.displayAlert('norecordingfound');\n return;\n }\n\n const fileName = this.getFileName((Math.random() * 1000).toString().replace('.', ''));\n\n // Upload recording to server.\n try {\n // Once uploading starts, do not allow any further changes to the recording.\n this.setRecordButtonVisibility(false);\n\n // Disable the upload button.\n this.setUploadButtonState(false);\n\n // Upload the recording.\n const fileURL = await uploadFile(this.editor, 'media', this.blob, fileName, (progress) => {\n this.setUploadButtonTextProgress(progress);\n });\n this.insertMedia(fileURL);\n this.close();\n addToast(await getString('recordinguploaded', component));\n } catch (error) {\n // Show a toast and unhide the button.\n this.setUploadButtonState(true);\n\n addToast(await getString('uploadfailed', component, {error}), {\n type: 'error',\n });\n\n }\n }\n\n /**\n * Helper to get the container that a button is in.\n *\n * @param {string} purpose The button purpose\n * @returns {HTMLElement}\n */\n getButtonContainer(purpose) {\n return this.modalRoot.querySelector(`[data-purpose=\"${purpose}-container\"]`);\n }\n\n /**\n * Check whether the browser is compatible with capturing media.\n *\n * @returns {boolean}\n */\n static isBrowserCompatible() {\n return this.checkSecure() && this.hasUserMedia();\n }\n\n static async display(editor) {\n const ModalClass = this.getModalClass();\n const modal = await ModalClass.create({\n templateContext: {\n isallowedpausing: isPausingAllowed(editor),\n },\n large: true,\n removeOnClose: true,\n });\n\n // Set up the VideoRecorder.\n const recorder = new this(editor, modal);\n if (recorder.isReady()) {\n modal.show();\n }\n return modal;\n }\n\n /**\n * Check whether the browser is compatible with capturing media, and display a warning if not.\n *\n * @returns {boolean}\n */\n checkAndWarnAboutBrowserCompatibility() {\n if (!this.constructor.checkSecure()) {\n getStrings(['insecurealert_title', 'insecurealert'].map((key) => ({key, component})))\n .then(([title, message]) => addToast(message, {title, type: 'error'}))\n .catch();\n return false;\n }\n\n if (!this.constructor.hasUserMedia) {\n getStrings(['nowebrtc_title', 'nowebrtc'].map((key) => ({key, component})))\n .then(([title, message]) => addToast(message, {title, type: 'error'}))\n .catch();\n return false;\n }\n\n return true;\n }\n\n /**\n * Check whether the browser supports WebRTC.\n *\n * @returns {boolean}\n */\n static hasUserMedia() {\n return (navigator.mediaDevices && window.MediaRecorder);\n }\n\n /**\n * Check whether the hostname is either hosted over SSL, or from a valid localhost hostname.\n *\n * The UserMedia API can only be used in secure contexts as noted.\n * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#privacy_and_security}\n *\n * @returns {boolean} Whether the plugin can be loaded.\n */\n static checkSecure() {\n // Note: We can now use window.isSecureContext.\n // https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#feature_detection\n // https://developer.mozilla.org/en-US/docs/Web/API/isSecureContext\n return window.isSecureContext;\n }\n\n /**\n * Update the content of the stop recording button timer.\n */\n async setStopRecordingButton() {\n const {html, js} = await Templates.renderForPromise('tiny_recordrtc/timeremaining', this.getTimeRemaining());\n Templates.replaceNodeContents(this.startStopButton, html, js);\n this.startButtonTimer();\n }\n\n /**\n * Update the time on the stop recording button.\n */\n updateRecordButtonTime() {\n const {remaining, minutes, seconds} = this.getTimeRemaining();\n if (remaining < 0) {\n this.requestRecordingStop();\n } else {\n this.startStopButton.querySelector('[data-type=\"minutes\"]').textContent = minutes;\n this.startStopButton.querySelector('[data-type=\"seconds\"]').textContent = seconds;\n }\n }\n\n /**\n * Set the text of the record button using a language string.\n *\n * @param {string} string The string identifier\n */\n async setRecordButtonTextFromString(string) {\n this.startStopButton.textContent = await getString(string, component);\n }\n\n /**\n * Set the text of the pause button using a language string.\n *\n * @param {string} string The string identifier\n */\n async setPauseButtonTextFromString(string) {\n if (this.pauseResumeButton) {\n this.pauseResumeButton.textContent = await getString(string, component);\n }\n }\n\n /**\n * Set the upload button text progress.\n *\n * @param {number} progress The progress\n */\n async setUploadButtonTextProgress(progress) {\n this.uploadButton.textContent = await getString('uploading', component, {\n progress: Math.round(progress * 100) / 100,\n });\n }\n\n async resetUploadButtonText() {\n this.uploadButton.textContent = await getString('upload', component);\n }\n\n /**\n * Clear the timer for the stop recording button.\n */\n clearButtonTimer() {\n if (this.buttonTimer) {\n clearInterval(this.buttonTimer);\n }\n this.buttonTimer = null;\n this.pauseTime = null;\n this.startTime = null;\n }\n\n /**\n * Pause the timer for the stop recording button.\n */\n pauseButtonTimer() {\n // Stop the countdown timer.\n this.pauseTime = new Date().getTime(); // Store pause time.\n if (this.buttonTimer) {\n clearInterval(this.buttonTimer);\n }\n }\n\n /**\n * Start the timer for the start recording button.\n * If the recording was paused, the timer will resume from the pause time.\n */\n startButtonTimer() {\n if (this.pauseTime !== null) {\n // Resume from pause.\n const pauseDuration = new Date().getTime() - this.pauseTime;\n // Adjust start time by pause duration.\n this.startTime += pauseDuration;\n this.pauseTime = null;\n }\n this.buttonTimer = setInterval(this.updateRecordButtonTime.bind(this), 500);\n }\n\n /**\n * Get the time remaining for the recording.\n *\n * @returns {Object} The minutes and seconds remaining.\n */\n getTimeRemaining() {\n // All times are in milliseconds.\n let now = new Date().getTime();\n if (this.pauseTime !== null) {\n // If paused, use pauseTime instead of current time.\n now = this.pauseTime;\n }\n const remaining = Math.floor(this.getTimeLimit() - ((now - this.startTime) / 1000));\n\n const formatter = new Intl.NumberFormat(navigator.language, {minimumIntegerDigits: 2});\n const seconds = formatter.format(remaining % 60);\n const minutes = formatter.format(Math.floor((remaining - seconds) / 60));\n return {\n remaining,\n minutes,\n seconds,\n };\n }\n\n /**\n * Get the maximum file size that can be uploaded.\n *\n * @returns {number} The max byte size\n */\n getMaxUploadSize() {\n return this.config.maxrecsize;\n }\n\n /**\n * Stop the recording.\n * Please note that this should only stop the recording.\n * Anything related to processing the recording should be handled by the\n * mediaRecorder's stopped event handler which is processed after it has stopped.\n */\n requestRecordingStop() {\n if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {\n this.stopRequested = true;\n if (this.isPaused()) {\n this.stopRecorder();\n }\n } else {\n // There is no recording to stop, but the stream must still be cleaned up.\n this.cleanupStream();\n }\n }\n\n stopRecorder() {\n if (this.isPaused()) {\n this.pauseTime = null;\n }\n this.mediaRecorder.stop();\n\n // Unmute the player so that the audio is heard during playback.\n this.player.muted = false;\n }\n\n /**\n * Clean up the stream.\n *\n * This involves stopping any track which is still active.\n */\n cleanupStream() {\n if (this.stream) {\n this.stream.getTracks()\n .filter((track) => track.readyState !== 'ended')\n .forEach((track) => track.stop());\n }\n }\n\n /**\n * Handle the mediaRecorder `stop` event.\n */\n handleStopped() {\n // Handle the stream data.\n this.onMediaStopped();\n\n // Clear the button timer.\n this.clearButtonTimer();\n }\n\n /**\n * Handle the mediaRecorder `start` event.\n *\n * This event is called when the recording starts.\n */\n handleStarted() {\n this.startTime = new Date().getTime();\n if (isPausingAllowed(this.editor) && !this.isPaused()) {\n this.setPauseButtonVisibility(true);\n }\n this.setStopRecordingButton();\n }\n\n /**\n * Handle the mediaRecorder `pause` event.\n *\n * This event is called when the recording pauses.\n */\n handlePaused() {\n this.pauseButtonTimer();\n this.setPauseButtonTextFromString('resume');\n }\n\n /**\n * Handle the mediaRecorder `resume` event.\n *\n * This event is called when the recording resumes.\n */\n handleResume() {\n this.startButtonTimer();\n this.setPauseButtonTextFromString('pause');\n }\n\n /**\n * Handle the mediaRecorder `dataavailable` event.\n *\n * @param {Event} event\n */\n handleDataAvailable(event) {\n if (this.isRecording() || this.isPaused()) {\n const newSize = this.data.blobSize + event.data.size;\n // Max upload size is -1 mean there is no limit.\n // Recording stops when either the maximum upload size is reached, or the time limit expires.\n // The time limit is checked in the `updateButtonTime` function.\n if (this.getMaxUploadSize() !== -1 && newSize >= this.getMaxUploadSize()) {\n this.stopRecorder();\n this.displayFileLimitHitMessage();\n } else {\n // Push recording slice to array.\n this.data.chunks.push(event.data);\n\n // Size of all recorded data so far.\n this.data.blobSize = newSize;\n\n if (this.stopRequested) {\n this.stopRecorder();\n }\n }\n }\n }\n\n async displayFileLimitHitMessage() {\n addToast(await getString('maxfilesizehit', component), {\n title: await getString('maxfilesizehit_title', component),\n type: 'error',\n });\n }\n\n /**\n * Check whether the recording is in progress.\n *\n * @returns {boolean}\n */\n isRecording() {\n return this.mediaRecorder?.state === 'recording';\n }\n\n /**\n * Check whether the recording is paused.\n *\n * @returns {boolean}\n */\n isPaused() {\n return this.mediaRecorder?.state === 'paused';\n }\n\n /**\n * Whether any data has been recorded.\n *\n * @returns {boolean}\n */\n hasData() {\n return !!this.data?.blobSize;\n }\n\n /**\n * Start the recording\n */\n async startRecording() {\n if (this.mediaRecorder) {\n // Stop the existing recorder if it exists.\n if (this.isRecording() || this.isPaused()) {\n this.mediaRecorder.stop();\n }\n\n if (this.hasData()) {\n const resetRecording = await this.recordAgainConfirmation();\n if (!resetRecording) {\n // User cancelled at the confirmation to reset the data, so exit early.\n return;\n }\n this.setUploadButtonVisibility(false);\n this.setPlayerState(false);\n if (!this.stream.active) {\n await this.captureUserMedia();\n }\n }\n\n this.mediaRecorder = null;\n }\n\n // The options for the recording codecs and bitrates.\n this.mediaRecorder = new MediaRecorder(this.stream, this.getParsedRecordingOptions());\n\n this.mediaRecorder.addEventListener('dataavailable', this.handleDataAvailable.bind(this));\n this.mediaRecorder.addEventListener('stop', this.handleStopped.bind(this));\n this.mediaRecorder.addEventListener('start', this.handleStarted.bind(this));\n this.mediaRecorder.addEventListener('pause', this.handlePaused.bind(this));\n this.mediaRecorder.addEventListener('resume', this.handleResume.bind(this));\n\n this.data = {\n chunks: [],\n blobSize: 0\n };\n this.setupPlayerSource();\n this.stopRequested = false;\n\n // Capture in 50ms chunks.\n this.mediaRecorder.start(50);\n }\n\n /**\n * Confirm whether the user wants to reset the existing recoring.\n *\n * @returns {Promise} Whether the user confirmed the reset.\n */\n async recordAgainConfirmation() {\n try {\n await saveCancelPromise(\n await getString(\"recordagain_title\", component),\n await getString(\"recordagain_desc\", component),\n await getString(\"confirm_yes\", component)\n );\n return true;\n } catch {\n return false;\n }\n }\n\n /**\n * Insert the HTML to embed the recording into the editor content.\n *\n * @param {string} source The URL to view the media.\n */\n async insertMedia(source) {\n const {html} = await Templates.renderForPromise(\n this.getEmbedTemplateName(),\n this.getEmbedTemplateContext({\n source,\n })\n );\n this.editor.insertContent(html);\n }\n\n /**\n * Add or modify the template parameters for the specified type.\n *\n * @param {Object} templateContext The Tempalte context to use\n * @returns {Object} The finalised template context\n */\n getEmbedTemplateContext(templateContext) {\n return templateContext;\n }\n}\n"],"names":["constructor","editor","modal","ready","this","checkAndWarnAboutBrowserCompatibility","config","params","modalRoot","getRoot","startStopButton","querySelector","uploadButton","pauseResumeButton","setRecordButtonState","player","configurePlayer","registerEventListeners","captureUserMedia","prefetchContent","isReady","Error","name","getSupportedTypes","getRecordingOptions","getFileName","prefix","getMediaConstraints","playOnCapture","getTimeLimit","getEmbedTemplateName","getParsedRecordingOptions","compatTypes","reduce","result","type","push","replace","filter","window","MediaRecorder","isTypeSupported","options","length","mimeType","console","info","stream","navigator","mediaDevices","getUserMedia","handleCaptureSuccess","error","handleCaptureFailure","component","title","content","pendingPromise","Pending","AlertModal","create","body","removeOnClose","show","resolve","srcObject","muted","play","setupPlayerSource","enabled","disabled","setRecordButtonVisibility","visible","getButtonContainer","classList","toggle","setPauseButtonVisibility","setUploadButtonState","setUploadButtonVisibility","setPlayerState","state","controls","subject","toLowerCase","displayAlert","close","hide","addEventListener","handleModalClick","bind","on","ModalEvents","outsideClick","outsideClickHandler","hidden","cleanupStream","requestRecordingStop","handlePlayerError","handlePlayerLoadedMetadata","message","isFinite","duration","currentTime","event","isRecording","isPaused","preventDefault","hasData","button","target","closest","dataset","action","handleRecordingStartStopRequested","uploadRecording","handleRecordingPauseResumeRequested","startRecording","mediaRecorder","pause","resume","blob","Blob","data","chunks","src","URL","createObjectURL","setRecordButtonTextFromString","setPauseButtonTextFromString","fileName","Math","random","toString","fileURL","progress","setUploadButtonTextProgress","insertMedia","purpose","checkSecure","hasUserMedia","ModalClass","getModalClass","templateContext","isallowedpausing","large","map","key","then","_ref2","catch","_ref","isSecureContext","html","js","Templates","renderForPromise","getTimeRemaining","replaceNodeContents","startButtonTimer","updateRecordButtonTime","remaining","minutes","seconds","textContent","string","round","clearButtonTimer","buttonTimer","clearInterval","pauseTime","startTime","pauseButtonTimer","Date","getTime","pauseDuration","setInterval","now","floor","formatter","Intl","NumberFormat","language","minimumIntegerDigits","format","getMaxUploadSize","maxrecsize","stopRequested","stopRecorder","stop","getTracks","track","readyState","forEach","handleStopped","onMediaStopped","handleStarted","setStopRecordingButton","handlePaused","handleResume","handleDataAvailable","newSize","blobSize","size","displayFileLimitHitMessage","_this$data","recordAgainConfirmation","active","start","source","getEmbedTemplateContext","insertContent"],"mappings":"u1DAoDIA,YAAYC,OAAQC,6CAXJ,sCACF,uCACF,uCACA,WASHC,OAAQ,EAERC,KAAKC,+CAILJ,OAASA,YACTK,QAAS,oBAAQL,QAAQM,YACzBL,MAAQA,WACRM,UAAYN,MAAMO,UAAU,QAC5BC,gBAAkBN,KAAKI,UAAUG,cAAc,wCAC/CC,aAAeR,KAAKI,UAAUG,cAAc,qCAC5CE,kBAAoBT,KAAKI,UAAUG,cAAc,0CAGjDG,sBAAqB,QAErBC,OAASX,KAAKY,uBACdC,8BACAd,OAAQ,OAERe,wBACAC,mBAQTC,iBACWhB,KAAKD,MAYhBa,wBACU,IAAIK,yDAAkDjB,KAAKJ,YAAYsB,OASjFC,0BACU,IAAIF,2DAAoDjB,KAAKJ,YAAYsB,OAUnFE,4BACU,IAAIH,6DAAsDjB,KAAKJ,YAAYsB,OAWrFG,YAAYC,cACF,IAAIL,qDAA8CjB,KAAKJ,YAAYsB,OAS7EK,4BACU,IAAIN,6DAAsDjB,KAAKJ,YAAYsB,OAOrFM,uBACW,EAQXC,qBACU,IAAIR,sDAA+CjB,KAAKJ,YAAYsB,OAQ9EQ,6BACU,IAAIT,8DAAuDjB,KAAKJ,YAAYsB,oCAS5E,IAAID,uDAAgDjB,KAAKJ,YAAYsB,OAU/ES,kCAUUC,YATiB5B,KAAKmB,oBACSU,QAAO,CAACC,OAAQC,QACjDD,OAAOE,KAAKD,MAGZD,OAAOE,KAAKD,KAAKE,QAAQ,IAAK,MACvBH,SACR,IAE+BI,QAAQH,MAASI,OAAOC,cAAcC,gBAAgBN,QAElFO,QAAUtC,KAAKoB,6BACM,IAAvBQ,YAAYW,SACZD,QAAQE,SAAWZ,YAAY,IAEnCO,OAAOM,QAAQC,8BACOJ,QAAQE,0BAAiBZ,YAAYW,oBACvDX,aAGGU,2CAQGK,aAAeC,UAAUC,aAAaC,aAAa9C,KAAKuB,4BACzDwB,qBAAqBJ,QAC5B,MAAOK,YACAC,qBAAqBD,QAUlCjC,gDACoBmC,kBAAW,CACvB,YACA,oBACA,mBACA,gBACA,eACA,cACA,oBACA,iBACA,uBACA,eACA,QACA,2CAGc,CACdlD,KAAK0B,uBACL,oDAWWyB,MAAOC,eAChBC,eAAiB,IAAIC,iBAAQ,sBAC7BxD,YAAcyD,eAAWC,OAAO,CAClCL,MAAOA,MACPM,KAAML,QACNM,eAAe,WAGnB5D,MAAM6D,OACNN,eAAeO,UAER9D,MAQXiD,qBAAqBJ,aAEZhC,OAAOkD,UAAYlB,OAEpB3C,KAAKwB,uBAEAb,OAAOmD,OAAQ,OAEfnD,OAAOoD,aAGXpB,OAASA,YACTqB,yBACAtD,sBAAqB,GAM9BsD,oBACShE,KAAKW,OAAOkD,iBACRlD,OAAOkD,UAAY7D,KAAK2C,YAGxBhC,OAAOmD,OAAQ,OAEfnD,OAAOoD,QASpBrD,qBAAqBuD,cACZ3D,gBAAgB4D,UAAYD,QAQrCE,0BAA0BC,SACJpE,KAAKqE,mBAAmB,cAChCC,UAAUC,OAAO,QAASH,SAQxCI,yBAAyBJ,SACjBpE,KAAKS,wBACAA,kBAAkB6D,UAAUC,OAAO,UAAWH,SAS3DK,qBAAqBR,cACZzD,aAAa0D,UAAYD,QAQlCS,0BAA0BN,SACJpE,KAAKqE,mBAAmB,UAChCC,UAAUC,OAAO,QAASH,SAQxCO,eAAeC,sCAENjE,OAAOmD,OAASc,WAChBjE,OAAOkE,SAAWD,yCAElBP,mBAAmB,kEAAWC,UAAUC,OAAO,QAASK,OAQjE3B,qBAAqBD,WAEb8B,qBAAgB9B,MAAM9B,KAAKe,QAAQ,QAAS,IAAI8C,oBAC/CC,cACD,4BAAaF,kBAAiB5B,oBAC9B,kBAAU4B,QAAS5B,oBAO3B+B,aAGSnF,MAAMoF,OAMfrE,8BACST,UAAU+E,iBAAiB,QAASnF,KAAKoF,iBAAiBC,KAAKrF,YAC/DF,MAAMO,UAAUiF,GAAGC,YAAYC,aAAcxF,KAAKyF,oBAAoBJ,KAAKrF,YAC3EF,MAAMO,UAAUiF,GAAGC,YAAYG,QAAQ,UACnCC,qBACAC,+BAEJjF,OAAOwE,iBAAiB,QAASnF,KAAK6F,kBAAkBR,KAAKrF,YAC7DW,OAAOwE,iBAAiB,iBAAkBnF,KAAK8F,2BAA2BT,KAAKrF,OAQxF6F,0BACU7C,MAAQhD,KAAKW,OAAOqC,SACtBA,MAAO,OACD+C,qCAAgC/C,MAAM+C,SAAW,sDAC9CA,QAAS,CAAChE,KAAMiB,aAEpByB,sBAAqB,IAOlCqB,6BACQE,SAAShG,KAAKW,OAAOsF,iBAGhBtF,OAAOuF,YAAc,8BASRC,UAClBnG,KAAKoG,eAAiBpG,KAAKqG,WAG3BF,MAAMG,sBACH,GAAItG,KAAKuG,UAAW,CAIvBJ,MAAMG,2BAGI,yCACI,kBAAU,gBAAiBpD,yBAC3B,kBAAU,eAAgBA,yBAC1B,kBAAU,cAAeA,yBAE9BpD,MAAMoF,OACb,MAAOlC,UAWjBoC,iBAAiBe,aACPK,OAASL,MAAMM,OAAOC,QAAQ,aAChCF,QAAUA,OAAOG,QAAQC,OAAQ,OAC3BA,OAASJ,OAAOG,QAAQC,OACf,cAAXA,aACKC,oCAGM,WAAXD,aACKE,kBAGM,gBAAXF,aACKG,uCAQjBF,oCACQ7G,KAAKoG,eAAiBpG,KAAKqG,gBACtBT,4BAEAoB,iBAObD,sCACQ/G,KAAKoG,mBAEAa,cAAcC,QACZlH,KAAKqG,iBAEPY,cAAcE,qCASlBC,KAAO,IAAIC,KAAKrH,KAAKsH,KAAKC,OAAQ,CACnCxF,KAAM/B,KAAKiH,cAAczE,gBAExB7B,OAAOkD,UAAY,UACnBlD,OAAO6G,IAAMC,IAAIC,gBAAgB1H,KAAKoH,WAGtCO,8BAA8B,oBAG9BjD,2BAA0B,QAC1BC,gBAAe,QACfF,sBAAqB,QAGrBD,0BAAyB,GACG,aAA7BxE,KAAKiH,cAAcrC,YACdgD,6BAA6B,oCASN,IAA5B5H,KAAKsH,KAAKC,OAAOhF,wBACZyC,aAAa,0BAIhB6C,SAAW7H,KAAKqB,aAA6B,IAAhByG,KAAKC,UAAiBC,WAAW/F,QAAQ,IAAK,cAKxEkC,2BAA0B,QAG1BM,sBAAqB,SAGpBwD,cAAgB,qBAAWjI,KAAKH,OAAQ,QAASG,KAAKoH,KAAMS,UAAWK,gBACpEC,4BAA4BD,kBAEhCE,YAAYH,cACZhD,6BACU,kBAAU,oBAAqB/B,oBAChD,MAAOF,YAEAyB,sBAAqB,wBAEX,kBAAU,eAAgBvB,kBAAW,CAACF,MAAAA,QAAS,CAC1DjB,KAAM,WAYlBsC,mBAAmBgE,gBACRrI,KAAKI,UAAUG,uCAAgC8H,6DAS/CrI,KAAKsI,eAAiBtI,KAAKuI,oCAGjB1I,cACX2I,WAAaxI,KAAKyI,gBAClB3I,YAAc0I,WAAWhF,OAAO,CAClCkF,gBAAiB,CACbC,kBAAkB,6BAAiB9I,SAEvC+I,OAAO,EACPlF,eAAe,WAIF,IAAI1D,KAAKH,OAAQC,OACrBkB,WACTlB,MAAM6D,OAEH7D,MAQXG,+CACSD,KAAKJ,YAAY0I,gBAOjBtI,KAAKJ,YAAY2I,mCACP,CAAC,iBAAkB,YAAYM,KAAKC,OAAUA,IAAAA,IAAK5F,UAAAA,uBACzD6F,MAAKC,YAAE7F,MAAO4C,sBAAa,cAASA,QAAS,CAAC5C,MAAAA,MAAOpB,KAAM,aAC3DkH,SACE,wBAVI,CAAC,sBAAuB,iBAAiBJ,KAAKC,OAAUA,IAAAA,IAAK5F,UAAAA,uBACnE6F,MAAKG,WAAE/F,MAAO4C,qBAAa,cAASA,QAAS,CAAC5C,MAAAA,MAAOpB,KAAM,aAC3DkH,SACE,gCAmBHrG,UAAUC,cAAgBV,OAAOC,0CAelCD,OAAOgH,qDAORC,KAACA,KAADC,GAAOA,UAAYC,UAAUC,iBAAiB,+BAAgCvJ,KAAKwJ,oBACzFF,UAAUG,oBAAoBzJ,KAAKM,gBAAiB8I,KAAMC,SACrDK,mBAMTC,+BACUC,UAACA,UAADC,QAAYA,QAAZC,QAAqBA,SAAW9J,KAAKwJ,mBACvCI,UAAY,OACPhE,6BAEAtF,gBAAgBC,cAAc,yBAAyBwJ,YAAcF,aACrEvJ,gBAAgBC,cAAc,yBAAyBwJ,YAAcD,6CAS9CE,aAC3B1J,gBAAgByJ,kBAAoB,kBAAUC,OAAQ9G,sDAQ5B8G,QAC3BhK,KAAKS,yBACAA,kBAAkBsJ,kBAAoB,kBAAUC,OAAQ9G,sDASnCgF,eACzB1H,aAAauJ,kBAAoB,kBAAU,YAAa7G,kBAAW,CACpEgF,SAAUJ,KAAKmC,MAAiB,IAAX/B,UAAkB,yCAKtC1H,aAAauJ,kBAAoB,kBAAU,SAAU7G,mBAM9DgH,mBACQlK,KAAKmK,aACLC,cAAcpK,KAAKmK,kBAElBA,YAAc,UACdE,UAAY,UACZC,UAAY,KAMrBC,wBAESF,WAAY,IAAIG,MAAOC,UACxBzK,KAAKmK,aACLC,cAAcpK,KAAKmK,aAQ3BT,sBAC2B,OAAnB1J,KAAKqK,UAAoB,OAEnBK,eAAgB,IAAIF,MAAOC,UAAYzK,KAAKqK,eAE7CC,WAAaI,mBACbL,UAAY,UAEhBF,YAAcQ,YAAY3K,KAAK2J,uBAAuBtE,KAAKrF,MAAO,KAQ3EwJ,uBAEQoB,KAAM,IAAIJ,MAAOC,UACE,OAAnBzK,KAAKqK,YAELO,IAAM5K,KAAKqK,iBAETT,UAAY9B,KAAK+C,MAAM7K,KAAKyB,gBAAmBmJ,IAAM5K,KAAKsK,WAAa,KAEvEQ,UAAY,IAAIC,KAAKC,aAAapI,UAAUqI,SAAU,CAACC,qBAAsB,IAC7EpB,QAAUgB,UAAUK,OAAOvB,UAAY,UAEtC,CACHA,UAAAA,UACAC,QAHYiB,UAAUK,OAAOrD,KAAK+C,OAAOjB,UAAYE,SAAW,KAIhEA,QAAAA,SASRsB,0BACWpL,KAAKE,OAAOmL,WASvBzF,uBACQ5F,KAAKiH,eAA8C,aAA7BjH,KAAKiH,cAAcrC,YACpC0G,eAAgB,EACjBtL,KAAKqG,iBACAkF,qBAIJ5F,gBAIb4F,eACQvL,KAAKqG,kBACAgE,UAAY,WAEhBpD,cAAcuE,YAGd7K,OAAOmD,OAAQ,EAQxB6B,gBACQ3F,KAAK2C,aACAA,OAAO8I,YACPvJ,QAAQwJ,OAA+B,UAArBA,MAAMC,aACxBC,SAASF,OAAUA,MAAMF,SAOtCK,qBAESC,sBAGA5B,mBAQT6B,qBACSzB,WAAY,IAAIE,MAAOC,WACxB,6BAAiBzK,KAAKH,UAAYG,KAAKqG,iBAClC7B,0BAAyB,QAE7BwH,yBAQTC,oBACS1B,wBACA3C,6BAA6B,UAQtCsE,oBACSxC,wBACA9B,6BAA6B,SAQtCuE,oBAAoBhG,UACZnG,KAAKoG,eAAiBpG,KAAKqG,WAAY,OACjC+F,QAAUpM,KAAKsH,KAAK+E,SAAWlG,MAAMmB,KAAKgF,MAIf,IAA7BtM,KAAKoL,oBAA6BgB,SAAWpM,KAAKoL,yBAC7CG,oBACAgB,oCAGAjF,KAAKC,OAAOvF,KAAKmE,MAAMmB,WAGvBA,KAAK+E,SAAWD,QAEjBpM,KAAKsL,oBACAC,yEAOF,kBAAU,iBAAkBrI,mBAAY,CACnDC,YAAa,kBAAU,uBAAwBD,mBAC/CnB,KAAM,UASdqE,4CACyC,gDAAzBa,wEAAerC,OAQ/ByB,0CACyC,8CAAzBY,0EAAerC,OAQ/B2B,oDACavG,KAAKsH,6BAALkF,WAAWH,oCAOhBrM,KAAKiH,cAAe,KAEhBjH,KAAKoG,eAAiBpG,KAAKqG,kBACtBY,cAAcuE,OAGnBxL,KAAKuG,UAAW,WACavG,KAAKyM,sCAK7B/H,2BAA0B,QAC1BC,gBAAe,GACf3E,KAAK2C,OAAO+J,cACP1M,KAAKc,wBAIdmG,cAAgB,UAIpBA,cAAgB,IAAI7E,cAAcpC,KAAK2C,OAAQ3C,KAAK2B,kCAEpDsF,cAAc9B,iBAAiB,gBAAiBnF,KAAKmM,oBAAoB9G,KAAKrF,YAC9EiH,cAAc9B,iBAAiB,OAAQnF,KAAK6L,cAAcxG,KAAKrF,YAC/DiH,cAAc9B,iBAAiB,QAASnF,KAAK+L,cAAc1G,KAAKrF,YAChEiH,cAAc9B,iBAAiB,QAASnF,KAAKiM,aAAa5G,KAAKrF,YAC/DiH,cAAc9B,iBAAiB,SAAUnF,KAAKkM,aAAa7G,KAAKrF,YAEhEsH,KAAO,CACRC,OAAQ,GACR8E,SAAU,QAETrI,yBACAsH,eAAgB,OAGhBrE,cAAc0F,MAAM,qDAUf,yCACI,kBAAU,oBAAqBzJ,yBAC/B,kBAAU,mBAAoBA,yBAC9B,kBAAU,cAAeA,qBAE5B,EACT,aACS,qBASG0J,cACRxD,KAACA,YAAcE,UAAUC,iBAC3BvJ,KAAK0B,uBACL1B,KAAK6M,wBAAwB,CACzBD,OAAAA,eAGH/M,OAAOiN,cAAc1D,MAS9ByD,wBAAwBnE,wBACbA"} \ No newline at end of file diff --git a/public/lib/editor/tiny/plugins/recordrtc/amd/src/base_recorder.js b/public/lib/editor/tiny/plugins/recordrtc/amd/src/base_recorder.js index c9ce7d6daf108..00d5ac70f8059 100644 --- a/public/lib/editor/tiny/plugins/recordrtc/amd/src/base_recorder.js +++ b/public/lib/editor/tiny/plugins/recordrtc/amd/src/base_recorder.js @@ -873,9 +873,10 @@ export default class { handleDataAvailable(event) { if (this.isRecording() || this.isPaused()) { const newSize = this.data.blobSize + event.data.size; + // Max upload size is -1 mean there is no limit. // Recording stops when either the maximum upload size is reached, or the time limit expires. // The time limit is checked in the `updateButtonTime` function. - if (newSize >= this.getMaxUploadSize()) { + if (this.getMaxUploadSize() !== -1 && newSize >= this.getMaxUploadSize()) { this.stopRecorder(); this.displayFileLimitHitMessage(); } else { diff --git a/public/lib/editor/tiny/plugins/recordrtc/classes/plugininfo.php b/public/lib/editor/tiny/plugins/recordrtc/classes/plugininfo.php index 12c05c16a8dec..1bab0b12ab3c1 100644 --- a/public/lib/editor/tiny/plugins/recordrtc/classes/plugininfo.php +++ b/public/lib/editor/tiny/plugins/recordrtc/classes/plugininfo.php @@ -115,7 +115,7 @@ public static function get_plugin_configuration_for_context( } } - $maxrecsize = get_max_upload_file_size(); + $maxrecsize = get_user_max_upload_file_size($context); if (!empty($options['maxbytes'])) { $maxrecsize = min($maxrecsize, $options['maxbytes']); } diff --git a/public/lib/editor/tiny/plugins/recordrtc/tests/plugininfo_test.php b/public/lib/editor/tiny/plugins/recordrtc/tests/plugininfo_test.php index 51b66c03fe86c..b9092ef232f28 100644 --- a/public/lib/editor/tiny/plugins/recordrtc/tests/plugininfo_test.php +++ b/public/lib/editor/tiny/plugins/recordrtc/tests/plugininfo_test.php @@ -30,13 +30,12 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ final class plugininfo_test extends advanced_testcase { - /** * Basic setup for tests. */ public function setUp(): void { parent::setUp(); - $this->resetAfterTest(true); + $this->resetAfterTest(); } /** @@ -49,8 +48,6 @@ public function setUp(): void { * @return void */ public function test_for_external(bool $guest, bool $expectedenabled, array $expectedconfiguration): void { - global $CFG; - $generator = $this->getDataGenerator(); $user = $generator->create_user(); $context = \context_system::instance(); @@ -59,11 +56,28 @@ public function test_for_external(bool $guest, bool $expectedenabled, array $exp } else { $this->setGuestUser(); } + $expectedconfiguration['maxrecsize'] = get_user_max_upload_file_size($context); $this->assertEquals($expectedenabled, plugininfo::is_enabled_for_external($context, ['pluginname' => 'recordrtc'])); $this->assertEquals($expectedconfiguration, plugininfo::get_plugin_configuration_for_external($context)); } + /** + * Test that the get_plugin_configuration_for_external method returns -1 for maxrecsize + * when the user has the 'moodle/course:ignorefilesizelimits' capability. + */ + public function test_for_external_with_ignore_limits_capability(): void { + $generator = $this->getDataGenerator(); + $user = $generator->create_user(); + $context = \context_system::instance(); + $roleid = $generator->create_role(); + assign_capability('moodle/course:ignorefilesizelimits', CAP_ALLOW, $roleid, $context->id); + role_assign($roleid, $user->id, $context->id); + $this->setUser($user); + $config = plugininfo::get_plugin_configuration_for_external($context); + $this->assertSame('-1', $config['maxrecsize']); + } + /** * Data provider for test_for_external. * @@ -79,7 +93,6 @@ public static function for_external_provider(): array { 'audiotimelimit' => get_config('tiny_recordrtc', 'audiotimelimit'), 'videotimelimit' => get_config('tiny_recordrtc', 'videotimelimit'), 'screentimelimit' => get_config('tiny_recordrtc', 'screentimelimit'), - 'maxrecsize' => (string) get_max_upload_file_size(), 'videoscreenwidth' => explode(',', get_config('tiny_recordrtc', 'screensize'))[0], 'videoscreenheight' => explode(',', get_config('tiny_recordrtc', 'screensize'))[1], 'audiortcformat' => (string) get_config('tiny_recordrtc', 'audiortcformat'), diff --git a/public/lib/filelib.php b/public/lib/filelib.php index 1d2d8ee75fbd4..af1f6fccfb798 100644 --- a/public/lib/filelib.php +++ b/public/lib/filelib.php @@ -1080,6 +1080,7 @@ function file_save_draft_area_files($draftitemid, $contextid, $component, $filea $usercontext = context_user::instance($USER->id); $fs = get_file_storage(); + $isdraftdestination = $component === 'user' && $filearea === 'draft'; $options = (array)$options; if (!isset($options['subdirs'])) { @@ -1200,8 +1201,9 @@ function file_save_draft_area_files($draftitemid, $contextid, $component, $filea // Field files.source for draftarea files contains serialised object with source and original information. // We only store the source part of it for non-draft file area. $newsource = $newfile->get_source(); - if ($source = unserialize_object($newfile->get_source() ?? '')) { - if (isset($source->source)) { + if (!$isdraftdestination) { + $source = unserialize_object($newfile->get_source() ?? ''); + if ($source && isset($source->source)) { $newsource = $source->source; } } @@ -1236,10 +1238,11 @@ function file_save_draft_area_files($draftitemid, $contextid, $component, $filea // the size and subdirectory tests are extra safety only, the UI should prevent it foreach ($newhashes as $file) { $file_record = array('contextid'=>$contextid, 'component'=>$component, 'filearea'=>$filearea, 'itemid'=>$itemid, 'timemodified'=>time()); - if ($source = unserialize_object($file->get_source() ?? '')) { + if (!$isdraftdestination) { + $source = unserialize_object($file->get_source() ?? ''); // Field files.source for draftarea files contains serialised object with source and original information. // We only store the source part of it for non-draft file area. - if (isset($source->source)) { + if ($source && isset($source->source)) { $file_record['source'] = $source->source; } } diff --git a/public/lib/filestorage/file_storage.php b/public/lib/filestorage/file_storage.php index 76d9a5bf79daa..15dec64341065 100644 --- a/public/lib/filestorage/file_storage.php +++ b/public/lib/filestorage/file_storage.php @@ -414,7 +414,7 @@ protected function create_file_preview(stored_file $file, $mode) { $mimetype = $file->get_mimetype(); - if ($mimetype === 'image/gif' or $mimetype === 'image/jpeg' or $mimetype === 'image/png') { + if (in_array($mimetype, ['image/gif', 'image/jpeg', 'image/png', 'image/webp'])) { // make a preview of the image $data = $this->create_imagefile_preview($file, $mode); } else if ($mimetype === 'image/svg+xml') { @@ -1746,7 +1746,7 @@ public function convert_image($filerecord, $fid, $newwidth = null, $newheight = $newimg = imagecreatetruecolor($newwidth, $newheight); // Determine if the file supports transparency. - $hasalpha = $filerecord['mimetype'] == 'image/png' || $filerecord['mimetype'] == 'image/gif'; + $hasalpha = in_array($filerecord['mimetype'], ['image/png', 'image/gif', 'image/webp']); // Maintain transparency. if ($hasalpha) { @@ -1812,8 +1812,16 @@ public function convert_image($filerecord, $fid, $newwidth = null, $newheight = imagepng($img, null, $quality, PNG_NO_FILTER); break; + case 'image/webp': + if (is_null($quality)) { + imagewebp($img); + } else { + imagewebp($img, null, $quality); + } + break; + default: - throw new file_exception('storedfileproblem', 'Unsupported mime type'); + throw new file_exception('storedfileproblem', 'Unsupported mime type', $filerecord['mimetype']); } $content = ob_get_contents(); diff --git a/public/lib/form/filemanager.js b/public/lib/form/filemanager.js index cf10f6ceece2b..c6b5a491e74ea 100644 --- a/public/lib/form/filemanager.js +++ b/public/lib/form/filemanager.js @@ -119,17 +119,16 @@ M.form_filemanager.init = function(Y, options) { this.selectnode.setAttribute('role', 'dialog'); this.selectnode.generateID(); - var labelid = 'fm-dialog-label_'+ this.selectnode.get('id'); this.selectui = new M.core.dialogue({ draggable : true, - headerContent: '

' + M.util.get_string('edit', 'moodle') + '

', + headerContent: M.util.get_string('edit', 'moodle'), bodyContent : this.selectnode, centered : true, width : '480px', modal : true, visible : false }); - Y.one('#'+this.selectnode.get('id')).setAttribute('aria-labelledby', labelid); + Y.one('#'+this.selectnode.get('id')).setAttribute('aria-labelledby', this.selectui.get('id') + '-wrap-header-text'); this.selectui.hide(); this.setup_select_file(); // setup buttons onclick events @@ -1272,14 +1271,11 @@ M.form_filemanager.init = function(Y, options) { new Popover(popoverTriggerEl); }); }); + // update dialog header - var nodename = node.fullname; - // Limit the string length so it fits nicely on mobile devices - var namelength = 50; - if (nodename.length > namelength) { - nodename = nodename.substring(0, namelength) + '...'; - } - Y.one('#fm-dialog-label_'+selectnode.get('id')).setContent(Y.Escape.html(M.util.get_string('edit', 'moodle')+' '+nodename)); + Y.one('#' + this.selectui.get('id') + '-wrap-header-text') + .setContent(Y.Escape.html(M.util.get_string('edita', 'moodle', node.fullname))); + // show panel this.selectui.show(); Y.one('#'+selectnode.get('id')).focus(); diff --git a/public/lib/form/form.js b/public/lib/form/form.js index 0761e5f0098dc..f46340f8fc5eb 100644 --- a/public/lib/form/form.js +++ b/public/lib/form/form.js @@ -268,7 +268,7 @@ if (typeof M.form.dependencyManager === 'undefined') { * @param {Boolean} disabled True to disable, false to enable. */ _disableElement: function(name, disabled) { - const els = this.elementsByName(name), + const els = this.elementsByName(name, true), filepicker = this.isFilePicker(name), editors = this.get('form').all('.fitem [data-fieldtype="editor"] textarea[name="' + name + '[text]"]'), staticElement = this.isStaticElement(name); @@ -280,6 +280,19 @@ if (typeof M.form.dependencyManager === 'undefined') { } else { node.removeAttribute('disabled'); } + // Handle element groups. + const groupName = node.getData('groupname'); + if (groupName) { + const groupNameChildren = '[name^="' + groupName + '\\["]'; + node.all(groupNameChildren).each(function(child) { + if (disabled) { + child.setAttribute('disabled', 'disabled'); + } else { + child.removeAttribute('disabled'); + } + }); + } + // Enable/Disable static elements if exist. if (staticElement) { const disabledNonTextElements = 'INPUT,SELECT,TEXTAREA,BUTTON,A'; diff --git a/public/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js b/public/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js index e378576277ff9..53e4b6be06f6a 100644 --- a/public/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js +++ b/public/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js @@ -107,8 +107,13 @@ M.form.dateselector = { showNextMonth: true, firstdayofweek: parseInt(config.firstdayofweek, 10), headerRenderer: function(date) { - // We fetch the current language's preferred time format from the language pack. var calendar = this; + var headerNode = calendar.get('contentBox') + .one('#' + calendar._calendarId + '_header'); + var currentHeader = headerNode ? headerNode.getContent() : ''; + var placeholder = M.util.get_string('loading', 'moodle'); + + // We fetch the current language's preferred time format from the language pack. require(['core/user_date', 'core/notification'], function(UserDate, Notification) { UserDate.get([{ timestamp: Math.floor(date.getTime() / 1000), @@ -122,7 +127,7 @@ M.form.dateselector = { return dateStrs[0]; }).catch(Notification.exception); }); - return ''; + return currentHeader || placeholder; }, WEEKDAYS_MEDIUM: [ diff --git a/public/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js b/public/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js index 2533d921a3310..10235da5c061d 100644 --- a/public/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js +++ b/public/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js @@ -1,2 +1,2 @@ -YUI.add("moodle-form-dateselector",function(n,e){var t,o;n.mix(n.Node.prototype,{firstOptionValue:function(){return"select"===this.get("nodeName").toLowerCase()&&this.one("option").get("value")},lastOptionValue:function(){return"select"===this.get("nodeName").toLowerCase()&&this.all("option").item(this.optionSize()-1).get("value")},optionSize:function(){return"select"===this.get("nodeName").toLowerCase()&&parseInt(this.all("option").size(),10)},selectedOptionValue:function(){return"select"===this.get("nodeName").toLowerCase()&&this.all("option").item(this.get("selectedIndex")).get("value")}}),M.form=M.form||{},M.form.dateselector={panel:null,calendar:null,currentowner:null,hidetimeout:null,repositiontimeout:null,init_date_selectors:function(e){null===this.panel&&this.initPanel(e),n.all(".fdate_time_selector").each(function(){e.node=this,new t(e)}),n.all(".fdate_selector").each(function(){e.node=this,new t(e)})},initPanel:function(e){this.panel=new n.Overlay({visible:!1,bodyContent:n.Node.create('
'),id:"dateselector-calendar-panel",constrain:!0}),this.panel.render(document.body),this.panel.on("focus",function(){var e,t=0;n.all(" [role=dialog], [role=menubar], .moodle-has-zindex").each(function(e){e=this.findZIndex(e);t
'),id:"dateselector-calendar-panel",constrain:!0}),this.panel.render(document.body),this.panel.on("focus",function(){var e,t=0;n.all(" [role=dialog], [role=menubar], .moodle-has-zindex").each(function(e){e=this.findZIndex(e);trequires->yui_module($module, $function, $config); $PAGE->requires->string_for_js('strftimemonthyear', 'langconfig'); + $PAGE->requires->string_for_js('loading', 'moodle'); } } diff --git a/public/lib/google/readme_moodle.txt b/public/lib/google/readme_moodle.txt index 1cd5b86ebf364..76c97e55c4edc 100644 --- a/public/lib/google/readme_moodle.txt +++ b/public/lib/google/readme_moodle.txt @@ -57,6 +57,8 @@ Local changes (to reapply until upstream upgrades contain them): - Converted use of `get_class()` to `static::class` * MDL-81634 - PHP 8.4 compliance - Implicitly defined nullables + * MDL-86733 - PHP 8.2 compliance + - Initialise $userAgent to empty string to prevent null being passed to str_replace() Information ----------- diff --git a/public/lib/google/src/Google/Http/Request.php b/public/lib/google/src/Google/Http/Request.php index 6abb404346fa4..07089fe03c60f 100644 --- a/public/lib/google/src/Google/Http/Request.php +++ b/public/lib/google/src/Google/Http/Request.php @@ -44,7 +44,7 @@ class Google_Http_Request protected $baseComponent = null; protected $path; protected $postBody; - protected $userAgent; + protected $userAgent = ''; protected $canGzip = null; protected $responseHttpCode; diff --git a/public/lib/grade/grade_item.php b/public/lib/grade/grade_item.php index c5144f58697e8..fb63b72fe966b 100644 --- a/public/lib/grade/grade_item.php +++ b/public/lib/grade/grade_item.php @@ -874,7 +874,15 @@ public function regrade_final_grades($userid=null, ?\core\progress\base $progres continue; } - $grade->finalgrade = $this->adjust_raw_grade($grade->rawgrade, $grade->rawgrademin, $grade->rawgrademax); + if ($grade->deductedmark > 0) { + // A penalty is recorded on this grade. Preserve it by recalculating + // from the penalised raw grade so that a full regrade does not silently + // undo the penalty that penalty_manager already applied. + $penalisedraw = max($this->grademin, $grade->rawgrade - $grade->deductedmark); + $grade->finalgrade = $this->adjust_raw_grade($penalisedraw, $grade->rawgrademin, $grade->rawgrademax); + } else { + $grade->finalgrade = $this->adjust_raw_grade($grade->rawgrade, $grade->rawgrademin, $grade->rawgrademax); + } if (grade_floats_different($grade_record->finalgrade, $grade->finalgrade)) { $success = $grade->update('system'); @@ -2067,7 +2075,15 @@ public function update_raw_grade($userid, $rawgrade = false, $source = null, $fe // update final grade if possible if (!$grade->is_locked() and !$grade->is_overridden()) { - $grade->finalgrade = $this->adjust_raw_grade($grade->rawgrade, $grade->rawgrademin, $grade->rawgrademax); + if ($grade->deductedmark > 0 && $rawgrade === false) { + // No new rawgrade was provided (e.g. a submission-date update). The existing + // penalty must be preserved: recalculate finalgrade from the penalised raw grade + // rather than the plain rawgrade, so that the penalty indicator remains visible. + $penalisedraw = max($this->grademin, $grade->rawgrade - $grade->deductedmark); + $grade->finalgrade = $this->adjust_raw_grade($penalisedraw, $grade->rawgrademin, $grade->rawgrademax); + } else { + $grade->finalgrade = $this->adjust_raw_grade($grade->rawgrade, $grade->rawgrademin, $grade->rawgrademax); + } } // TODO: hack alert - create new fields for these in 2.0 @@ -2098,7 +2114,7 @@ public function update_raw_grade($userid, $rawgrade = false, $source = null, $fe // end of hack alert // Only reset the deducted mark if the grade has changed. - if ($grade->timemodified !== $oldgrade->timemodified) { + if ($grade->timemodified !== $oldgrade->timemodified && $rawgrade !== false) { $grade->deductedmark = 0; } diff --git a/public/lib/jslib.php b/public/lib/jslib.php index 3444d8677eff1..7a4b3ced2fd32 100644 --- a/public/lib/jslib.php +++ b/public/lib/jslib.php @@ -54,7 +54,9 @@ function js_send_cached($jspath, $etag, $filename = 'javascript.php') { header('Content-Length: '.filesize($jspath)); } - readfile($jspath); + if (readfile($jspath) === false) { + js_send_css_not_found(); + } die; } @@ -128,6 +130,7 @@ function js_write_cache_file_content($file, $content) { * Sends a 404 message about CSS not being found. */ function js_send_css_not_found() { + header_remove(); header('HTTP/1.0 404 not found'); die('JS was not found, sorry.'); } diff --git a/public/lib/mlbackend/python/classes/processor.php b/public/lib/mlbackend/python/classes/processor.php index 9ec8d6843d0d5..a0a998d9ae39f 100644 --- a/public/lib/mlbackend/python/classes/processor.php +++ b/public/lib/mlbackend/python/classes/processor.php @@ -152,7 +152,7 @@ protected function is_webserver_ready() { } // Check the installed pip package version. - $cmd = "{$this->pathtopython} -m moodlemlbackend.version"; + $cmd = escapeshellarg($this->pathtopython) . ' -m moodlemlbackend.version'; $output = null; $exitcode = null; @@ -568,7 +568,7 @@ public static function check_pip_package_version($actual, $required = self::REQU */ protected function exec_command(string $modulename, array $params, string $errorlangstr) { - $cmd = $this->pathtopython . ' -m moodlemlbackend.' . $modulename . ' '; + $cmd = escapeshellarg($this->pathtopython) . ' -m moodlemlbackend.' . $modulename . ' '; foreach ($params as $param) { $cmd .= escapeshellarg($param) . ' '; } @@ -707,7 +707,7 @@ private function version_check_return($actual, $vercheck) { } if (!$this->useserver) { - $cmd = "{$this->pathtopython} -m moodlemlbackend.version"; + $cmd = escapeshellarg($this->pathtopython) . ' -m moodlemlbackend.version'; } else { // We can't not know which is the python bin in the python ML server, the most likely // value is 'python'. diff --git a/public/lib/moodlelib.php b/public/lib/moodlelib.php index b5222b11b9212..540007bd46318 100644 --- a/public/lib/moodlelib.php +++ b/public/lib/moodlelib.php @@ -6145,7 +6145,7 @@ function send_password_change_confirmation_email($user, $resetrecord) { foreach ($placeholders as $field => $value) { $data->{$field} = $value; } - $data->username = $user->username; + $data->username = s($user->username); $data->sitename = format_string($site->fullname); $data->link = $CFG->wwwroot .'/login/forgot_password.php?token='. $resetrecord->token; $data->admin = generate_email_signoff(); @@ -8499,9 +8499,25 @@ function address_in_subnet($addr, $subnetstr, $checkallzeros = false) { if ($addr == '0.0.0.0' && !$checkallzeros) { return false; } + + $addr = trim($addr); + + // An IPv4-mapped IPv6 address (::ffff:x.x.x.x) is equivalent to its plain IPv4 form. + // Also test the unwrapped IPv4 form against the subnet list, so IPv4-notation rules apply + // (e.g. 127.0.0.0/8) without changing how $addr itself is matched against rules already + // expressed in IPv6 notation (e.g. ::ffff:127.0.0.0/104) below. + $packed = @inet_pton($addr); + if ($packed !== false && strlen($packed) === 16 + && substr($packed, 0, 12) === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff") { + $unwrapped = inet_ntop(substr($packed, 12)); + if ($unwrapped !== false && address_in_subnet($unwrapped, $subnetstr, $checkallzeros)) { + return true; + } + } + $subnets = explode(',', $subnetstr); $found = false; - $addr = trim($addr); + $addr = cleanremoteaddr($addr, false); // Normalise. if ($addr === null) { return false; @@ -9600,52 +9616,25 @@ function is_mnet_remote_user($user) { function setup_lang_from_browser() { global $CFG, $SESSION, $USER; + // Lang is defined in session or user profile, nothing to do. if (!empty($SESSION->lang) or !empty($USER->lang) or empty($CFG->autolang)) { - // Lang is defined in session or user profile, nothing to do. return; } - if (!isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { // There isn't list of browser langs, nothing to do. + $lang = \core\lang::match_lang_from_browser_header($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? null); + + if (empty($lang)) { return; } - // Extract and clean langs from headers. - $rawlangs = $_SERVER['HTTP_ACCEPT_LANGUAGE']; - $rawlangs = str_replace('-', '_', $rawlangs); // We are using underscores. - $rawlangs = explode(',', $rawlangs); // Convert to array. - $langs = array(); - - $order = 1.0; - foreach ($rawlangs as $lang) { - if (strpos($lang, ';') === false) { - $langs[(string)$order] = $lang; - $order = $order-0.01; - } else { - $parts = explode(';', $lang); - $pos = strpos($parts[1], '='); - $langs[substr($parts[1], $pos+1)] = $parts[0]; - } - } - krsort($langs, SORT_NUMERIC); - - // Look for such langs under standard locations. - foreach ($langs as $lang) { - // Clean it properly for include. - $lang = strtolower(clean_param($lang, PARAM_SAFEDIR)); - if (get_string_manager()->translation_exists($lang, false)) { - // If the translation for this language exists then try to set it - // for the rest of the session, if this is a read only session then - // we can only set it temporarily in $CFG. - if (defined('READ_ONLY_SESSION') && !empty($CFG->enable_read_only_sessions)) { - $CFG->lang = $lang; - } else { - $SESSION->lang = $lang; - } - // We have finished. Go out. - break; - } + // If the translation for this language exists then try to set it + // for the rest of the session, if this is a read only session then + // we can only set it temporarily in $CFG. + if (defined('READ_ONLY_SESSION') && !empty($CFG->enable_read_only_sessions)) { + $CFG->lang = $lang; + } else { + $SESSION->lang = $lang; } - return; } /** diff --git a/public/lib/questionlib.php b/public/lib/questionlib.php index da66c8f385029..48e221db195db 100644 --- a/public/lib/questionlib.php +++ b/public/lib/questionlib.php @@ -272,6 +272,8 @@ function question_category_delete_safe($category, bool $coursedeletion = false): $name = $context->get_context_name(); $parentcontext = $context->get_course_context(false); $course = ($parentcontext && !$coursedeletion) ? get_course($parentcontext->instanceid) : get_site(); + } else { + $course = get_site(); } $qbank = core_question\local\bank\question_bank_helper::get_default_open_instance_system_type($course, true); question_save_from_deletion(array_keys($questionids), $qbank->context->id, $name, $rescue); @@ -642,8 +644,18 @@ function question_move_questions_to_category($questionids, $newcategoryid): bool $DB->update_record('question_bank_entries', $entry); // Log this question move. - $event = \core\event\question_moved::create_from_question_instance($question, context::instance_by_id($question->contextid), - ['oldcategoryid' => $question->category, 'newcategoryid' => $newcategorydata->id]); + $oldcontext = context::instance_by_id($question->contextid, IGNORE_MISSING); + // When fixing orphaned question categories (e.g. via admin/cli/fix_orphaned_question_categories.php), + // the original context may be missing. The question_moved event requires a valid context + // and will throw a fatal exception otherwise, so fall back to the system context. + if ($oldcontext === false) { + $oldcontext = \context_system::instance(); + } + $event = \core\event\question_moved::create_from_question_instance( + $question, + $oldcontext, + ['oldcategoryid' => $question->category, 'newcategoryid' => $newcategorydata->id] + ); $event->trigger(); } @@ -1840,27 +1852,39 @@ function core_question_question_preview_pluginfile($previewcontext, $questionid, } /** - * Return a list of page types + * Return a list of page types for questions and the page types for the current module/context. + * + * This list is used when displaying blocks on a question page, to provide the list of possible page type patterns for the block. + * * @param string $pagetype current page type * @param stdClass $parentcontext Block's parent context * @param stdClass $currentcontext Current context of block * @return array */ function question_page_type_list($pagetype, $parentcontext, $currentcontext): array { - global $CFG; $types = [ 'question-*' => get_string('page-question-x', 'question'), 'question-edit' => get_string('page-question-edit', 'question'), - 'question-category' => get_string('page-question-category', 'question'), - 'question-export' => get_string('page-question-export', 'question'), - 'question-import' => get_string('page-question-import', 'question') + 'question-bank-managecategories-category' => get_string('page-question-category', 'question'), + 'question-bank-exportquestions-export' => get_string('page-question-export', 'question'), + 'question-bank-importquestions-import' => get_string('page-question-import', 'question'), ]; - if ($currentcontext && $currentcontext->contextlevel == CONTEXT_COURSE) { - require_once($CFG->dirroot . '/course/lib.php'); - return array_merge(course_page_type_list($pagetype, $parentcontext, $currentcontext), $types); - } else { - return $types; + // If current page is in a module context, include the list of page types for that module, if it provides one. + if ($currentcontext && $currentcontext->contextlevel == CONTEXT_MODULE) { + [, $cm] = get_course_and_cm_from_cmid($currentcontext->instanceid); + $directory = core_component::get_plugin_directory('mod', $cm->modname); + if (!empty($directory)) { + $libfile = $directory . '/lib.php'; + if (file_exists($libfile)) { + require_once($libfile); + $function = $cm->modname . '_page_type_list'; + if (function_exists($function)) { + return array_merge($function($pagetype, $parentcontext, $currentcontext), $types); + } + } + } } + return $types; } /** diff --git a/public/lib/templates/block.mustache b/public/lib/templates/block.mustache index 840ef29a969be..a19a72e7caa30 100644 --- a/public/lib/templates/block.mustache +++ b/public/lib/templates/block.mustache @@ -52,15 +52,17 @@
{{! Block header }} - {{#showtitle}} -

{{{title}}}

- {{/showtitle}} +
+ {{#showtitle}} +

{{{title}}}

+ {{/showtitle}} - {{#hascontrols}} -
- {{{controls}}} -
- {{/hascontrols}} + {{#hascontrols}} +
+ {{{controls}}} +
+ {{/hascontrols}} +
{{{content}}} diff --git a/public/lib/templates/filemanager_loginform.mustache b/public/lib/templates/filemanager_loginform.mustache index 1d497c2e0af9c..02023be4a3609 100644 --- a/public/lib/templates/filemanager_loginform.mustache +++ b/public/lib/templates/filemanager_loginform.mustache @@ -32,9 +32,9 @@
-

diff --git a/public/lib/templates/help_icon.mustache b/public/lib/templates/help_icon.mustache index e8d614169ac99..778e053994560 100644 --- a/public/lib/templates/help_icon.mustache +++ b/public/lib/templates/help_icon.mustache @@ -17,9 +17,10 @@ } } }} -
+ data-bs-html="true" tabindex="0" aria-label="{{#str}} help {{/str}}"> {{#pix}}help, core, {{{alt}}}{{/pix}} diff --git a/public/lib/templates/loginform.mustache b/public/lib/templates/loginform.mustache index 577e181f51a79..750c7444ad01a 100644 --- a/public/lib/templates/loginform.mustache +++ b/public/lib/templates/loginform.mustache @@ -166,7 +166,7 @@