Merge branch 'MDL-66592_master' of git://github.com/dmonllao/moodle
authorJun Pataleta <jun@moodle.com>
Thu, 7 Nov 2019 02:35:21 +0000 (10:35 +0800)
committerJun Pataleta <jun@moodle.com>
Thu, 7 Nov 2019 02:35:21 +0000 (10:35 +0800)
218 files changed:
admin/cli/install.php
admin/roles/admins.php
admin/tool/dataprivacy/classes/external.php
admin/tool/dataprivacy/templates/form-user-selector-suggestion.mustache
admin/tool/mobile/classes/api.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/tests/externallib_test.php
admin/user.php
admin/user/user_bulk_cohortadd.php
admin/user/user_bulk_display.php
analytics/classes/model.php
analytics/templates/insight_info_message_prediction.mustache
analytics/templates/notification_styles.mustache
analytics/tests/prediction_actions_test.php
auth/cas/auth.php
auth/shibboleth/auth.php
blocks/myoverview/block_myoverview.php
blocks/myoverview/lang/en/block_myoverview.php
blocks/myoverview/tests/myoverview_test.php [new file with mode: 0644]
cache/stores/redis/tests/compressor_test.php
calendar/amd/build/calendar_threemonth.min.js
calendar/amd/build/calendar_threemonth.min.js.map
calendar/amd/build/view_manager.min.js
calendar/amd/build/view_manager.min.js.map
calendar/amd/src/calendar_threemonth.js
calendar/amd/src/view_manager.js
calendar/externallib.php
calendar/managesubscriptions.php
calendar/tests/behat/calendar_import.feature
calendar/tests/externallib_test.php
calendar/upgrade.txt
course/externallib.php
course/tests/externallib_test.php
enrol/locallib.php
filter/displayh5p/db/install.php [new file with mode: 0644]
filter/displayh5p/filter.php
filter/displayh5p/lang/en/filter_displayh5p.php
filter/displayh5p/tests/filter_test.php
filter/emoticon/filter.php
filter/emoticon/tests/filter_test.php
filter/mathjaxloader/yui/build/moodle-filter_mathjaxloader-loader/moodle-filter_mathjaxloader-loader-debug.js
filter/mathjaxloader/yui/build/moodle-filter_mathjaxloader-loader/moodle-filter_mathjaxloader-loader-min.js
filter/mathjaxloader/yui/build/moodle-filter_mathjaxloader-loader/moodle-filter_mathjaxloader-loader.js
filter/mathjaxloader/yui/src/loader/js/loader.js
grade/export/xls/grade_export_xls.php
grade/grading/lib.php
grade/report/grader/lib.php
h5p/classes/core.php
h5p/classes/factory.php
h5p/classes/framework.php
h5p/classes/helper.php [new file with mode: 0644]
h5p/classes/player.php
h5p/embed.php
h5p/templates/h5perror.mustache
h5p/tests/fixtures/greeting-card-887.h5p [new file with mode: 0644]
h5p/tests/framework_test.php
h5p/tests/h5p_file_storage_test.php
h5p/tests/helper_test.php [new file with mode: 0644]
install/lang/ca_valencia_racv/langconfig.php [new file with mode: 0644]
install/lang/pt/install.php
lang/en/admin.php
lang/en/auth.php
lang/en/availability.php
lang/en/badges.php
lang/en/completion.php
lang/en/course.php
lang/en/h5p.php
lang/en/install.php
lang/en/moodle.php
lang/en/role.php
lang/en/user.php
lib/amd/build/toast.min.js
lib/amd/build/toast.min.js.map
lib/amd/src/toast.js
lib/authlib.php
lib/classes/filetypes.php
lib/csvlib.class.php
lib/dataformatlib.php
lib/db/access.php
lib/db/services.php
lib/editor/atto/plugins/emojipicker/classes/privacy/provider.php [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/lang/en/atto_emojipicker.php [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/lib.php [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/styles.css [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/version.php [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button-debug.js [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button-min.js [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button.js [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/yui/src/button/build.json [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/yui/src/button/js/button.js [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/yui/src/button/meta/button.json [new file with mode: 0644]
lib/editor/atto/plugins/h5p/lang/en/atto_h5p.php
lib/editor/atto/plugins/h5p/lib.php
lib/editor/atto/plugins/h5p/pix/icon-white.png [new file with mode: 0644]
lib/editor/atto/plugins/h5p/pix/icon-white.svg [new file with mode: 0644]
lib/editor/atto/plugins/h5p/styles.css
lib/editor/atto/plugins/h5p/tests/behat/h5p.feature
lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-debug.js
lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-min.js
lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button.js
lib/editor/atto/plugins/h5p/yui/src/button/js/button.js
lib/editor/atto/tests/fixtures/ipsums.h5p [new file with mode: 0644]
lib/filelib.php
lib/form/amd/build/submit.min.js
lib/form/amd/build/submit.min.js.map
lib/form/amd/src/submit.js
lib/form/classes/util.php [new file with mode: 0644]
lib/form/editor.php
lib/formslib.php
lib/installlib.php
lib/mlbackend/python/lang/en/mlbackend_python.php
lib/moodlelib.php
lib/outputrenderers.php
lib/php-css-parser/CSSList/CSSList.php
lib/php-css-parser/Rule/Rule.php
lib/php-css-parser/moodle_readme.txt
lib/tests/rtlcss_test.php [new file with mode: 0644]
lib/tests/weblib_test.php
lib/upgrade.txt
lib/weblib.php
login/index.php
mod/assign/lang/en/assign.php
mod/data/preset.php
mod/forum/amd/build/discussion_list.min.js
mod/forum/amd/build/discussion_list.min.js.map
mod/forum/amd/build/discussion_modern.min.js [deleted file]
mod/forum/amd/build/discussion_modern.min.js.map [deleted file]
mod/forum/amd/build/discussion_nested_v2.min.js [new file with mode: 0644]
mod/forum/amd/build/discussion_nested_v2.min.js.map [new file with mode: 0644]
mod/forum/amd/build/inpage_reply.min.js
mod/forum/amd/build/inpage_reply.min.js.map
mod/forum/amd/build/local/grades/grader.min.js
mod/forum/amd/build/local/grades/grader.min.js.map
mod/forum/amd/build/local/grades/local/grader/user_picker.min.js
mod/forum/amd/build/local/grades/local/grader/user_picker.min.js.map
mod/forum/amd/build/local/grades/local/grader/user_picker/selectors.min.js
mod/forum/amd/build/local/grades/local/grader/user_picker/selectors.min.js.map
mod/forum/amd/build/local/layout/fullscreen.min.js
mod/forum/amd/build/local/layout/fullscreen.min.js.map
mod/forum/amd/build/posts_list.min.js
mod/forum/amd/build/posts_list.min.js.map
mod/forum/amd/src/discussion_list.js
mod/forum/amd/src/discussion_nested_v2.js [moved from mod/forum/amd/src/discussion_modern.js with 98% similarity]
mod/forum/amd/src/inpage_reply.js
mod/forum/amd/src/local/grades/grader.js
mod/forum/amd/src/local/grades/local/grader/user_picker.js
mod/forum/amd/src/local/grades/local/grader/user_picker/selectors.js
mod/forum/amd/src/local/layout/fullscreen.js
mod/forum/amd/src/posts_list.js
mod/forum/classes/local/exporters/author.php
mod/forum/classes/local/factories/renderer.php
mod/forum/classes/local/renderers/discussion.php
mod/forum/classes/local/renderers/discussion_list.php
mod/forum/discuss.php
mod/forum/lang/en/forum.php
mod/forum/lib.php
mod/forum/report/summary/amd/build/filters.min.js
mod/forum/report/summary/amd/build/filters.min.js.map
mod/forum/report/summary/amd/build/selectors.min.js
mod/forum/report/summary/amd/build/selectors.min.js.map
mod/forum/report/summary/amd/src/filters.js
mod/forum/report/summary/amd/src/selectors.js
mod/forum/report/summary/classes/form/dates_filter_form.php [new file with mode: 0644]
mod/forum/report/summary/classes/output/filters.php
mod/forum/report/summary/classes/summary_table.php
mod/forum/report/summary/index.php
mod/forum/report/summary/lang/en/forumreport_summary.php
mod/forum/report/summary/templates/filter_dates.mustache [new file with mode: 0644]
mod/forum/report/summary/templates/filter_dates_popover.mustache [new file with mode: 0644]
mod/forum/report/summary/templates/filter_groups.mustache [new file with mode: 0644]
mod/forum/report/summary/templates/filters.mustache
mod/forum/report/summary/version.php
mod/forum/templates/discussion_list.mustache
mod/forum/templates/discussion_settings_body_content.mustache
mod/forum/templates/forum_action_menu.mustache
mod/forum/templates/forum_discussion_nested_v2.mustache [moved from mod/forum/templates/forum_discussion_modern.mustache with 88% similarity]
mod/forum/templates/forum_discussion_nested_v2_first_post.mustache [moved from mod/forum/templates/forum_discussion_modern_first_post.mustache with 94% similarity]
mod/forum/templates/forum_discussion_nested_v2_post_reply.mustache [moved from mod/forum/templates/forum_discussion_modern_post_reply.mustache with 94% similarity]
mod/forum/templates/forum_discussion_nested_v2_posts.mustache [moved from mod/forum/templates/forum_discussion_modern_posts.mustache with 88% similarity]
mod/forum/templates/grades/grader/discussion/post_modal.mustache
mod/forum/templates/grades/grader/discussion/posts.mustache
mod/forum/templates/inpage_reply_v2.mustache [moved from mod/forum/templates/inpage_reply_modern.mustache with 95% similarity]
mod/forum/templates/local/grades/local/grader/content.mustache
mod/forum/templates/local/grades/local/grader/user_picker.mustache
mod/forum/templates/local/grades/local/grader/user_picker/user_search.mustache [new file with mode: 0644]
mod/forum/templates/settings_header.mustache
mod/forum/tests/behat/behat_mod_forum.php
mod/forum/tests/behat/discussion_subscriptions.feature
mod/forum/tests/behat/forum_subscriptions_default.feature
mod/forum/tests/lib_test.php
mod/forum/view.php
mod/lesson/editpage.php
mod/lesson/locallib.php
pix/f/h5p-180.png [new file with mode: 0644]
pix/f/h5p-24.png [new file with mode: 0644]
pix/f/h5p-256.png [new file with mode: 0644]
pix/f/h5p-48.png [new file with mode: 0644]
pix/f/h5p-64.png [new file with mode: 0644]
pix/f/h5p-72.png [new file with mode: 0644]
pix/f/h5p-80.png [new file with mode: 0644]
pix/f/h5p-96.png [new file with mode: 0644]
pix/f/h5p.png [new file with mode: 0644]
report/insights/classes/output/insights_list.php
report/insights/lang/en/report_insights.php
report/security/locallib.php
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/modules.scss
theme/boost/scss/moodle/undo.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
theme/classic/tests/behat/behat_theme_classic_behat_mod_forum.php [new file with mode: 0644]
tokenpluginfile.php
user/forum.php
user/forum_form.php
user/selector/lib.php
user/templates/upcoming_activities_due_insight_body.mustache
version.php
webservice/renderer.php

index 2dea6ca..ea71571 100644 (file)
@@ -511,100 +511,105 @@ if ($interactive) {
 $database = $databases[$CFG->dbtype];
 
 
-// ask for db host
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('databasehost', 'install'));
-    if ($options['dbhost'] !== '') {
-        $prompt = get_string('clitypevaluedefault', 'admin', $options['dbhost']);
+// We cannot do any validation until all DB connection data is provided.
+$hintdatabase = '';
+do {
+    echo $hintdatabase;
+
+    // Ask for db host.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('databasehost', 'install'));
+        if ($options['dbhost'] !== '') {
+            $prompt = get_string('clitypevaluedefault', 'admin', $options['dbhost']);
+        } else {
+            $prompt = get_string('clitypevalue', 'admin');
+        }
+        $CFG->dbhost = cli_input($prompt, $options['dbhost']);
+
     } else {
-        $prompt = get_string('clitypevalue', 'admin');
+        $CFG->dbhost = $options['dbhost'];
     }
-    $CFG->dbhost = cli_input($prompt, $options['dbhost']);
 
-} else {
-    $CFG->dbhost = $options['dbhost'];
-}
+    // Ask for db name.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('databasename', 'install'));
+        if ($options['dbname'] !== '') {
+            $prompt = get_string('clitypevaluedefault', 'admin', $options['dbname']);
+        } else {
+            $prompt = get_string('clitypevalue', 'admin');
+        }
+        $CFG->dbname = cli_input($prompt, $options['dbname']);
 
-// ask for db name
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('databasename', 'install'));
-    if ($options['dbname'] !== '') {
-        $prompt = get_string('clitypevaluedefault', 'admin', $options['dbname']);
     } else {
-        $prompt = get_string('clitypevalue', 'admin');
+        $CFG->dbname = $options['dbname'];
     }
-    $CFG->dbname = cli_input($prompt, $options['dbname']);
 
-} else {
-    $CFG->dbname = $options['dbname'];
-}
+    // Ask for db prefix.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('dbprefix', 'install'));
+        //TODO: solve somehow the prefix trouble for oci.
+        if ($options['prefix'] !== '') {
+            $prompt = get_string('clitypevaluedefault', 'admin', $options['prefix']);
+        } else {
+            $prompt = get_string('clitypevalue', 'admin');
+        }
+        $CFG->prefix = cli_input($prompt, $options['prefix']);
 
-// ask for db prefix
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('dbprefix', 'install'));
-    //TODO: solve somehow the prefix trouble for oci
-    if ($options['prefix'] !== '') {
-        $prompt = get_string('clitypevaluedefault', 'admin', $options['prefix']);
     } else {
-        $prompt = get_string('clitypevalue', 'admin');
+        $CFG->prefix = $options['prefix'];
     }
-    $CFG->prefix = cli_input($prompt, $options['prefix']);
 
-} else {
-    $CFG->prefix = $options['prefix'];
-}
+    // Ask for db port.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('databaseport', 'install'));
+        $prompt = get_string('clitypevaluedefault', 'admin', $options['dbport']);
+        $CFG->dboptions['dbport'] = (int) cli_input($prompt, $options['dbport']);
 
-// ask for db port
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('databaseport', 'install'));
-    $prompt = get_string('clitypevaluedefault', 'admin', $options['dbport']);
-    $CFG->dboptions['dbport'] = (int)cli_input($prompt, $options['dbport']);
+    } else {
+        $CFG->dboptions['dbport'] = (int) $options['dbport'];
+    }
+    if ($CFG->dboptions['dbport'] <= 0) {
+        $CFG->dboptions['dbport'] = '';
+    }
 
-} else {
-    $CFG->dboptions['dbport'] = (int)$options['dbport'];
-}
-if ($CFG->dboptions['dbport'] <= 0) {
-    $CFG->dboptions['dbport'] = '';
-}
+    // Ask for db socket.
+    if ($CFG->ostype === 'WINDOWS') {
+        $CFG->dboptions['dbsocket'] = '';
 
-// ask for db socket
-if ($CFG->ostype === 'WINDOWS') {
-    $CFG->dboptions['dbsocket'] = '';
+    } else if ($interactive and empty($CFG->dboptions['dbport'])) {
+        cli_separator();
+        cli_heading(get_string('databasesocket', 'install'));
+        $prompt = get_string('clitypevaluedefault', 'admin', $options['dbsocket']);
+        $CFG->dboptions['dbsocket'] = cli_input($prompt, $options['dbsocket']);
 
-} else if ($interactive and empty($CFG->dboptions['dbport'])) {
-    cli_separator();
-    cli_heading(get_string('databasesocket', 'install'));
-    $prompt = get_string('clitypevaluedefault', 'admin', $options['dbsocket']);
-    $CFG->dboptions['dbsocket'] = cli_input($prompt, $options['dbsocket']);
+    } else {
+        $CFG->dboptions['dbsocket'] = $options['dbsocket'];
+    }
 
-} else {
-    $CFG->dboptions['dbsocket'] = $options['dbsocket'];
-}
+    // Ask for db user.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('databaseuser', 'install'));
+        if ($options['dbuser'] !== '') {
+            $prompt = get_string('clitypevaluedefault', 'admin', $options['dbuser']);
+        } else {
+            $prompt = get_string('clitypevalue', 'admin');
+        }
+        $CFG->dbuser = cli_input($prompt, $options['dbuser']);
 
-// ask for db user
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('databaseuser', 'install'));
-    if ($options['dbuser'] !== '') {
-        $prompt = get_string('clitypevaluedefault', 'admin', $options['dbuser']);
     } else {
-        $prompt = get_string('clitypevalue', 'admin');
+        $CFG->dbuser = $options['dbuser'];
     }
-    $CFG->dbuser = cli_input($prompt, $options['dbuser']);
 
-} else {
-    $CFG->dbuser = $options['dbuser'];
-}
+    // Ask for db password.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('databasepass', 'install'));
 
-// ask for db password
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('databasepass', 'install'));
-    do {
         if ($options['dbpass'] !== '') {
             $prompt = get_string('clitypevaluedefault', 'admin', $options['dbpass']);
         } else {
@@ -612,19 +617,23 @@ if ($interactive) {
         }
 
         $CFG->dbpass = cli_input($prompt, $options['dbpass']);
-        if (function_exists('distro_pre_create_db')) { // Hook for distros needing to do something before DB creation
-            $distro = distro_pre_create_db($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix, array('dbpersist'=>0, 'dbport'=>$CFG->dboptions['dbport'], 'dbsocket'=>$CFG->dboptions['dbsocket']), $distro);
+        if (function_exists('distro_pre_create_db')) { // Hook for distros needing to do something before DB creation.
+            $distro = distro_pre_create_db($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix,
+                    array('dbpersist' => 0, 'dbport' => $CFG->dboptions['dbport'], 'dbsocket' => $CFG->dboptions['dbsocket']),
+                    $distro);
         }
-        $hint_database = install_db_validate($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix, array('dbpersist'=>0, 'dbport'=>$CFG->dboptions['dbport'], 'dbsocket'=>$CFG->dboptions['dbsocket']));
-    } while ($hint_database !== '');
+        $hintdatabase = install_db_validate($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix,
+                array('dbpersist' => 0, 'dbport' => $CFG->dboptions['dbport'], 'dbsocket' => $CFG->dboptions['dbsocket']));
 
-} else {
-    $CFG->dbpass = $options['dbpass'];
-    $hint_database = install_db_validate($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix, array('dbpersist'=>0, 'dbport'=>$CFG->dboptions['dbport'], 'dbsocket'=>$CFG->dboptions['dbsocket']));
-    if ($hint_database !== '') {
-        cli_error(get_string('dbconnectionerror', 'install'));
+    } else {
+        $CFG->dbpass = $options['dbpass'];
+        $hintdatabase = install_db_validate($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix,
+                array('dbpersist' => 0, 'dbport' => $CFG->dboptions['dbport'], 'dbsocket' => $CFG->dboptions['dbsocket']));
+        if ($hintdatabase !== '') {
+            cli_error(get_string('dbconnectionerror', 'install'));
+        }
     }
-}
+} while ($hintdatabase !== '');
 
 // ask for fullname
 if ($interactive) {
index 4b341b3..a4b034a 100644 (file)
@@ -36,15 +36,12 @@ if (!is_siteadmin()) {
 }
 
 $admisselector = new core_role_admins_existing_selector();
-$admisselector->set_extra_fields(array('username', 'email'));
-
 $potentialadmisselector = new core_role_admins_potential_selector();
-$potentialadmisselector->set_extra_fields(array('username', 'email'));
 
 if (optional_param('add', false, PARAM_BOOL) and confirm_sesskey()) {
     if ($userstoadd = $potentialadmisselector->get_selected_users()) {
         $user = reset($userstoadd);
-        $username = fullname($user) . " ($user->username, $user->email)";
+        $username = $potentialadmisselector->output_user($user);
         echo $OUTPUT->header();
         $yesurl = new moodle_url('/admin/roles/admins.php', array('confirmadd'=>$user->id, 'sesskey'=>sesskey()));
         echo $OUTPUT->confirm(get_string('confirmaddadmin', 'core_role', $username), $yesurl, $PAGE->url);
@@ -58,7 +55,7 @@ if (optional_param('add', false, PARAM_BOOL) and confirm_sesskey()) {
         if ($USER->id == $user->id) {
             // Can not remove self.
         } else {
-            $username = fullname($user) . " ($user->username, $user->email)";
+            $username = $admisselector->output_user($user);
             echo $OUTPUT->header();
             $yesurl = new moodle_url('/admin/roles/admins.php', array('confirmdel'=>$user->id, 'sesskey'=>sesskey()));
             echo $OUTPUT->confirm(get_string('confirmdeladmin', 'core_role', $username), $yesurl, $PAGE->url);
index 20c28f9..4c4e719 100644 (file)
@@ -713,7 +713,6 @@ class external extends external_api {
 
         list($sql, $params) = users_search_sql($query, '', false, $extrafields, $excludedusers);
         $users = $DB->get_records_select('user', $sql, $params, $sort, $fields, 0, 30);
-
         $useroptions = [];
         foreach ($users as $user) {
             $useroption = (object)[
@@ -722,9 +721,10 @@ class external extends external_api {
             ];
             $useroption->extrafields = [];
             foreach ($extrafields as $extrafield) {
+                // Sanitize the extra fields to prevent potential XSS exploit.
                 $useroption->extrafields[] = (object)[
                     'name' => $extrafield,
-                    'value' => $user->$extrafield
+                    'value' => s($user->$extrafield)
                 ];
             }
             $useroptions[$user->id] = $useroption;
index 0f8443d..92896a6 100644 (file)
@@ -47,6 +47,6 @@
 <span>
     <span>{{fullname}}</span>
     {{#extrafields}}
-        <span><small>{{value}}</small></span>
+        <span><small>{{{value}}}</small></span>
     {{/extrafields}}
 </span>
index 1b34498..7d49d92 100644 (file)
@@ -210,6 +210,12 @@ class api {
         $identityprovidersdata = \auth_plugin_base::prepare_identity_providers_for_output($identityproviders, $OUTPUT);
         if (!empty($identityprovidersdata)) {
             $settings['identityproviders'] = $identityprovidersdata;
+            // Clean URLs to avoid breaking Web Services.
+            // We can't do it in prepare_identity_providers_for_output() because it may break the web output.
+            foreach ($settings['identityproviders'] as &$ip) {
+                $ip['url'] = (!empty($ip['url'])) ? clean_param($ip['url'], PARAM_URL) : '';
+                $ip['iconurl'] = (!empty($ip['iconurl'])) ? clean_param($ip['iconurl'], PARAM_URL) : '';
+            }
         }
 
         // If age is verified, return also the admin contact details.
index 5c7686a..f982776 100644 (file)
@@ -75,7 +75,7 @@ $string['loginintheapp'] = 'Via the app';
 $string['logininthebrowser'] = 'Via a browser window (for SSO plugins)';
 $string['loginintheembeddedbrowser'] = 'Via an embedded browser (for SSO plugins)';
 $string['mainmenu'] = 'Main menu';
-$string['minimumversion'] = 'Require users to upgrade their apps to the minimum version indicated. Those using previous versions of the app will not be able to access to the site. This works since app version 3.8.0 onward.';
+$string['minimumversion'] = 'If an app version is specified (3.8.0 or higher), any users using an older app version will be prompted to upgrade their app before being allowed access to the site.';
 $string['minimumversion_key'] = 'Minimum app version required';
 $string['mobileapp'] = 'Mobile app';
 $string['mobileappconnected'] = 'Mobile app connected';
@@ -93,7 +93,7 @@ $string['readingthisemailgettheapp'] = 'Reading this in an email? <a href="{$a}"
 $string['remoteaddons'] = 'Remote add-ons';
 $string['selfsignedoruntrustedcertificatewarning'] = 'It seems that the HTTPS certificate is self-signed or not trusted. The mobile app will only work with trusted sites.';
 $string['setuplink'] = 'App download page';
-$string['setuplink_desc'] = 'URL of page with links to download the mobile app from the App Store and Google Play.';
+$string['setuplink_desc'] = 'URL of page with options to download the mobile app from the App Store and Google Play. The app download page link is displayed in the page footer and in a user\'s profile. Leave blank to not display a link.';
 $string['smartappbanners'] = 'App Banners';
 $string['typeoflogin'] = 'Type of login';
 $string['typeoflogin_desc'] = 'If the site uses a SSO authentication method, then select via a browser window or via an embedded browser. An embedded browser provides a better user experience, though it doesn\'t work with all SSO plugins.';
index c2118dc..04ed07b 100644 (file)
@@ -103,6 +103,7 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
         );
         $this->assertEquals($expected, $result);
 
+        $this->setAdminUser();
         // Change some values.
         set_config('registerauth', 'email');
         $authinstructions = 'Something with <b>html tags</b>';
@@ -117,6 +118,18 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
         set_config('disabledfeatures', 'myoverview', 'tool_mobile');
         set_config('minimumversion', '3.8.0', 'tool_mobile');
 
+        // Enable couple of issuers.
+        $issuer = \core\oauth2\api::create_standard_issuer('google');
+        $irecord = $issuer->to_record();
+        $irecord->clientid = 'mock';
+        $irecord->clientsecret = 'mock';
+        core\oauth2\api::update_issuer($irecord);
+
+        set_config('hostname', 'localhost', 'auth_cas');
+        set_config('auth_logo', 'http://invalidurl.com//invalid/', 'auth_cas');
+
+        set_config('auth', 'oauth2,cas');
+
         list($authinstructions, $notusedformat) = external_format_text($authinstructions, FORMAT_MOODLE, $context->id);
         $expected['registerauth'] = 'email';
         $expected['authinstructions'] = $authinstructions;
@@ -139,7 +152,26 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
 
         $result = external::get_public_config();
         $result = external_api::clean_returnvalue(external::get_public_config_returns(), $result);
+        // First check providers.
+        $identityproviders = $result['identityproviders'];
+        unset($result['identityproviders']);
+
+        $this->assertEquals('Google', $identityproviders[0]['name']);
+        $this->assertEquals($irecord->image, $identityproviders[0]['iconurl']);
+        $this->assertContains($CFG->wwwroot, $identityproviders[0]['url']);
+
+        $this->assertEquals('CAS', $identityproviders[1]['name']);
+        $this->assertEmpty($identityproviders[1]['iconurl']);
+        $this->assertContains($CFG->wwwroot, $identityproviders[1]['url']);
+
         $this->assertEquals($expected, $result);
+
+        // Change providers img.
+        $newurl = 'validimage.png';
+        set_config('auth_logo', $newurl, 'auth_cas');
+        $result = external::get_public_config();
+        $result = external_api::clean_returnvalue(external::get_public_config_returns(), $result);
+        $this->assertContains($newurl, $result['identityproviders'][1]['iconurl']);
     }
 
     /**
index f6ab61f..216906c 100644 (file)
             $row = array ();
             $row[] = "<a href=\"../user/view.php?id=$user->id&amp;course=$site->id\">$fullname</a>";
             foreach ($extracolumns as $field) {
-                $row[] = $user->{$field};
+                $row[] = s($user->{$field});
             }
             $row[] = $user->city;
             $row[] = $user->country;
index 6ce1d97..f5e3947 100644 (file)
@@ -138,7 +138,7 @@ foreach ($users as $user) {
             '<a href="' . $CFG->wwwroot . '/user/view.php?id=' . $user->id . '&amp;course=' . SITEID . '">' .
             $user->fullname .
             '</a>',
-            $user->email,
+            s($user->email),
             $user->city,
             $user->country,
             $user->lastaccess ? format_time(time() - $user->lastaccess) : $strnever
index dd79566..ae6cd8a 100644 (file)
@@ -72,7 +72,7 @@ foreach($users as $user) {
     $table->data[] = array (
         '<a href="'.$CFG->wwwroot.'/user/view.php?id='.$user->id.'&amp;course='.SITEID.'">'.$user->fullname.'</a>',
 //        $user->username,
-        $user->email,
+        s($user->email),
         $user->city,
         $user->country,
         $user->lastaccess ? format_time(time() - $user->lastaccess) : $strnever
index 9e484b1..5385c29 100644 (file)
@@ -1398,6 +1398,10 @@ class model {
             $current++;
         }
 
+        if (empty($predictions)) {
+            return array();
+        }
+
         return [$current, $predictions];
     }
 
index d6b2f09..6e66e8a 100644 (file)
@@ -38,9 +38,7 @@
 {{> core_analytics/notification_styles}}
 
 {{#body}}
-    <div>
-        {{{.}}}
-    </div>
+    {{{.}}}
 {{/body}}
 <br/>
 
index a942177..527db08 100644 (file)
     }
 }}
 
-<style>
+{{! The styles defined here will be included in the Moodle web UI and in emails. Emails do not include Moodle
+stylesheets so we want these styles to be applied to emails. However, they will also be included in the Moodle web UI.
+We use the not(.dir-ltr):not(.dir-rtl) so that this style is not applied to the Moodle UI.
+Note that gmail strips out HTML styles which selector includes the caracters (), so the font-family rule
+is not applied in gmail.}}
+<head><style>
 body:not(.dir-ltr):not(.dir-rtl) {
     font-family: 'Open Sans', sans-serif;
 }
-body:not(.dir-ltr):not(.dir-rtl) .btn-insight {
+.btn-insight {
     color: #007bff;
     background-color: transparent;
     display: inline-block;
@@ -47,10 +52,9 @@ body:not(.dir-ltr):not(.dir-rtl) .btn-insight {
     user-select: none;
     border: 1px solid #007bff;
     padding: .375rem .75rem;
-    font-size: .9375rem;
     line-height: 1.5;
     border-radius: 0;
     text-decoration: none;
     cursor: pointer;
 }
-</style>
+</style></head>
index 70d368d..f376878 100644 (file)
@@ -55,22 +55,24 @@ class analytics_prediction_actions_testcase extends advanced_testcase {
 
         $this->resetAfterTest(true);
 
-        $course1 = $this->getDataGenerator()->create_course();
-        $course2 = $this->getDataGenerator()->create_course();
-        $this->context = \context_course::instance($course1->id);
+        $this->course1 = $this->getDataGenerator()->create_course();
+        $this->course2 = $this->getDataGenerator()->create_course();
+        $this->context = \context_course::instance($this->course1->id);
 
         $this->teacher1 = $this->getDataGenerator()->create_user();
         $this->teacher2 = $this->getDataGenerator()->create_user();
+        $this->teacher3 = $this->getDataGenerator()->create_user();
 
-        $this->getDataGenerator()->enrol_user($this->teacher1->id, $course1->id, 'editingteacher');
-        $this->getDataGenerator()->enrol_user($this->teacher2->id, $course1->id, 'editingteacher');
+        $this->getDataGenerator()->enrol_user($this->teacher1->id, $this->course1->id, 'editingteacher');
+        $this->getDataGenerator()->enrol_user($this->teacher2->id, $this->course1->id, 'editingteacher');
+        $this->getDataGenerator()->enrol_user($this->teacher3->id, $this->course1->id, 'editingteacher');
 
         // The only relevant fields are modelid, contextid and sampleid. I'm cheating and setting
         // contextid as the course context so teachers can access these predictions.
         $pred = new \stdClass();
         $pred->modelid = $this->model->get_id();
         $pred->contextid = $this->context->id;
-        $pred->sampleid = $course1->id;
+        $pred->sampleid = $this->course1->id;
         $pred->rangeindex = 1;
         $pred->prediction = 1;
         $pred->predictionscore = 1;
@@ -78,7 +80,7 @@ class analytics_prediction_actions_testcase extends advanced_testcase {
         $pred->timecreated = time();
         $DB->insert_record('analytics_predictions', $pred);
 
-        $pred->sampleid = $course2->id;
+        $pred->sampleid = $this->course2->id;
         $DB->insert_record('analytics_predictions', $pred);
     }
 
@@ -114,6 +116,7 @@ class analytics_prediction_actions_testcase extends advanced_testcase {
      * test_get_predictions
      */
     public function test_get_predictions() {
+        global $DB;
 
         // Already logged in as admin.
         list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
@@ -152,5 +155,13 @@ class analytics_prediction_actions_testcase extends advanced_testcase {
         $recordset = $this->model->get_prediction_actions($this->context);
         $this->assertCount(3, $recordset);
         $recordset->close();
+
+        // Trying with a deleted course.
+        $DB->delete_records('course', ['id' => $this->course2->id]);
+        $this->setUser($this->teacher3);
+        list($ignored, $predictions) = $this->model->get_predictions($this->context);
+        $this->assertCount(1, $predictions);
+        reset($predictions)->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target());
+        $this->assertEmpty($this->model->get_predictions($this->context));
     }
 }
index 56e5a7d..c29c920 100644 (file)
@@ -365,13 +365,17 @@ class auth_plugin_cas extends auth_plugin_ldap {
             return [];
         }
 
-        $iconurl = moodle_url::make_pluginfile_url(
-            context_system::instance()->id,
-            'auth_cas',
-            'logo',
-            null,
-            '/',
-            $this->config->auth_logo);
+        if ($this->config->auth_logo) {
+            $iconurl = moodle_url::make_pluginfile_url(
+                context_system::instance()->id,
+                'auth_cas',
+                'logo',
+                null,
+                null,
+                $this->config->auth_logo);
+        } else {
+            $iconurl = null;
+        }
 
         return [
             [
index c52ad12..ad0c55f 100644 (file)
@@ -294,12 +294,19 @@ class auth_plugin_shibboleth extends auth_plugin_base {
         }
 
         $url = new moodle_url('/auth/shibboleth/index.php');
-        $iconurl = moodle_url::make_pluginfile_url(context_system::instance()->id,
-                                                   'auth_shibboleth',
-                                                   'logo',
-                                                   null,
-                                                   '/',
-                                                   $config->auth_logo);
+
+        if ($config->auth_logo) {
+            $iconurl = moodle_url::make_pluginfile_url(
+                context_system::instance()->id,
+                'auth_shibboleth',
+                'logo',
+                null,
+                null,
+                $config->auth_logo);
+        } else {
+            $iconurl = null;
+        }
+
         $result[] = ['url' => $url, 'iconurl' => $iconurl, 'name' => $config->login_name];
         return $result;
     }
index db02741..09a82fe 100644 (file)
@@ -93,6 +93,21 @@ class block_myoverview extends block_base {
         // Return all settings for all users since it is safe (no private keys, etc..).
         $configs = get_config('block_myoverview');
 
+        // Get the customfield values (if any).
+        if ($configs->displaygroupingcustomfield) {
+            $group = get_user_preferences('block_myoverview_user_grouping_preference');
+            $sort = get_user_preferences('block_myoverview_user_sort_preference');
+            $view = get_user_preferences('block_myoverview_user_view_preference');
+            $paging = get_user_preferences('block_myoverview_user_paging_preference');
+            $customfieldvalue = get_user_preferences('block_myoverview_user_grouping_customfieldvalue_preference');
+
+            $renderable = new \block_myoverview\output\main($group, $sort, $view, $paging, $customfieldvalue);
+            $customfieldsexport = $renderable->get_customfield_values_for_export();
+            if (!empty($customfieldsexport)) {
+                $configs->customfieldsexport = json_encode($customfieldsexport);
+            }
+        }
+
         return (object) [
             'instance' => new stdClass(),
             'plugin' => $configs,
index 7c5520f..09b5aed 100644 (file)
@@ -38,7 +38,7 @@ $string['aria:displaydropdown'] = 'Display drop-down menu';
 $string['aria:favourites'] = 'Show starred courses';
 $string['aria:future'] = 'Show future courses';
 $string['aria:groupingdropdown'] = 'Grouping drop-down menu';
-$string['aria:inprogress'] = 'Show in courses in progress';
+$string['aria:inprogress'] = 'Show courses in progress';
 $string['aria:lastaccessed'] = 'Sort courses by last accessed date';
 $string['aria:list'] = 'Switch to list view';
 $string['aria:title'] = 'Sort courses by course name';
@@ -54,7 +54,7 @@ $string['courseprogress'] = 'Course progress:';
 $string['completepercent'] = '{$a}% complete';
 $string['customfield'] = 'Custom field';
 $string['customfiltergrouping'] = 'Field to use';
-$string['customfiltergrouping_nofields'] = 'You need to add at least one course custom field in order to use this setting';
+$string['customfiltergrouping_nofields'] = 'This option requires a course custom field to be set up.';
 $string['displaycategories'] = 'Display categories';
 $string['displaycategories_help'] = 'Display the course category on dashboard course items including cards, list items and summary items.';
 $string['favourites'] = 'Starred';
diff --git a/blocks/myoverview/tests/myoverview_test.php b/blocks/myoverview/tests/myoverview_test.php
new file mode 100644 (file)
index 0000000..bc80abe
--- /dev/null
@@ -0,0 +1,127 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Online users tests
+ *
+ * @package    block_myoverview
+ * @category   test
+ * @copyright  2019 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Online users testcase
+ *
+ * @package    block_myoverview
+ * @category   test
+ * @copyright  2019 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class block_myoverview_testcase extends advanced_testcase {
+
+    /**
+     * Test getting block configuration
+     */
+    public function test_get_block_config_for_external() {
+        global $PAGE, $CFG, $OUTPUT;
+        require_once($CFG->dirroot . '/my/lib.php');
+
+        $this->resetAfterTest(true);
+
+        $user = $this->getDataGenerator()->create_user();
+
+        $fieldcategory = self::getDataGenerator()->create_custom_field_category(['name' => 'Other fields']);
+
+        $customfield = ['shortname' => 'test', 'name' => 'Custom field', 'type' => 'text',
+            'categoryid' => $fieldcategory->get('id')];
+        $field = self::getDataGenerator()->create_custom_field($customfield);
+
+        $customfieldvalue = ['shortname' => 'test', 'value' => 'Test value I'];
+        $course1  = self::getDataGenerator()->create_course(['customfields' => [$customfieldvalue]]);
+        $customfieldvalue = ['shortname' => 'test', 'value' => 'Test value II'];
+        $course2  = self::getDataGenerator()->create_course(['customfields' => [$customfieldvalue]]);
+        $this->getDataGenerator()->enrol_user($user->id, $course1->id, 'student');
+        $this->getDataGenerator()->enrol_user($user->id, $course2->id, 'student');
+
+        // Force a setting change to check the returned blocks settings.
+        set_config('displaygroupingcustomfield', 1, 'block_myoverview');
+        set_config('customfiltergrouping', $field->get('shortname'), 'block_myoverview');
+
+        $this->setUser($user);
+        $context = context_user::instance($user->id);
+
+        if (!$currentpage = my_get_page($user->id, MY_PAGE_PRIVATE)) {
+            throw new moodle_exception('mymoodlesetup');
+        }
+
+        $PAGE->set_url('/my/index.php');    // Need this because some internal API calls require the $PAGE url to be set.
+        $PAGE->set_context($context);
+        $PAGE->set_pagelayout('mydashboard');
+        $PAGE->set_pagetype('my-index');
+        $PAGE->blocks->add_region('content');   // Need to add this special regition to retrieve the central blocks.
+        $PAGE->set_subpage($currentpage->id);
+
+        // Load the block instances for all the regions.
+        $PAGE->blocks->load_blocks();
+        $PAGE->blocks->create_all_block_instances();
+
+        $blocks = $PAGE->blocks->get_content_for_all_regions($OUTPUT);
+        $configs = null;
+        foreach ($blocks as $region => $regionblocks) {
+            $regioninstances = $PAGE->blocks->get_blocks_for_region($region);
+
+            foreach ($regioninstances as $ri) {
+                // Look for myoverview block only.
+                if ($ri->instance->blockname == 'myoverview') {
+                    $configs = $ri->get_config_for_external();
+                    break 2;
+                }
+            }
+        }
+
+        // Test we receive all we expect (exact number and values of settings).
+        $this->assertNotEmpty($configs);
+        $this->assertEmpty((array) $configs->instance);
+        $this->assertCount(13, (array) $configs->plugin);
+        $this->assertEquals('test', $configs->plugin->customfiltergrouping);
+        // Test default values.
+        $this->assertEquals(1, $configs->plugin->displaycategories);
+        $this->assertEquals(1, $configs->plugin->displaygroupingall);
+        $this->assertEquals(0, $configs->plugin->displaygroupingallincludinghidden);
+        $this->assertEquals(1, $configs->plugin->displaygroupingcustomfield);
+        $this->assertEquals(1, $configs->plugin->displaygroupingfuture);
+        $this->assertEquals(1, $configs->plugin->displaygroupinghidden);
+        $this->assertEquals(1, $configs->plugin->displaygroupinginprogress);
+        $this->assertEquals(1, $configs->plugin->displaygroupingpast);
+        $this->assertEquals(1, $configs->plugin->displaygroupingstarred);
+        $this->assertEquals('card,list,summary', $configs->plugin->layouts);
+        $this->assertEquals(get_config('block_myoverview', 'version'), $configs->plugin->version);
+        // Test custom fields.
+        $this->assertJson($configs->plugin->customfieldsexport);
+        $fields = json_decode($configs->plugin->customfieldsexport);
+        $this->assertEquals('Test value I', $fields[0]->name);
+        $this->assertEquals('Test value I', $fields[0]->value);
+        $this->assertFalse($fields[0]->active);
+        $this->assertEquals('Test value II', $fields[1]->name);
+        $this->assertEquals('Test value II', $fields[1]->value);
+        $this->assertFalse($fields[1]->active);
+        $this->assertEquals('No Custom field', $fields[2]->name);
+        $this->assertFalse($fields[2]->active);
+    }
+}
index 0d9a583..7f8876e 100644 (file)
@@ -171,6 +171,11 @@ class cachestore_redis_compressor_test extends advanced_testcase {
      * @return array
      */
     public function provider_for_tests_setget() {
+        if (!cachestore_redis::are_requirements_met()) {
+            // Even though we skip all tests in this case, this provider can still show warnings about non-existing class.
+            return [];
+        }
+
         $data = [
             ['none, none',
                 Redis::SERIALIZER_NONE, cachestore_redis::COMPRESSOR_NONE,
index b3af839..7777a3b 100644 (file)
Binary files a/calendar/amd/build/calendar_threemonth.min.js and b/calendar/amd/build/calendar_threemonth.min.js differ
index 6591833..04406fe 100644 (file)
Binary files a/calendar/amd/build/calendar_threemonth.min.js.map and b/calendar/amd/build/calendar_threemonth.min.js.map differ
index 628c38d..79f116c 100644 (file)
Binary files a/calendar/amd/build/view_manager.min.js and b/calendar/amd/build/view_manager.min.js differ
index 5edaca0..161e799 100644 (file)
Binary files a/calendar/amd/build/view_manager.min.js.map and b/calendar/amd/build/view_manager.min.js.map differ
index 68d16be..33545c3 100644 (file)
@@ -93,6 +93,8 @@ function(
                 requestYear = nextMonth.data('nextYear');
                 requestMonth = nextMonth.data('nextMonth');
                 oldMonth = previousMonth;
+            } else {
+                return $.Deferred().resolve();
             }
 
             return CalendarViewManager.refreshMonthContent(
index 28b04d9..887645b 100644 (file)
@@ -317,7 +317,7 @@ define([
                     return arguments;
                 })
                 .then(function() {
-                    $('body').trigger(CalendarEvents.dayChanged, [year, month, day, courseId, categoryId]);
+                    $('body').trigger(CalendarEvents.dayChanged, [year, month, courseId, categoryId]);
                     return arguments;
                 });
         };
index eccd04e..d7f8347 100644 (file)
@@ -1371,4 +1371,82 @@ class core_calendar_external extends external_api {
             ]
         );
     }
+
+    /**
+     * Convert the specified dates into unix timestamps.
+     *
+     * @param   array $datetimes Array of arrays containing date time details, each in the format:
+     *           ['year' => a, 'month' => b, 'day' => c,
+     *            'hour' => d (optional), 'minute' => e (optional), 'key' => 'x' (optional)]
+     * @return  array Provided array of dates converted to unix timestamps
+     * @throws moodle_exception If one or more of the dates provided does not convert to a valid timestamp.
+     */
+    public static function get_timestamps($datetimes) {
+        $params = self::validate_parameters(self::get_timestamps_parameters(), ['data' => $datetimes]);
+
+        $type = \core_calendar\type_factory::get_calendar_instance();
+        $timestamps = ['timestamps' => []];
+
+        foreach ($params['data'] as $key => $datetime) {
+            $hour = $datetime['hour'] ?? 0;
+            $minute = $datetime['minute'] ?? 0;
+
+            try {
+                $timestamp = $type->convert_to_timestamp(
+                    $datetime['year'], $datetime['month'], $datetime['day'], $hour, $minute);
+
+                $timestamps['timestamps'][] = [
+                    'key' => $datetime['key'] ?? $key,
+                    'timestamp' => $timestamp,
+                ];
+
+            } catch (Exception $e) {
+                throw new moodle_exception('One or more of the dates provided were invalid');
+            }
+        }
+
+        return $timestamps;
+    }
+
+    /**
+     * Describes the parameters for get_timestamps.
+     *
+     * @return external_function_parameters
+     */
+    public static function get_timestamps_parameters() {
+        return new external_function_parameters ([
+            'data' => new external_multiple_structure(
+                new external_single_structure(
+                    [
+                        'key' => new external_value(PARAM_ALPHANUMEXT, 'key', VALUE_OPTIONAL),
+                        'year' => new external_value(PARAM_INT, 'year'),
+                        'month' => new external_value(PARAM_INT, 'month'),
+                        'day' => new external_value(PARAM_INT, 'day'),
+                        'hour' => new external_value(PARAM_INT, 'hour', VALUE_OPTIONAL),
+                        'minute' => new external_value(PARAM_INT, 'minute', VALUE_OPTIONAL),
+                    ]
+                )
+            )
+        ]);
+    }
+
+    /**
+     * Describes the timestamps return format.
+     *
+     * @return external_single_structure
+     */
+    public static function get_timestamps_returns() {
+        return new external_single_structure(
+            [
+                'timestamps' => new external_multiple_structure(
+                    new external_single_structure(
+                        [
+                            'key' => new external_value(PARAM_ALPHANUMEXT, 'Timestamp key'),
+                            'timestamp' => new external_value(PARAM_INT, 'Unix timestamp'),
+                        ]
+                    )
+                )
+            ]
+        );
+    }
 }
index 2bc8542..9c6af0a 100644 (file)
@@ -127,7 +127,8 @@ $searches = [];
 $params = [];
 
 $usedefaultfilters = true;
-if (!empty($courseid) && $courseid == SITEID && !empty($types['site'])) {
+
+if (!empty($types['site'])) {
     $searches[] = "(eventtype = 'site')";
     $usedefaultfilters = false;
 }
@@ -144,9 +145,14 @@ if (!empty($courseid) && !empty($types['course'])) {
     $usedefaultfilters = false;
 }
 
-if (!empty($categoryid) && !empty($types['category'])) {
-    $searches[] = "(eventtype = 'category' AND categoryid = :categoryid)";
-    $params += ['categoryid' => $categoryid];
+if (!empty($types['category'])) {
+    if (!empty($categoryid)) {
+        $searches[] = "(eventtype = 'category' AND categoryid = :categoryid)";
+        $params += ['categoryid' => $categoryid];
+    } else {
+        $searches[] = "(eventtype = 'category')";
+    }
+
     $usedefaultfilters = false;
 }
 
index a2c47cd..2ad027a 100644 (file)
@@ -47,3 +47,30 @@ Feature: Import and edit calendar events
     And I view the calendar for "2" "2017"
     And I should not see "Event on 2-25-2017"
     And I should not see "Event on 2-20-2017"
+
+  Scenario: Import events using different event types.
+    Given I log in as "admin"
+    And I view the calendar for "1" "2016"
+    And I press "Manage subscriptions"
+    And I set the following fields to these values:
+      | Calendar name  | Test Import |
+      | Import from    | Calendar file (.ics) |
+      | Type of event  | User |
+    And I upload "calendar/tests/fixtures/import.ics" file to "Calendar file (.ics)" filemanager
+    And I press "Add"
+    And I should see "User events"
+    And I set the following fields to these values:
+      | Calendar name  | Test Import |
+      | Import from    | Calendar file (.ics) |
+      | Type of event  | Category             |
+      | Category       | Miscellaneous   |
+    And I upload "calendar/tests/fixtures/import.ics" file to "Calendar file (.ics)" filemanager
+    And I press "Add"
+    And I should see "Category events"
+    And I set the following fields to these values:
+      | Calendar name  | Test Import |
+      | Import from    | Calendar file (.ics) |
+      | Type of event  | Site             |
+    And I upload "calendar/tests/fixtures/import.ics" file to "Calendar file (.ics)" filemanager
+    And I press "Add"
+    And I should see "Site events"
index d3a7cf9..810c25f 100644 (file)
@@ -2696,4 +2696,87 @@ class core_calendar_externallib_testcase extends externallib_advanced_testcase {
         );
         $this->assertEquals(['user', 'course', 'group'], $data['allowedeventtypes']);
     }
+
+    /**
+     * Test get_timestamps with string keys, with and without optional hour/minute values.
+     */
+    public function test_get_timestamps_string_keys() {
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        $time1 = new DateTime('2018-12-30 00:00:00');
+        $time2 = new DateTime('2019-03-27 23:59:00');
+
+        $dates = [
+            [
+                'key' => 'from',
+                'year' => $time1->format('Y'),
+                'month' => $time1->format('m'),
+                'day' => $time1->format('d'),
+            ],
+            [
+                'key' => 'to',
+                'year' => $time2->format('Y'),
+                'month' => (int) $time2->format('m'),
+                'day' => $time2->format('d'),
+                'hour' => $time2->format('H'),
+                'minute' => $time2->format('i'),
+            ],
+        ];
+
+        $expectedtimestamps = [
+            'from' => $time1->getTimestamp(),
+            'to' => $time2->getTimestamp(),
+        ];
+
+        $result = core_calendar_external::get_timestamps($dates);
+
+        $this->assertEquals(['timestamps'], array_keys($result));
+        $this->assertEquals(2, count($result['timestamps']));
+
+        foreach ($result['timestamps'] as $data) {
+            $this->assertTrue(in_array($data['key'], ['from', 'to']));
+            $this->assertEquals($expectedtimestamps[$data['key']], $data['timestamp']);
+        }
+    }
+
+    /**
+     * Test get_timestamps with no keys specified, with and without optional hour/minute values.
+     */
+    public function test_get_timestamps_no_keys() {
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        $time1 = new DateTime('2018-12-30 00:00:00');
+        $time2 = new DateTime('2019-03-27 23:59:00');
+
+        $dates = [
+            [
+                'year' => $time1->format('Y'),
+                'month' => $time1->format('m'),
+                'day' => $time1->format('d'),
+            ],
+            [
+                'year' => $time2->format('Y'),
+                'month' => (int) $time2->format('m'),
+                'day' => $time2->format('d'),
+                'hour' => $time2->format('H'),
+                'minute' => $time2->format('i'),
+            ],
+        ];
+
+        $expectedtimestamps = [
+            0 => $time1->getTimestamp(),
+            1 => $time2->getTimestamp(),
+        ];
+
+        $result = core_calendar_external::get_timestamps($dates);
+
+        $this->assertEquals(['timestamps'], array_keys($result));
+        $this->assertEquals(2, count($result['timestamps']));
+
+        foreach ($result['timestamps'] as $data) {
+            $this->assertEquals($expectedtimestamps[$data['key']], $data['timestamp']);
+        }
+    }
 }
index 7bfbeb1..2f4273a 100644 (file)
@@ -9,6 +9,8 @@ information provided here is intended especially for developers.
   * calendar_cron()
   * calendar_get_mini()
   * calendar_get_upcoming()
+* Added core_calendar_external::get_timestamps(), which allows an array containing an arbitrary number of arrays of
+  date/time data to be converted and returned as timestamps, along with an optional key.
 
 === 3.6 ===
 * calendar_get_default_courses() function now has optional $userid parameter.
index d3bab4d..992ba22 100644 (file)
@@ -4073,6 +4073,7 @@ class core_course_external extends external_api {
      * @throws invalid_parameter_exception
      */
     public static function get_enrolled_users_by_cmid(int $cmid) {
+        global $PAGE;
         $warnings = [];
 
         [
@@ -4087,8 +4088,11 @@ class core_course_external extends external_api {
 
         $enrolledusers = get_enrolled_users($coursecontext);
 
-        $users = array_map(function ($user) {
+        $users = array_map(function ($user) use ($PAGE) {
             $user->fullname = fullname($user);
+            $userpicture = new user_picture($user);
+            $userpicture->size = 1;
+            $user->profileimage = $userpicture->get_url($PAGE)->out(false);
             return $user;
         }, $enrolledusers);
         sort($users);
@@ -4119,6 +4123,7 @@ class core_course_external extends external_api {
     public static function user_description() {
         $userfields = array(
             'id'    => new external_value(core_user::get_property_type('id'), 'ID of the user'),
+            'profileimage' => new external_value(PARAM_URL, 'The location of the users larger image', VALUE_OPTIONAL),
             'fullname' => new external_value(PARAM_TEXT, 'The full name of the user', VALUE_OPTIONAL),
             'firstname'   => new external_value(
                     core_user::get_property_type('firstname'),
index dd752c0..6a9affb 100644 (file)
@@ -2384,8 +2384,8 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assertCount(2, $result['courses']);
 
         // Check default filters.
-        $this->assertCount(3, $result['courses'][0]['filters']);
-        $this->assertCount(3, $result['courses'][1]['filters']);
+        $this->assertCount(4, $result['courses'][0]['filters']);
+        $this->assertCount(4, $result['courses'][1]['filters']);
 
         $result = core_course_external::get_courses_by_field('category', $category1->id);
         $result = external_api::clean_returnvalue(core_course_external::get_courses_by_field_returns(), $result);
@@ -2427,7 +2427,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
 
         // Check default filters.
         $filters = $result['courses'][0]['filters'];
-        $this->assertCount(3, $filters);
+        $this->assertCount(4, $filters);
         $found = false;
         foreach ($filters as $filter) {
             if ($filter['filter'] == 'mediaplugin' and $filter['localstate'] == TEXTFILTER_OFF) {
@@ -2993,11 +2993,20 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
      * Test get enrolled users by cmid function.
      */
     public function test_get_enrolled_users_by_cmid() {
+        global $PAGE;
         $this->resetAfterTest(true);
 
         $user1 = self::getDataGenerator()->create_user();
         $user2 = self::getDataGenerator()->create_user();
 
+        $user1picture = new user_picture($user1);
+        $user1picture->size = 1;
+        $user1->profileimage = $user1picture->get_url($PAGE)->out(false);
+
+        $user2picture = new user_picture($user2);
+        $user2picture->size = 1;
+        $user2->profileimage = $user2picture->get_url($PAGE)->out(false);
+
         // Set the first created user to the test user.
         self::setUser($user1);
 
@@ -3024,12 +3033,14 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
             'fullname' => fullname($user1),
             'firstname' => $user1->firstname,
             'lastname' => $user1->lastname,
+            'profileimage' => $user1->profileimage,
         ];
         $expectedusers['users'][1] = [
             'id' => $user2->id,
             'fullname' => fullname($user2),
             'firstname' => $user2->firstname,
             'lastname' => $user2->lastname,
+            'profileimage' => $user2->profileimage,
         ];
 
         // Test getting the users in a given context.
index bb4c858..0a46f0f 100644 (file)
@@ -1220,7 +1220,7 @@ class course_enrolment_manager {
         );
 
         foreach ($extrafields as $field) {
-            $details[$field] = $user->{$field};
+            $details[$field] = s($user->{$field});
         }
 
         // Last time user has accessed the site.
diff --git a/filter/displayh5p/db/install.php b/filter/displayh5p/db/install.php
new file mode 100644 (file)
index 0000000..a5a03e0
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Display H5P active by default
+ *
+ * @package    filter_displayh5p
+ * @copyright  2019 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Enable displayh5p filter by default to render H5P contents.
+ * @throws coding_exception
+ */
+function xmldb_filter_displayh5p_install() {
+    global $CFG;
+
+    require_once("$CFG->libdir/filterlib.php");
+
+    // Display H5P filter should be enabled by default because we need this filter for H5P atto button to work.
+    filter_set_global_state('displayh5p', TEXTFILTER_ON, -1);
+}
index bd31b68..d13ff33 100644 (file)
@@ -47,49 +47,89 @@ class filter_displayh5p extends moodle_text_filter {
      * @return string
      */
     public function filter($text, array $options = array()) {
+        global $CFG;
 
         if (!is_string($text) or empty($text)) {
             // Non string data can not be filtered anyway.
             return $text;
         }
 
-        if (stripos($text, 'http') === false) {
+        // We are trying to minimize performance impact checking there's some H5P related URL.
+        $h5purl = '(http[^ &<]*h5p)';
+        if (!preg_match($h5purl, $text)) {
             return $text;
         }
 
         $allowedsources = get_config('filter_displayh5p', 'allowedsources');
         $allowedsources = array_filter(array_map('trim', explode("\n", $allowedsources)));
-        if (empty($allowedsources)) {
-            return $text;
-        }
+
+        $localsource = '('.preg_quote($CFG->wwwroot).'/[^ &<]*\.h5p([?][^ <]*)?[^ &<]*)';
+        $allowedsources[] = $localsource;
 
         $params = array(
-            'tagbegin' => "<iframe src=",
-            'tagend' => "</iframe>"
+            'tagbegin' => '<iframe src="',
+            'tagend' => '</iframe>'
         );
 
+        $specialchars = ['*', '?', '&', '[^<]'];
+        $escapedspecialchars = ['[^.]+', '\?', '&amp;', '[^<]*'];
+        $h5pcontents = array();
+
+        // Check all allowed sources.
         foreach ($allowedsources as $source) {
             // It is needed to add "/embed" at the end of URLs like https:://*.h5p.com/content/12345 (H5P.com).
             $params['urlmodifier'] = '';
-            if (!(stripos($source, 'embed'))) {
-                $params['urlmodifier'] = '/embed';
+
+            if (($source == $localsource)) {
+                $params['tagbegin'] = '<iframe src="'.$CFG->wwwroot.'/h5p/embed.php?url=';
+                $ultimatepattern = '#'.$source.'#';
+            } else {
+                if (!stripos($source, 'embed')) {
+                    $params['urlmodifier'] = '/embed';
+                }
+                // Convert special chars.
+                $sourceid = str_replace('[id]', '[0-9]+', $source);
+                $escapechars = str_replace($specialchars, $escapedspecialchars, $sourceid);
+                $ultimatepattern = '#(' . $escapechars . ')#';
             }
 
-            // Convert special chars.
-            $specialchars = ['*', '?', '&'];
-            $escapedspecialchars = ['[^.]+', '\?', '&amp;'];
-            $sourceid = str_replace('[id]', '[0-9]+', $source);
-            $escapechars = str_replace($specialchars, $escapedspecialchars, $sourceid);
-            $ultimatepattern = '#(' . $escapechars . ')#';
+            // Improve performance creating filterobjects only when needed.
+            if (!preg_match($ultimatepattern, $text)) {
+                continue;
+            }
 
             $h5pcontenturl = new filterobject($source, null, null, false,
-                   false, null, [$this, 'filterobject_prepare_replacement_callback'], $params);
+                false, null, [$this, 'filterobject_prepare_replacement_callback'], $params);
 
             $h5pcontenturl->workregexp = $ultimatepattern;
             $h5pcontents[] = $h5pcontenturl;
         }
 
-        return filter_phrases($text, $h5pcontents, null, null, false, true);
+        if (empty($h5pcontents)) {
+            // No matches to deal with.
+            return $text;
+        }
+
+        $result = filter_phrases($text, $h5pcontents, null, null, false, true);
+
+        // Encoding H5P file URLs.
+        // embed.php page is requesting a PARAM_LOCALURL url parameter, so for files/directories use non-alphanumeric
+        // characters, we need to encode the parameter. Fetch url parameter added to embed.php and encode the whole url.
+        $localurl = '#\?url=([^" <]*[\/]+[^" <]*\.h5p)([?][^"]*)?#';
+        $result = preg_replace_callback($localurl,
+            function ($matches) {
+                $baseurl = rawurlencode($matches[1]);
+                // Deal with possible parameters in the url link.
+                if (!empty($matches[2])) {
+                    $match = explode('?', $matches[2]);
+                    if (!empty($match[1])) {
+                        $baseurl = $baseurl."&".$match[1];
+                    }
+                }
+                return "?url=".$baseurl;
+            }, $result);
+
+        return $result;
     }
 
     /**
@@ -101,13 +141,13 @@ class filter_displayh5p extends moodle_text_filter {
      * @return array [$hreftagbegin, $hreftagend, $replacementphrase] for filterobject.
      */
     public function filterobject_prepare_replacement_callback($tagbegin, $tagend, $urlmodifier) {
-
         $sourceurl = "$1";
         if ($urlmodifier !== "") {
             $sourceurl .= $urlmodifier;
         }
 
-        $h5piframesrc = "\"".$sourceurl."\" width=\"100%\" height=\"637\" allowfullscreen=\"allowfullscreen\" style=\"border: 0;\">";
+        $h5piframesrc = $sourceurl.
+            '" class="h5p-iframe" style="height:230px; width: 100%; border: 0;" allowfullscreen="allowfullscreen">';
 
         // We want to request the resizing script only once.
         if (self::$loadresizerjs) {
index 6317fb6..950324d 100644 (file)
 defined('MOODLE_INTERNAL') || die;
 
 $string['allowedsourceslist'] = 'Allowed sources';
-$string['allowedsourceslistdesc'] = 'List of sources from which users can embed H5P content. If empty, the filter won\'t convert any external URL.
+$string['allowedsourceslistdesc'] = 'A list of URLs from which users can embed H5P content. If none are specified, all URLs will remain as links and not be displayed as embedded H5P content.
 
-<b>[id]</b> is a placeholder for the H5P content id in the external source.
+\'[id]\' is a placeholder for the H5P content ID in the external source.
 
-<b>*</b> wildcard is supported. For example, *.example.com will embed H5P content from any subdomain of example.com, but not from the example.com domain.';
+The wildcard character \'*\' may be used to specify subdomains. For example, *.example.com will allow embedded H5P content from any subdomain of example.com, but not from the example.com domain.';
 $string['filtername'] = 'Display H5P';
-$string['privacy:metadata'] = 'This H5P filter does not store any personal data.';
+$string['privacy:metadata'] = 'The display H5P filter does not store any personal data.';
index 2ac3aae..a8313c8 100644 (file)
@@ -45,8 +45,6 @@ class filter_displayh5p_testcase extends advanced_testcase {
             "https://h5p.org/h5p/embed/[id]\nhttps://*.h5p.com/content/[id]/embed\nhttps://*.h5p.com/content/[id]
                 \nhttps://generic.wordpress.soton.ac.uk/altc/wp-admin/admin-ajax.php?action=h5p_embed&id=[id]",
             'filter_displayh5p');
-        // Enable display h5p filter at top level.
-        filter_set_global_state('displayh5p', TEXTFILTER_ON);
     }
 
     /**
@@ -71,6 +69,8 @@ class filter_displayh5p_testcase extends advanced_testcase {
      * @return array
      */
     public function texts_provider() {
+        global $CFG;
+
         return [
             ["http:://example.com", "#http:://example.com#"],
             ["http://google.es/h5p/embed/3425234", "#http://google.es/h5p/embed/3425234#"],
@@ -83,7 +83,13 @@ class filter_displayh5p_testcase extends advanced_testcase {
             ["https://generic.wordpress.soton.ac.uk/altc/wp-admin/admin-ajax.php?action=h5p_embed&amp;id=13",
                     "#<iframe src=\"https://generic.wordpress.soton.ac.uk/altc/wp-admin/admin-ajax.php\?action=h5p_embed\&amp\;id=13\"[^>]+?>#"],
             ["https://h5p.org/h5p/embed/547225 another content in the same page https://moodle.h5p.com/content/1290729733828858779/embed",
-                    "#<iframe src=\"https://h5p.org/h5p/embed/547225\"[^>]+?>((?!<iframe).)*<iframe src=\"https://moodle.h5p.com/content/1290729733828858779/embed\"[^>]+?>#"]
+                    "#<iframe src=\"https://h5p.org/h5p/embed/547225\"[^>]+?>((?!<iframe).)*<iframe src=\"https://moodle.h5p.com/content/1290729733828858779/embed\"[^>]+?>#"],
+            [$CFG->wwwroot."/pluginfile.php/5/user/private/interactive-video.h5p?export=1&embed=1",
+                    "#<iframe src=\"{$CFG->wwwroot}/h5p/embed.php\?url=".rawurlencode("{$CFG->wwwroot}/pluginfile.php/5/user/private/interactive-video.h5p").
+                    "&export=1&embed=1\"[^>]*?></iframe>#"],
+            [$CFG->wwwroot."/pluginfile.php/5/user/private/accordion-6-7138%20%281%29.h5p.h5p",
+                    "#<iframe src=\"{$CFG->wwwroot}/h5p/embed.php\?url=".rawurlencode("{$CFG->wwwroot}/pluginfile.php/5/user/private/accordion-6-7138%20%281%29.h5p.h5p").
+                    "\"[^>]*?></iframe>#"]
         ];
     }
 }
\ No newline at end of file
index 5f5b3ff..6b6651d 100644 (file)
@@ -104,14 +104,14 @@ class filter_emoticon extends moodle_text_filter {
         }
 
         // Detect all zones that we should not handle (including the nested tags).
-        $processing = preg_split('/(<\/?(?:span|script)[^>]*>)/is', $text, 0, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
+        $processing = preg_split('/(<\/?(?:span|script|pre)[^>]*>)/is', $text, 0, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
 
         // Initialize the results.
         $resulthtml = "";
         $exclude = 0;
 
         // Define the patterns that mark the start of the forbidden zones.
-        $excludepattern = array('/^<script/is', '/^<span[^>]+class="nolink[^"]*"/is');
+        $excludepattern = array('/^<script/is', '/^<span[^>]+class="nolink[^"]*"/is', '/^<pre/is');
 
         // Loop through the fragments.
         foreach ($processing as $fragment) {
@@ -126,13 +126,15 @@ class filter_emoticon extends moodle_text_filter {
             }
             if ($exclude > 0) {
                 // If we are ignoring the fragment, then we must check if we may have reached the end of the zone.
-                if (strpos($fragment, '</span') !== false || strpos($fragment, '</script') !== false) {
+                if (strpos($fragment, '</span') !== false || strpos($fragment, '</script') !== false
+                    || strpos($fragment, '</pre') !== false) {
                     $exclude -= 1;
                     // This is needed because of a double increment at the first element.
                     if ($exclude == 1) {
                         $exclude -= 1;
                     }
-                } else if (strpos($fragment, '<span') !== false || strpos($fragment, '<script') !== false) {
+                } else if (strpos($fragment, '<span') !== false || strpos($fragment, '<script') !== false
+                    || strpos($fragment, '<pre') !== false) {
                     // If we find a nested tag we increase the exclusion level.
                     $exclude = $exclude + 1;
                 }
index 10ee76e..9935db9 100644 (file)
@@ -95,6 +95,21 @@ class filter_emoticon_testcase extends advanced_testcase {
                 'format' => FORMAT_HTML,
                 'expected' => '<span class="nolink"><span>(n)</span>(n)</span>' . $this->get_converted_content_for_emoticon('(n)'),
             ],
+            'Basic pre should not be processed' => [
+                'input' => '<pre>(n)</pre>',
+                'format' => FORMAT_HTML,
+                'expected' => '<pre>(n)</pre>',
+            ],
+            'Nested pre should not be processed' => [
+                'input' => '<pre><pre>(n)</pre>(n)</pre>',
+                'format' => FORMAT_HTML,
+                'expected' => '<pre><pre>(n)</pre>(n)</pre>',
+            ],
+            'Nested pre should not be processed but following emoticon' => [
+                'input' => '<pre><pre>(n)</pre>(n)</pre>(n)',
+                'format' => FORMAT_HTML,
+                'expected' => '<pre><pre>(n)</pre>(n)</pre>' . $this->get_converted_content_for_emoticon('(n)'),
+            ],
         ];
     }
 
index b22217e..c2441d2 100644 (file)
Binary files a/filter/mathjaxloader/yui/build/moodle-filter_mathjaxloader-loader/moodle-filter_mathjaxloader-loader-debug.js and b/filter/mathjaxloader/yui/build/moodle-filter_mathjaxloader-loader/moodle-filter_mathjaxloader-loader-debug.js differ
index 809018e..dec529d 100644 (file)
Binary files a/filter/mathjaxloader/yui/build/moodle-filter_mathjaxloader-loader/moodle-filter_mathjaxloader-loader-min.js and b/filter/mathjaxloader/yui/build/moodle-filter_mathjaxloader-loader/moodle-filter_mathjaxloader-loader-min.js differ
index b22217e..c2441d2 100644 (file)
Binary files a/filter/mathjaxloader/yui/build/moodle-filter_mathjaxloader-loader/moodle-filter_mathjaxloader-loader.js and b/filter/mathjaxloader/yui/build/moodle-filter_mathjaxloader-loader/moodle-filter_mathjaxloader-loader.js differ
index 9be8157..1258a5c 100644 (file)
@@ -117,6 +117,8 @@ M.filter_mathjaxloader = M.filter_mathjaxloader || {
             var processdelay = window.MathJax.Hub.processSectionDelay;
             // Set the process section delay to 0 when updating the formula.
             window.MathJax.Hub.processSectionDelay = 0;
+            // When content is updated never position to hash, it may cause unexpected document scrolling.
+            window.MathJax.Hub.Config({positionToHash: false});
             self._setLocale();
             event.nodes.each(function(node) {
                 node.all('.filter_mathjaxloader_equation').each(function(node) {
index f81b0e2..ec4bbcd 100644 (file)
@@ -45,6 +45,9 @@ class grade_export_xls extends grade_export {
 
         $strgrades = get_string('grades');
 
+        // If this file was requested from a form, then mark download as complete (before sending headers).
+        \core_form\util::form_download_complete();
+
         // Calculate file name
         $shortname = format_string($this->course->shortname, true, array('context' => context_course::instance($this->course->id)));
         $downloadfilename = clean_filename("$shortname $strgrades.xls");
index d495448..a75dd20 100644 (file)
@@ -339,8 +339,10 @@ class grading_manager {
             }
 
         } else if ($this->get_context()->contextlevel == CONTEXT_MODULE) {
-            list($context, $course, $cm) = get_context_info_array($this->get_context()->id);
-            return self::available_areas('mod_'.$cm->modname);
+            $modulecontext = $this->get_context();
+            $coursecontext = $modulecontext->get_course_context();
+            $cm = get_fast_modinfo($coursecontext->instanceid)->get_cm($modulecontext->instanceid);
+            return self::available_areas("mod_{$cm->modname}");
 
         } else {
             throw new coding_exception('Unsupported gradable area context level');
index 1b82a20..53741c4 100644 (file)
@@ -767,7 +767,7 @@ class grade_report_grader extends grade_report {
                 $fieldcell = new html_table_cell();
                 $fieldcell->attributes['class'] = 'userfield user' . $field;
                 $fieldcell->header = false;
-                $fieldcell->text = $user->{$field};
+                $fieldcell->text = s($user->{$field});
                 $userrow->cells[] = $fieldcell;
             }
 
index c855c2f..3702161 100644 (file)
@@ -15,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * H5P player class.
+ * H5P core class.
  *
  * @package    core_h5p
  * @copyright  2019 Sara Arjona <sara@moodle.com>
 namespace core_h5p;
 
 use H5PCore;
+use H5PFrameworkInterface;
 use stdClass;
 use moodle_url;
 
 defined('MOODLE_INTERNAL') || die();
 
 /**
- * H5P player class, for displaying any local H5P content.
+ * H5P core class, containing functions and storage shared by the other H5P classes.
  *
  * @package    core_h5p
  * @copyright  2019 Sara Arjona <sara@moodle.com>
@@ -39,14 +40,45 @@ defined('MOODLE_INTERNAL') || die();
  */
 class core extends \H5PCore {
 
+    /** @var array The array containing all the present libraries */
     protected $libraries;
 
+    /**
+     * Constructor for core_h5p/core.
+     *
+     * @param H5PFrameworkInterface $framework The frameworks implementation of the H5PFrameworkInterface
+     * @param string|\H5PFileStorage $path The H5P file storage directory or class
+     * @param string $url The URL to the file storage directory
+     * @param string $language The language code. Defaults to english
+     * @param boolean $export Whether export is enabled
+     */
+    public function __construct(H5PFrameworkInterface $framework, $path, string $url, string $language = 'en',
+            bool $export = false) {
+
+        parent::__construct($framework, $path, $url, $language, $export);
+
+        // Aggregate the assets by default.
+        $this->aggregateAssets = true;
+    }
+
+    /**
+     * Get the path to the dependency.
+     *
+     * @param array $dependency An array containing the information of the requested dependency library
+     * @return string The path to the dependency library
+     */
     protected function getDependencyPath(array $dependency): string {
         $library = $this->find_library($dependency);
 
         return "libraries/{$library->id}/{$library->machinename}-{$library->majorversion}.{$library->minorversion}";
     }
 
+    /**
+     * Get the paths to the content dependencies.
+     *
+     * @param int $id The H5P content ID
+     * @return array An array containing the path of each content dependency
+     */
     public function get_dependency_roots(int $id): array {
         $roots = [];
         $dependencies = $this->h5pF->loadContentDependencies($id);
@@ -66,7 +98,13 @@ class core extends \H5PCore {
         return $roots;
     }
 
-    protected function find_library($dependency): \stdClass {
+    /**
+     * Get a particular dependency library.
+     *
+     * @param array $dependency An array containing information of the dependency library
+     * @return stdClass|null The library object if the library dependency exists, null otherwise
+     */
+    protected function find_library(array $dependency): ?\stdClass {
         global $DB;
         if (null === $this->libraries) {
             $this->libraries = $DB->get_records('h5p_libraries');
@@ -97,6 +135,11 @@ class core extends \H5PCore {
         return null;
     }
 
+    /**
+     * Get core JavaScript files.
+     *
+     * @return array The array containg urls of the core JavaScript files
+     */
     public static function get_scripts(): array {
         global $CFG;
         $cachebuster = '?ver='.$CFG->jsrev;
index d63bfc2..52c8cc0 100644 (file)
@@ -86,14 +86,14 @@ class factory {
      */
     public function get_core(): core {
         if (null === $this->core) {
-          $fs = new \core_h5p\file_storage();
-          $language = \core_h5p\framework::get_language();
-          $context = \context_system::instance();
+            $fs = new \core_h5p\file_storage();
+            $language = \core_h5p\framework::get_language();
+            $context = \context_system::instance();
 
-          $url = \moodle_url::make_pluginfile_url($context->id, 'core_h5p', '', null,
-              '', '')->out();
+            $url = \moodle_url::make_pluginfile_url($context->id, 'core_h5p', '', null,
+                '', '')->out();
 
-          $this->core = new core($this->get_framework(), $fs, $url, $language, true);
+            $this->core = new core($this->get_framework(), $fs, $url, $language, true);
         }
 
         return $this->core;
index 4905ab2..e5293cf 100644 (file)
@@ -41,6 +41,9 @@ class framework implements \H5PFrameworkInterface {
     /** @var string The path to the last uploaded h5p file */
     private $lastuploadedfile;
 
+    /** @var stored_file The .h5p file */
+    private $file;
+
     /**
      * Returns info for the current platform.
      * Implements getPlatformInfo.
@@ -634,8 +637,29 @@ class framework implements \H5PFrameworkInterface {
      *                 FALSE if the user is not allowed to update libraries.
      */
     public function mayUpdateLibraries() {
-        // Currently, capabilities are not being set/used, so everyone can update libraries.
-        return true;
+        return helper::can_update_library($this->get_file());
+    }
+
+    /**
+     * Get the .h5p file.
+     *
+     * @return stored_file The .h5p file.
+     */
+    public function get_file(): \stored_file {
+        if (!isset($this->file)) {
+            throw new \coding_exception('Using get_file() before file is set');
+        }
+
+        return $this->file;
+    }
+
+    /**
+     * Set the .h5p file.
+     *
+     * @param  stored_file $file The .h5p file.
+     */
+    public function set_file(\stored_file $file): void {
+        $this->file = $file;
     }
 
     /**
@@ -757,7 +781,19 @@ class framework implements \H5PFrameworkInterface {
             $content['contenthash'] = '';
         }
 
-        $data = array(
+        // If the libraryid declared in the package is empty, get the latest version.
+        if (empty($content['library']['libraryId'])) {
+            $mainlibrary = $this->get_latest_library_version($content['library']['machineName']);
+            if (empty($mainlibrary)) {
+                // Raise an error if the main library is not defined and the latest version doesn't exist.
+                $message = $this->t('Missing required library @library', ['@library' => $content['library']['machineName']]);
+                $this->setErrorMessage($message, 'missing-required-library');
+                return false;
+            }
+            $content['library']['libraryId'] = $mainlibrary->id;
+        }
+
+        $data = [
             'jsoncontent' => $content['params'],
             'displayoptions' => $content['disable'],
             'mainlibraryid' => $content['library']['libraryId'],
@@ -765,7 +801,7 @@ class framework implements \H5PFrameworkInterface {
             'filtered' => null,
             'pathnamehash' => $content['pathnamehash'],
             'contenthash' => $content['contenthash']
-        );
+        ];
 
         if (!isset($content['id'])) {
             $data['timecreated'] = $data['timemodified'];
@@ -1518,44 +1554,6 @@ class framework implements \H5PFrameworkInterface {
         return !empty($results);
     }
 
-    /**
-     * Get type of h5p instance
-     *
-     * @param string|null $type Type of h5p instance to get
-     * @return \H5PContentValidator|\H5PCore|\H5PStorage|\H5PValidator|\core_h5p\framework|\H5peditor
-     */
-    public static function instance($type = null) {
-        global $CFG;
-        static $interface, $core;
-
-        if (!isset($interface)) {
-            $interface = new \core_h5p\framework();
-            $fs = new \core_h5p\file_storage();
-            $language = self::get_language();
-
-            $context = \context_system::instance();
-            $url = "{$CFG->wwwroot}/pluginfile.php/{$context->id}/core_h5p";
-
-            require_once("{$CFG->libdir}/h5p/h5p.classes.php");
-            $core = new core($interface, $fs, $url, $language, true);
-            $core->aggregateAssets = !(isset($CFG->core_h5p_aggregate_assets) && $CFG->core_h5p_aggregate_assets === '0');
-        }
-
-        switch ($type) {
-            case 'validator':
-                return new \H5PValidator($interface, $core);
-            case 'storage':
-                return new \H5PStorage($interface, $core);
-            case 'contentvalidator':
-                return new \H5PContentValidator($interface, $core);
-            case 'interface':
-                return $interface;
-            case 'core':
-            default:
-                return $core;
-        }
-    }
-
     /**
      * Get current H5P language code.
      *
@@ -1623,4 +1621,22 @@ class framework implements \H5PFrameworkInterface {
         }
         return '';
     }
+
+    /**
+     * Get the latest library version.
+     *
+     * @param  string $machinename The library's machine name
+     * @return stdClass|null An object with the latest library version
+     */
+    public function get_latest_library_version(string $machinename): ?\stdClass {
+        global $DB;
+
+        $libraries = $DB->get_records('h5p_libraries', ['machinename' => $machinename],
+            'majorversion DESC, minorversion DESC, patchversion DESC', '*', 0, 1);
+        if ($libraries) {
+            return reset($libraries);
+        }
+
+        return null;
+    }
 }
diff --git a/h5p/classes/helper.php b/h5p/classes/helper.php
new file mode 100644 (file)
index 0000000..de3d857
--- /dev/null
@@ -0,0 +1,179 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains helper class for the H5P area.
+ *
+ * @package    core_h5p
+ * @copyright  2019 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_h5p;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Helper class for the H5P area.
+ *
+ * @copyright  2019 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class helper {
+
+    /**
+     * Store an H5P file.
+     *
+     * @param factory $factory The \core_h5p\factory object
+     * @param stored_file $file Moodle file instance
+     * @param stdClass $config Button options config
+     * @param bool $onlyupdatelibs Whether new libraries can be installed or only the existing ones can be updated
+     * @param bool $skipcontent Should the content be skipped (so only the libraries will be saved)?
+     *
+     * @return int|false|null The H5P identifier or null if there is an error when saving or false if it's not a valid H5P package
+     */
+    public static function save_h5p(factory $factory, \stored_file $file, \stdClass $config, bool $onlyupdatelibs = false,
+            bool $skipcontent = false) {
+        // This may take a long time.
+        \core_php_time_limit::raise();
+
+        $core = $factory->get_core();
+        $path = $core->fs->getTmpPath();
+        $core->h5pF->getUploadedH5pFolderPath($path);
+        // Add manually the extension to the file to avoid the validation fails.
+        $path .= '.h5p';
+        $core->h5pF->getUploadedH5pPath($path);
+
+        // Copy the .h5p file to the temporary folder.
+        $file->copy_content_to($path);
+
+        // Check if the h5p file is valid before saving it.
+        $h5pvalidator = $factory->get_validator();
+        if ($h5pvalidator->isValidPackage($skipcontent, $onlyupdatelibs)) {
+            $h5pstorage = $factory->get_storage();
+
+            $content = [
+                'pathnamehash' => $file->get_pathnamehash(),
+                'contenthash' => $file->get_contenthash(),
+            ];
+            $options = ['disable' => self::get_display_options($core, $config)];
+
+            $h5pstorage->savePackage($content, null, $skipcontent, $options);
+
+            return $h5pstorage->contentId;
+        }
+
+        return false;
+    }
+
+
+    /**
+     * Get the representation of display options as int.
+     *
+     * @param core $core The \core_h5p\core object
+     * @param stdClass $config Button options config
+     *
+     * @return int The representation of display options as int
+     */
+    public static function get_display_options(core $core, \stdClass $config): int {
+        $export = isset($config->export) ? $config->export : 0;
+        $embed = isset($config->embed) ? $config->embed : 0;
+        $copyright = isset($config->copyright) ? $config->copyright : 0;
+        $frame = ($export || $embed || $copyright);
+        if (!$frame) {
+            $frame = isset($config->frame) ? $config->frame : 0;
+        }
+
+        $disableoptions = [
+            core::DISPLAY_OPTION_FRAME     => $frame,
+            core::DISPLAY_OPTION_DOWNLOAD  => $export,
+            core::DISPLAY_OPTION_EMBED     => $embed,
+            core::DISPLAY_OPTION_COPYRIGHT => $copyright,
+        ];
+
+        return $core->getStorableDisplayOptions($disableoptions, 0);
+    }
+
+    /**
+     * Checks if the author of the .h5p file is "trustable". If the file hasn't been uploaded by a user with the
+     * required capability, the content won't be deployed.
+     *
+     * @param  stored_file $file The .h5p file to be deployed
+     * @return bool Returns true if the file can be deployed, false otherwise.
+     */
+    public static function can_deploy_package(\stored_file $file): bool {
+        $context = \context::instance_by_id($file->get_contextid());
+        if (has_capability('moodle/h5p:deploy', $context, $file->get_userid())) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Checks if the content-type libraries can be upgraded.
+     * The H5P content-type libraries can only be upgraded if the author of the .h5p file can manage content-types or if all the
+     * content-types exist, to avoid users without the required capability to upload malicious content.
+     *
+     * @param  stored_file $file The .h5p file to be deployed
+     * @return bool Returns true if the content-type libraries can be created/updated, false otherwise.
+     */
+    public static function can_update_library(\stored_file $file): bool {
+        if (is_null($file)) {
+            debugging('\core_h5p\h5p::can_update_library() now expects a \'file\' to be passed.',
+                DEBUG_DEVELOPER);
+            return false;
+        }
+
+        $context = \context::instance_by_id($file->get_contextid());
+        // Check if the owner of the .h5p file has the capability to manage content-types.
+        if (has_capability('moodle/h5p:updatelibraries', $context, $file->get_userid())) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Convenience to take a fixture test file and create a stored_file.
+     *
+     * @param string $filepath The filepath of the file
+     * @param  int   $userid  The author of the file
+     * @param  \context $context The context where the file will be created
+     * @return stored_file The file created
+     */
+    public static function create_fake_stored_file_from_path(string $filepath, int $userid = 0,
+            \context $context = null): \stored_file {
+        if (is_null($context)) {
+            $context = \context_system::instance();
+        }
+        $filerecord = [
+            'contextid' => $context->id,
+            'component' => 'core_h5p',
+            'filearea'  => 'unittest',
+            'itemid'    => rand(),
+            'filepath'  => '/',
+            'filename'  => basename($filepath),
+        ];
+        if (!is_null($userid)) {
+            $filerecord['userid'] = $userid;
+        }
+
+        $fs = get_file_storage();
+        return $fs->create_file_from_pathname($filerecord, $filepath);
+    }
+
+}
index 5605a97..fd1dac6 100644 (file)
@@ -76,24 +76,38 @@ class player {
     private $context;
 
     /**
-     * @var context The \core_h5p\factory object.
+     * @var factory The \core_h5p\factory object.
      */
     private $factory;
 
+    /**
+     * @var stdClass The error, exception and info messages, raised while preparing and running the player.
+     */
+    private $messages;
+
+    /**
+     * @var bool Set to true in scripts that can not redirect (CLI, RSS feeds, etc.), throws exceptions.
+     */
+    private $preventredirect;
+
     /**
      * Inits the H5P player for rendering the content.
      *
      * @param string $url Local URL of the H5P file to display.
      * @param stdClass $config Configuration for H5P buttons.
+     * @param bool $preventredirect Set to true in scripts that can not redirect (CLI, RSS feeds, etc.), throws exceptions
      */
-    public function __construct(string $url, \stdClass $config) {
+    public function __construct(string $url, \stdClass $config, bool $preventredirect = true) {
         if (empty($url)) {
             throw new \moodle_exception('h5pinvalidurl', 'core_h5p');
         }
         $this->url = new \moodle_url($url);
+        $this->preventredirect = $preventredirect;
 
         $this->factory = new \core_h5p\factory();
 
+        $this->messages = new \stdClass();
+
         // Create \core_h5p\core instance.
         $this->core = $this->factory->get_core();
 
@@ -113,13 +127,20 @@ class player {
      * @return stdClass with framework error messages.
      */
     public function get_messages() : \stdClass {
-        $messages = new \stdClass();
-        $messages->error = $this->core->h5pF->getMessages('error');
+        // Check if there are some errors and store them in $messages.
+        if (empty($this->messages->error)) {
+            $this->messages->error = $this->core->h5pF->getMessages('error') ?: false;
+        } else {
+            $this->messages->error = array_merge($this->messages->error, $this->core->h5pF->getMessages('error'));
+        }
 
-        if (empty($messages->error)) {
-            $messages->error = false;
+        if (empty($this->messages->info)) {
+            $this->messages->info = $this->core->h5pF->getMessages('info') ?: false;
+        } else {
+            $this->messages->info = array_merge($this->messages->info, $this->core->h5pF->getMessages('info'));
         }
-        return $messages;
+
+        return $this->messages;
     }
 
     /**
@@ -214,7 +235,7 @@ class player {
      * @return int|false H5P DB identifier.
      */
     private function get_h5p_id(string $url, \stdClass $config) {
-        global $DB;
+        global $DB, $USER;
 
         $fs = get_file_storage();
 
@@ -255,13 +276,48 @@ class player {
 
             // Check if the user uploading the H5P content is "trustable". If the file hasn't been uploaded by a user with this
             // capability, the content won't be deployed and an error message will be displayed.
-            if (!has_capability('moodle/h5p:deploy', $this->context, $file->get_userid())) {
+            if (!helper::can_deploy_package($file)) {
                 $this->core->h5pF->setErrorMessage(get_string('nopermissiontodeploy', 'core_h5p'));
                 return false;
             }
 
+            // The H5P content can be only deployed if the author of the .h5p file can update libraries or if all the
+            // content-type libraries exist, to avoid users without the h5p:updatelibraries capability upload malicious content.
+            $onlyupdatelibs = !helper::can_update_library($file);
+
+            // Set the .h5p file, in order to check later the permissions to update libraries.
+            $this->core->h5pF->set_file($file);
+
             // Validate and store the H5P content before displaying it.
-            return $this->save_h5p($file, $config);
+            $h5pid = helper::save_h5p($this->factory, $file, $config, $onlyupdatelibs, false);
+            if (!$h5pid && $file->get_userid() != $USER->id && has_capability('moodle/h5p:updatelibraries', $this->context)) {
+                // The user has permission to update libraries but the package has been uploaded by a different
+                // user without this permission. Check if there is some missing required library error.
+                $missingliberror = false;
+                $messages = $this->get_messages();
+                if (!empty($messages->error)) {
+                    foreach ($messages->error as $error) {
+                        if ($error->code == 'missing-required-library') {
+                            $missingliberror = true;
+                            break;
+                        }
+                    }
+                }
+                if ($missingliberror) {
+                    // The message about the permissions to upload libraries should be removed.
+                    $infomsg = "Note that the libraries may exist in the file you uploaded, but you're not allowed to upload " .
+                        "new libraries. Contact the site administrator about this.";
+                    if (($key = array_search($infomsg, $messages->info)) !== false) {
+                        unset($messages->info[$key]);
+                    }
+
+                    // No library will be installed and an error will be displayed, because this content is not trustable.
+                    $this->core->h5pF->setInfoMessage(get_string('notrustablefile', 'core_h5p'));
+                }
+                return false;
+
+            }
+            return $h5pid;
         }
     }
 
@@ -273,7 +329,7 @@ class player {
      * @return string|false pathnamehash for the file in the internal URL.
      */
     private function get_pluginfile_hash(string $url) {
-        global $USER;
+        global $USER, $CFG;
 
         // Decode the URL before start processing it.
         $url = new \moodle_url(urldecode($url));
@@ -310,17 +366,54 @@ class player {
             throw new \moodle_exception('h5pprivatefile', 'core_h5p');
         }
 
-        // For CONTEXT_MODULE, check if the user is enrolled in the course and has permissions view this .h5p file.
-        if ($this->context->contextlevel == CONTEXT_MODULE) {
+        // For CONTEXT_COURSECAT No login necessary - unless login forced everywhere.
+        if ($this->context->contextlevel == CONTEXT_COURSECAT) {
+            if ($CFG->forcelogin) {
+                require_login(null, true, null, false, true);
+            }
+        }
+
+        // For CONTEXT_BLOCK.
+        if ($this->context->contextlevel == CONTEXT_BLOCK) {
+            if ($this->context->get_course_context(false)) {
+                // If block is in course context, then check if user has capability to access course.
+                require_course_login($course, true, null, false, true);
+            } else if ($CFG->forcelogin) {
+                // No login necessary - unless login forced everywhere.
+                require_login(null, true, null, false, true);
+            } else {
+                // Get parent context and see if user have proper permission.
+                $parentcontext = $this->context->get_parent_context();
+                if ($parentcontext->contextlevel === CONTEXT_COURSECAT) {
+                    // Check if category is visible and user can view this category.
+                    if (!core_course_category::get($parentcontext->instanceid, IGNORE_MISSING)) {
+                        send_file_not_found();
+                    }
+                } else if ($parentcontext->contextlevel === CONTEXT_USER && $parentcontext->instanceid != $USER->id) {
+                    // The block is in the context of a user, it is only visible to the user who it belongs to.
+                    send_file_not_found();
+                }
+                if ($filearea !== 'content') {
+                    send_file_not_found();
+                }
+            }
+        }
+
+        // For CONTEXT_MODULE and CONTEXT_COURSE check if the user is enrolled in the course.
+        // And for CONTEXT_MODULE has permissions view this .h5p file.
+        if ($this->context->contextlevel == CONTEXT_MODULE ||
+                $this->context->contextlevel == CONTEXT_COURSE) {
             // Require login to the course first (without login to the module).
-            require_course_login($course, true, null, false, true);
+            require_course_login($course, true, null, !$this->preventredirect, $this->preventredirect);
 
             // Now check if module is available OR it is restricted but the intro is shown on the course page.
-            $cminfo = \cm_info::create($cm);
-            if (!$cminfo->uservisible) {
-                if (!$cm->showdescription || !$cminfo->is_visible_on_course_page()) {
-                    // Module intro is not visible on the course page and module is not available, show access error.
-                    require_course_login($course, true, $cminfo, false, true);
+            if ($this->context->contextlevel == CONTEXT_MODULE) {
+                $cminfo = \cm_info::create($cm);
+                if (!$cminfo->uservisible) {
+                    if (!$cm->showdescription || !$cminfo->is_visible_on_course_page()) {
+                        // Module intro is not visible on the course page and module is not available, show access error.
+                        require_course_login($course, true, $cminfo, !$this->preventredirect, $this->preventredirect);
+                    }
                 }
             }
         }
@@ -328,12 +421,15 @@ class player {
         // Some components, such as mod_page or mod_resource, add the revision to the URL to prevent caching problems.
         // So the URL contains this revision number as itemid but a 0 is always stored in the files table.
         // In order to get the proper hash, a callback should be done (looking for those exceptions).
-        $pathdata = component_callback($component, 'get_path_from_pluginfile', [$filearea, $parts], null);
+        $pathdata = null;
+        if ($this->context->contextlevel == CONTEXT_MODULE || $this->context->contextlevel == CONTEXT_BLOCK) {
+            $pathdata = component_callback($component, 'get_path_from_pluginfile', [$filearea, $parts], null);
+        }
         if (null === $pathdata) {
             // Look for the components and fileareas which have empty itemid defined in xxx_pluginfile.
             $hasnullitemid = false;
             $hasnullitemid = $hasnullitemid || ($component === 'user' && ($filearea === 'private' || $filearea === 'profile'));
-            $hasnullitemid = $hasnullitemid || ($component === 'mod' && $filearea === 'intro');
+            $hasnullitemid = $hasnullitemid || (substr($component, 0, 4) === 'mod_' && $filearea === 'intro');
             $hasnullitemid = $hasnullitemid || ($component === 'course' &&
                     ($filearea === 'summary' || $filearea === 'overviewfiles'));
             $hasnullitemid = $hasnullitemid || ($component === 'coursecat' && $filearea === 'description');
@@ -362,45 +458,6 @@ class player {
         return $fs->get_pathname_hash($contextid, $component, $filearea, $itemid, $filepath, $filename);
     }
 
-    /**
-     * Store an H5P file
-     *
-     * @param stored_file $file Moodle file instance
-     * @param stdClass $config Button options config.
-     *
-     * @return int|false The H5P identifier or false if it's not a valid H5P package.
-     */
-    private function save_h5p($file, \stdClass $config) : int {
-        // This may take a long time.
-        \core_php_time_limit::raise();
-
-        $path = $this->core->fs->getTmpPath();
-        $this->core->h5pF->getUploadedH5pFolderPath($path);
-        // Add manually the extension to the file to avoid the validation fails.
-        $path .= '.h5p';
-        $this->core->h5pF->getUploadedH5pPath($path);
-
-        // Copy the .h5p file to the temporary folder.
-        $file->copy_content_to($path);
-
-        // Check if the h5p file is valid before saving it.
-        $h5pvalidator = $this->factory->get_validator();
-        if ($h5pvalidator->isValidPackage(false, false)) {
-            $h5pstorage = $this->factory->get_storage();
-
-            $options = ['disable' => $this->get_display_options($config)];
-            $content = [
-                'pathnamehash' => $file->get_pathnamehash(),
-                'contenthash' => $file->get_contenthash(),
-            ];
-
-            $h5pstorage->savePackage($content, null, false, $options);
-            return $h5pstorage->contentId;
-        }
-
-        return false;
-    }
-
     /**
      * Get the representation of display options as int.
      * @param stdClass $config Button options config.
index af23629..d29ffc1 100644 (file)
@@ -34,9 +34,11 @@ $config->export = optional_param('export', 0, PARAM_INT);
 $config->embed = optional_param('embed', 0, PARAM_INT);
 $config->copyright = optional_param('copyright', 0, PARAM_INT);
 
+$preventredirect = optional_param('preventredirect', true, PARAM_BOOL);
+
 $PAGE->set_url(new \moodle_url('/h5p/embed.php', array('url' => $url)));
 try {
-    $h5pplayer = new \core_h5p\player($url, $config);
+    $h5pplayer = new \core_h5p\player($url, $config, $preventredirect);
     $messages = $h5pplayer->get_messages();
 
 } catch (\Exception $e) {
@@ -61,15 +63,18 @@ if (empty($messages->error) && empty($messages->exception)) {
     // Add H5P assets to the page.
     $h5pplayer->add_assets_to_page();
 
-    // Check if there is some error when adding assets to the page.
-    $messages = $h5pplayer->get_messages();
-    if (empty($messages->error) && empty($messages->exception)) {
-
-        // Print page HTML.
-        echo $OUTPUT->header();
+    // Print page HTML.
+    echo $OUTPUT->header();
 
-        echo $h5pplayer->output();
+    // Check if some error has been raised when adding assets to the page. If that's the case, display them above the H5P content.
+    $messages = $h5pplayer->get_messages();
+    if (!empty($messages->exception) || !empty($messages->error)) {
+        $messages->h5picon = new \moodle_url('/h5p/pix/icon.svg');
+        echo $OUTPUT->render_from_template('core_h5p/h5perror', $messages);
     }
+
+    // Display the H5P content.
+    echo $h5pplayer->output();
 } else {
     // If there is any error or exception when creating the player, it should be displayed.
     $PAGE->set_context(context_system::instance());
@@ -80,12 +85,9 @@ if (empty($messages->error) && empty($messages->exception)) {
     $PAGE->add_body_class('h5p-embed');
     $PAGE->set_pagelayout('embedded');
 
-    // Errors can't be printed yet, because some more errors might been added while preparing the output
-}
+    echo $OUTPUT->header();
 
-if (!empty($messages->error) || !empty($messages->exception)) {
     // Print all the errors.
-    echo $OUTPUT->header();
     $messages->h5picon = new \moodle_url('/h5p/pix/icon.svg');
     echo $OUTPUT->render_from_template('core_h5p/h5perror', $messages);
 }
index 9c0d9d9..76107c9 100644 (file)
@@ -21,7 +21,7 @@
 
     Variables required for this template:
     * h5picon - The icon
-    * message - The error message to display.
+    * message - The error messages to display.
 
     Example context (json):
     {
@@ -33,7 +33,9 @@
 <div class="d-flex h-100 position-relative align-items-center bg-light h5pmessages">
    <div class="position-absolute py-2 bg-secondary" style="top: 0px; left: 0px; right: 0px;">
       <div class="container">
-         <img src="{{{h5picon}}}" class="h5picon" alt="{{#str}}h5p, core_h5p{{/str}}" style="width: 50px; height: auto; opacity: 0.5">
+        {{#h5picon}}
+         <img src="{{{.}}}" class="h5picon" alt="{{#str}}h5p, core_h5p{{/str}}" style="width: 50px; height: auto; opacity: 0.5">
+        {{/h5picon}}
       </div>
    </div>
    <div class="container mt-5">
          {{{ exception }}}
       </div>
       {{/exception}}
+      {{#info}}
+      <div class="alert alert-block fade in alert-info my-2" role="alert">
+         {{{ . }}}
+      </div>
+      {{/info}}
       {{#error}}
       <div class="alert alert-block fade in alert-warning my-2" role="alert">
-         {{{code}}} : {{{ message }}}
+         {{#code}}{{{code}}} : {{/code}}{{{ message }}}
       </div>
       {{/error}}
    </div>
diff --git a/h5p/tests/fixtures/greeting-card-887.h5p b/h5p/tests/fixtures/greeting-card-887.h5p
new file mode 100644 (file)
index 0000000..40d3093
Binary files /dev/null and b/h5p/tests/fixtures/greeting-card-887.h5p differ
index 0eb5ee0..555dbba 100644 (file)
@@ -702,10 +702,107 @@ class framework_testcase extends \advanced_testcase {
     /**
      * Test the behaviour of mayUpdateLibraries().
      */
-    public function test_mayUpdateLibraries() {
+    public function test_mayUpdateLibraries(): void {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        // Create some users.
+        $contextsys = \context_system::instance();
+        $user = $this->getDataGenerator()->create_user();
+        $admin = get_admin();
+        $managerrole = $DB->get_record('role', ['shortname' => 'manager'], '*', MUST_EXIST);
+        $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+        $manager = $this->getDataGenerator()->create_user();
+        role_assign($managerrole->id, $manager->id, $contextsys);
+
+        // Create a course with a label and enrol the user.
+        $course = $this->getDataGenerator()->create_course();
+        $label = $this->getDataGenerator()->create_module('label', ['course' => $course->id]);
+        list(, $labelcm) = get_course_and_cm_from_instance($label->id, 'label');
+        $contextlabel = \context_module::instance($labelcm->id);
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        // Create the .h5p file.
+        $path = __DIR__ . '/fixtures/h5ptest.zip';
+
+        // Admin and manager should have permission to update libraries.
+        $file = helper::create_fake_stored_file_from_path($path, $admin->id, $contextsys);
+        $this->framework->set_file($file);
         $mayupdatelib = $this->framework->mayUpdateLibraries();
+        $this->assertTrue($mayupdatelib);
 
+        $file = helper::create_fake_stored_file_from_path($path, $manager->id, $contextsys);
+        $this->framework->set_file($file);
+        $mayupdatelib = $this->framework->mayUpdateLibraries();
         $this->assertTrue($mayupdatelib);
+
+        // By default, normal user hasn't permission to update libraries (in both contexts, system and module label).
+        $file = helper::create_fake_stored_file_from_path($path, $user->id, $contextsys);
+        $this->framework->set_file($file);
+        $mayupdatelib = $this->framework->mayUpdateLibraries();
+        $this->assertFalse($mayupdatelib);
+
+        $file = helper::create_fake_stored_file_from_path($path, $user->id, $contextlabel);
+        $this->framework->set_file($file);
+        $mayupdatelib = $this->framework->mayUpdateLibraries();
+        $this->assertFalse($mayupdatelib);
+
+        // If the current user (admin) can update libraries, the method should return true (even if the file userid hasn't the
+        // required capabilility in the file context).
+        $file = helper::create_fake_stored_file_from_path($path, $admin->id, $contextlabel);
+        $this->framework->set_file($file);
+        $mayupdatelib = $this->framework->mayUpdateLibraries();
+        $this->assertTrue($mayupdatelib);
+
+        // If the update capability is assigned to the user, they should be able to update the libraries (only in the context
+        // where the capability has been assigned).
+        $file = helper::create_fake_stored_file_from_path($path, $user->id, $contextlabel);
+        $this->framework->set_file($file);
+        $mayupdatelib = $this->framework->mayUpdateLibraries();
+        $this->assertFalse($mayupdatelib);
+        assign_capability('moodle/h5p:updatelibraries', CAP_ALLOW, $studentrole->id, $contextlabel);
+        $mayupdatelib = $this->framework->mayUpdateLibraries();
+        $this->assertTrue($mayupdatelib);
+        $file = helper::create_fake_stored_file_from_path($path, $user->id, $contextsys);
+        $this->framework->set_file($file);
+        $mayupdatelib = $this->framework->mayUpdateLibraries();
+        $this->assertFalse($mayupdatelib);
+    }
+
+    /**
+     * Test the behaviour of get_file() and set_file().
+     */
+    public function test_get_file(): void {
+        $this->resetAfterTest();
+
+        // Create some users.
+        $contextsys = \context_system::instance();
+        $user = $this->getDataGenerator()->create_user();
+
+        // The H5P file.
+        $path = __DIR__ . '/fixtures/h5ptest.zip';
+
+        // An error should be raised when it's called before initialitzing it.
+        $this->expectException('coding_exception');
+        $this->expectExceptionMessage('Using get_file() before file is set');
+        $this->framework->get_file();
+
+        // Check the value when only path and user are set.
+        $file = helper::create_fake_stored_file_from_path($path, $user->id);
+        $this->framework->set_file($file);
+        $file = $this->framework->get_file();
+        $this->assertEquals($user->id, $$file->get_userid());
+        $this->assertEquals($contextsys->id, $file->get_contextid());
+
+        // Check the value when also the context is set.
+        $course = $this->getDataGenerator()->create_course();
+        $contextcourse = \context_course::instance($course->id);
+        $file = helper::create_fake_stored_file_from_path($path, $user->id, $contextcourse);
+        $this->framework->set_file($file);
+        $file = $this->framework->get_file();
+        $this->assertEquals($user->id, $$file->get_userid());
+        $this->assertEquals($contextcourse->id, $file->get_contextid());
     }
 
     /**
@@ -845,6 +942,41 @@ class framework_testcase extends \advanced_testcase {
         $this->assertEquals($content['disable'], $dbcontent->displayoptions);
     }
 
+    /**
+     * Test the behaviour of insertContent().
+     */
+    public function test_insertContent_latestlibrary() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
+        // Create a library record.
+        $lib = $generator->create_library_record('TestLibrary', 'Test', 1, 1, 2);
+
+        $content = array(
+            'params' => json_encode(['param1' => 'Test']),
+            'library' => array(
+                'libraryId' => 0,
+                'machineName' => 'TestLibrary',
+            ),
+            'disable' => 8
+        );
+
+        // Insert h5p content.
+        $contentid = $this->framework->insertContent($content);
+
+        // Get the entered content from the db.
+        $dbcontent = $DB->get_record('h5p', ['id' => $contentid]);
+
+        // Make sure the h5p content was properly inserted.
+        $this->assertNotEmpty($dbcontent);
+        $this->assertEquals($content['params'], $dbcontent->jsoncontent);
+        $this->assertEquals($content['disable'], $dbcontent->displayoptions);
+        // As the libraryId was empty, the latest library has been used.
+        $this->assertEquals($lib->id, $dbcontent->mainlibraryid);
+    }
+
     /**
      * Test the behaviour of updateContent().
      */
@@ -2014,4 +2146,48 @@ class framework_testcase extends \advanced_testcase {
             ]
         ];
     }
+
+
+    /**
+     * Test the behaviour of get_latest_library_version().
+     */
+    public function test_get_latest_library_version() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
+        // Create a library record.
+        $machinename = 'TestLibrary';
+        $lib1 = $generator->create_library_record($machinename, 'Test', 1, 1, 2);
+        $lib2 = $generator->create_library_record($machinename, 'Test', 1, 2, 1);
+
+        $content = array(
+            'params' => json_encode(['param1' => 'Test']),
+            'library' => array(
+                'libraryId' => 0,
+                'machineName' => 'TestLibrary',
+            ),
+            'disable' => 8
+        );
+
+        // Get the latest id (at this point, should be lib2).
+        $latestlib = $this->framework->get_latest_library_version($machinename);
+        $this->assertEquals($lib2->id, $latestlib->id);
+
+        // Get the latest id (at this point, should be lib3).
+        $lib3 = $generator->create_library_record($machinename, 'Test', 2, 1, 0);
+        $latestlib = $this->framework->get_latest_library_version($machinename);
+        $this->assertEquals($lib3->id, $latestlib->id);
+
+        // Get the latest id (at this point, should be still lib3).
+        $lib4 = $generator->create_library_record($machinename, 'Test', 1, 1, 3);
+        $latestlib = $this->framework->get_latest_library_version($machinename);
+        $this->assertEquals($lib3->id, $latestlib->id);
+
+        // Get the latest id (at this point, should be lib5).
+        $lib5 = $generator->create_library_record($machinename, 'Test', 2, 1, 6);
+        $latestlib = $this->framework->get_latest_library_version($machinename);
+        $this->assertEquals($lib5->id, $latestlib->id);
+    }
 }
index dc25c07..832b4f0 100644 (file)
@@ -26,6 +26,7 @@
 namespace core_h5p\local\tests;
 
 use core_h5p\file_storage;
+use core_h5p\autoloader;
 use file_archive;
 use zip_archive;
 
@@ -59,6 +60,8 @@ class h5p_file_storage_testcase extends \advanced_testcase {
         parent::setUp();
         $this->resetAfterTest(true);
 
+        autoloader::register();
+
         // Fetch generator.
         $generator = \testing_util::get_data_generator();
         $this->h5p_generator = $generator->get_plugin_generator('core_h5p');
diff --git a/h5p/tests/helper_test.php b/h5p/tests/helper_test.php
new file mode 100644 (file)
index 0000000..9323757
--- /dev/null
@@ -0,0 +1,286 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Testing the H5P helper.
+ *
+ * @package    core_h5p
+ * @category   test
+ * @copyright  2019 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types = 1);
+
+namespace core_h5p;
+
+use advanced_testcase;
+
+/**
+ * Test class covering the H5P helper.
+ *
+ * @package    core_h5p
+ * @copyright  2019 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class helper_testcase extends \advanced_testcase {
+
+    /**
+     * Test the behaviour of get_display_options().
+     *
+     * @dataProvider get_display_options_provider
+     * @param  bool   $frame     Whether the frame should be displayed or not
+     * @param  bool   $export    Whether the export action button should be displayed or not
+     * @param  bool   $embed     Whether the embed action button should be displayed or not
+     * @param  bool   $copyright Whether the copyright action button should be displayed or not
+     * @param  int    $expected The expectation with the displayoptions value
+     */
+    public function test_get_display_options(bool $frame, bool $export, bool $embed, bool $copyright, int $expected): void {
+        $this->setRunTestInSeparateProcess(true);
+        $this->resetAfterTest();
+
+        $factory = new \core_h5p\factory();
+        $core = $factory->get_core();
+        $config = (object)[
+            'frame' => $frame,
+            'export' => $export,
+            'embed' => $embed,
+            'copyright' => $copyright,
+        ];
+        $displayoptions = helper::get_display_options($core, $config);
+
+        $this->assertEquals($expected, $displayoptions);
+    }
+
+    /**
+     * Data provider for test_get_display_options().
+     *
+     * @return array
+     */
+    public function get_display_options_provider(): array {
+        return [
+            'All display options disabled' => [
+                false,
+                false,
+                false,
+                false,
+                15,
+            ],
+            'All display options enabled' => [
+                true,
+                true,
+                true,
+                true,
+                0,
+            ],
+            'Frame disabled and the rest enabled' => [
+                false,
+                true,
+                true,
+                true,
+                0,
+            ],
+            'Only export enabled' => [
+                false,
+                true,
+                false,
+                false,
+                12,
+            ],
+            'Only embed enabled' => [
+                false,
+                false,
+                true,
+                false,
+                10,
+            ],
+            'Only copyright enabled' => [
+                false,
+                false,
+                false,
+                true,
+                6,
+            ],
+        ];
+    }
+
+    /**
+     * Test the behaviour of save_h5p() when there are some missing libraries in the system.
+     * @runInSeparateProcess
+     */
+    public function test_save_h5p_missing_libraries(): void {
+        $this->resetAfterTest();
+        $factory = new \core_h5p\factory();
+
+        // Create a user.
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        // This is a valid .H5P file.
+        $path = __DIR__ . '/fixtures/greeting-card-887.h5p';
+        $file = helper::create_fake_stored_file_from_path($path, (int)$user->id);
+        $factory->get_framework()->set_file($file);
+
+        $config = (object)[
+            'frame' => 1,
+            'export' => 1,
+            'embed' => 0,
+            'copyright' => 0,
+        ];
+
+        // There are some missing libraries in the system, so an error should be returned.
+        $h5pid = helper::save_h5p($factory, $file, $config);
+        $this->assertFalse($h5pid);
+        $errors = $factory->get_framework()->getMessages('error');
+        $this->assertCount(1, $errors);
+        $error = reset($errors);
+        $this->assertEquals('missing-required-library', $error->code);
+        $this->assertEquals('Missing required library H5P.GreetingCard 1.0', $error->message);
+    }
+
+    /**
+     * Test the behaviour of save_h5p() when the libraries exist in the system.
+     * @runInSeparateProcess
+     */
+    public function test_save_h5p_existing_libraries(): void {
+        global $DB;
+
+        $this->resetAfterTest();
+        $factory = new \core_h5p\factory();
+
+        // Create a user.
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        // This is a valid .H5P file.
+        $path = __DIR__ . '/fixtures/greeting-card-887.h5p';
+        $file = helper::create_fake_stored_file_from_path($path, (int)$user->id);
+        $factory->get_framework()->set_file($file);
+
+        $config = (object)[
+            'frame' => 1,
+            'export' => 1,
+            'embed' => 0,
+            'copyright' => 0,
+        ];
+        // The required libraries exist in the system before saving the .h5p file.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
+        $lib = $generator->create_library_record('H5P.GreetingCard', 'GreetingCard', 1, 0);
+        $h5pid = helper::save_h5p($factory, $file, $config);
+        $this->assertNotEmpty($h5pid);
+
+        // No errors are raised.
+        $errors = $factory->get_framework()->getMessages('error');
+        $this->assertCount(0, $errors);
+
+        // And the content in the .h5p file has been saved as expected.
+        $h5p = $DB->get_record('h5p', ['id' => $h5pid]);
+        $this->assertEquals($lib->id, $h5p->mainlibraryid);
+        $this->assertEquals(helper::get_display_options($factory->get_core(), $config), $h5p->displayoptions);
+        $this->assertContains('Hello world!', $h5p->jsoncontent);
+    }
+
+    /**
+     * Test the behaviour of save_h5p() when the .h5p file is invalid.
+     * @runInSeparateProcess
+     */
+    public function test_save_h5p_invalid_file(): void {
+        $this->resetAfterTest();
+        $factory = new \core_h5p\factory();
+
+        // Create a user.
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        // Prepare an invalid .H5P file.
+        $path = __DIR__ . '/fixtures/h5ptest.zip';
+        $file = helper::create_fake_stored_file_from_path($path, (int)$user->id);
+        $factory->get_framework()->set_file($file);
+        $config = (object)[
+            'frame' => 1,
+            'export' => 1,
+            'embed' => 0,
+            'copyright' => 0,
+        ];
+
+        // When saving an invalid .h5p file, an error should be raised.
+        $h5pid = helper::save_h5p($factory, $file, $config);
+        $this->assertFalse($h5pid);
+        $errors = $factory->get_framework()->getMessages('error');
+        $this->assertCount(2, $errors);
+
+        $expectederrorcodes = ['invalid-content-folder', 'invalid-h5p-json-file'];
+        foreach ($errors as $error) {
+            $this->assertContains($error->code, $expectederrorcodes);
+        }
+    }
+
+    /**
+     * Test the behaviour of can_deploy_package().
+     */
+    public function test_can_deploy_package(): void {
+        $this->resetAfterTest();
+        $factory = new \core_h5p\factory();
+
+        // Create a user.
+        $user = $this->getDataGenerator()->create_user();
+        $admin = get_admin();
+
+        // Prepare a valid .H5P file.
+        $path = __DIR__ . '/fixtures/greeting-card-887.h5p';
+
+        // Files created by users can't be deployed.
+        $file = helper::create_fake_stored_file_from_path($path, (int)$user->id);
+        $factory->get_framework()->set_file($file);
+        $candeploy = helper::can_deploy_package($file);
+        $this->assertFalse($candeploy);
+
+        // Files created by admins can be deployed, even when the current user is not the admin.
+        $this->setUser($user);
+        $file = helper::create_fake_stored_file_from_path($path, (int)$admin->id);
+        $factory->get_framework()->set_file($file);
+        $candeploy = helper::can_deploy_package($file);
+        $this->assertTrue($candeploy);
+    }
+
+    /**
+     * Test the behaviour of can_update_library().
+     */
+    public function can_update_library(): void {
+        $this->resetAfterTest();
+        $factory = new \core_h5p\factory();
+
+        // Create a user.
+        $user = $this->getDataGenerator()->create_user();
+        $admin = get_admin();
+
+        // Prepare a valid .H5P file.
+        $path = __DIR__ . '/fixtures/greeting-card-887.h5p';
+
+        // Libraries can't be updated when the file has been created by users.
+        $file = helper::create_fake_stored_file_from_path($path, (int)$user->id);
+        $factory->get_framework()->set_file($file);
+        $candeploy = helper::can_update_library($file);
+        $this->assertFalse($candeploy);
+
+        // Libraries can be updated when the file has been created by admin, even when the current user is not the admin.
+        $this->setUser($user);
+        $file = helper::create_fake_stored_file_from_path($path, (int)$admin->id);
+        $factory->get_framework()->set_file($file);
+        $candeploy = helper::can_update_library($file);
+        $this->assertTrue($candeploy);
+    }
+}
diff --git a/install/lang/ca_valencia_racv/langconfig.php b/install/lang/ca_valencia_racv/langconfig.php
new file mode 100644 (file)
index 0000000..71ff0e8
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['thislanguage'] = 'Valencià_RACV';
index 086ae62..61f701b 100644 (file)
@@ -73,7 +73,7 @@ $string['welcomep10'] = '{$a->installername} ({$a->installerversion})';
 $string['welcomep20'] = 'A apresentação desta página confirma a correta instalação e ativação do pacote <strong>{$a->packname} {$a->packversion}</strong> no servidor.';
 $string['welcomep30'] = 'Esta versão do pacote <strong>{$a->installername}</strong> inclui as aplicações necessárias para o correto funcionamento do  <strong>Moodle</strong>, nomeadamente:';
 $string['welcomep40'] = 'Este pacote inclui o lançamento <strong>{$a->moodlerelease} do Moodle ({$a->moodleversion})</strong>.';
-$string['welcomep50'] = 'A utilização de todas as aplicações incluídas neste pacote é limitada pelas respetivas licenças. O pacote completo <strong>{$a->installername}</strong> é <ahref="http://www.opensource.org/docs/definition_plain.html">código aberto</a> e é distribuído nos termos da licença <a href="http://www.gnu.org/copyleft/gpl.html">GPL</a>.';
+$string['welcomep50'] = 'A utilização de todas as aplicações incluídas neste pacote é limitada pelas respetivas licenças. O pacote completo <strong>{$a->installername}</strong> é <ahref="https://www.opensource.org/docs/definition_plain.html">código aberto</a> e é distribuído nos termos da licença <a href="http://www.gnu.org/copyleft/gpl.html">GPL</a>.';
 $string['welcomep60'] = 'As páginas seguintes irão levá-lo através de alguns passos simples para
      configurar e definir o <strong>Moodle</strong> no seu computador. Você pode aceitar as configurações predefinidas ou, opcionalmente, alterá-las para atender às suas próprias necessidades.';
 $string['welcomep70'] = 'Clique no botão "Seguinte" para continuar a configuração do <strong>Moodle</strong>.';
index ad10a50..02427a6 100644 (file)
@@ -53,7 +53,7 @@ $string['allowcoursethemes'] = 'Allow course themes';
 $string['allowediplist'] = 'Allowed IP list';
 $string['allowedemaildomains'] = 'Allowed email domains';
 $string['allowemailaddresses'] = 'Allowed email domains';
-$string['allowemojipicker'] = 'Enable emoji picker';
+$string['allowemojipicker'] = 'Emoji picker';
 $string['allowindexing'] = 'Allow indexing by search engines';
 $string['allowindexing_desc'] = 'This determines whether to allow search engines to index your site. "Everywhere" will allow the search engines to search everywhere including login and signup pages, which means sites with Force Login turned on are still indexed. To avoid the risk of spam involved with the signup page being searchable, use "Everywhere except login and signup pages". "Nowhere" will tell search engines not to index any page. Note this is only a tag in the header of the site. It is up to the search engine to respect the tag.';
 $string['allowindexingeverywhere'] = 'Everywhere';
@@ -105,7 +105,7 @@ $string['bookmarkdeleted'] = 'Bookmark deleted.';
 $string['bookmarkthispage'] = 'Bookmark this page';
 $string['cachejs'] = 'Cache Javascript';
 $string['cachejs_help'] = 'Javascript caching and compression greatly improves page loading performance. it is strongly recommended for production sites. Developers will probably want to disable this feature.';
-$string['cachetemplates'] = 'Cache Templates';
+$string['cachetemplates'] = 'Cache templates';
 $string['cachetemplates_help'] = 'Template caching will improve page loading performance and is strongly recommended for production sites. Developers will probably want to disable this feature.';
 $string['calendarexportsalt'] = 'Calendar export salt';
 $string['calendarsettings'] = 'Calendar';
@@ -154,8 +154,8 @@ $string['configallowcohortthemes'] = 'If you enable this, then themes can be set
 $string['configallowcoursethemes'] = 'If you enable this, then courses will be allowed to set their own themes.  Course themes override all other theme choices (site, user, or session themes)';
 $string['configallowedemaildomains'] = 'List email domains that are allowed to be disclosed in the "From" section of outgoing email. The default of "Empty" will use the No-reply address for all outgoing email. The use of wildcards is allowed e.g. *.example.com will allow emails sent from any subdomain of example.com, but not example.com itself. This will require separate entry.';
 $string['configallowemailaddresses'] = 'To restrict new email addresses to particular domains, list them here separated by spaces. All other domains will be rejected. To allow subdomains, add the domain with a preceding \'.\'. To allow a root domain together with its subdomains, add the domain twice - once with a preceding \'.\' and once without e.g. .ourcollege.edu.au ourcollege.edu.au.';
-$string['configallowemojipicker'] = 'If enabled, the emoji picker will be available within the site';
-$string['configallowemojipickerincompatible'] = 'Your current database configuration does not properly support emojis. In order to enable the emoji picker you will need to <a href="https://docs.moodle.org/37/en/MySQL_full_unicode_support">upgrade your database for full unicode support</a>.';
+$string['configallowemojipicker'] = 'The emoji picker enables users to select emojis, such as smilies, to add to messages.';
+$string['configallowemojipickerincompatible'] = 'Your current database configuration does not properly support emojis. In order to enable the emoji picker you will need to <a href="https://docs.moodle.org/en/MySQL_full_unicode_support">upgrade your database for full unicode support</a>.';
 $string['configallowguestmymoodle'] = 'If enabled guests can access Dashboard, otherwise guests are redirected to the site front page.';
 $string['configallowobjectembed'] = 'As a default security measure, normal users are not allowed to embed multimedia (like Flash) within texts using explicit EMBED and OBJECT tags in their HTML (although it can still be done safely using the mediaplugins filter).  If you wish to allow these tags then enable this option.';
 $string['configallowoverride'] = 'You can allow people with the roles on the left side to override some of the column roles';
@@ -206,7 +206,7 @@ $string['configdebugdisplay'] = 'Set to on, the error reporting will go to the H
 $string['configdebugpageinfo'] = 'Enable if you want page information printed in page footer.';
 $string['configdebugvalidators'] = 'Enable if you want to have links to external validator servers in page footer. You may need to create new user with username <em>w3cvalidator</em>, and enable guest access. These changes may allow unauthorized access to server, do not enable on production sites!';
 $string['configdefaulthomepage'] = 'This determines the first link in the navigation for logged-in users.';
-$string['configdefaultrequestcategory'] = 'Courses requested by users will be placed in this category if the category is not specified.';
+$string['configdefaultrequestcategory'] = 'Courses requested by users with the capability to request new courses in the system context will be placed in this category unless users are able to select a different category.';
 $string['configdefaultrequestedcategory'] = 'Default category to put courses that were requested into, if they\'re approved.';
 $string['configdefaultuserroleid'] = 'All logged in users will be given the capabilities of the role you specify here, at the site level, in ADDITION to any other roles they may have been given.  The default is the Authenticated user role.  Note that this will not conflict with other roles they have unless you prohibit capabilities, it just ensures that all users have capabilities that are not assignable at the course level (eg post blog entries, manage own calendar, etc).';
 $string['configdeleteincompleteusers'] = 'After this period, any account without the first name, last name or email field filled in is deleted.';
@@ -228,7 +228,7 @@ $string['configemailfromvia'] = 'Add via information in the "From" section of ou
 $string['configemailsubjectprefix'] = 'Text to be prefixed to the subject line of all outgoing mail.';
 $string['configenablecalendarexport'] = 'Enable exporting or subscribing to calendars.';
 $string['configenablecomments'] = 'Enable comments';
-$string['configenablecourserequests'] = 'Enable course request functionality. Users with capability to request courses but without capability to create courses will be able to request courses.';
+$string['configenablecourserequests'] = 'If enabled, users with the capability to request new courses (moodle/course:request) will have the option to request a course. This capability is not allowed for any of the default roles. It may be applied in the system or category context.';
 $string['configenablemobilewebservice'] = 'Enable mobile service for the official Moodle app or other app requesting it. For more information, read the {$a}';
 $string['configenablerssfeeds'] = 'If enabled, RSS feeds are generated by various features across the site, such as blogs, forums, database activities and glossaries. Note that RSS feeds also need to be enabled for the particular activity modules.';
 $string['configenablerssfeedsdisabled'] = 'It is not available because RSS feeds are disabled in all the Site. To enable them, go to the Variables settings under Admin Configuration.';
@@ -273,7 +273,7 @@ $string['configlanglist'] = 'If left blank, all languages installed on the site
 $string['configlangmenu'] = 'Choose whether or not you want to display the general-purpose language menu on the home page, login page etc.  This does not affect the user\'s ability to set the preferred language in their own profile.';
 $string['configlatinexcelexport'] = 'Choose the encoding for Excel exports.';
 $string['configlocale'] = 'Choose a sitewide locale - this will override the format and language of dates for all language packs (though names of days in calendar are not affected). You need to have this locale data installed on your operating system (eg for linux en_US.UTF-8 or es_ES.UTF-8). In most cases this field should be left blank.';
-$string['configlockrequestcategory'] = 'Only allow course requests in the default course request category. This is a legacy setting, it is better not to use it but instead assign capability to request courses in the appropriate course category context';
+$string['configlockrequestcategory'] = 'If enabled, users with the capability to request new courses in the system context will not be able to select a category in the request a new course form. An alternative way of restricting users to requesting a new course in just one category is to apply the capability to request new courses in the category context.';
 $string['configloglifetime'] = 'This specifies the length of time you want to keep logs about user activity.  Logs that are older than this age are automatically deleted.  It is best to keep logs as long as possible, in case you need them, but if you have a very busy server and are experiencing performance problems, then you may want to lower the log lifetime. Values lower than 30 are not recommended because statistics may not work properly.';
 $string['configlookahead'] = 'Days to look ahead';
 $string['configmailnewline'] = 'Newline characters used in mail messages. CRLF is required according to RFC 822bis, some mail servers do automatic conversion from LF to CRLF, other mail servers do incorrect conversion from CRLF to CRCRLF, yet others reject mails with bare LF (qmail for example). Try changing this setting if you are having problems with undelivered emails or double newlines.';
@@ -719,7 +719,7 @@ $string['lockoutthreshold'] = 'Account lockout threshold';
 $string['lockoutthreshold_desc'] = 'Select number of failed login attempts that result in account lockout. This feature may be abused in denial of service attacks.';
 $string['lockoutwindow'] = 'Account lockout observation window';
 $string['lockoutwindow_desc'] = 'Observation time for lockout threshold, if there are no failed attempts the threshold counter is reset after this time.';
-$string['lockrequestcategory'] = 'Lock category for the course requests';
+$string['lockrequestcategory'] = 'Prevent category selection';
 $string['log'] = 'Logs';
 $string['logguests'] = 'Log guest access';
 $string['logguests_help'] = 'This setting enables logging of actions by guest account and not logged in users. High profile sites may want to disable this logging for performance reasons. It is recommended to keep this setting enabled on production sites.';
index 0b395a7..5caae68 100644 (file)
@@ -51,7 +51,7 @@ $string['auth_remove_keep'] = 'Keep internal';
 $string['auth_remove_suspend'] = 'Suspend internal';
 $string['auth_remove_user'] = 'Specify what to do with internal user account during mass synchronisation when user was removed from external source. Only suspended users are automatically restored if they reappear in the external source.';
 $string['auth_remove_user_key'] = 'Removed ext user';
-$string['auth_sync_suspended']  = 'When enabled, the suspended attribute will be used to update the local user account\'s suspension status.';
+$string['auth_sync_suspended']  = 'If enabled, the suspended attribute will be used to update the local user account\'s suspension status.';
 $string['auth_sync_suspended_key'] = 'Synchronise local user suspension status';
 $string['auth_sync_script'] = 'User account synchronisation';
 $string['auth_updatelocal'] = 'Update local';
index c92b544..e6620e2 100644 (file)
@@ -28,7 +28,7 @@ $string['and'] = 'and';
 $string['condition_group'] = 'Restriction set';
 $string['condition_group_info'] = 'Add a set of nested restrictions to apply complex logic.';
 $string['enableavailability'] = 'Enable restricted access';
-$string['enableavailability_desc'] = 'When enabled, this lets you set conditions (based on date, grade, or completion) that control whether an activity or resource can be accessed.';
+$string['enableavailability_desc'] = 'If enabled, conditions (based on date, grade, completion etc.) may be set to control whether an activity or resource can be accessed.';
 $string['error_list_nochildren'] = 'Restriction sets should contain at least one condition.';
 $string['hidden_marker'] = '(hidden otherwise)';
 $string['hidden_individual'] = 'Hidden entirely if user does not meet this condition';
index 6ff0088..3d3c4de 100644 (file)
@@ -176,7 +176,7 @@ $string['claimid'] = 'Claim URL';
 $string['clearsettings'] = 'Clear settings';
 $string['completionnotenabled'] = 'Course completion is not enabled for this course, so it cannot be included in badge criteria. Course completion may be enabled in the course settings.';
 $string['completioninfo'] = 'This badge was issued for completing: ';
-$string['configenablebadges'] = 'When enabled, this feature lets you create badges and award them to site users.';
+$string['configenablebadges'] = 'If enabled, this feature lets you create badges and award them to site users.';
 $string['configuremessage'] = 'Badge message';
 $string['connect'] = 'Connect';
 $string['connected'] = 'Connected';
index cae7b1e..786835c 100644 (file)
@@ -112,7 +112,7 @@ $string['completionupdated'] = 'Updated completion for activity <b>{$a}</b>';
 $string['completionview'] = 'Require view';
 $string['completionview_desc'] = 'Student must view this activity to complete it';
 $string['configcompletiondefault'] = 'The default setting for completion tracking when creating new activities.';
-$string['configenablecompletion'] = 'When enabled, this lets you turn on completion tracking (progress) features at course level.';
+$string['configenablecompletion'] = 'If enabled, course and activity completion conditions may be set. Setting activity completion conditions is recommended so that meaningful data is displayed for users in their course overview on the Dashboard.';
 $string['confirmselfcompletion'] = 'Confirm self completion';
 $string['courseaggregation'] = 'Condition requires';
 $string['courseaggregation_all'] = 'ALL selected courses to be completed';
index 636cf13..0d7e186 100644 (file)
@@ -48,10 +48,10 @@ $string['nocoursesections'] = 'No course sections';
 $string['nocoursestudents'] = 'No students';
 $string['noaccesssincestartinfomessage'] = 'Hi {$a->userfirstname},
 
-</br><br/>Students in {$a->coursename} have never accessed the course.';
+</br><br/>A number of students in {$a->coursename} have never accessed the course.';
 $string['norecentaccessesinfomessage'] = 'Hi {$a->userfirstname},
 
-</br><br/>Students in {$a->coursename} have not accessed the course recently.';
+</br><br/>A number of students in {$a->coursename} have not accessed the course recently.';
 $string['noteachinginfomessage'] = 'Hi {$a->userfirstname},
 
 </br><br/>Courses with start dates in the next week have been identified as having no teacher or student enrolments.';
@@ -75,7 +75,7 @@ $string['target:coursegradetopass_help'] = 'This target describes whether the st
 $string['target:noaccesssincecoursestart'] = 'Students who have not accessed the course yet';
 $string['target:noaccesssincecoursestart_help'] = 'This target describes students who never accessed a course they are enrolled in.';
 $string['target:norecentaccesses'] = 'Students who have not accessed the course recently';
-$string['target:norecentaccesses_help'] = 'This target describes students who have not accessed a course recently.';
+$string['target:norecentaccesses_help'] = 'This target identifies students who have not accessed a course they are enrolled in within the set analysis interval (by default the past month).';
 $string['target:noteachingactivity'] = 'Courses at risk of not starting';
 $string['target:noteachingactivity_help'] = 'This target describes whether courses due to start in the coming week will have teaching activity.';
 $string['targetlabelstudentcompletionno'] = 'Student who is likely to meet the course completion conditions';
index a65724f..91a2fca 100644 (file)
@@ -125,6 +125,7 @@ $string['nocopyright'] = 'No copyright information available for this content.';
 $string['noextension'] = 'The file you uploaded is not a valid HTML5 Package (It does not have the .h5p file extension)';
 $string['nopermissiontodeploy'] = 'This file can\'t be displayed because it has been uploaded by a user without the required capability to deploy H5P content.';
 $string['nojson'] = 'The main h5p.json file is not valid';
+$string['notrustablefile'] = 'This file can\'t be displayed because it has been uploaded by a user without the capability to update H5P content types.  Please contact your administrator to ask for the content type to be installed.';
 $string['nounzip'] = 'The file you uploaded is not a valid HTML5 Package (We are unable to unzip it)';
 $string['offlineDialogBody'] = 'We were unable to send information about your completion of this task. Please check your internet connection.';
 $string['offlineDialogHeader'] = 'Your connection to the server was lost';
@@ -158,4 +159,4 @@ $string['wrongversion'] = 'The version of the H5P library {$a->%machineName} use
 $string['year'] = 'Year';
 $string['years'] = 'Year(s)';
 $string['yearsfrom'] = 'Years (from)';
-$string['yearsto'] = 'Years (to)';
\ No newline at end of file
+$string['yearsto'] = 'Years (to)';
index 9d06480..a077113 100644 (file)
@@ -144,6 +144,7 @@ $string['inputdatadirectory'] = 'Data directory:';
 $string['inputwebadress'] = 'Web address :';
 $string['inputwebdirectory'] = 'Moodle directory:';
 $string['installation'] = 'Installation';
+$string['invaliddbprefix'] = 'Invalid prefix. The prefix can only consist of lower case letters and underscore.';
 $string['langdownloaderror'] = 'Unfortunately the language "{$a}" could not be downloaded. The installation process will continue in English.';
 $string['langdownloadok'] = 'The language "{$a}" was installed successfully. The installation process will continue in this language.';
 $string['memorylimit'] = 'Memory limit';
index 2f79d87..d3e80a7 100644 (file)
@@ -724,7 +724,7 @@ $string['emailresetconfirmationsubject'] = '{$a}: Password reset request';
 $string['emailresetconfirmsent'] = 'An email has been sent to your address at <b>{$a}</b>.
 <br />It contains easy instructions to confirm and complete this password change.
 If you continue to have difficulty, contact the site administrator.';
-$string['emailstop'] = 'Email stop';
+$string['emailstop'] = 'Disable notifications';
 $string['emailtoprivatefiles'] = 'You can also e-mail files as attachments straight to your private files space. Simply attach your files to an e-mail and send it to {$a}';
 $string['emailtoprivatefilesdenied'] = 'Your administrator has disabled the option to upload your own private files.';
 $string['emailvia'] = '{$a->name} (via {$a->siteshortname})';
@@ -1057,7 +1057,7 @@ $string['indicator:accessesbeforestart_help'] = 'This indicator reflects if the
 $string['indicator:activitiesdue'] = 'Activities due';
 $string['indicator:activitiesdue_help'] = 'The user has activities due.';
 $string['indicator:anycourseaccess'] = 'Any course access';
-$string['indicator:anycourseaccess_help'] = 'This indicator reflects any accesses to the provided course for the provided user.';
+$string['indicator:anycourseaccess_help'] = 'This indicator reflects access to any course that the user is enrolled in.';
 $string['indicator:anywrite'] = 'Any write action';
 $string['indicator:anywrite_help'] = 'This indicator represents any write (submit) action taken by the student.';
 $string['indicator:anywriteincourse'] = 'Any write action in the course';
@@ -1824,8 +1824,8 @@ $string['separateandconnected'] = 'Separate and Connected ways of knowing';
 $string['separateandconnectedinfo'] = 'The scale based on the theory of separate and connected knowing. This theory describes two different ways that we can evaluate and learn about the things we see and hear.<ul><li><strong>Separate knowers</strong> remain as objective as possible without including feelings and emotions. In a discussion with other people, they like to defend their own ideas, using logic to find holes in opponent\'s ideas.</li><li><strong>Connected knowers</strong> are more sensitive to other people. They are skilled at empathy and tend to listen and ask questions until they feel they can connect and "understand things from their point of view". They learn by trying to share the experiences that led to the knowledge they find in other people.</li></ul>';
 $string['servererror'] = 'An error occurred whilst communicating with the server';
 $string['serverlocaltime'] = 'Server\'s local time';
-$string['sessionforceclean'] = 'As a security precaution, user-generated scripts have been disabled within this session';
-$string['sessiontimeoutsoon'] = 'Your session is about to timeout. Do you want to extend your current session?';
+$string['sessionforceclean'] = 'As a security precaution, user-generated scripts have been disabled within this session.';
+$string['sessiontimeoutsoon'] = 'Your session is about to time out. Do you want to extend your current session?';
 $string['setcategorytheme'] = 'Set category theme';
 $string['setpassword'] = 'Set password';
 $string['setpasswordinstructions'] = 'Please enter your new password below, then save changes.';
index 07d9407..72edb50 100644 (file)
@@ -260,8 +260,9 @@ $string['grade:unlock'] = 'Unlock grades or items';
 $string['grade:view'] = 'View own grades';
 $string['grade:viewall'] = 'View grades of other users';
 $string['grade:viewhidden'] = 'View hidden grades for owner';
-$string['h5p:deploy'] = 'Allow to deploy H5P content';
-$string['h5p:setdisplayoptions'] = 'Set the display options to an H5P content';
+$string['h5p:deploy'] = 'Deploy H5P content';
+$string['h5p:updatelibraries'] = 'Manage H5P content types';
+$string['h5p:setdisplayoptions'] = 'Set H5P display options';
 $string['highlightedcellsshowdefault'] = 'The permissions highlighted in the table below are the defaults for the role archetype currently selected above.';
 $string['highlightedcellsshowinherit'] = 'The highlighted cells in the table below show the permission (if any) that will be inherited. Apart from the capabilities whose permission you actually want to alter, you should leave everything set to Inherit.';
 $string['checkglobalpermissions'] = 'Check system permissions';
@@ -496,4 +497,4 @@ $string['privacy:metadata:role_cohortroles'] = 'Roles to cohort';
 $string['course:togglecompletion'] = 'Manually mark activities as complete';
 
 // Deprecated since Moodle 3.8.
-$string['eventrolecapabilitiesupdated'] = 'Role capabilities updated';
\ No newline at end of file
+$string['eventrolecapabilitiesupdated'] = 'Role capabilities updated';
index 56777bb..aefc750 100644 (file)
@@ -47,7 +47,7 @@ $string['privacy:metadata:description'] = 'General details about this user.';
 $string['privacy:metadata:devicename'] = 'The device name, occam or iPhone etc..';
 $string['privacy:metadata:devicetablesummary'] = 'This table stores user\'s mobile devices information in order to send PUSH notifications';
 $string['privacy:metadata:email'] = 'An email address for contact.';
-$string['privacy:metadata:emailstop'] = 'A preference to stop email being sent to the user.';
+$string['privacy:metadata:emailstop'] = 'A preference to disable notifications from being sent to the user.';
 $string['privacy:metadata:fieldid'] = 'The ID relating to the custom user field.';
 $string['privacy:metadata:filelink'] = 'There are multiple different files for the user stored in the files table.';
 $string['privacy:metadata:firstaccess'] = 'The time that this user first accessed the site.';
index 0ec003f..25aacf0 100644 (file)
Binary files a/lib/amd/build/toast.min.js and b/lib/amd/build/toast.min.js differ
index ef6ab4a..c540fcc 100644 (file)
Binary files a/lib/amd/build/toast.min.js.map and b/lib/amd/build/toast.min.js.map differ
index 8fe5688..7128cc5 100644 (file)
@@ -78,10 +78,10 @@ export const add = async(message, configuration) => {
 };
 
 const getTargetNode = async() => {
-    const region = document.querySelector('.toast-wrapper');
+    const regions = document.querySelectorAll('.toast-wrapper');
 
-    if (region) {
-        return region;
+    if (regions.length) {
+        return regions[regions.length - 1];
     }
 
     await addToastRegion(document.body, 'fixed-bottom');
index 336bf87..0ea720d 100644 (file)
@@ -766,6 +766,9 @@ class auth_plugin_base {
      * @return string[] An array of strings with keys subject and message
      */
     public function get_password_change_info(stdClass $user) : array {
+
+        global $USER;
+
         $site = get_site();
         $systemcontext = context_system::instance();
 
@@ -776,6 +779,10 @@ class auth_plugin_base {
         $data->sitename  = format_string($site->fullname);
         $data->admin     = generate_email_signoff();
 
+        // This is a workaround as change_password_url() is designed to allow
+        // use of the $USER global. See MDL-66984.
+        $olduser = $USER;
+        $USER = $user;
         if ($this->can_change_password() and $this->change_password_url()) {
             // We have some external url for password changing.
             $data->link = $this->change_password_url();
@@ -783,6 +790,7 @@ class auth_plugin_base {
             // No way to change password, sorry.
             $data->link = '';
         }
+        $USER = $olduser;
 
         if (!empty($data->link) and has_capability('moodle/user:changeownpassword', $systemcontext, $user->id)) {
             $subject = get_string('emailpasswordchangeinfosubject', '', format_string($site->fullname));
index 34b8a6e..258170d 100644 (file)
@@ -114,6 +114,7 @@ abstract class core_filetypes {
             'gzip' => array('type' => 'application/g-zip', 'icon' => 'archive',
                     'groups' => array('archive'), 'string' => 'archive'),
             'h' => array('type' => 'text/plain', 'icon' => 'sourcecode'),
+            'h5p' => array('type' => 'application/zip', 'icon' => 'h5p', 'string' => 'archive'),
             'hpp' => array('type' => 'text/plain', 'icon' => 'sourcecode'),
             'hqx' => array('type' => 'application/mac-binhex40', 'icon' => 'archive',
                     'groups' => array('archive'), 'string' => 'archive'),
index f159d5a..daf8da5 100644 (file)
@@ -503,6 +503,9 @@ class csv_export_writer {
      * Download the csv file.
      */
     public function download_file() {
+        // If this file was requested from a form, then mark download as complete.
+        \core_form\util::form_download_complete();
+
         $this->send_header();
         $this->print_csv_data();
         exit;
index 4412e96..5842e53 100644 (file)
@@ -54,6 +54,9 @@ function download_as_dataformat($filename, $dataformat, $columns, $iterator, $ca
     // Close the session so that the users other tabs in the same session are not blocked.
     \core\session\manager::write_close();
 
+    // If this file was requested from a form, then mark download as complete (before sending headers).
+    \core_form\util::form_download_complete();
+
     $format->set_filename($filename);
     $format->send_http_headers();
     // This exists to support all dataformats - see MDL-56046.
index 9b7668e..ee6ee09 100644 (file)
@@ -2450,4 +2450,14 @@ $capabilities = array(
             'editingteacher' => CAP_ALLOW,
         )
     ),
+
+    // Allow to update H5P content-type libraries.
+    'moodle/h5p:updatelibraries' => [
+        'riskbitmask' => RISK_XSS,
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_MODULE,
+        'archetypes' => [
+            'manager' => CAP_ALLOW,
+        ]
+    ],
 );
index 0076942..15adc29 100644 (file)
@@ -261,6 +261,14 @@ $functions = array(
         'type' => 'read',
         'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
+    'core_calendar_get_timestamps' => [
+        'classname'     => 'core_calendar_external',
+        'methodname'    => 'get_timestamps',
+        'description'   => 'Fetch unix timestamps for given date times.',
+        'classpath' => 'calendar/externallib.php',
+        'type'          => 'read',
+        'ajax'          => true,
+    ],
     'core_cohort_add_cohort_members' => array(
         'classname' => 'core_cohort_external',
         'methodname' => 'add_cohort_members',
diff --git a/lib/editor/atto/plugins/emojipicker/classes/privacy/provider.php b/lib/editor/atto/plugins/emojipicker/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..308bf19
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Privacy Subsystem implementation for atto_emojipicker.
+ *
+ * @package    atto_emojipicker
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace atto_emojipicker\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Privacy Subsystem for atto_emojipicker implementing null_provider.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
diff --git a/lib/editor/atto/plugins/emojipicker/lang/en/atto_emojipicker.php b/lib/editor/atto/plugins/emojipicker/lang/en/atto_emojipicker.php
new file mode 100644 (file)
index 0000000..50bee0f
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Strings for component 'atto_emojipicker', language 'en'.
+ *
+ * @package    atto_emojipicker
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['emojipicker'] = 'Emoji picker';
+$string['pluginname'] = 'Emoji picker';
+$string['privacy:metadata'] = 'The atto_emojipicker plugin does not store any personal data.';
diff --git a/lib/editor/atto/plugins/emojipicker/lib.php b/lib/editor/atto/plugins/emojipicker/lib.php
new file mode 100644 (file)
index 0000000..2c50535
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Atto text editor emoji picker plugin lib.
+ *
+ * @package    atto_emojipicker
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Initialise the strings required for JS.
+ *
+ * @return void
+ */
+function atto_emojipicker_strings_for_js() {
+    global $PAGE;
+    $PAGE->requires->strings_for_js(['emojipicker'], 'atto_emojipicker');
+}
+
+/**
+ * Sends the parameters to JS module.
+ *
+ * @return array
+ */
+function atto_emojipicker_params_for_js() {
+    global $CFG;
+
+    return [
+        'disabled' => empty($CFG->allowemojipicker) ? true : false
+    ];
+}
diff --git a/lib/editor/atto/plugins/emojipicker/styles.css b/lib/editor/atto/plugins/emojipicker/styles.css
new file mode 100644 (file)
index 0000000..34278fe
--- /dev/null
@@ -0,0 +1,8 @@
+.emoji-picker-dialogue.moodle-dialogue-base .moodle-dialogue .moodle-dialogue-bd {
+    padding: 0;
+}
+
+.emoji-picker-dialogue .emoji-picker {
+    box-shadow: none !important; /* stylelint-disable-line declaration-no-important */
+    border: 0;
+}
\ No newline at end of file
diff --git a/lib/editor/atto/plugins/emojipicker/version.php b/lib/editor/atto/plugins/emojipicker/version.php
new file mode 100644 (file)
index 0000000..c569f25
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Atto text editor emoji picker plugin version file.
+ *
+ * @package    atto_emojipicker
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->version   = 2019101700;        // The current plugin version (Date: YYYYMMDDXX).
+$plugin->requires  = 2019101200;        // Requires this Moodle version.
+$plugin->component = 'atto_emojipicker';  // Full name of the plugin (used for diagnostics).
diff --git a/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button-debug.js b/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button-debug.js
new file mode 100644 (file)
index 0000000..059c96a
Binary files /dev/null and b/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button-debug.js differ
diff --git a/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button-min.js b/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button-min.js
new file mode 100644 (file)
index 0000000..e4293f0
Binary files /dev/null and b/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button-min.js differ
diff --git a/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button.js b/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button.js
new file mode 100644 (file)
index 0000000..059c96a
Binary files /dev/null and b/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button.js differ
diff --git a/lib/editor/atto/plugins/emojipicker/yui/src/button/build.json b/lib/editor/atto/plugins/emojipicker/yui/src/button/build.json
new file mode 100644 (file)
index 0000000..0a50ee9
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "name": "moodle-atto_emojipicker-button",
+  "builds": {
+    "moodle-atto_emojipicker-button": {
+      "jsfiles": [
+        "button.js"
+      ]
+    }
+  }
+}
diff --git a/lib/editor/atto/plugins/emojipicker/yui/src/button/js/button.js b/lib/editor/atto/plugins/emojipicker/yui/src/button/js/button.js
new file mode 100644 (file)
index 0000000..be0c29e
--- /dev/null
@@ -0,0 +1,135 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/*
+ * @package    atto_emojipicker
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * @module moodle-atto_emojipicker-button
+ */
+var COMPONENTNAME = 'atto_emojipicker';
+
+/**
+ * Atto text editor emoji picker plugin.
+ *
+ * @namespace M.atto_emojipicker
+ * @class button
+ * @extends M.editor_atto.EditorPlugin
+ */
+
+Y.namespace('M.atto_emojipicker').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {
+
+    /**
+     * A reference to the current selection at the time that the dialogue
+     * was opened.
+     *
+     * @property _currentSelection
+     * @type Range
+     * @private
+     */
+    _currentSelection: null,
+
+    initializer: function() {
+        if (this.get('disabled')) {
+            return;
+        }
+
+        this.addButton({
+            icon: 'e/emoticons',
+            callback: this._displayDialogue
+        });
+    },
+
+    /**
+     * Display the emoji picker.
+     *
+     * @method _displayDialogue
+     * @private
+     */
+    _displayDialogue: function() {
+        // Store the current selection.
+        this._currentSelection = this.get('host').getSelection();
+        if (this._currentSelection === false) {
+            return;
+        }
+
+        var dialogue = this.getDialogue({
+            headerContent: M.util.get_string('emojipicker', COMPONENTNAME),
+            width: 'auto',
+            focusAfterHide: true,
+            additionalBaseClass: 'emoji-picker-dialogue'
+        }, true);
+
+        // Set the dialogue content, and then show the dialogue.
+        dialogue.set('bodyContent', this._getDialogueContent())
+                .show();
+    },
+
+    /**
+     * Insert the emoticon.
+     *
+     * @method _insertEmote
+     * @param {String} emoji
+     * @private
+     */
+    _insertEmoji: function(emoji) {
+        var host = this.get('host');
+
+        // Hide the dialogue.
+        this.getDialogue({
+            focusAfterHide: null
+        }).hide();
+
+        // Focus on the previous selection.
+        host.setSelection(this._currentSelection);
+
+        // And add the character.
+        host.insertContentAtFocusPoint(emoji);
+
+        this.markUpdated();
+    },
+
+    /**
+     * Generates the content of the dialogue, attaching event listeners to
+     * the content.
+     *
+     * @method _getDialogueContent
+     * @return {Node} Node containing the dialogue content
+     * @private
+     */
+    _getDialogueContent: function() {
+        var wrapper = Y.Node.create('<div></div>');
+
+        require(['core/templates', 'core/emoji/picker'], function(Templates, initialiseEmojiPicker) {
+                Templates.render('core/emoji/picker', {}).then(function(html) {
+                    var domNode = wrapper.getDOMNode();
+                    domNode.innerHTML = html;
+                    initialiseEmojiPicker(domNode, this._insertEmoji.bind(this));
+                    this.getDialogue().centerDialogue();
+                }.bind(this));
+        }.bind(this));
+
+        return wrapper;
+    }
+}, {
+    ATTRS: {
+        disabled: {
+            value: true
+        }
+    }
+});
diff --git a/lib/editor/atto/plugins/emojipicker/yui/src/button/meta/button.json b/lib/editor/atto/plugins/emojipicker/yui/src/button/meta/button.json
new file mode 100644 (file)
index 0000000..9ea3e81
--- /dev/null
@@ -0,0 +1,7 @@
+{
+    "moodle-atto_emojipicker-button": {
+        "requires": [
+            "moodle-editor_atto-plugin"
+        ]
+    }
+}
index 2343281..382ba5f 100644 (file)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-$string['enterurl'] = 'Enter URL';
+$string['browserepositories'] = 'Browse repositories...';
+$string['copyrightbutton'] = 'Copyright button';
+$string['downloadbutton'] = 'Allow download';
+$string['either'] = 'Either';
+$string['embedbutton'] = 'Embed button';
+$string['enterurl'] = 'URL or Embed code';
+$string['h5p:addembed'] = 'Add embedded H5P';
+$string['h5pfile'] = 'H5P file upload';
+$string['h5poptions'] = 'H5P options';
 $string['h5pproperties'] = 'H5P properties';
+$string['h5purl'] = 'H5P URL';
 $string['invalidh5purl'] = 'Invalid URL';
+$string['instructions'] = 'You can insert H5P content by <strong>either</strong> entering a URL or embed code from an external H5P site <strong>or</strong> by uploading an H5P file.';
+$string['noh5pcontent'] = 'No H5P content added';
 $string['pluginname'] = 'Insert H5P';
 $string['privacy:metadata'] = 'The atto_h5p plugin does not store any personal data.';
-$string['h5p:addembed'] = 'Add embedded H5P';
-$string['saveh5p'] = 'Save H5P';
\ No newline at end of file
+$string['or'] = 'or';
\ No newline at end of file
index d07ad2a..60b34e7 100644 (file)
@@ -36,14 +36,23 @@ function atto_h5p_params_for_js($elementid, $options, $fpoptions) {
     if (!$context) {
         $context = context_system::instance();
     }
+
     $addembed = has_capability('atto/h5p:addembed', $context);
+    $upload = has_capability('moodle/h5p:deploy', $context);
 
     $allowedmethods = 'none';
-    if ($addembed) {
+    if ($addembed && $upload) {
+        $allowedmethods = 'both';
+    } else if ($addembed) {
         $allowedmethods = 'embed';
+    } else if ($upload) {
+        $allowedmethods = 'upload';
     }
 
-    $params = ['allowedmethods' => $allowedmethods];
+    $params = [
+        'allowedmethods' => $allowedmethods,
+        'storeinrepo' => true
+    ];
     return $params;
 }
 
@@ -54,10 +63,21 @@ function atto_h5p_strings_for_js() {
     global $PAGE;
 
     $strings = array(
-        'saveh5p',
-        'h5pproperties',
+        'browserepositories',
+        'copyrightbutton',
+        'downloadbutton',
+        'instructions',
+        'either',
+        'embedbutton',
         'enterurl',
-        'invalidh5purl'
+        'h5pfile',
+        'h5poptions',
+        'h5pproperties',
+        'h5purl',
+        'invalidh5purl',
+        'noh5pcontent',
+        'or',
+        'pluginname'
     );
 
     $PAGE->requires->strings_for_js($strings, 'atto_h5p');
diff --git a/lib/editor/atto/plugins/h5p/pix/icon-white.png b/lib/editor/atto/plugins/h5p/pix/icon-white.png
new file mode 100644 (file)
index 0000000..d2ac8b2
Binary files /dev/null and b/lib/editor/atto/plugins/h5p/pix/icon-white.png differ
diff --git a/lib/editor/atto/plugins/h5p/pix/icon-white.svg b/lib/editor/atto/plugins/h5p/pix/icon-white.svg
new file mode 100644 (file)
index 0000000..54e8e58
--- /dev/null
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+        viewBox="0 0 345 150" style="enable-background:new 0 0 345 150;" xml:space="preserve" preserveAspectRatio="xMinYMid meet">
+<style type="text/css">
+       .st0{fill:#FFFFFF;}
+</style>
+<g>
+       <path class="st0" d="M325.7,14.7C317.6,6.9,305.3,3,289,3h-43.5H234v31h-66l-5.4,22.2c4.5-2.1,10.9-4.2,15.3-5.3
+               c4.4-1.1,8.8-0.9,13.1-0.9c14.6,0,26.5,4.5,35.6,13.3c9.1,8.8,13.6,20,13.6,33.4c0,9.4-2.3,18.5-7,27.2s-11.3,15.4-19.9,20
+               c-3.1,1.6-6.5,3.1-10.2,4.1h42.4H259V95h25c18.2,0,31.7-4.2,40.6-12.5s13.3-19.9,13.3-34.6C337.9,33.6,333.8,22.5,325.7,14.7z
+                M288.7,60.6c-3.5,3-9.6,4.4-18.3,4.4H259V33h13.2c8.4,0,14.2,1.5,17.2,4.7c3.1,3.2,4.6,6.9,4.6,11.5
+               C294,53.9,292.2,57.6,288.7,60.6z"/>
+       <path class="st0" d="M176.5,76.3c-7.9,0-14.7,4.6-18,11.2L119,81.9L136.8,3h-23.6H101v62H51V3H7v145h44V95h50v53h12.2h42
+               c-6.7-2-12.5-4.6-17.2-8.1c-4.8-3.6-8.7-7.7-11.7-12.3c-3-4.6-5.3-9.7-7.3-16.5l39.6-5.7c3.3,6.6,10.1,11.1,17.9,11.1
+               c11.1,0,20.1-9,20.1-20.1S187.5,76.3,176.5,76.3z"/>
+</g>
+</svg>
index c808561..4fecf84 100644 (file)
@@ -1,17 +1,20 @@
 .attoh5poverlay {
     display: none;
 }
-.editor_atto_content_wrap .attoh5poverlay {
-    display: block;
-    position: absolute;
-    cursor: pointer;
-    top: 0;
-    left: 0;
-    bottom: 0;
-    width: 100%;
-    height: 100%;
-    background: url([[pix:atto_h5p|icon]]) center center / 100px auto no-repeat #adb5bd;
+.attoh5pinstructions {
+    max-width: 500px;
 }
-.h5p-embed-placeholder .attoh5poverlay + br {
+.editor_atto_content_wrap .h5p-placeholder + br {
     display: none;
+}
+.editor_atto_content_wrap .h5p-placeholder {
+    color: #6c757d;
+    width: 100%;
+    word-break: break-all;
+    height: 260px;
+    cursor: pointer;
+    background: url([[pix:atto_h5p|icon-white]]) center center / 100px auto no-repeat #6c757d;
+}
+.atto_h5p_button .icon {
+    width: 24px;
 }
\ No newline at end of file
index 1d2c920..467e74e 100644 (file)
@@ -1,4 +1,4 @@
-@editor @editor_atto @atto @atto_h5p @_switch_iframe
+@editor @editor_atto @atto @atto_h5p @_file_upload @_switch_iframe
 Feature: Add h5ps to Atto
   To write rich text - I need to add h5ps.
 
@@ -19,38 +19,82 @@ Feature: Add h5ps to Atto
   @javascript
   Scenario: Insert an embedded h5p
     Given I log in as "admin"
+    And I change window size to "large"
     And I am on "Course 1" course homepage
     And I follow "PageName1"
     And I navigate to "Edit settings" in current page administration
     And I click on "Insert H5P" "button" in the "#fitem_id_page" "css_element"
-    And I set the field "Enter URL" to "https://h5p.org/h5p/embed/576651"
-    And I click on "Save H5P" "button" in the "H5P properties" "dialogue"
+    And I set the field with xpath "//textarea[@data-region='h5purl']" to "https://h5p.org/h5p/embed/576651"
+    And I click on "Insert H5P" "button" in the "Insert H5P" "dialogue"
     And I wait until the page is ready
     When I click on "Save and display" "button"
-    And I switch to "h5pcontent" iframe
-    Then ".h5p-iframe" "css_element" should exist
+    Then ".h5p-placeholder" "css_element" should exist
+
+  @javascript
+  Scenario: Insert an h5p file
+    Given I log in as "admin"
+    And I change window size to "large"
+    And I follow "Manage private files..."
+    And I upload "lib/editor/atto/tests/fixtures/ipsums.h5p" file to "Files" filemanager
+    And I click on "Save changes" "button"
+    And I am on "Course 1" course homepage
+    And I follow "PageName1"
+    And I navigate to "Edit settings" in current page administration
+    And I click on "Insert H5P" "button" in the "#fitem_id_page" "css_element"
+    And I click on "Browse repositories..." "button" in the "Insert H5P" "dialogue"
+    And I click on "Private files" "link" in the ".fp-repo-area" "css_element"
+    And I click on "ipsums.h5p" "link"
+    And I click on "Select this file" "button"
+    And I click on "Insert H5P" "button" in the "Insert H5P" "dialogue"
+    And I wait until the page is ready
+    When I click on "Save and display" "button"
+    Then ".h5p-placeholder" "css_element" should exist
 
   @javascript
   Scenario: Test an invalid url
     Given I log in as "admin"
+    And I change window size to "large"
     And I am on "Course 1" course homepage
     And I follow "PageName1"
     And I navigate to "Edit settings" in current page administration
     And I click on "Insert H5P" "button" in the "#fitem_id_page" "css_element"
-    And I set the field "Enter URL" to "ftp://h5p.org/h5p/embed/576651"
-    And I click on "Save H5P" "button" in the "H5P properties" "dialogue"
+    And I set the field with xpath "//textarea[@data-region='h5purl']" to "ftp://h5p.org/h5p/embed/576651"
+    When I click on "Insert H5P" "button" in the "Insert H5P" "dialogue"
     And I wait until the page is ready
-    Then I should see "Invalid URL" in the "H5P properties" "dialogue"
+    Then I should see "Invalid URL" in the "Insert H5P" "dialogue"
+
+  @javascript
+  Scenario: No h5p capabilities
+    Given the following "permission overrides" exist:
+    | capability | permission | role | contextlevel | reference |
+    | atto/h5p:addembed | Prohibit | editingteacher | Course | C1 |
+    | moodle/h5p:deploy | Prohibit | editingteacher | Course | C1 |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "PageName1"
+    When I navigate to "Edit settings" in current page administration
+    Then "Insert H5P" "button" should not exist
 
   @javascript
   Scenario: No embed h5p capabilities
-    Given I log in as "admin"
-    And I set the following system permissions of "Teacher" role:
-    | capability | permission |
-    | atto/h5p:addembed | Prohibit |
-    And I log out
+    Given the following "permission overrides" exist:
+    | capability | permission | role | contextlevel | reference |
+    | atto/h5p:addembed | Prohibit | editingteacher | Course | C1 |
     And I log in as "teacher1"
     And I am on "Course 1" course homepage
     And I follow "PageName1"
-    And I navigate to "Edit settings" in current page administration
-    Then "Insert H5P" "button" should not exist
\ No newline at end of file
+    When I navigate to "Edit settings" in current page administration
+    And I click on "Insert H5P" "button"
+    Then I should not see "URL or Embed code" in the "Insert H5P" "dialogue"
+
+  @javascript
+  Scenario: No upload h5p capabilities
+    Given the following "permission overrides" exist:
+    | capability | permission | role | contextlevel | reference |
+    | moodle/h5p:deploy | Prohibit | editingteacher | Course | C1 |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "PageName1"
+    When I navigate to "Edit settings" in current page administration
+    And I click on "Insert H5P" "button"
+    Then I should not see "H5P file upload" in the "Insert H5P" "dialogue"
index f954471..61169c4 100644 (file)
Binary files a/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-debug.js and b/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-debug.js differ
index 5661975..a31effc 100644 (file)
Binary files a/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-min.js and b/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-min.js differ
index f954471..61169c4 100644 (file)
Binary files a/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button.js and b/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button.js differ
index d79e190..dbe30b3 100644 (file)
  */
 
 var CSS = {
+        CONTENTWARNING: 'att_h5p_contentwarning',
+        H5PBROWSER: 'openh5pbrowser',
         INPUTALT: 'atto_h5p_altentry',
-        INPUTSUBMIT: 'atto_h5p_urlentrysubmit',
+        INPUTH5PFILE: 'atto_h5p_file',
         INPUTH5PURL: 'atto_h5p_url',
+        INPUTSUBMIT: 'atto_h5p_urlentrysubmit',
+        OPTION_DOWNLOAD_BUTTON: 'atto_h5p_option_download_button',
+        OPTION_COPYRIGHT_BUTTON: 'atto_h5p_option_copyright_button',
+        OPTION_EMBED_BUTTON: 'atto_h5p_option_embed_button',
         URLWARNING: 'atto_h5p_warning'
     },
     SELECTORS = {
-        INPUTH5PURL: '.' + CSS.INPUTH5PURL
+        CONTENTWARNING: '.' + CSS.CONTENTWARNING,
+        H5PBROWSER: '.' + CSS.H5PBROWSER,
+        INPUTH5PFILE: '.' + CSS.INPUTH5PFILE,
+        INPUTH5PURL: '.' + CSS.INPUTH5PURL,
+        INPUTSUBMIT: '.' + CSS.INPUTSUBMIT,
+        OPTION_DOWNLOAD_BUTTON: '.' + CSS.OPTION_DOWNLOAD_BUTTON,
+        OPTION_COPYRIGHT_BUTTON: '.' + CSS.OPTION_COPYRIGHT_BUTTON,
+        OPTION_EMBED_BUTTON: '.' + CSS.OPTION_EMBED_BUTTON,
+        URLWARNING: '.' + CSS.URLWARNING
     },
 
     COMPONENTNAME = 'atto_h5p',
 
     TEMPLATE = '' +
-            '<form class="atto_form">' +
+            '<form class="atto_form mform" id="{{elementid}}_atto_h5p_form">' +
+                '<div style="display:none" role="alert" class="alert alert-warning mb-1 {{CSS.CONTENTWARNING}}">' +
+                    '{{get_string "noh5pcontent" component}}' +
+                '</div>' +
+                '{{#if canUploadAndEmbed}}' +
+                    '<div class="mt-2 attoh5pinstructions">{{{get_string "instructions" component}}}</div>' +
+                    '<div class="my-2"><strong>{{get_string "either" component}}</strong></div>' +
+                '{{/if}}' +
+                '{{#if canEmbed}}' +
                 '<div class="mb-4">' +
                     '<label for="{{elementid}}_{{CSS.INPUTH5PURL}}">{{get_string "enterurl" component}}</label>' +
                     '<div style="display:none" role="alert" class="alert alert-warning mb-1 {{CSS.URLWARNING}}">' +
                         '{{get_string "invalidh5purl" component}}' +
                     '</div>' +
-                    '<input class="form-control fullwidth {{CSS.INPUTH5PURL}}" type="url" ' +
-                    'id="{{elementid}}_{{CSS.INPUTH5PURL}}" size="32"/>' +
+                    '<textarea rows="3" data-region="h5purl" class="form-control {{CSS.INPUTH5PURL}}" type="url" ' +
+                    'id="{{elementid}}_{{CSS.INPUTH5PURL}}" />{{embedURL}}</textarea>' +
+                '</div>' +
+                '{{/if}}' +
+                '{{#if canUploadAndEmbed}}' +
+                    '<div class="my-2"><strong>{{get_string "or" component}}</strong></div>' +
+                '{{/if}}' +
+                '{{#if canUpload}}' +
+                '<div class="mb-4">' +
+                    '<label for="{{elementid}}_{{CSS.H5PBROWSER}}">{{get_string "h5pfile" component}}</label>' +
+                    '<div class="input-group input-append w-100">' +
+                        '<input class="form-control {{CSS.INPUTH5PFILE}}" type="url" value="{{fileURL}}" ' +
+                        'id="{{elementid}}_{{CSS.INPUTH5PFILE}}" size="32"/>' +
+                        '<span class="input-group-append">' +
+                            '<button class="btn btn-secondary {{CSS.H5PBROWSER}}" type="button">' +
+                            '{{get_string "browserepositories" component}}</button>' +
+                        '</span>' +
+                    '</div>' +
+                    '<fieldset class="collapsible {{#if collapseOptions}}collapsed{{/if}}" id="{{elementid}}_h5poptions">' +
+                        '<legend class="ftoggler">{{get_string "h5poptions" component}}</legend>' +
+                        '<div class="fcontainer">' +
+                            '<div class="form-check">' +
+                                '<input type="checkbox" {{optionDownloadButton}} ' +
+                                'class="form-check-input {{CSS.OPTION_DOWNLOAD_BUTTON}}"' +
+                                'id="{{elementid}}_h5p-option-allow-download"/>' +
+                                '<label class="form-check-label" for="{{elementid}}_h5p-option-allow-download">' +
+                                '{{get_string "downloadbutton" component}}' +
+                                '</label>' +
+                            '</div>' +
+                            '<div class="form-check">' +
+                                '<input type="checkbox" {{optionEmbedButton}} ' +
+                                'class="form-check-input {{CSS.OPTION_EMBED_BUTTON}}" ' +
+                                    'id="{{elementid}}_h5p-option-embed-button"/>' +
+                                '<label class="form-check-label" for="{{elementid}}_h5p-option-embed-button">' +
+                                '{{get_string "embedbutton" component}}' +
+                                '</label>' +
+                            '</div>' +
+                            '<div class="form-check mb-2">' +
+                                '<input type="checkbox" {{optionCopyrightButton}} ' +
+                                'class="form-check-input {{CSS.OPTION_COPYRIGHT_BUTTON}}" ' +
+                                    'id="{{elementid}}_h5p-option-copyright-button"/>' +
+                                '<label class="form-check-label" for="{{elementid}}_h5p-option-copyright-button">' +
+                                '{{get_string "copyrightbutton" component}}' +
+                                '</label>' +
+                            '</div>' +
+                        '</div>' +
+                    '</fieldset>' +
                 '</div>' +
+                '{{/if}}' +
                 '<div class="text-center">' +
                 '<button class="btn btn-secondary {{CSS.INPUTSUBMIT}}" type="submit">' + '' +
-                    '{{get_string "saveh5p" component}}</button>' +
+                    '{{get_string "pluginname" component}}</button>' +
                 '</div>' +
             '</form>',
 
         H5PTEMPLATE = '' +
-            '<div class="position-relative h5p-embed-placeholder">' +
-                '<div class="attoh5poverlay"></div>' +
-                '<iframe id="h5pcontent" class="h5pcontent" src="{{url}}/embed" ' +
-                    'width="100%" height="637" frameborder="0"' +
-                    'allowfullscreen="{{allowfullscreen}}" allowmedia="{{allowmedia}}">' +
-                '</iframe>' +
-                '<script src="' + M.cfg.wwwroot + '/lib/h5p/js/h5p-resizer.js"' +
-                    'charset="UTF-8"></script>' +
-                '</div>' +
-            '</div>' +
-            '<p><br></p>';
+            '<div class="h5p-placeholder">' +
+                '{{{url}}}' +
+            '</div><div><br></div>';
 
 Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {
     /**
@@ -93,31 +153,60 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
     _form: null,
 
     /**
-     * A reference to the currently selected H5P placeholder.
+     * A reference to the currently selected H5P div.
      *
      * @param _form
      * @type Node
      * @private
      */
-    _placeholderH5P: null,
+    _H5PDiv: null,
+
+    /**
+     * Allowed methods of adding H5P.
+     *
+     * @param _allowedmethods
+     * @type String
+     * @private
+     */
+    _allowedmethods: 'none',
 
     initializer: function() {
-        var allowedmethods = this.get('allowedmethods');
-        if (allowedmethods !== 'embed') {
+        this._allowedmethods = this.get('allowedmethods');
+        if (this._allowedmethods === 'none') {
             // Plugin not available here.
             return;
         }
-
         this.addButton({
             icon: 'icon',
             iconComponent: 'atto_h5p',
             callback: this._displayDialogue,
-            tags: '.attoh5poverlay',
+            tags: '.h5p-placeholder',
             tagMatchRequiresAll: false
         });
 
-        this.editor.delegate('dblclick', this._handleDblClick, '.attoh5poverlay', this);
-        this.editor.delegate('click', this._handleClick, '.attoh5poverlay', this);
+        this.editor.on(['keyup', 'cut'], this._clearH5P, this);
+        this.editor.delegate('dblclick', this._handleDblClick, '.h5p-placeholder', this);
+        this.editor.delegate('click', this._handleClick, '.h5p-placeholder', this);
+    },
+
+    /**
+     * Deletes elements with class .h5p-placeholder on backspace and delete.
+     *
+     * @method _clearH5P
+     * @param {EventFacade} e
+     * @private
+     */
+    _clearH5P: function(e) {
+        if (e.keyCode === 8 || e.keyCode === 46) {
+            var parentNodes = this.get('host').getSelectedNodes().get('parentNode');
+            if (parentNodes.hasOwnProperty('_nodes')) {
+                var placeholder = parentNodes.filter('.h5p-placeholder');
+                if (!placeholder.isEmpty()) {
+                    placeholder.remove();
+                }
+            }
+        }
+        e.preventDefault();
     },
 
     /**
@@ -138,9 +227,7 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
      * @private
      */
     _handleClick: function(e) {
-        var h5pplaceholder = e.target;
-
-        var selection = this.get('host').getSelectionFromNode(h5pplaceholder);
+        var selection = this.get('host').getSelectionFromNode(e.target);
         if (this.get('host').getSelection() !== selection) {
             this.get('host').setSelection(selection);
         }
@@ -155,21 +242,22 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
     _displayDialogue: function() {
         // Store the current selection.
         this._currentSelection = this.get('host').getSelection();
-        this._placeholderH5P = this._getH5PIframe();
 
         if (this._currentSelection === false) {
             return;
         }
+
+        this._getH5PDiv();
+
         var dialogue = this.getDialogue({
-            headerContent: M.util.get_string('h5pproperties', COMPONENTNAME),
+            headerContent: M.util.get_string('pluginname', COMPONENTNAME),
             width: 'auto',
-            focusAfterHide: true,
-            focusOnShowSelector: SELECTORS.INPUTH5PURL
+            focusAfterHide: true
         });
-
         // Set the dialogue content, and then show the dialogue.
         dialogue.set('bodyContent', this._getDialogueContent())
             .show();
+        M.form.shortforms({formid: this.get('host').get('elementid') + '_atto_h5p_form'});
     },
 
     /**
@@ -179,12 +267,43 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
      * @return {Node} The H5P iframe selected.
      * @private
      */
-    _getH5PIframe: function() {
-        var selectednode = this.get('host').getSelectionParentNode();
-        if (!selectednode) {
-            return;
+    _getH5PDiv: function() {
+        var selectednodes = this.get('host').getSelectedNodes();
+        var H5PDiv = null;
+        selectednodes.each(function(selNode) {
+            if (selNode.hasClass('h5p-placeholder')) {
+                H5PDiv = selNode;
+            }
+        });
+        this._H5PDiv = H5PDiv;
+    },
+
+    /**
+     * Get the H5P button permissions.
+     *
+     * @return {Object} H5P button permissions.
+     * @private
+     */
+    _getPermissions: function() {
+        var permissions = {
+            'canUpload': false,
+            'canUploadAndEbmed': false,
+            'canEmbed': false
+        };
+
+        if (this.get('host').canShowFilepicker('h5p')) {
+            if (this._allowedmethods === 'both') {
+                permissions.canUploadAndEmbed = true;
+                permissions.canUpload = true;
+            } else if (this._allowedmethods === 'upload') {
+                permissions.canUpload = true;
+            }
         }
-        return Y.one(selectednode).one('iframe.h5pcontent');
+
+        if (this._allowedmethods === 'both' || this._allowedmethods === 'embed') {
+            permissions.canEmbed = true;
+        }
+        return permissions;
     },
 
 
@@ -197,27 +316,130 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
      * @private
      */
     _getDialogueContent: function() {
+
+        var permissions = this._getPermissions();
+
+        var fileURL,
+            embedURL,
+            optionDownloadButton,
+            optionEmbedButton,
+            optionCopyrightButton,
+            collapseOptions = true;
+
+        if (this._H5PDiv) {
+            var H5PURL = this._H5PDiv.get('innerHTML');
+            var fileBaseUrl = M.cfg.wwwroot + '/draftfile.php';
+            if (fileBaseUrl == H5PURL.substring(0, fileBaseUrl.length)) {
+                fileURL = H5PURL.split("?")[0];
+
+                var parameters = H5PURL.split("?")[1];
+                if (parameters) {
+                    if (parameters.match(/export=1/)) {
+                        optionDownloadButton = 'checked';
+                        collapseOptions = false;
+                    }
+
+                    if (parameters.match(/embed=1/)) {
+                        optionEmbedButton = 'checked';
+                        collapseOptions = false;
+                    }
+
+                    if (parameters.match(/copyright=1/)) {
+                        optionCopyrightButton = 'checked';
+                        collapseOptions = false;
+                    }
+                }
+            } else {
+                embedURL = H5PURL;
+            }
+        }
+
         var template = Y.Handlebars.compile(TEMPLATE),
             content = Y.Node.create(template({
                 elementid: this.get('host').get('elementid'),
                 CSS: CSS,
-                component: COMPONENTNAME
+                component: COMPONENTNAME,
+                canUpload: permissions.canUpload,
+                canEmbed: permissions.canEmbed,
+                fileURL: fileURL,
+                embedURL: embedURL,
+                canUploadAndEmbed: permissions.canUploadAndEmbed,
+                collapseOptions: collapseOptions,
+                optionDownloadButton: optionDownloadButton,
+                optionEmbedButton: optionEmbedButton,
+                optionCopyrightButton: optionCopyrightButton
             }));
 
         this._form = content;
 
-        if (this._placeholderH5P) {
-            var oldurl = this._placeholderH5P.getAttribute('src');
-            this._form.one(SELECTORS.INPUTH5PURL).setAttribute('value', oldurl);
+        // Listen to and act on Dialogue content events.
+        this._setEventListeners();
+
+        return content;
+    },
+
+    /**
+     * Update the dialogue after an h5p was selected in the File Picker.
+     *
+     * @method _filepickerCallback
+     * @param {object} params The parameters provided by the filepicker
+     * containing information about the h5p.
+     * @private
+     */
+    _filepickerCallback: function(params) {
+        if (params.url !== '') {
+            var input = this._form.one(SELECTORS.INPUTH5PFILE);
+            input.set('value', params.url);
+            this._form.one(SELECTORS.INPUTH5PURL).set('value', '');
+            this._removeWarnings();
         }
+    },
 
-        this._form.one('.' + CSS.INPUTSUBMIT).on('click', this._setH5P, this);
+    /**
+     * Set event Listeners for Dialogue content actions.
+     *
+     * @method  _setEventListeners
+     * @private
+     */
+    _setEventListeners: function() {
+        var form = this._form;
+        var permissions = this._getPermissions();
 
-        return content;
+        form.one(SELECTORS.INPUTSUBMIT).on('click', this._setH5P, this);
+
+        if (permissions.canUpload) {
+            form.one(SELECTORS.H5PBROWSER).on('click', function() {
+                this.get('host').showFilepicker('h5p', this._filepickerCallback, this);
+            }, this);
+        }
+
+        if (permissions.canUploadAndEmbed) {
+            form.one(SELECTORS.INPUTH5PFILE).on('change', function() {
+                form.one(SELECTORS.INPUTH5PURL).set('value', '');
+                this._removeWarnings();
+            }, this);
+            form.one(SELECTORS.INPUTH5PURL).on('change', function() {
+                form.one(SELECTORS.INPUTH5PFILE).set('value', '');
+                this._removeWarnings();
+            }, this);
+        }
     },
 
     /**
-     * Set the h5p in the contenteditable.
+     * Remove warnings shown in the dialogue.
+     *
+     * @method _removeWarnings
+     * @private
+     */
+    _removeWarnings: function() {
+        var form = this._form;
+        form.one(SELECTORS.URLWARNING).setStyle('display', 'none');
+        form.one(SELECTORS.CONTENTWARNING).setStyle('display', 'none');
+    },
+
+    /**
+     * Update the h5p in the contenteditable.
+
      *
      * @method _setH5P
      * @param {EventFacade} e
@@ -227,7 +449,17 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
         var form = this._form,
             url = form.one(SELECTORS.INPUTH5PURL).get('value'),
             h5phtml,
-            host = this.get('host');
+            host = this.get('host'),
+            h5pfile,
+            permissions = this._getPermissions();
+
+        if (permissions.canEmbed) {
+            url = form.one(SELECTORS.INPUTH5PURL).get('value');
+        }
+
+        if (permissions.canUpload) {
+            h5pfile = form.one(SELECTORS.INPUTH5PFILE).get('value');
+        }
 
         e.preventDefault();
 
@@ -239,19 +471,69 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
         // Focus on the editor in preparation for inserting the h5p.
         host.focus();
 
-        // If a H5P placeholder was selected we only update the placeholder.
-        if (this._placeholderH5P) {
-            this._placeholderH5P.setAttribute('src', url);
+        // If a H5P placeholder was selected we can destroy it now.
+        if (this._H5PDiv) {
+            this._H5PDiv.remove();
+        }
 
-        } else if (url !== '') {
+        if (url !== '') {
 
             host.setSelection(this._currentSelection);
 
-            var template = Y.Handlebars.compile(H5PTEMPLATE);
-            h5phtml = template({
-                url: url,
-                allowfullscreen: 'allowfullscreen',
-                allowmedia: 'geolocation *; microphone *; camera *; midi *; encrypted-media *'
+            if (this._validEmbed(url)) {
+                var embedtemplate = Y.Handlebars.compile(H5PTEMPLATE);
+                var regex = /<iframe.*?src="(.*?)".*<\/iframe>/;
+                var src = url.match(regex)[1];
+
+                // In case a local H5P embed code is used we need get the url
+                // param form the src and decode it.
+                if (src.startsWith(M.cfg.wwwroot + '/h5p/embed.php')) {
+                    src = decodeURIComponent(src.split("url=")[1]);
+                }
+
+                h5phtml = embedtemplate({
+                    url: src
+                });
+            } else {
+                var urltemplate = Y.Handlebars.compile(H5PTEMPLATE);
+                h5phtml = urltemplate({
+                    url: url
+                });
+            }
+
+            this.get('host').insertContentAtFocusPoint(h5phtml);
+
+            this.markUpdated();
+        } else if (h5pfile !== '') {
+
+            host.setSelection(this._currentSelection);
+
+            var options = {};
+
+            if (form.one(SELECTORS.OPTION_DOWNLOAD_BUTTON).get('checked')) {
+                options['export'] = '1';
+            }
+            if (form.one(SELECTORS.OPTION_EMBED_BUTTON).get('checked')) {
+                options.embed = '1';
+            }
+            if (form.one(SELECTORS.OPTION_COPYRIGHT_BUTTON).get('checked')) {
+                options.copyright = '1';
+            }
+
+            var params = "";
+            for (var opt in options) {
+                if (params === "" && (h5pfile.indexOf("?") === -1)) {
+                    params += "?";
+                } else {
+                    params += "&amp;";
+                }
+                params += opt + "=" + options[opt];
+            }
+
+            var h5ptemplate = Y.Handlebars.compile(H5PTEMPLATE);
+
+            h5phtml = h5ptemplate({
+                url: h5pfile + params
             });
 
             this.get('host').insertContentAtFocusPoint(h5phtml);
@@ -264,12 +546,25 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
         }).hide();
     },
 
+    /**
+     * Check if this could be a h5p embed.
+     *
+     * @method _validEmbed
+     * @param {String} str
+     * @return {boolean} whether this is a iframe tag.
+     * @private
+     */
+    _validEmbed: function(str) {
+        var pattern = new RegExp('^(<iframe).*(<\\/iframe>)'); // Port and path.
+        return !!pattern.test(str);
+    },
+
     /**
      * Check if this could be a h5p URL.
      *
-     * @method _updateWarning
+     * @method _validURL
      * @param {String} str
-     * @return {boolean} whether a warning should be displayed.
+     * @return {boolean} whether this is a valid URL.
      * @private
      */
     _validURL: function(str) {
@@ -290,14 +585,36 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
     _updateWarning: function() {
         var form = this._form,
             state = true,
-            url = form.one('.' + CSS.INPUTH5PURL).get('value');
-        if (this._validURL(url)) {
-            form.one('.' + CSS.URLWARNING).setStyle('display', 'none');
-            state = false;
-        } else {
-            form.one('.' + CSS.URLWARNING).setStyle('display', 'block');
-            state = true;
+            url,
+            h5pfile,
+            permissions = this._getPermissions();
+
+
+        if (permissions.canEmbed) {
+            url = form.one(SELECTORS.INPUTH5PURL).get('value');
+            if (url !== '') {
+                if (this._validURL(url) || this._validEmbed(url)) {
+                    form.one(SELECTORS.URLWARNING).setStyle('display', 'none');
+                    state = false;
+                } else {
+                    form.one(SELECTORS.URLWARNING).setStyle('display', 'block');
+                    state = true;
+                }
+                return state;
+            }
         }
+
+        if (permissions.canUpload) {
+            h5pfile = form.one(SELECTORS.INPUTH5PFILE).get('value');
+            if (h5pfile !== '') {
+                form.one(SELECTORS.CONTENTWARNING).setStyle('display', 'none');
+                state = false;
+            } else {
+                form.one(SELECTORS.CONTENTWARNING).setStyle('display', 'block');
+                state = true;
+            }
+        }
+
         return state;
     }
 }, {
diff --git a/lib/editor/atto/tests/fixtures/ipsums.h5p b/lib/editor/atto/tests/fixtures/ipsums.h5p
new file mode 100644 (file)
index 0000000..a903fd5
Binary files /dev/null and b/lib/editor/atto/tests/fixtures/ipsums.h5p differ
index daae058..36a85d8 100644 (file)
@@ -2299,6 +2299,9 @@ function send_temp_file($path, $filename, $pathisstring=false) {
         $filename = urlencode($filename);
     }
 
+    // If this file was requested from a form, then mark download as complete.
+    \core_form\util::form_download_complete();
+
     header('Content-Disposition: attachment; filename="'.$filename.'"');
     if (is_https()) { // HTTPS sites - watch out for IE! KB812935 and KB316431.
         header('Cache-Control: private, max-age=10, no-transform');
@@ -2450,6 +2453,9 @@ function send_file($path, $filename, $lifetime = null , $filter=0, $pathisstring
 
     if ($forcedownload) {
         header('Content-Disposition: attachment; filename="'.$filename.'"');
+
+        // If this file was requested from a form, then mark download as complete.
+        \core_form\util::form_download_complete();
     } else if ($mimetype !== 'application/x-shockwave-flash') {
         // If this is an swf don't pass content-disposition with filename as this makes the flash player treat the file
         // as an upload and enforces security that may prevent the file from being loaded.
index 46f722e..2631f65 100644 (file)
Binary files a/lib/form/amd/build/submit.min.js and b/lib/form/amd/build/submit.min.js differ
index afc8bae..1649623 100644 (file)
Binary files a/lib/form/amd/build/submit.min.js.map and b/lib/form/amd/build/submit.min.js.map differ
index 1e4e7dd..af4271e 100644 (file)
  * @since 3.8
  */
 
+/** @type {number} ID for setInterval used when polling for download cookie */
+let cookieListener = 0;
+
+/** @type {Array} Array of buttons that need re-enabling if we get a download cookie */
+const cookieListeningButtons = [];
+
+/**
+ * Listens in case a download cookie is provided.
+ *
+ * This function is used to detect file downloads. If there is a file download then we get a
+ * beforeunload event, but the page is never unloaded and when the file download completes we
+ * should re-enable the buttons. We detect this by watching for a specific cookie.
+ *
+ * PHP function \core_form\util::form_download_complete() can be used to send this cookie.
+ *
+ * @param {HTMLElement} button Button to re-enable
+ */
+const listenForDownloadCookie = (button) => {
+    cookieListeningButtons.push(button);
+    if (!cookieListener) {
+        cookieListener = setInterval(() => {
+            // Look for cookie.
+            const parts = document.cookie.split(getCookieName() + '=');
+            if (parts.length == 2) {
+                // We found the cookie, so the file is ready. Expire the cookie and cancel polling.
+                clearDownloadCookie();
+                clearInterval(cookieListener);
+                cookieListener = 0;
+
+                // Re-enable all the buttons.
+                cookieListeningButtons.forEach((button) => {
+                    button.disabled = false;
+                });
+            }
+        }, 500);
+    }
+};
+
+/**
+ * Gets a unique name for the download cookie.
+ *
+ * @returns {string} Cookie name
+ */
+const getCookieName = () => {
+    return 'moodledownload_' + M.cfg.sesskey;
+};
+
+/**
+ * Clears the download cookie if there is one.
+ */
+const clearDownloadCookie = () => {
+    document.cookie = encodeURIComponent(getCookieName()) + '=deleted; expires=' + new Date(0).toUTCString();
+};
+
 /**
  * Initialises submit buttons.
  *
  */
 export const init = (elementId) => {
     const button = document.getElementById(elementId);
-    button.form.addEventListener('submit', function() {
+    // If the form has double submit protection disabled, do nothing.
+    if (button.form.dataset.doubleSubmitProtection === 'off') {
+        return;
+    }
+    button.form.addEventListener('submit', function(event) {
         // Only disable it if the browser is really going to another page as a result of the
         // submit.
         const disableAction = function() {
+            // If the submit was cancelled, or the button is already disabled, don't do anything.
+            if (event.defaultPrevented || button.disabled) {
+                return;
+            }
+
             button.disabled = true;
+            clearDownloadCookie();
+            listenForDownloadCookie(button);
         };
         window.addEventListener('beforeunload', disableAction);
         // If there is no beforeunload event as a result of this form submit, then the form
diff --git a/lib/form/classes/util.php b/lib/form/classes/util.php
new file mode 100644 (file)
index 0000000..2a252a3
--- /dev/null
@@ -0,0 +1,64 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Provides the {@link core_form\util} class.
+ *
+ * @package core_form
+ * @copyright 2019 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_form;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * General utility class for form-related methods.
+ *
+ * @copyright 2019 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class util {
+    /**
+     * This function should be called if a form submit results in a file download (i.e. with the
+     * Content-Disposition: attachment header) instead of navigating to a new page, before the
+     * file download is sent. It will set a cookie which is used to inform page javascript in
+     * submit.js.
+     *
+     * You may call this function in scripts which might not necessarily be called from forms; it
+     * will only set the cookie if there is a POST request from a form.
+     *
+     * This is automatically called from various points in Moodle such as send_file_xx functions
+     * in filelib.php.
+     */
+    public static function form_download_complete() {
+        // If this doesn't look like a Moodle QuickForms request, ignore.
+        $quickform = false;
+        foreach ($_POST as $name => $value) {
+            if (preg_match('~^_qf__~', $name)) {
+                $quickform = true;
+                break;
+            }
+        }
+        if (!$quickform) {
+            return;
+        }
+
+        // Set a session cookie.
+        setcookie('moodledownload_' . sesskey(), time());
+    }
+}
index bbec709..1b6d895 100644 (file)
@@ -390,6 +390,20 @@ class MoodleQuickForm_editor extends HTML_QuickForm_element implements templatab
             $subtitle_options->env = 'editor';
             $subtitle_options->itemid = $draftitemid;
 
+            if (has_capability('moodle/h5p:deploy', $ctx)) {
+                // Only set H5P Plugin settings if the user can deploy new H5P content.
+                // H5P plugin.
+                $args->accepted_types = array('.h5p');
+                $h5poptions = initialise_filepicker($args);
+                $h5poptions->context = $ctx;
+                $h5poptions->client_id = uniqid();
+                $h5poptions->maxbytes  = $this->_options['maxbytes'];
+                $h5poptions->areamaxbytes  = $this->_options['areamaxbytes'];
+                $h5poptions->env = 'editor';
+                $h5poptions->itemid = $draftitemid;
+                $fpoptions['h5p'] = $h5poptions;
+            }
+
             $fpoptions['image'] = $image_options;
             $fpoptions['media'] = $media_options;
             $fpoptions['link'] = $link_options;
index 990ab73..4a000a3 100644 (file)
@@ -169,6 +169,10 @@ abstract class moodleform {
      * @param mixed $attributes you can pass a string of html attributes here or an array.
      *               Special attribute 'data-random-ids' will randomise generated elements ids. This
      *               is necessary when there are several forms on the same page.
+     *               Special attribute 'data-double-submit-protection' set to 'off' will turn off
+     *               double-submit protection JavaScript - this may be necessary if your form sends
+     *               downloadable files in response to a submit button, and can't call
+     *               \core_form\util::form_download_complete();
      * @param bool $editable
      * @param array $ajaxformdata Forms submitted via ajax, must pass their data here, instead of relying on _GET and _POST.
      */
index e8a4bc9..5144936 100644 (file)
@@ -179,6 +179,9 @@ function install_helpbutton($url, $title='') {
  * @return string
  */
 function install_db_validate($database, $dbhost, $dbuser, $dbpass, $dbname, $prefix, $dboptions) {
+    if (!preg_match('/^[a-z_]*$/', $prefix)) {
+        return get_string('invaliddbprefix', 'install');
+    }
     try {
         try {
             $database->connect($dbhost, $dbuser, $dbpass, $dbname, $prefix, $dboptions);
index 5cc2050..5464537 100644 (file)
@@ -26,21 +26,21 @@ $string['errornoconfigdata'] = 'The server configuration is not complete.';
 $string['errorserver'] = 'Server error {$a}';
 $string['host'] = 'Host';
 $string['hostdesc'] = 'Host';
-$string['packageinstalledshouldbe'] = '"moodlemlbackend" python package should be updated. The required version is "{$a->required}" and the installed version is "{$a->installed}"';
-$string['packageinstalledtoohigh'] = '"moodlemlbackend" python package is not compatible with this Moodle version. The required version is "{$a->required}" or higher as long as it is API-compatible. The installed version "{$a->installed}" is too high.';
+$string['packageinstalledshouldbe'] = 'The moodlemlbackend Python package should be updated. The required version is "{$a->required}" and the installed version is "{$a->installed}".';
+$string['packageinstalledtoohigh'] = 'The moodlemlbackend Python package is not compatible with this version of Moodle. The required version is "{$a->required}" or higher as long as it is API-compatible. The installed version "{$a->installed}" is too high.';
 $string['pluginname'] = 'Python machine learning backend';
 $string['port'] = 'Port';
 $string['portdesc'] = 'Port';
 $string['privacy:metadata'] = 'The Python machine learning backend plugin does not store any personal data.';
-$string['pythonpackagenotinstalled'] = '"moodlemlbackend" python package is not installed or there is a problem with it. Please execute "{$a}" from command line interface for more info';
+$string['pythonpackagenotinstalled'] = 'The moodlemlbackend Python package is not installed or there is a problem with it. Please execute "{$a}" from command line interface for more info.';
 $string['pythonpathnotdefined'] = 'The path to your executable Python binary has not been defined. Please visit "{$a}" to set it.';
-$string['serversettingsinfo'] = 'Tick "Use a server" setting to show the server settings.';
+$string['serversettingsinfo'] = 'If \'Use a server\' is enabled, the server settings will be displayed.';
 $string['username'] = 'Username';
-$string['usernamedesc'] = 'String of characters used as a username to communicate between your Moodle server and the python server';
+$string['usernamedesc'] = 'String of characters used as a username to communicate between the Moodle server and the Python server.';
 $string['password'] = 'Password';
-$string['passworddesc'] = 'String of characters used as a password to communicate between your Moodle server and the python server';
+$string['passworddesc'] = 'String of characters used as a password to communicate between the Moodle server and the Python server.';
 $string['secure'] = 'Use HTTPS';
-$string['securedesc'] = 'Whether to use HTTP or HTTPS';
+$string['securedesc'] = 'Whether to use HTTP or HTTPS.';
 $string['useserver'] = 'Use a server';
-$string['useserverdesc'] = 'The machine learning backend python package is not installed in the web server but in a different server.';
+$string['useserverdesc'] = 'The machine learning backend Python package is not installed on the web server but on a different server.';
 $string['tensorboardinfo'] = 'Launch TensorBoard from command line by typing tensorboard --logdir=\'{$a}\' in your web server.';
\ No newline at end of file
index b8489f5..d10f31d 100644 (file)
@@ -3258,6 +3258,8 @@ function require_user_key_login($script, $instance = null, $keyvalue = null) {
         print_error('invaliduserid');
     }
 
+    core_user::require_active_user($user, true, true);
+
     // Emulate normal session.
     enrol_check_plugins($user);
     \core\session\manager::set_user($user);
index df50b1a..037ec67 100644 (file)
@@ -2807,8 +2807,8 @@ EOD;
             $output .= $this->header();
         }
 
-        $message = '<p class="errormessage">' . $message . '</p>'.
-                '<p class="errorcode"><a href="' . $moreinfourl . '">' .
+        $message = '<p class="errormessage">' . s($message) . '</p>'.
+                '<p class="errorcode"><a href="' . s($moreinfourl) . '">' .
                 get_string('moreinformation') . '</a></p>';
         if (empty($CFG->rolesactive)) {
             $message .= '<p class="errormessage">' . get_string('installproblem', 'error') . '</p>';
index bf4efcb..11e1dcc 100644 (file)
@@ -61,7 +61,6 @@ abstract class CSSList implements Renderable, Commentable {
                                $oListItem->setComments($comments);
                                $oList->append($oListItem);
                        }
-                       $oParserState->consumeWhiteSpace();
                }
                if(!$bIsRoot && !$bLenientParsing) {
                        throw new SourceException("Unexpected end of document", $oParserState->currentLine());
index 3fa031b..4480948 100644 (file)
@@ -56,7 +56,6 @@ class Rule implements Renderable, Commentable {
                while ($oParserState->comes(';')) {
                        $oParserState->consume(';');
                }
-               $oParserState->consumeWhiteSpace();
 
                return $oRule;
        }
index f4a2cc2..07f3b4d 100644 (file)
@@ -8,3 +8,5 @@ Import procedure:
 - Copy all the files from the folder 'lib/Sabberworm/CSS/' in this directory.
 
 - Apply the patch in Sabberworm/PHP-CSS-Parser#115
+
+- Apply the patch in sabberworm/PHP-CSS-Parser/issues/173 (if this has not already been resolved upstream).
diff --git a/lib/tests/rtlcss_test.php b/lib/tests/rtlcss_test.php
new file mode 100644 (file)
index 0000000..bc6d327
--- /dev/null
@@ -0,0 +1,1266 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Tests for the core_rtlcss class.
+ *
+ * The core_rtlcss class extends \MoodleHQ\RTLCSS\RTLCSS library which depends on sabberworm/php-css-parser library.
+ * This test verifies that css parsing works as expected should any of the above change.
+ *
+ * @package    core
+ * @category   phpunit
+ * @copyright  2019 Jake Dallimore <jrhdallimore@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die();
+
+use Sabberworm\CSS\Parser;
+use Sabberworm\CSS\OutputFormat;
+
+/**
+ * Class rtlcss_test.
+ */
+class rtlcss_test extends basic_testcase {
+    /**
+     * Data provider.
+     * @return array
+     */
+    public function background_image_provider() {
+        return [
+            /* Not supported by MoodleHQ/RTLCSS yet.
+            [[
+                'should' => 'Should process string map in url (processUrls:true)',
+                'expected' => 'div { background-image: url(images/rtl.png), url(images/right.png);}',
+                'input'    => 'div { background-image: url(images/ltr.png), url(images/left.png);}',
+                'reversable' => true,
+                'options' => [ 'processUrls' => true ],
+                'skip' => true
+            ]],
+            [[
+                'should' => 'Should not negate color value for linear gradient',
+                'expected' => 'div { background-image: linear-gradient(rgba(255, 255, 255, 0.3) 0%, #ff8 100%);}',
+                'input'    => 'div { background-image: linear-gradient(rgba(255, 255, 255, 0.3) 0%, #ff8 100%);}',
+                'reversable' => true,
+                'skip' => true
+            ]],
+            [[
+                'should' => 'Should not negate color value for linear gradient with calc',
+                'expected' => 'div { background-image: linear-gradient(rgba(255, 255, calc((125 * 2) + 5), 0.3) 0%, #ff8 100%);}',
+                'input'    => 'div { background-image: linear-gradient(rgba(255, 255, calc((125 * 2) + 5), 0.3) 0%, #ff8 100%);}',
+                'reversable' => true,
+                'skip' => true
+            ]],
+            [[
+                'should' => 'Should negate angle value for linear gradient',
+                'expected' => 'div { background-image: linear-gradient(13.25deg, rgba(255, 255, 255, .15) 25%, transparent 25%);}',
+                'input'    => 'div { background-image: linear-gradient(-13.25deg, rgba(255, 255, 255, .15) 25%, transparent 25%);}',
+                'reversable' => true,
+                'skip' => true
+            ]]
+            */
+        ];
+    }
+
+    /**
+     * Data provider.
+     * @return array
+     */
+    public function background_position_provider() {
+        return [
+            [[
+                'should' => 'Should complement percentage horizontal position ',
+                'expected' => 'div {background-position:100% 75%;}',
+                'input' => 'div {background-position:0 75%;}',
+                'reversable' => false
+            ]],
+            /* Not supported by MoodleHQ/RTLCSS yet.
+            [[
+                'should' => 'Should complement percentage horizontal position with calc',
+                'expected' => 'div {background-position:calc(100% - (30% + 50px)) 75%;}',
+                'input' => 'div {background-position:calc(30% + 50px) 75%;}',
+                'reversable' => false,
+                'skip' => true
+            ]],
+            [[
+                'should' => 'Should complement percentage horizontal position ',
+                'expected' => 'div {background-position:81.25% 75%, 10.75% top;}',
+                'input' => 'div {background-position:18.75% 75%, 89.25% top;}',
+                'reversable' => true,
+                'skip' => true
+            ]],
+            [[
+                'should' => 'Should complement percentage horizontal position with calc',
+                'expected' => 'div {background-position:calc(100% - (30% + 50px)) calc(30% + 50px), 10.75% top;}',
+                'input' => 'div {background-position:calc(30% + 50px) calc(30% + 50px), 89.25% top;}',
+                'reversable' => false,
+                'skip' => true
+            ]],
+            [[
+                'should' => 'Should swap left with right',
+                'expected' => 'div {background-position:right 75%, left top;}',
+                'input' => 'div {background-position:left 75%, right top;}',
+                'reversable' => true,
+                'skip' => true
+            ]],
+            [[
+                'should' => 'Should swap left with right wit calc',
+                'expected' => 'div {background-position:right -ms-calc(30% + 50px), left top;}',
+                'input' => 'div {background-position:left -ms-calc(30% + 50px), right top;}',
+                'reversable' => true,
+                'skip' => true
+            ]],
+            */
+            [[
+                'should' => 'Should complement percentage: position-x (treat 0 as 0%)',
+                'expected' => 'div {background-position-x:100%, 0%;}',
+                'input' => 'div {background-position-x:0, 100%;}',
+                'reversable' => false
+            ]],
+            [[
+                'should' => 'Should complement percentage: position-x',
+                'expected' => 'div {background-position-x:81.75%, 11%;}',
+                'input' => 'div {background-position-x:18.25%, 89%;}',
+                'reversable' => true
+            ]],
+            /* Not supported by MoodleHQ/RTLCSS yet.
+            [[
+                'should' => 'Should complement percentage with calc: position-x',
+                'expected' => 'div {background-position-x:calc(100% - (30% + 50px)), -webkit-calc(100% - (30% + 50px));}',
+                'input' => 'div {background-position-x:calc(30% + 50px), -webkit-calc(30% + 50px);}',
+                'reversable' => false,
+                'skip' => true
+            ]],
+            */
+            [[
+                'should' => 'Should swap left with right: position-x',
+                'expected' => 'div {background-position-x:right, left;}',
+                'input' => 'div {background-position-x:left, right;}',
+                'reversable' => true
+            ]],
+            [[
+                'should' => 'Should keep as is: position-x',
+                'expected' => 'div {background-position-x:100px, 0px;}',
+                'input' => 'div {background-position-x:100px, 0px;}',
+                'reversable' => true
+            ]],
+
+            [[
+                'should' => 'Should flip when using 3 positions',
+                'expected' => 'div {background-position:center right 1px;}',
+                'input' => 'div {background-position:center left 1px;}',
+                'reversable' => true
+            ]],
+            [[
+                'should' => 'Should flip when using 4 positions',
+                'expected' => 'div {background-position:center 2px right 1px;}',
+                'input' => 'div {background-position:center 2px left 1px;}',
+                'reversable' => true
+            ]],
+            [[
+                'should' => 'Should flip when using 4 positions mixed',
+                'expected' => 'div {background-position:right 2px bottom 1px;}',
+                'input' => 'div {background-position:left 2px bottom 1px;}',
+                'reversable' => true
+            ]]
+        ];
+    }
+
+    /**
+     * Data provider.
+     * @return array
+     */
+    public function background_provider() {
+        return [
+            [[
+                'should' => 'Should treat 0 as 0%',
+                'expected' => '.banner { background: 100% top url("topbanner.png") #00d repeat-y fixed; }',
+                'input' => '.banner { background: 0 top url("topbanner.png") #00d repeat-y fixed; }',
+                'reversable' => false
+            ]],
+            [[
+                'should' => 'Should complement percentage horizontal position',
+                'expected' => '.banner { background: 81% top url("topbanner.png") #00d repeat-y fixed; }',
+                'input' => '.banner { background: 19% top url("topbanner.png") #00d repeat-y fixed; }',
+                'reversable' => true
+            ]],
+            /* Not supported by MoodleHQ/RTLCSS yet.
+            [[
+                'should' => 'Should complement calc horizontal position',
+                'expected' => '.banner { background: calc(100% - (19% + 2px)) top url(topbanner.png) #00d repeat-y fixed; }',
+                'input' => '.banner { background: calc(19% + 2px) top url(topbanner.png) #00d repeat-y fixed; }',
+                'reversable' => false,
+                'skip' => true
+            ]],
+            */
+            [[
+                'should' => 'Should mirror keyword horizontal position',
+                'expected' => '.banner { background: right top url("topbanner.png") #00d repeat-y fixed; }',
+                'input' => '.banner { background: left top url("topbanner.png") #00d repeat-y fixed; }',
+                'reversable' => true
+            ]],
+            [[
+                'should' => 'Should not process string map in url (default)',
+                'expected' => '.banner { background: 10px top url("ltr-top-right-banner.png") #00d repeat-y fixed; }',
+                'input' => '.banner { background: 10px top url("ltr-top-right-banner.png") #00d repeat-y fixed; }',
+                'reversable' => true
+            ]],
+            /* Not supported by MoodleHQ/RTLCSS yet.
+            [[
+                'should' => 'Should process string map in url (processUrls:true)',
+                'expected' => '.banner { background: 10px top url(rtl-top-left-banner.png) #00d repeat-y fixed; }',
+                'input' => '.banner { background: 10px top url(ltr-top-right-banner.png) #00d repeat-y fixed; }',
+                'reversable' => true,
+                'options' => [ 'processUrls' => true ],
+                'skip' => true
+            ]],
+            [[
+                'should' => 'Should process string map in url (processUrls:{decl:true})',
+                'expected' => '.banner { background: 10px top url(rtl-top-left-banner.png) #00d repeat-y fixed; }',
+                'input' => '.banner { background: 10px top url(ltr-top-right-banner.png) #00d repeat-y fixed; }',
+                'reversable' => true,
+                'options' => [ 'processUrls' => [ 'decl' => true ] ],
+                'skip' => true
+            ]],
+            */
+            [[
+                'should' => 'Should not process string map in url (processUrls:{atrule:true})',
+                'expected' => '.banner { background: 10px top url("ltr-top-right-banner.png") #00d repeat-y fixed; }',
+                'input' => '.banner { background: 10px top url("ltr-top-right-banner.png") #00d repeat-y fixed; }',
+                'reversable' => true,
+                'options' => [ 'processUrls' => [ 'atrule' => true ] ]
+            ]],
+            [[
+                'should' => 'Should not swap bright:bleft, ultra:urtla',
+                'expected' => '.banner { background: 10px top url("ultra/bright.png") #00d repeat-y fixed; }',
+                'input' => '.banner { background: 10px top url("ultra/bright.png") #00d repeat-y fixed; }',
+                'reversable' => true
+            ]],
+            /* Not supported by MoodleHQ/RTLCSS yet.
+            [[
+                'should' => 'Should swap bright:bleft, ultra:urtla (processUrls: true, greedy)',
+                'expected' => '.banner { background: 10px top url("urtla/bleft.png") #00d repeat-y fixed; }',
+                'input' => '.banner { background: 10px top url("ultra/bright.png") #00d repeat-y fixed; }',
+                'reversable' => true,
+                'options' => [ 'processUrls' => true, 'greedy' => true ],
+                'skip' => true
+            ]],
+            */
+            [[
+                'should' => 'Should not flip hex colors ',
+                'expected' => '.banner { background: #ff0; }',
+                'input' => '.banner { background: #ff0; }',
+                'reversable' => true
+            ]]
+        ];
+    }
+
+    /**
+     * Data provider.
+     * @return array
+     */
+    public function directives_provider() {
+        return [
+            [[
+                'should' => 'Should ignore flipping - rule level (default)',
+                'expected' => 'div {left:10px;text-align:right;}',
+                'input' => '/*rtl:ignore*/div { left:10px; text-align:right;}',
+                'reversable' => false
+            ]],
+            [[
+                'should' => 'Should ignore flipping - rule level (!important comment)',
+                'expected' => 'div {left:10px;text-align:right;}',
+                'input' => '/*!rtl:ignore*/div { left:10px; text-align:right;}',
+                'reversable' => false,
+            ]],
+            // Not supported by MoodleHQ/RTLCSS yet.
+            //[[
+            //    'should' => 'Should ignore flipping - decl. level (default)',
+            //    'expected' => 'div {left:10px;text-align:left;}',
+            //    'input' => 'div {left:10px/*rtl:ignore*/;text-align:right;}',
+            //    'reversable' => false,
+            //    'skip' => true
+            //]],
+            [[
+                'should' => 'Should add raw css rules',
+                'expected' => 'div {left:10px;text-align:right;} a {display:block;}',
+                'input' => '/*rtl:raw: div { left:10px;text-align:right;}*/ a {display:block;}',
+                'reversable' => false
+            ]],
+            [[
+                'should' => 'Should add raw css declarations',
+                'expected' => 'div {font-family:"Droid Kufi Arabic";right:10px;text-align:left;}',
+                'input' => 'div { /*rtl:raw: font-family: "Droid Kufi Arabic";*/ left:10px;text-align:right;}',
+                'reversable' => false
+            ]],
+            [[
+                'should' => 'Should support block-style',
+                'expected' => 'div {left:10px;text-align:right;}',
+                'input' => ' div {/*rtl:begin:ignore*/left:10px;/*rtl:end:ignore*/ text-align:left;}',
+                'reversable' => false
+            ]],
+            [[
+                'should' => 'Should support none block-style',
+                'expected' => 'div {left:10px;text-align:left;}',
+                'input' => ' /*rtl:ignore*/div {left:10px; text-align:left;}',
+                'reversable' => false
+            ]],
+            [[
+                'should' => 'Should remove rules (block-style)',
+                'expected' => 'b {float:right;}',
+                'input' => ' /*rtl:begin:remove*/div {left:10px; text-align:left;} a { display:block;} /*rtl:end:remove*/ b{float:left;}',
+                'reversable' => false
+            ]],
+            [[
+                'should' => 'Should remove rules',
+                'expected' => 'a {display:block;} b {float:right;}',
+                'input' => ' /*rtl:remove*/div {left:10px; text-align:left;} a { display:block;} b{float:left;}',
+                'reversable' => false
+            ]],
+            [[
+                'should' => 'Should remove declarations',
+                'expected' => 'div {text-align:right;}',
+                'input' => 'div {/*rtl:remove*/left:10px; text-align:left;}',
+                'reversable' => false
+            ]],
+            [[
+                'should' => 'Should remove declarations (block-style)',
+                'expected' => 'div {display:inline;}',
+                'input' => 'div {/*rtl:begin:remove*/left:10px; text-align:left;/*rtl:end:remove*/ display:inline;}',
+                'reversable' => false
+            ]],
+            // Not supported by MoodleHQ/RTLCSS yet.
+            //[[
+            //    'should' => 'Final/trailing comment ignored bug (block style): note a tag rules are&nbs