diff --git a/admin/settings/h5p.php b/admin/settings/h5p.php index a5128531012..1bf9e5049f4 100644 --- a/admin/settings/h5p.php +++ b/admin/settings/h5p.php @@ -41,4 +41,18 @@ if (!empty($defaulth5plib)) { $settings->add(new admin_settings_h5plib_handler_select('h5plibraryhandler', new lang_string('h5plibraryhandler', 'core_h5p'), new lang_string('h5plibraryhandler_help', 'core_h5p'), $defaulth5plib)); + + $setting = new admin_setting_configtextarea( + 'core_h5p/h5pcustomcss', + new lang_string('h5pcustomcss', 'core_h5p'), + new lang_string('h5pcustomcss_help', 'core_h5p'), + '', + PARAM_NOTAGS + ); + $setting->set_updatedcallback(function () { + // Enables use of file_storage constants. + \core_h5p\local\library\autoloader::register(); + \core_h5p\file_storage::generate_custom_styles(); + }); + $settings->add($setting); } diff --git a/admin/tool/mobile/classes/api.php b/admin/tool/mobile/classes/api.php index 6af51f5809d..b12a32abbe8 100644 --- a/admin/tool/mobile/classes/api.php +++ b/admin/tool/mobile/classes/api.php @@ -393,6 +393,14 @@ class api { $settings->tool_dataprivacy_showdataretentionsummary = get_config('tool_dataprivacy', 'showdataretentionsummary'); } + if (empty($section) || $section === 'h5psettings') { + \core_h5p\local\library\autoloader::register(); + $customcss = \core_h5p\file_storage::get_custom_styles(); + if (!empty($customcss)) { + $settings->h5pcustomcssurl = $customcss['cssurl']->out() . '?ver=' . $customcss['cssversion']; + } + } + return $settings; } diff --git a/admin/tool/mobile/tests/externallib_test.php b/admin/tool/mobile/tests/externallib_test.php index 27123d4e1fc..c93dd4a9e1b 100644 --- a/admin/tool/mobile/tests/externallib_test.php +++ b/admin/tool/mobile/tests/externallib_test.php @@ -273,6 +273,18 @@ class externallib_test extends externallib_advanced_testcase { $this->assertCount(0, $result['warnings']); $this->assertEquals($expected, $result['settings']); + // H5P custom CSS. + set_config('h5pcustomcss', '.debug { color: #fab; }', 'core_h5p'); + \core_h5p\local\library\autoloader::register(); + \core_h5p\file_storage::generate_custom_styles(); + $result = external::get_config(); + $result = external_api::clean_returnvalue(external::get_config_returns(), $result); + + $customcss = \core_h5p\file_storage::get_custom_styles(); + $expected[] = ['name' => 'h5pcustomcssurl', 'value' => $customcss['cssurl']->out() . '?ver=' . $customcss['cssversion']]; + $this->assertCount(0, $result['warnings']); + $this->assertEquals($expected, $result['settings']); + // Change a value and retrieve filtering by section. set_config('commentsperpage', 1); $expected[10]['value'] = 1; diff --git a/h5p/classes/file_storage.php b/h5p/classes/file_storage.php index 7104137f519..25d681764c4 100644 --- a/h5p/classes/file_storage.php +++ b/h5p/classes/file_storage.php @@ -54,6 +54,8 @@ class file_storage implements H5PFileStorage { public const CSS_FILEAREA = 'css'; /** The icon filename */ public const ICON_FILENAME = 'icon.svg'; + /** The custom CSS filename */ + private const CUSTOM_CSS_FILENAME = 'custom_h5p.css'; /** * @var \context $context Currently we use the system context everywhere. @@ -879,4 +881,104 @@ class file_storage implements H5PFileStorage { $this->fs->create_file_from_pathname($record, $sourcefile); } + + /** + * Generate H5P custom styles if any. + */ + public static function generate_custom_styles(): void { + $record = self::get_custom_styles_file_record(); + $cssfile = self::get_custom_styles_file($record); + if ($cssfile) { + // The CSS file needs to be updated, so delete and recreate it + // if there is CSS in the 'h5pcustomcss' setting. + $cssfile->delete(); + } + + $css = get_config('core_h5p', 'h5pcustomcss'); + if (!empty($css)) { + $fs = get_file_storage(); + $fs->create_file_from_string($record, $css); + } + } + + /** + * Get H5P custom styles if any. + * + * @throws \moodle_exception If the CSS setting is empty but there is a file to serve + * or there is no file but the CSS setting is not empty. + * @return array|null If there is CSS then an array with the keys 'cssurl' + * and 'cssversion' is returned otherwise null. 'cssurl' is a link to the + * generated 'custom_h5p.css' file and 'cssversion' the md5 hash of its contents. + */ + public static function get_custom_styles(): ?array { + $record = self::get_custom_styles_file_record(); + + $css = get_config('core_h5p', 'h5pcustomcss'); + if (self::get_custom_styles_file($record)) { + if (empty($css)) { + // The custom CSS file exists and yet the setting 'h5pcustomcss' is empty. + // This prevents an invalid content hash. + throw new \moodle_exception( + 'The H5P \'h5pcustomcss\' setting is empty and yet the custom CSS file \''. + $record['filename']. + '\' exists.', + 'core_h5p' + ); + } + // File exists, so generate the url and version hash. + $cssurl = \moodle_url::make_pluginfile_url( + $record['contextid'], + $record['component'], + $record['filearea'], + null, + $record['filepath'], + $record['filename'] + ); + return ['cssurl' => $cssurl, 'cssversion' => md5($css)]; + } else if (!empty($css)) { + // The custom CSS file does not exist and yet should do. + throw new \moodle_exception( + 'The H5P custom CSS file \''. + $record['filename']. + '\' does not exist and yet there is CSS in the \'h5pcustomcss\' setting.', + 'core_h5p' + ); + } + return null; + } + + /** + * Get H5P custom styles file record. + * + * @return array File record for the CSS custom styles. + */ + private static function get_custom_styles_file_record(): array { + return [ + 'contextid' => \context_system::instance()->id, + 'component' => self::COMPONENT, + 'filearea' => self::CSS_FILEAREA, + 'itemid' => 0, + 'filepath' => '/', + 'filename' => self::CUSTOM_CSS_FILENAME, + ]; + } + + /** + * Get H5P custom styles file. + * + * @param array $record The H5P custom styles file record. + * + * @return stored_file|bool stored_file instance if exists, false if not. + */ + private static function get_custom_styles_file($record): stored_file|bool { + $fs = get_file_storage(); + return $fs->get_file( + $record['contextid'], + $record['component'], + $record['filearea'], + $record['itemid'], + $record['filepath'], + $record['filename'] + ); + } } diff --git a/h5p/classes/output/renderer.php b/h5p/classes/output/renderer.php index 6c0f51ccab0..9f716c9b0d9 100644 --- a/h5p/classes/output/renderer.php +++ b/h5p/classes/output/renderer.php @@ -39,67 +39,14 @@ class renderer extends plugin_renderer_base { * @param string $embedtype Possible values: div, iframe, external, editor */ public function h5p_alter_styles(&$styles, array $libraries, string $embedtype) { - global $CFG, $DB; - - $record = [ - 'contextid' => \context_system::instance()->id, - 'component' => \core_h5p\file_storage::COMPONENT, - 'filearea' => \core_h5p\file_storage::CSS_FILEAREA, - 'itemid' => 0, - 'filepath' => '/', - 'filename' => $CFG->theme . '_h5p.css', - ]; - $fs = get_file_storage(); - // Check if the CSS file for the current theme needs to be updated (because the SCSS settings have changed recently). - if ($cssfile = $fs->get_file( - $record['contextid'], - $record['component'], - $record['filearea'], - $record['itemid'], - $record['filepath'], - $record['filename'])) { - // Get the last time when the SCSS and CSSPRE settings were updated for the current theme and compare it with the - // time modified of the H5P CSS file, to determine whether it needs to be updated. - $sql = "SELECT MAX(timemodified) as timemodified - FROM {config_log} - WHERE plugin = :theme AND (name = 'scss' OR name = 'scsspre')"; - $params = ['theme' => 'theme_' . $CFG->theme]; - $setting = $DB->get_record_sql($sql, $params); - if ($setting && $setting->timemodified > $cssfile->get_timemodified()) { - // The CSS file needs to be updated. First, delete it to recreate it later with the current CSS. - $cssfile->delete(); - $cssfile = null; - } + $customcss = \core_h5p\file_storage::get_custom_styles(); + if (!empty($customcss)) { + // Add the CSS file to the styles array, to load it from the H5P player. + $styles[] = (object) [ + 'path' => $customcss['cssurl']->out(), + 'version' => '?ver='.$customcss['cssversion'], + ]; } - - $theme = \theme_config::load($CFG->theme); - // When 'Raw initial SCSS' and 'Raw SCSS' theme settings are empty, the file doesn't need to be created. - if (empty($theme->settings->scsspre) && empty($theme->settings->scss)) { - return; - } - - // If the CSS file doesn't exist, create it with the styles defined in 'Raw initial SCSS' and 'Raw SCSS' theme settings. - // As these scss and scsspre settings might have dependencies on the theme, the whole CSS theme content will be used and - // passed to the H5P player. - if (!$cssfile) { - $css = $theme->get_css_content(); - $cssfile = $fs->create_file_from_string($record, $css); - } - - $cssurl = \moodle_url::make_pluginfile_url( - $record['contextid'], - $record['component'], - $record['filearea'], - null, - $record['filepath'], - $record['filename'] - ); - - // Add the CSS file to the styles array, to load it from the H5P player. - $styles[] = (object) [ - 'path' => $cssurl->out(), - 'version' => '?ver='.$cssfile->get_timemodified(), - ]; } /** diff --git a/h5p/tests/file_storage_test.php b/h5p/tests/file_storage_test.php index fa61ed79b3e..cbf0ee5a13e 100644 --- a/h5p/tests/file_storage_test.php +++ b/h5p/tests/file_storage_test.php @@ -848,4 +848,115 @@ class file_storage_test extends \advanced_testcase { $this->assertFalse($this->h5p_fs_fs->file_exists($this->h5p_fs_context->id, file_storage::COMPONENT, file_storage::CONTENT_FILEAREA, $h5pcontentid, $filepath, $filename)); } + + /** + * Test H5P custom styles generation. + * + * @covers ::generate_custom_styles + */ + public function test_generate_custom_styles(): void { + \set_config('h5pcustomcss', '.debug { color: #fab; }', 'core_h5p'); + $h5pfsrc = new \ReflectionClass(file_storage::class); + $customcssfilename = $h5pfsrc->getConstant('CUSTOM_CSS_FILENAME'); + + // Test 'h5pcustomcss' with data. + file_storage::generate_custom_styles(); + + $this->assertTrue($this->h5p_fs_fs->file_exists( + \context_system::instance()->id, + file_storage::COMPONENT, + file_storage::CSS_FILEAREA, + 0, + '/', + $customcssfilename) + ); + + $cssfile = $this->h5p_fs_fs->get_file( + \context_system::instance()->id, + file_storage::COMPONENT, + file_storage::CSS_FILEAREA, + 0, + '/', + $customcssfilename + ); + $this->assertInstanceOf('stored_file', $cssfile); + + $csscontents = $cssfile->get_content(); + $this->assertEquals($csscontents, '.debug { color: #fab; }'); + + // Test 'h5pcustomcss' without data. + \set_config('h5pcustomcss', '', 'core_h5p'); + file_storage::generate_custom_styles(); + $this->assertFalse($this->h5p_fs_fs->file_exists( + \context_system::instance()->id, + file_storage::COMPONENT, + file_storage::CSS_FILEAREA, + 0, + '/', + $customcssfilename) + ); + } + + /** + * Test H5P custom styles retrieval. + * + * @covers ::get_custom_styles + */ + public function test_get_custom_styles(): void { + global $CFG; + $css = '.debug { color: #fab; }'; + $cssurl = $CFG->wwwroot . '/pluginfile.php/1/core_h5p/css/custom_h5p.css'; + \set_config('h5pcustomcss', $css, 'core_h5p'); + $h5pfsrc = new \ReflectionClass(file_storage::class); + $customcssfilename = $h5pfsrc->getConstant('CUSTOM_CSS_FILENAME'); + + // Normal operation without data. + \set_config('h5pcustomcss', '', 'core_h5p'); + file_storage::generate_custom_styles(); + $style = file_storage::get_custom_styles(); + $this->assertNull($style); + + // Normal operation with data. + \set_config('h5pcustomcss', $css, 'core_h5p'); + file_storage::generate_custom_styles(); + $style = file_storage::get_custom_styles(); + + $this->assertNotEmpty($style); + $this->assertEquals($style['cssurl']->out(), $cssurl); + $this->assertEquals($style['cssversion'], md5($css)); + + // No CSS set when there is a file. + \set_config('h5pcustomcss', '', 'core_h5p'); + try { + $style = file_storage::get_custom_styles(); + $this->fail('moodle_exception for when there is no CSS and yet there is a file, was not thrown'); + } catch (\moodle_exception $me) { + $this->assertEquals( + 'The H5P \'h5pcustomcss\' setting is empty and yet the custom CSS file \''.$customcssfilename.'\' exists.', + $me->errorcode + ); + } + \set_config('h5pcustomcss', $css, 'core_h5p'); // Reset for next assertion. + + // No CSS file when there is CSS. + $cssfile = $this->h5p_fs_fs->get_file( + \context_system::instance()->id, + file_storage::COMPONENT, + file_storage::CSS_FILEAREA, + 0, + '/', + $customcssfilename + ); + $cssfile->delete(); + try { + $style = file_storage::get_custom_styles(); + $this->fail('moodle_exception for when there is CSS and yet there is a file, was not thrown'); + } catch (\moodle_exception $me) { + $this->assertEquals( + 'The H5P custom CSS file \''.$customcssfilename. + '\' does not exist and yet there is CSS in the \'h5pcustomcss\' setting.', + $me->errorcode + ); + } + } } diff --git a/h5p/upgrade.txt b/h5p/upgrade.txt index f212555c0f3..a75cc2280f1 100644 --- a/h5p/upgrade.txt +++ b/h5p/upgrade.txt @@ -1,6 +1,12 @@ This files describes API changes in core libraries and APIs, information provided here is intended especially for developers. +=== 4.4 === +* Added new methods, 'generate_custom_styles' and 'get_custom_styles' to 'core_h5p\file_storage' to generate then +provide a CSS file to be used by the 'core_h5p\output\renderer\h5p_alter_styles' method, reference: +(https://h5p.org/documentation/for-developers/visual-changes) when there is custom CSS in the 'core_h5p\h5pcustomcss' +setting. This will then apply to the H5P content. + === 4.3 === * The `\core_h5p\file_storage::EDITOR_FILEAREA` constant has been removed, as all editor files are stored in user draft diff --git a/lang/en/h5p.php b/lang/en/h5p.php index 872aa07a2f4..c87a3999e06 100644 --- a/lang/en/h5p.php +++ b/lang/en/h5p.php @@ -131,6 +131,8 @@ $string['h5pfilenotfound'] = 'H5P file not found'; $string['h5pinvalidurl'] = 'Invalid H5P content URL.'; $string['h5plibraryhandler'] = 'H5P framework handler'; $string['h5plibraryhandler_help'] = 'The H5P framework used to display H5P content. The latest version is recommended.'; +$string['h5pcustomcss'] = 'Custom CSS'; +$string['h5pcustomcss_help'] = 'Custom CSS to apply to your H5P modules.'; $string['h5pprivatefile'] = 'This H5P content can\'t be displayed because you don\'t have access to the .h5p file.'; $string['h5pmanage'] = 'Manage H5P content types'; $string['h5poverview'] = 'H5P overview';