Merge branch 'MDL-66752-master-2' of git://github.com/junpataleta/moodle
authorSara Arjona <sara@moodle.com>
Wed, 8 Jan 2020 13:40:01 +0000 (14:40 +0100)
committerSara Arjona <sara@moodle.com>
Wed, 8 Jan 2020 13:40:01 +0000 (14:40 +0100)
59 files changed:
Gruntfile.js
GruntfileComponents.js [new file with mode: 0644]
admin/roles/classes/define_role_table_advanced.php
admin/templates/setting_configpasswordunmask.mustache
admin/tool/customlang/locallib.php
admin/tool/dataprivacy/tests/api_test.php
babel-plugin-add-module-to-define.js
calendar/lib.php
calendar/tests/lib_test.php
competency/tests/privacy_test.php
enrol/meta/lib.php
grade/amd/build/edittree_index.min.js
grade/amd/build/edittree_index.min.js.map
grade/amd/src/edittree_index.js
grade/edit/tree/lib.php
grade/templates/edit_tree.mustache
lang/en/moodle.php
lib/adminlib.php
lib/amd/build/loglevel.min.js
lib/amd/build/loglevel.min.js.map
lib/amd/build/mustache.min.js
lib/amd/build/mustache.min.js.map
lib/amd/src/loglevel.js
lib/amd/src/mustache.js
lib/classes/task/adhoc_task.php
lib/classes/task/manager.php
lib/cronlib.php
lib/editor/tests/fixtures/editor_form.php
lib/filelib.php
lib/filestorage/file_system.php
lib/form/duration.php
lib/form/filetypes.php
lib/mustache/readme_moodle.txt
lib/mustache/src/Mustache/Parser.php
lib/scssphp/Compiler.php
lib/scssphp/moodle_readme.txt
lib/setuplib.php
lib/tests/adhoc_task_test.php
lib/tests/fixtures/repeated_events.ics [new file with mode: 0644]
lib/tests/session_manager_test.php
lib/thirdpartylibs.xml
message/amd/build/message_drawer_view_overview_section.min.js
message/amd/build/message_drawer_view_overview_section.min.js.map
message/amd/src/message_drawer_view_overview_section.js
mod/feedback/classes/responses_table.php
mod/forum/classes/local/exporters/author.php
mod/forum/classes/local/exporters/discussion_summary.php
mod/forum/classes/local/factories/url.php
mod/forum/externallib.php
mod/forum/tests/exporters_author_test.php
mod/forum/tests/externallib_test.php
mod/glossary/import_form.php
mod/wiki/comments_form.php
mod/wiki/parser/parser.php
question/engine/questionusage.php
repository/dropbox/classes/dropbox.php
theme/boost/amd/build/loader.min.js
theme/boost/amd/build/loader.min.js.map
theme/boost/amd/src/loader.js

index f05bb19..f45c261 100644 (file)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+/* eslint-env node */
+
 /**
- * Grunt configuration
+ * Calculate the cwd, taking into consideration the `root` option (for Windows).
+ *
+ * @param {Object} grunt
+ * @returns {String} The current directory as best we can determine
  */
+const getCwd = grunt => {
+    const fs = require('fs');
+    const path = require('path');
 
-/* eslint-env node */
-module.exports = function(grunt) {
-    var path = require('path'),
-        tasks = {},
-        cwd = process.env.PWD || process.cwd(),
-        async = require('async'),
-        DOMParser = require('xmldom').DOMParser,
-        xpath = require('xpath'),
-        semver = require('semver'),
-        watchman = require('fb-watchman'),
-        watchmanClient = new watchman.Client(),
-        gruntFilePath = process.cwd();
-
-    // Verify the node version is new enough.
-    var expected = semver.validRange(grunt.file.readJSON('package.json').engines.node);
-    var actual = semver.valid(process.version);
-    if (!semver.satisfies(actual, expected)) {
-        grunt.fail.fatal('Node version not satisfied. Require ' + expected + ', version installed: ' + actual);
-    }
+    let cwd = fs.realpathSync(process.env.PWD || process.cwd());
 
     // Windows users can't run grunt in a subdirectory, so allow them to set
     // the root by passing --root=path/to/dir.
     if (grunt.option('root')) {
-        var root = grunt.option('root');
+        const root = grunt.option('root');
         if (grunt.file.exists(__dirname, root)) {
-            cwd = path.join(__dirname, root);
+            cwd = fs.realpathSync(path.join(__dirname, root));
             grunt.log.ok('Setting root to ' + cwd);
         } else {
             grunt.fail.fatal('Setting root to ' + root + ' failed - path does not exist');
         }
     }
 
+    return cwd;
+};
+
+/**
+ * Register any stylelint tasks.
+ *
+ * @param {Object} grunt
+ * @param {Array} files
+ * @param {String} fullRunDir
+ */
+const registerStyleLintTasks = (grunt, files, fullRunDir) => {
+    const getCssConfigForFiles = files => {
+        return {
+            stylelint: {
+                css: {
+                    // Use a fully-qualified path.
+                    src: files,
+                    options: {
+                        configOverrides: {
+                            rules: {
+                                // These rules have to be disabled in .stylelintrc for scss compat.
+                                "at-rule-no-unknown": true,
+                            }
+                        }
+                    }
+                },
+            },
+        };
+    };
+
+    const getScssConfigForFiles = files => {
+        return {
+            stylelint: {
+                scss: {
+                    options: {syntax: 'scss'},
+                    src: files,
+                },
+            },
+        };
+    };
+
+    let hasCss = true;
+    let hasScss = true;
+
+    if (files) {
+        // Specific files were passed. Just set them up.
+        grunt.config.merge(getCssConfigForFiles(files));
+        grunt.config.merge(getScssConfigForFiles(files));
+    } else {
+        // The stylelint system does not handle the case where there was no file to lint.
+        // Check whether there are any files to lint in the current directory.
+        const glob = require('glob');
+
+        const scssSrc = [];
+        glob.sync(`${fullRunDir}/**/*.scss`).forEach(path => scssSrc.push(path));
+
+        if (scssSrc.length) {
+            grunt.config.merge(getScssConfigForFiles(scssSrc));
+        } else {
+            hasScss = false;
+        }
+
+        const cssSrc = [];
+        glob.sync(`${fullRunDir}/**/*.css`).forEach(path => cssSrc.push(path));
+
+        if (cssSrc.length) {
+            grunt.config.merge(getCssConfigForFiles(cssSrc));
+        } else {
+            hasCss = false;
+        }
+    }
+
+    const scssTasks = ['sass'];
+    if (hasScss) {
+        scssTasks.unshift('stylelint:scss');
+    }
+    grunt.registerTask('scss', scssTasks);
+
+    const cssTasks = [];
+    if (hasCss) {
+        cssTasks.push('stylelint:css');
+    }
+    grunt.registerTask('rawcss', cssTasks);
+
+    grunt.registerTask('css', ['scss', 'rawcss']);
+};
+
+/**
+ * Grunt configuration.
+ *
+ * @param {Object} grunt
+ */
+module.exports = function(grunt) {
+    const path = require('path');
+    const tasks = {};
+    const async = require('async');
+    const DOMParser = require('xmldom').DOMParser;
+    const xpath = require('xpath');
+    const semver = require('semver');
+    const watchman = require('fb-watchman');
+    const watchmanClient = new watchman.Client();
+    const fs = require('fs');
+    const ComponentList = require(path.resolve('GruntfileComponents.js'));
+
+    // Verify the node version is new enough.
+    var expected = semver.validRange(grunt.file.readJSON('package.json').engines.node);
+    var actual = semver.valid(process.version);
+    if (!semver.satisfies(actual, expected)) {
+        grunt.fail.fatal('Node version not satisfied. Require ' + expected + ', version installed: ' + actual);
+    }
+
+    // Detect directories:
+    // * gruntFilePath          The real path on disk to this Gruntfile.js
+    // * cwd                    The current working directory, which can be overridden by the `root` option
+    // * relativeCwd            The cwd, relative to the Gruntfile.js
+    // * componentDirectory     The root directory of the component if the cwd is in a valid component
+    // * inComponent            Whether the cwd is in a valid component
+    // * runDir                 The componentDirectory or cwd if not in a component, relative to Gruntfile.js
+    // * fullRunDir             The full path to the runDir
+    const gruntFilePath = fs.realpathSync(process.cwd());
+    const cwd = getCwd(grunt);
+    const relativeCwd = cwd.replace(new RegExp(`${gruntFilePath}/?`), '');
+    const componentDirectory = ComponentList.getOwningComponentDirectory(relativeCwd);
+    const inComponent = !!componentDirectory;
+    const runDir = inComponent ? componentDirectory : relativeCwd;
+    const fullRunDir = fs.realpathSync(gruntFilePath + path.sep + runDir);
+    grunt.log.debug(`The cwd was detected as ${cwd} with a fullRunDir of ${fullRunDir}`);
+
+    if (inComponent) {
+        grunt.log.ok(`Running tasks for component directory ${componentDirectory}`);
+    }
+
     var files = null;
     if (grunt.option('files')) {
         // Accept a comma separated list of files to process.
         files = grunt.option('files').split(',');
     }
 
-    var inAMD = path.basename(cwd) == 'amd';
+    const inAMD = path.basename(cwd) == 'amd';
 
     // Globbing pattern for matching all AMD JS source files.
-    var amdSrc = [];
-    if (inAMD) {
-        amdSrc.push(cwd + "/src/*.js");
-        amdSrc.push(cwd + "/src/**/*.js");
+    let amdSrc = [];
+    if (inComponent) {
+        amdSrc.push(componentDirectory + "/amd/src/*.js");
+        amdSrc.push(componentDirectory + "/amd/src/**/*.js");
     } else {
-        amdSrc.push("**/amd/src/*.js");
-        amdSrc.push("**/amd/src/**/*.js");
+        amdSrc = ComponentList.getAmdSrcGlobList();
+    }
+
+    let yuiSrc = [];
+    if (inComponent) {
+        yuiSrc.push(componentDirectory + "/yui/src/**/*.js");
+    } else {
+        yuiSrc = ComponentList.getYuiSrcGlobList(gruntFilePath + '/');
     }
 
     /**
@@ -97,28 +225,29 @@ module.exports = function(grunt) {
      * @return {array} The list of thirdparty paths.
      */
     var getThirdPartyPathsFromXML = function() {
-        var thirdpartyfiles = grunt.file.expand('*/**/thirdpartylibs.xml');
-        var libs = ['node_modules/', 'vendor/'];
+        const thirdpartyfiles = ComponentList.getThirdPartyLibsList(gruntFilePath + '/');
+        const libs = ['node_modules/', 'vendor/'];
 
         thirdpartyfiles.forEach(function(file) {
-          var dirname = path.dirname(file);
+            const dirname = path.dirname(file);
 
-          var doc = new DOMParser().parseFromString(grunt.file.read(file));
-          var nodes = xpath.select("/libraries/library/location/text()", doc);
+            const doc = new DOMParser().parseFromString(grunt.file.read(file));
+            const nodes = xpath.select("/libraries/library/location/text()", doc);
 
-          nodes.forEach(function(node) {
-            var lib = path.join(dirname, node.toString());
-            if (grunt.file.isDir(lib)) {
-                // Ensure trailing slash on dirs.
-                lib = lib.replace(/\/?$/, '/');
-            }
+            nodes.forEach(function(node) {
+                let lib = path.join(dirname, node.toString());
+                if (grunt.file.isDir(lib)) {
+                    // Ensure trailing slash on dirs.
+                    lib = lib.replace(/\/?$/, '/');
+                }
 
-            // Look for duplicate paths before adding to array.
-            if (libs.indexOf(lib) === -1) {
-                libs.push(lib);
-            }
-          });
+                // Look for duplicate paths before adding to array.
+                if (libs.indexOf(lib) === -1) {
+                    libs.push(lib);
+                }
+            });
         });
+
         return libs;
     };
 
@@ -130,7 +259,7 @@ module.exports = function(grunt) {
             options: {quiet: !grunt.option('show-lint-warnings')},
             amd: {src: files ? files : amdSrc},
             // Check YUI module source files.
-            yui: {src: files ? files : ['**/yui/src/**/*.js', '!*/**/yui/src/*/meta/*.js']}
+            yui: {src: files ? files : yuiSrc},
         },
         babel: {
             options: {
@@ -198,30 +327,41 @@ module.exports = function(grunt) {
                 nospawn: true // We need not to spawn so config can be changed dynamically.
             },
             amd: {
-                files: ['**/amd/src/**/*.js'],
+                files: inComponent
+                    ? ['amd/src/*.js', 'amd/src/**/*.js']
+                    : ['**/amd/src/**/*.js'],
                 tasks: ['amd']
             },
             boost: {
-                files: ['**/theme/boost/scss/**/*.scss'],
+                files: [inComponent ? 'scss/**/*.scss' : 'theme/boost/scss/**/*.scss'],
                 tasks: ['scss']
             },
             rawcss: {
-                files: ['**/*.css', '**/theme/**/!(moodle.css|editor.css)'],
+                files: [
+                    '**/*.css',
+                ],
+                excludes: [
+                    '**/moodle.css',
+                    '**/editor.css',
+                ],
                 tasks: ['rawcss']
             },
             yui: {
-                files: ['**/yui/src/**/*.js'],
+                files: inComponent
+                    ? ['yui/src/*.json', 'yui/src/**/*.js']
+                    : ['**/yui/src/**/*.js'],
                 tasks: ['yui']
             },
             gherkinlint: {
-                files: ['**/tests/behat/*.feature'],
+                files: [inComponent ? 'tests/behat/*.feature' : '**/tests/behat/*.feature'],
                 tasks: ['gherkinlint']
             }
         },
         shifter: {
             options: {
                 recursive: true,
-                paths: files ? files : [cwd]
+                // Shifter takes a relative path.
+                paths: files ? files : [runDir]
             }
         },
         gherkinlint: {
@@ -229,42 +369,30 @@ module.exports = function(grunt) {
                 files: files ? files : ['**/tests/behat/*.feature'],
             }
         },
-        stylelint: {
-            scss: {
-                options: {syntax: 'scss'},
-                src: files ? files : ['*/**/*.scss']
-            },
-            css: {
-                src: files ? files : ['*/**/*.css'],
-                options: {
-                    configOverrides: {
-                        rules: {
-                            // These rules have to be disabled in .stylelintrc for scss compat.
-                            "at-rule-no-unknown": true,
-                        }
-                    }
-                }
-            }
-        }
     });
 
     /**
      * Generate ignore files (utilising thirdpartylibs.xml data)
      */
     tasks.ignorefiles = function() {
-      // An array of paths to third party directories.
-      var thirdPartyPaths = getThirdPartyPathsFromXML();
-      // Generate .eslintignore.
-      var eslintIgnores = ['# Generated by "grunt ignorefiles"', '*/**/yui/src/*/meta/', '*/**/build/'].concat(thirdPartyPaths);
-      grunt.file.write('.eslintignore', eslintIgnores.join('\n'));
-      // Generate .stylelintignore.
-      var stylelintIgnores = [
-          '# Generated by "grunt ignorefiles"',
-          '**/yui/build/*',
-          'theme/boost/style/moodle.css',
-          'theme/classic/style/moodle.css',
-      ].concat(thirdPartyPaths);
-      grunt.file.write('.stylelintignore', stylelintIgnores.join('\n'));
+        // An array of paths to third party directories.
+        const thirdPartyPaths = getThirdPartyPathsFromXML();
+        // Generate .eslintignore.
+        const eslintIgnores = [
+            '# Generated by "grunt ignorefiles"',
+            '*/**/yui/src/*/meta/',
+            '*/**/build/',
+        ].concat(thirdPartyPaths);
+        grunt.file.write('.eslintignore', eslintIgnores.join('\n'));
+
+        // Generate .stylelintignore.
+        const stylelintIgnores = [
+            '# Generated by "grunt ignorefiles"',
+            '**/yui/build/*',
+            'theme/boost/style/moodle.css',
+            'theme/classic/style/moodle.css',
+        ].concat(thirdPartyPaths);
+        grunt.file.write('.stylelintignore', stylelintIgnores.join('\n'));
     };
 
     /**
@@ -428,7 +556,7 @@ module.exports = function(grunt) {
                             grunt: true,
                             // Run from current working dir and inherit stdio from process.
                             opts: {
-                                cwd: cwd,
+                                cwd: fullRunDir,
                                 stdio: 'inherit'
                             },
                             args: [task, filesOption]
@@ -454,18 +582,26 @@ module.exports = function(grunt) {
             );
         };
 
-        var watchConfig = grunt.config.get(['watch']);
-        watchConfig = Object.keys(watchConfig).reduce(function(carry, key) {
+        const originalWatchConfig = grunt.config.get(['watch']);
+        const watchConfig = Object.keys(originalWatchConfig).reduce(function(carry, key) {
             if (key == 'options') {
                 return carry;
             }
 
-            var value = watchConfig[key];
-            var fileGlobs = value.files;
-            var taskNames = value.tasks;
+            const value = originalWatchConfig[key];
+
+            const taskNames = value.tasks;
+            const files = value.files;
+            let excludes = [];
+            if (value.excludes) {
+                excludes = value.excludes;
+            }
 
             taskNames.forEach(function(taskName) {
-                carry[taskName] = fileGlobs;
+                carry[taskName] = {
+                    files,
+                    excludes,
+                };
             });
 
             return carry;
@@ -499,12 +635,14 @@ module.exports = function(grunt) {
             resp.files.forEach(function(file) {
                 grunt.log.ok('File changed: ' + file.name);
 
-                var fullPath = cwd + '/' + file.name;
+                var fullPath = fullRunDir + '/' + file.name;
                 Object.keys(watchConfig).forEach(function(task) {
-                    var fileGlobs = watchConfig[task];
-                    var match = fileGlobs.every(function(fileGlob) {
-                        return grunt.file.isMatch(fileGlob, fullPath);
+
+                    const fileGlobs = watchConfig[task].files;
+                    var match = fileGlobs.some(function(fileGlob) {
+                        return grunt.file.isMatch(`**/${fileGlob}`, fullPath);
                     });
+
                     if (match) {
                         // If we are watching a subdirectory then the file.name will be relative
                         // to that directory. However the grunt tasks  expect the file paths to be
@@ -536,7 +674,7 @@ module.exports = function(grunt) {
         });
 
         // Initiate the watch on the current directory.
-        watchmanClient.command(['watch-project', cwd], function(watchError, watchResponse) {
+        watchmanClient.command(['watch-project', fullRunDir], function(watchError, watchResponse) {
             if (watchError) {
                 grunt.log.error('Error initiating watch:', watchError);
                 watchTaskDone(1);
@@ -558,18 +696,40 @@ module.exports = function(grunt) {
                     return;
                 }
 
-                // Use the matching patterns specified in the watch config.
+                // Generate the expression query used by watchman.
+                // Documentation is limited, but see https://facebook.github.io/watchman/docs/expr/allof.html for examples.
+                // We generate an expression to match any value in the files list of all of our tasks, but excluding
+                // all value in the  excludes list of that task.
+                //
+                // [anyof, [
+                //      [allof, [
+                //          [anyof, [
+                //              ['match', validPath, 'wholename'],
+                //              ['match', validPath, 'wholename'],
+                //          ],
+                //          [not,
+                //              [anyof, [
+                //                  ['match', invalidPath, 'wholename'],
+                //                  ['match', invalidPath, 'wholename'],
+                //              ],
+                //          ],
+                //      ],
+                var matchWholeName = fileGlob => ['match', fileGlob, 'wholename'];
                 var matches = Object.keys(watchConfig).map(function(task) {
-                    var fileGlobs = watchConfig[task];
-                    var fileGlobMatches = fileGlobs.map(function(fileGlob) {
-                        return ['match', fileGlob, 'wholename'];
-                    });
+                    const matchAll = [];
+                    matchAll.push(['anyof'].concat(watchConfig[task].files.map(matchWholeName)));
 
-                    return ['allof'].concat(fileGlobMatches);
+                    if (watchConfig[task].excludes.length) {
+                        matchAll.push(['not', ['anyof'].concat(watchConfig[task].excludes.map(matchWholeName))]);
+                    }
+
+                    return ['allof'].concat(matchAll);
                 });
 
+                matches = ['anyof'].concat(matches);
+
                 var sub = {
-                    expression: ["anyof"].concat(matches),
+                    expression: matches,
                     // Which fields we're interested in.
                     fields: ["name", "size", "type"],
                     // Add our time constraint.
@@ -589,7 +749,7 @@ module.exports = function(grunt) {
                         return;
                     }
 
-                    grunt.log.ok('Listening for changes to files in ' + cwd);
+                    grunt.log.ok('Listening for changes to files in ' + fullRunDir);
                 });
             });
         });
@@ -634,10 +794,8 @@ module.exports = function(grunt) {
     grunt.registerTask('amd', ['eslint:amd', 'babel']);
     grunt.registerTask('js', ['amd', 'yui']);
 
-    // Register CSS taks.
-    grunt.registerTask('css', ['stylelint:scss', 'sass', 'stylelint:css']);
-    grunt.registerTask('scss', ['stylelint:scss', 'sass']);
-    grunt.registerTask('rawcss', ['stylelint:css']);
+    // Register CSS tasks.
+    registerStyleLintTasks(grunt, files, fullRunDir);
 
     // Register the startup task.
     grunt.registerTask('startup', 'Run the correct tasks for the current directory', tasks.startup);
diff --git a/GruntfileComponents.js b/GruntfileComponents.js
new file mode 100644 (file)
index 0000000..06ed999
--- /dev/null
@@ -0,0 +1,185 @@
+// 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/>.
+
+/**
+ * Helper functions for working with Moodle component names, directories, and sources.
+ *
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+"use strict";
+/* eslint-env node */
+
+/** @var {Object} A list of subsystems in Moodle */
+const componentData = {};
+
+/**
+ * Load details of all moodle modules.
+ *
+ * @returns {object}
+ */
+const fetchComponentData = () => {
+    const fs = require('fs');
+    const path = require('path');
+    const glob = require('glob');
+    const gruntFilePath = process.cwd();
+
+    if (!Object.entries(componentData).length) {
+        componentData.subsystems = {};
+        componentData.pathList = [];
+
+        // Fetch the component definiitions from the distributed JSON file.
+        const components = JSON.parse(fs.readFileSync(`${gruntFilePath}/lib/components.json`));
+
+        // Build the list of moodle subsystems.
+        componentData.subsystems.lib = 'core';
+        componentData.pathList.push(process.cwd() + path.sep + 'lib');
+        for (const [component, thisPath] of Object.entries(components.subsystems)) {
+            if (thisPath) {
+                // Prefix "core_" to the front of the subsystems.
+                componentData.subsystems[thisPath] = `core_${component}`;
+                componentData.pathList.push(process.cwd() + path.sep + thisPath);
+            }
+        }
+
+        // The list of components incldues the list of subsystems.
+        componentData.components = componentData.subsystems;
+
+        // Go through each of the plugintypes.
+        Object.entries(components.plugintypes).forEach(([pluginType, pluginTypePath]) => {
+            // We don't allow any code in this place..?
+            glob.sync(`${pluginTypePath}/*/version.php`).forEach(versionPath => {
+                const componentPath = fs.realpathSync(path.dirname(versionPath));
+                const componentName = path.basename(componentPath);
+                const frankenstyleName = `${pluginType}_${componentName}`;
+                componentData.components[`${pluginTypePath}/${componentName}`] = frankenstyleName;
+                componentData.pathList.push(componentPath);
+
+                // Look for any subplugins.
+                const subPluginConfigurationFile = `${componentPath}/db/subplugins.json`;
+                if (fs.existsSync(subPluginConfigurationFile)) {
+                    const subpluginList = JSON.parse(fs.readFileSync(fs.realpathSync(subPluginConfigurationFile)));
+
+                    Object.entries(subpluginList.plugintypes).forEach(([subpluginType, subpluginTypePath]) => {
+                        glob.sync(`${subpluginTypePath}/*/version.php`).forEach(versionPath => {
+                            const componentPath = fs.realpathSync(path.dirname(versionPath));
+                            const componentName = path.basename(componentPath);
+                            const frankenstyleName = `${subpluginType}_${componentName}`;
+
+                            componentData.components[`${subpluginTypePath}/${componentName}`] = frankenstyleName;
+                            componentData.pathList.push(componentPath);
+                        });
+                    });
+                }
+            });
+        });
+
+    }
+
+    return componentData;
+};
+
+/**
+ * Get the list of paths to build AMD sources.
+ *
+ * @returns {Array}
+ */
+const getAmdSrcGlobList = () => {
+    const globList = [];
+    fetchComponentData().pathList.forEach(componentPath => {
+        globList.push(`${componentPath}/amd/src/*.js`);
+        globList.push(`${componentPath}/amd/src/**/*.js`);
+    });
+
+    return globList;
+};
+
+/**
+ * Get the list of paths to build YUI sources.
+ *
+ * @param {String} relativeTo
+ * @returns {Array}
+ */
+const getYuiSrcGlobList = relativeTo => {
+    const globList = [];
+    fetchComponentData().pathList.forEach(componentPath => {
+        const relativeComponentPath = componentPath.replace(relativeTo, '');
+        globList.push(`${relativeComponentPath}/yui/src/**/*.js`);
+    });
+
+    return globList;
+};
+
+/**
+ * Get the list of paths to thirdpartylibs.xml.
+ *
+ * @param {String} relativeTo
+ * @returns {Array}
+ */
+const getThirdPartyLibsList = relativeTo => {
+    const fs = require('fs');
+
+    return fetchComponentData().pathList
+        .map(componentPath => componentPath.replace(relativeTo, '') + '/thirdpartylibs.xml')
+        .filter(path => fs.existsSync(path))
+        .sort();
+};
+
+/**
+ * Find the name of the component matching the specified path.
+ *
+ * @param {String} path
+ * @returns {String|null} Name of matching component.
+ */
+const getComponentFromPath = path => {
+    const componentList = fetchComponentData().components;
+
+    if (componentList.hasOwnProperty(path)) {
+        return componentList[path];
+    }
+
+    return null;
+};
+
+/**
+ * Check whether the supplied path, relative to the Gruntfile.js, is in a known component.
+ *
+ * @param {String} checkPath The path to check
+ * @returns {String|null}
+ */
+const getOwningComponentDirectory = checkPath => {
+    const path = require('path');
+
+    const pathList = fetchComponentData().components;
+    for (const componentPath of Object.keys(pathList)) {
+        if (checkPath === componentPath) {
+            return componentPath;
+        }
+        if (checkPath.startsWith(componentPath + path.sep)) {
+            return componentPath;
+        }
+    }
+
+    return null;
+};
+
+module.exports = {
+    getAmdSrcGlobList,
+    getComponentFromPath,
+    getOwningComponentDirectory,
+    getYuiSrcGlobList,
+    getThirdPartyLibsList,
+};
index c0e3d8d..6fb58c6 100644 (file)
@@ -158,19 +158,19 @@ class core_role_define_role_table_advanced extends core_role_capability_table_wi
         // Allowed roles.
         $allow = optional_param_array('allowassign', null, PARAM_INT);
         if (!is_null($allow)) {
-            $this->allowassign = $allow;
+            $this->allowassign = array_filter($allow);
         }
         $allow = optional_param_array('allowoverride', null, PARAM_INT);
         if (!is_null($allow)) {
-            $this->allowoverride = $allow;
+            $this->allowoverride = array_filter($allow);
         }
         $allow = optional_param_array('allowswitch', null, PARAM_INT);
         if (!is_null($allow)) {
-            $this->allowswitch = $allow;
+            $this->allowswitch = array_filter($allow);
         }
         $allow = optional_param_array('allowview', null, PARAM_INT);
         if (!is_null($allow)) {
-            $this->allowview = $allow;
+            $this->allowview = array_filter($allow);
         }
 
         // Now read the permissions for each capability.
@@ -619,7 +619,9 @@ class core_role_define_role_table_advanced extends core_role_capability_table_wi
         if ($this->roleid == 0) {
             $options[-1] = get_string('thisnewrole', 'core_role');
         }
-        return html_writer::select($options, 'allow'.$type.'[]', $selected, false, array('multiple' => 'multiple',
+        return
+            html_writer::empty_tag('input', array('type' => 'hidden', 'name' => 'allow'.$type.'[]', 'value' => "")) .
+            html_writer::select($options, 'allow'.$type.'[]', $selected, false, array('multiple' => 'multiple',
             'size' => 10, 'class' => 'form-control'));
     }
 
index 8cb2d66..7092e82 100644 (file)
     * size - form element size
     * value - form element value
     * id - element id
+    * forced - has value been defined in config.php
 
     Example context (json):
     {
         "name": "test",
         "id": "test0",
         "size": "8",
-        "value": "secret"
+        "value": "secret",
+        "forced": false
     }
 }}
+{{#forced}}
+    <div class="form-password">
+        <input type="text"
+               name = "{{ name }}"
+               id="{{ id }}"
+               value="********"
+               size="{{ size }}"
+               class="form-control"
+               disabled
+        >
+    </div>
+{{/forced}}
+{{^forced}}
 <div class="form-password">
     <span data-passwordunmask="wrapper" data-passwordunmaskid="{{ id }}">
         <span data-passwordunmask="editor">
@@ -61,3 +76,4 @@ require(['core_form/passwordunmask'], function(PasswordUnmask) {
     new PasswordUnmask("{{ id }}");
 });
 {{/js}}
+{{/forced}}
index 83e879a..2f02b45 100644 (file)
@@ -254,20 +254,19 @@ class tool_customlang_utils {
      *
      * @param string $component the name of the component
      * @param array $strings
+     * @return void
      */
     protected static function dump_strings($lang, $component, $strings) {
         global $CFG;
 
         if ($lang !== clean_param($lang, PARAM_LANG)) {
-            debugging('Unable to dump local strings for non-installed language pack .'.s($lang));
-            return false;
+            throw new moodle_exception('Unable to dump local strings for non-installed language pack .'.s($lang));
         }
         if ($component !== clean_param($component, PARAM_COMPONENT)) {
             throw new coding_exception('Incorrect component name');
         }
         if (!$filename = self::get_component_filename($component)) {
-            debugging('Unable to find the filename for the component '.s($component));
-            return false;
+            throw new moodle_exception('Unable to find the filename for the component '.s($component));
         }
         if ($filename !== clean_param($filename, PARAM_FILE)) {
             throw new coding_exception('Incorrect file name '.s($filename));
@@ -284,8 +283,7 @@ class tool_customlang_utils {
         }
 
         if (!$f = fopen($filepath, 'w')) {
-            debugging('Unable to write '.s($filepath));
-            return false;
+            throw new moodle_exception('Unable to write '.s($filepath));
         }
         fwrite($f, <<<EOF
 <?php
index d86d177..7bf94cc 100644 (file)
@@ -301,6 +301,11 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $datarequest = api::create_data_request($student->id, api::DATAREQUEST_TYPE_EXPORT);
 
         $requestid = $datarequest->get('id');
+
+        // Login as a user without DPO role.
+        $this->setUser($teacher);
+        $this->expectException(required_capability_exception::class);
+        api::approve_data_request($requestid);
     }
 
     /**
index cdbd8a7..dfe68c6 100644 (file)
 module.exports = ({template, types}) => {
     const fs = require('fs');
     const path = require('path');
-    const glob = require('glob');
     const cwd = process.cwd();
-
-    // Static variable to hold the modules.
-    let moodleSubsystems = null;
-    let moodlePlugins = null;
-
-    /**
-     * Parse Moodle's JSON files containing the lists of components.
-     *
-     * The values are stored in the static variables because we
-     * only need to load them once per transpiling run.
-     */
-    function loadMoodleModules() {
-        moodleSubsystems = {'lib': 'core'};
-        moodlePlugins = {};
-        let components = fs.readFileSync('lib/components.json');
-        components = JSON.parse(components);
-
-        for (const [component, path] of Object.entries(components.subsystems)) {
-            if (path) {
-                // Prefix "core_" to the front of the subsystems.
-                moodleSubsystems[path] = `core_${component}`;
-            }
-        }
-
-        for (const [component, path] of Object.entries(components.plugintypes)) {
-            if (path) {
-                moodlePlugins[path] = component;
-            }
-        }
-
-        for (const file of glob.sync('**/db/subplugins.json')) {
-            var rawContents = fs.readFileSync(file);
-            var subplugins = JSON.parse(rawContents);
-
-            for (const [component, path] of Object.entries(subplugins.plugintypes)) {
-                if (path) {
-                    moodlePlugins[path] = component;
-                }
-            }
-        }
-    }
+    const ComponentList = require(path.resolve('GruntfileComponents.js'));
 
     /**
      * Search the list of components that match the given file name
@@ -99,26 +58,14 @@ module.exports = ({template, types}) => {
         const fileName = file.replace('.js', '');
 
         // Check subsystems first which require an exact match.
-        if (moodleSubsystems.hasOwnProperty(componentPath)) {
-            return `${moodleSubsystems[componentPath]}/${fileName}`;
-        }
-
-        // It's not a subsystem so it must be a plugin. Moodle defines root folders
-        // where plugins can be installed so our path with be <plugin_root>/<plugin_name>.
-        // Let's separate the two.
-        let pathParts = componentPath.split('/');
-        const pluginName = pathParts.pop();
-        const pluginPath = pathParts.join('/');
-
-        // The plugin path mutch match exactly because some plugins are subplugins of
-        // other plugins which means their paths would partially match.
-        if (moodlePlugins.hasOwnProperty(pluginPath)) {
-            return `${moodlePlugins[pluginPath]}_${pluginName}/${fileName}`;
+        const componentName = ComponentList.getComponentFromPath(componentPath);
+        if (componentName) {
+            return `${componentName}/${fileName}`;
         }
 
         // This matches the previous PHP behaviour that would throw an exception
         // if it couldn't parse an AMD file.
-        throw new Error('Unable to find module name for ' + searchFileName);
+        throw new Error(`Unable to find module name for ${searchFileName} (${componentPath}::${file}}`);
     }
 
     /**
@@ -149,10 +96,6 @@ module.exports = ({template, types}) => {
         pre() {
             this.seenDefine = false;
             this.addedReturnForDefaultExport = false;
-
-            if (moodleSubsystems === null) {
-                loadMoodleModules();
-            }
         },
         visitor: {
             // Plugin ordering is only respected if we visit the "Program" node.
index 43e9de8..f378c10 100644 (file)
@@ -3016,7 +3016,7 @@ function calendar_get_icalendar($url) {
  * Import events from an iCalendar object into a course calendar.
  *
  * @param iCalendar $ical The iCalendar object.
- * @param int $courseid The course ID for the calendar.
+ * @param int $unused Deprecated
  * @param int $subscriptionid The subscription ID.
  * @return string A log of the import progress, including errors.
  */
@@ -3066,7 +3066,8 @@ function calendar_import_icalendar_events($ical, $unused = null, $subscriptionid
         }
     }
 
-    if (!empty($subscriptionid)) {
+    $existing = $DB->get_field('event_subscriptions', 'lastupdated', ['id' => $subscriptionid]);
+    if (!empty($existing)) {
         $eventsuuids = $DB->get_records_menu('event', ['subscriptionid' => $subscriptionid], '', 'id, uuid');
 
         $icaleventscount = count($icaluuids);
index 96af607..94a3cb4 100644 (file)
@@ -227,6 +227,24 @@ class core_calendar_lib_testcase extends advanced_testcase {
         calendar_import_icalendar_events($ical, null, $sub->id);
         $count = $DB->count_records('event', array('subscriptionid' => $sub->id));
         $this->assertEquals($count, 1);
+
+        // Test for ICS file with repeated events.
+        $subscription = new stdClass();
+        $subscription->name = 'Repeated events';
+        $subscription->importfrom = CALENDAR_IMPORT_FROM_FILE;
+        $subscription->eventtype = 'site';
+        $id = calendar_add_subscription($subscription);
+        $calendar = file_get_contents($CFG->dirroot . '/lib/tests/fixtures/repeated_events.ics');
+        $ical = new iCalendar();
+        $ical->unserialize($calendar);
+        $this->assertEquals($ical->parser_errors, []);
+
+        $sub = calendar_get_subscription($id);
+        $output = calendar_import_icalendar_events($ical, null, $sub->id);
+        $this->assertStringNotContainsString('Events deleted: 17', $output);
+        $this->assertStringContainsString('Events imported: 1', $output);
+        $this->assertStringContainsString('Events skipped: 0', $output);
+        $this->assertStringContainsString('Events updated: 0', $output);
     }
 
     /**
index 77c5a5a..7105753 100644 (file)
@@ -2302,7 +2302,7 @@ class core_competency_privacy_testcase extends provider_testcase {
         $this->assertEquals('-', $comp['rating']['rating']);
         $comp = $data->competencies[2];
         $this->assertEquals($comp4->get('shortname'), $comp['name']);
-        $this->assertNull($comp['rating']['rating']);
+        $this->assertNull($comp['rating']);
         $data = writer::with_context($u1ctx)->get_data(array_merge($path, ["{$p1a->get('name')} ({$p1a->get('id')})",
             get_string('commentsubcontext', 'core_comment')]));
         $this->assert_exported_comments(['Hello.', 'It\'s me.', 'After all these years...'], $data->comments);
@@ -2320,7 +2320,7 @@ class core_competency_privacy_testcase extends provider_testcase {
         $this->assertEquals('C', $comp['rating']['rating']);
         $comp = $data->competencies[2];
         $this->assertEquals($comp4->get('shortname'), $comp['name']);
-        $this->assertNull($comp['rating']['rating']);
+        $this->assertNull($comp['rating']);
 
         // This plan is complete.
         $data = writer::with_context($u1ctx)->get_data(array_merge($path, ["{$p1c->get('name')} ({$p1c->get('id')})"]));
index b64817b..632860a 100644 (file)
@@ -120,10 +120,12 @@ class enrol_meta_plugin extends enrol_plugin {
         require_once("$CFG->dirroot/enrol/meta/locallib.php");
 
         // Support creating multiple at once.
-        if (is_array($fields['customint1'])) {
+        if (isset($fields['customint1']) && is_array($fields['customint1'])) {
             $courses = array_unique($fields['customint1']);
-        } else {
+        } else if (isset($fields['customint1'])) {
             $courses = array($fields['customint1']);
+        } else {
+            $courses = array(null); // Strange? Yes, but that's how it's working or instance is not created ever.
         }
         foreach ($courses as $courseid) {
             if (!empty($fields['customint2']) && $fields['customint2'] == ENROL_META_CREATE_GROUP) {
index cc73e30..4692f4a 100644 (file)
Binary files a/grade/amd/build/edittree_index.min.js and b/grade/amd/build/edittree_index.min.js differ
index e3569f3..26c6df9 100644 (file)
Binary files a/grade/amd/build/edittree_index.min.js.map and b/grade/amd/build/edittree_index.min.js.map differ
index 9316f84..bb9aa79 100644 (file)
@@ -30,12 +30,6 @@ define([
      * @method edittree
      */
     var edittree = function() {
-        // Watch items and toggle the move menu accordingly.
-        $('body').on('change', '.itemselect.ignoredirty', edittree.checkMoveMenuState);
-
-        // Watch for the 'All' and 'None' links.
-        $('body').on('click', '[data-action="grade_edittree-index-bulkselect"]', edittree.toggleAllSelectItems);
-
         // Watch for the weight override checkboxes.
         $('body').on('change', '.weightoverride', edittree.toggleWeightInput);
 
@@ -47,9 +41,6 @@ define([
             bulkmove.val(1);
             form.submit();
         });
-
-        // CHeck the initial state of the move menu.
-        edittree.checkMoveMenuState();
     };
 
     /**
@@ -67,60 +58,6 @@ define([
         $('input[name="weight_' + row.data('itemid') + '"]').prop('disabled', !node.prop('checked'));
     };
 
-    /**
-     * Toggle all select boxes on or off.
-     *
-     * @method toggleAllSelectItems
-     * @param {EventFacade} e
-     * @private
-     */
-    edittree.toggleAllSelectItems = function(e) {
-        e.preventDefault();
-
-        var node = $(this),
-            row = node.closest('tr');
-        $('.' + row.data('category') + ' .itemselect').prop('checked', node.data('checked'));
-
-        edittree.checkMoveMenuState();
-    };
-
-    /**
-     * Get the move menu.
-     *
-     * @method getMoveMenu
-     * @private
-     * @return {jQuery}
-     */
-    edittree.getMoveMenu = function() {
-        return $('#menumoveafter');
-    };
-
-    /**
-     * Check whether any checkboxes are ticked.
-     *
-     * @method checkMoveMenuState
-     * @private
-     * @return {Boolean}
-     */
-    edittree.checkMoveMenuState = function() {
-        var menu = edittree.getMoveMenu();
-        if (!menu.length) {
-            return false;
-        }
-
-        var selected;
-        $('.itemselect').each(function() {
-            selected = $(this).prop('checked');
-
-            // Return early if any are checked.
-            return !selected;
-        });
-
-        menu.prop('disabled', !selected);
-
-        return selected;
-    };
-
     return /** @alias module:core_grades/edittree_index */ {
         enhance: edittree
     };
index 86cf33e..8eac5fe 100644 (file)
@@ -840,20 +840,39 @@ class grade_edit_tree_column_select extends grade_edit_tree_column {
     }
 
     public function get_category_cell($category, $levelclass, $params) {
+        global $OUTPUT;
+
         if (empty($params['eid'])) {
             throw new Exception('Array key (eid) missing from 3rd param of grade_edit_tree_column_select::get_category_cell($category, $levelclass, $params)');
         }
-        $selectall = html_writer::link('#', get_string('all'), [
-            'data-action' => 'grade_edittree-index-bulkselect',
-            'data-checked' => true,
-        ]);
-        $selectnone = html_writer::link('#', get_string('none'), [
-            'data-action' => 'grade_edittree-index-bulkselect',
-            'data-checked' => false,
+
+        // Get toggle group for this master checkbox.
+        $togglegroup = $this->get_checkbox_togglegroup($category);
+        // Set label for this master checkbox.
+        $masterlabel = get_string('all');
+        // Use category name if available.
+        if ($category->fullname !== '?') {
+            $masterlabel = format_string($category->fullname);
+            // Limit the displayed category name to prevent the Select column from getting too wide.
+            if (core_text::strlen($masterlabel) > 20) {
+                $masterlabel = get_string('textellipsis', 'core', core_text::substr($masterlabel, 0, 12));
+            }
+        }
+        // Build the master checkbox.
+        $mastercheckbox = new \core\output\checkbox_toggleall($togglegroup, true, [
+            'id' => $togglegroup,
+            'name' => $togglegroup,
+            'value' => 1,
+            'classes' => 'itemselect ignoredirty',
+            'label' => $masterlabel,
+            // Consistent label to prevent the select column from resizing.
+            'selectall' => $masterlabel,
+            'deselectall' => $masterlabel,
+            'labelclasses' => 'm-0',
         ]);
 
         $categorycell = parent::get_category_cell($category, $levelclass, $params);
-        $categorycell->text = $selectall . ' / ' . $selectnone;
+        $categorycell->text = $OUTPUT->render($mastercheckbox);
         return $categorycell;
     }
 
@@ -864,12 +883,43 @@ class grade_edit_tree_column_select extends grade_edit_tree_column {
         $itemcell = parent::get_item_cell($item, $params);
 
         if ($params['itemtype'] != 'course' && $params['itemtype'] != 'category') {
-            $itemcell->text = '<label class="accesshide" for="select_'.$params['eid'].'">'.
-                get_string('select', 'grades', $item->itemname).'</label>
-                <input class="itemselect ignoredirty" type="checkbox" name="select_'.$params['eid'].'" id="select_'.$params['eid'].
-                '"/>';
+            global $OUTPUT;
+
+            // Fetch the grade item's category.
+            $category = grade_category::fetch(['id' => $item->categoryid]);
+            $togglegroup = $this->get_checkbox_togglegroup($category);
+
+            $checkboxid = 'select_' . $params['eid'];
+            $checkbox = new \core\output\checkbox_toggleall($togglegroup, false, [
+                'id' => $checkboxid,
+                'name' => $checkboxid,
+                'label' => get_string('select', 'grades', $item->itemname),
+                'labelclasses' => 'accesshide',
+                'classes' => 'itemselect ignoredirty',
+            ]);
+            $itemcell->text = $OUTPUT->render($checkbox);
         }
         return $itemcell;
     }
+
+    /**
+     * Generates a toggle group name for a bulk-action checkbox based on the given grade category.
+     *
+     * @param grade_category $category The grade category.
+     * @return string
+     */
+    protected function get_checkbox_togglegroup(grade_category $category): string {
+        $levels = [];
+        $categories = explode('/', $category->path);
+        foreach ($categories as $categoryid) {
+            $level = 'category' . $categoryid;
+            if (!in_array($level, $levels)) {
+                $levels[] = 'category' . $categoryid;
+            }
+        }
+        $togglegroup = implode(' ', $levels);
+
+        return $togglegroup;
+    }
 }
 
index 5c4c539..5767d76 100644 (file)
 {{!
     @template core_grades/edit_tree
 
-    Edit tree.
+    Edit tree template.
+
+    Context variables required for this template:
+    * actionurl - string - Form action URL.
+    * sesskey - string - The session key.
+    * notification - object - Context data for the notification.
+    * showsave - boolean - Whether to show the save changes button.
+    * showbulkmove - boolean - Whether to show the bulk move select menu.
+    * table - string - HTML content of the grade items table.
+    * bulkmoveoptions - array - Key-value pair array for the options of the the bulk move select menu element.
 
     Example context (json):
     {
-        "actionurl": "https://domain.example/grade/edit/tree/index.php?id=4",
+        "actionurl": "#",
         "sesskey": "fakesesskey",
         "notification": "",
         "table": "<table class='generaltable simple setup-grades' id='grade_edit_tree_table'><thead> <tr><th>Name</th><th>Weights</th><th>Max grade</th><th>Actions</th> </tr></thead><tbody></tbody></table>",
@@ -48,7 +57,8 @@
                 <div class="form-inline mt-3">
                     <input type="hidden" name="bulkmove" value="0" id="bulkmoveinput">
                     <label for="menumoveafter">{{#str}}moveselectedto, grades{{/str}}</label>
-                    <select name="moveafter" id="menumoveafter" class="ignoredirty singleselect custom-select form-control">
+                    <select name="moveafter" id="menumoveafter" class="ignoredirty singleselect custom-select form-control"
+                            data-action="toggle" data-toggle="action" data-togglegroup="category" disabled>
                         {{#bulkmoveoptions}}
                             <option value="{{value}}">{{name}}</option>
                         {{/bulkmoveoptions}}
index 0b950d1..74644c0 100644 (file)
@@ -2002,6 +2002,7 @@ If \'plain text area\' is selected, a format for text input areas such as HTML o
 The list of available text editors is determined by the site administrator.';
 $string['texteditor'] = 'Use standard web forms';
 $string['textformat'] = 'Plain text format';
+$string['textellipsis'] = '{$a}...';
 $string['timesplitting:deciles'] = 'Last tenth';
 $string['timesplitting:deciles_help'] = 'This analysis interval divides the course into tenths (10 equal parts), with each prediction being based on the data of only the most recent previous tenth.';
 $string['timesplitting:decilesaccum'] = 'All previous tenths';
index 047a574..da19957 100644 (file)
@@ -2641,13 +2641,25 @@ class admin_setting_configpasswordunmask extends admin_setting_configtext {
      * @return  string              Rendered HTML
      */
     public function output_html($data, $query='') {
-        global $OUTPUT;
+        global $OUTPUT, $CFG;
+        $forced = false;
+        if (empty($this->plugin)) {
+            if (array_key_exists($this->name, $CFG->config_php_settings)) {
+                $forced = true;
+            }
+        } else {
+            if (array_key_exists($this->plugin, $CFG->forced_plugin_settings)
+                and array_key_exists($this->name, $CFG->forced_plugin_settings[$this->plugin])) {
+                $forced = true;
+            }
+        }
         $context = (object) [
             'id' => $this->get_id(),
             'name' => $this->get_full_name(),
             'size' => $this->size,
-            'value' => $data,
+            'value' => $forced ? null : $data,
             'forceltr' => $this->get_force_ltr(),
+            'forced' => $forced
         ];
         $element = $OUTPUT->render_from_template('core_admin/setting_configpasswordunmask', $context);
         return format_admin_setting($this, $this->visiblename, $element, $this->description, true, '', null, $query);
index 0888103..456f345 100644 (file)
Binary files a/lib/amd/build/loglevel.min.js and b/lib/amd/build/loglevel.min.js differ
index aa558f6..3fb6ea7 100644 (file)
Binary files a/lib/amd/build/loglevel.min.js.map and b/lib/amd/build/loglevel.min.js.map differ
index a9e781f..ebe3aa2 100644 (file)
Binary files a/lib/amd/build/mustache.min.js and b/lib/amd/build/mustache.min.js differ
index 589186e..62d7036 100644 (file)
Binary files a/lib/amd/build/mustache.min.js.map and b/lib/amd/build/mustache.min.js.map differ
index 0c31946..e3d9fd7 100644 (file)
@@ -1,39 +1,32 @@
-// The MIT License
-//
 // Copyright (c) 2013 Tim Perry
 //
-// Permission is hereby granted, free of charge, to any person obtaining
-// a copy of this software and associated documentation files (the
-// "Software"), to deal in the Software without restriction, including
-// without limitation the rights to use, copy, modify, merge, publish,
-// distribute, sublicense, and/or sell copies of the Software, and to
-// permit persons to whom the Software is furnished to do so, subject to
-// the following conditions:
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
 //
 // The above copyright notice and this permission notice shall be
 // included in all copies or substantial portions of the Software.
 //
 // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
-// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
-// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
-// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
-// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-//
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
 
 // Description of import into Moodle:
-// Download from https://github.com/pimterry/loglevel/dist
+// Download from https://github.com/pimterry/loglevel/tree/master/dist
 // Copy loglevel.js into lib/amd/src/ in Moodle folder.
 // Add the license as a comment to the file and these instructions.
 
-/*
- * loglevel - https://github.com/pimterry/loglevel
- *
- * Copyright (c) 2013 Tim Perry
- * Licensed under the MIT license.
- */
-/*! loglevel - v1.6.2 - https://github.com/pimterry/loglevel - (c) 2019 Tim Perry - licensed MIT */
+/*! loglevel - v1.6.6 - https://github.com/pimterry/loglevel - (c) 2019 Tim Perry - licensed MIT */
 (function (root, definition) {
     "use strict";
     if (typeof define === 'function' && define.amd) {
@@ -49,6 +42,9 @@
     // Slightly dubious tricks to cut down minimized file size
     var noop = function() {};
     var undefinedType = "undefined";
+    var isIE = (typeof window !== undefinedType) && (
+        /Trident\/|MSIE /.test(window.navigator.userAgent)
+    );
 
     var logMethods = [
         "trace",
         }
     }
 
+    // Trace() doesn't print the message in IE, so for that case we need to wrap it
+    function traceForIE() {
+        if (console.log) {
+            if (console.log.apply) {
+                console.log.apply(console, arguments);
+            } else {
+                // In old IE, native console methods themselves don't have apply().
+                Function.prototype.apply.apply(console.log, [console, arguments]);
+            }
+        }
+        if (console.trace) console.trace();
+    }
+
     // Build the best logging method possible for this env
     // Wherever possible we want to bind, not wrap, to preserve stack traces
     function realMethod(methodName) {
@@ -84,6 +93,8 @@
 
         if (typeof console === undefinedType) {
             return false; // No method possible, for now - fixed later by enableLoggingWhenConsoleArrives
+        } else if (methodName === 'trace' && isIE) {
+            return traceForIE;
         } else if (console[methodName] !== undefined) {
             return bindMethod(console, methodName);
         } else if (console.log !== undefined) {
     function defaultMethodFactory(methodName, level, loggerName) {
         /*jshint validthis:true */
         return realMethod(methodName) ||
-            enableLoggingWhenConsoleArrives.apply(this, arguments);
+               enableLoggingWhenConsoleArrives.apply(this, arguments);
     }
 
     function Logger(name, defaultLevel, factory) {
-        var self = this;
-        var currentLevel;
-        var storageKey = "loglevel";
-        if (name) {
-            storageKey += ":" + name;
-        }
-
-        function persistLevelIfPossible(levelNum) {
-            var levelName = (logMethods[levelNum] || 'silent').toUpperCase();
-
-            if (typeof window === undefinedType) return;
-
-            // Use localStorage if available
-            try {
-                window.localStorage[storageKey] = levelName;
-                return;
-            } catch (ignore) {}
-
-            // Use session cookie as fallback
-            try {
-                window.document.cookie =
-                    encodeURIComponent(storageKey) + "=" + levelName + ";";
-            } catch (ignore) {}
-        }
-
-        function getPersistedLevel() {
-            var storedLevel;
-
-            if (typeof window === undefinedType) return;
-
-            try {
-                storedLevel = window.localStorage[storageKey];
-            } catch (ignore) {}
-
-            // Fallback to cookies if local storage gives us nothing
-            if (typeof storedLevel === undefinedType) {
-                try {
-                    var cookie = window.document.cookie;
-                    var location = cookie.indexOf(
-                        encodeURIComponent(storageKey) + "=");
-                    if (location !== -1) {
-                        storedLevel = /^([^;]+)/.exec(cookie.slice(location))[1];
-                    }
-                } catch (ignore) {}
-            }
-
-            // If the stored level is not valid, treat it as if nothing was stored.
-            if (self.levels[storedLevel] === undefined) {
-                storedLevel = undefined;
-            }
-
-            return storedLevel;
-        }
-
-        /*
-         *
-         * Public logger API - see https://github.com/pimterry/loglevel for details
-         *
-         */
-
-        self.name = name;
-
-        self.levels = { "TRACE": 0, "DEBUG": 1, "INFO": 2, "WARN": 3,
-            "ERROR": 4, "SILENT": 5};
-
-        self.methodFactory = factory || defaultMethodFactory;
-
-        self.getLevel = function () {
-            return currentLevel;
-        };
-
-        self.setLevel = function (level, persist) {
-            if (typeof level === "string" && self.levels[level.toUpperCase()] !== undefined) {
-                level = self.levels[level.toUpperCase()];
-            }
-            if (typeof level === "number" && level >= 0 && level <= self.levels.SILENT) {
-                currentLevel = level;
-                if (persist !== false) {  // defaults to true
-                    persistLevelIfPossible(level);
-                }
-                replaceLoggingMethods.call(self, level, name);
-                if (typeof console === undefinedType && level < self.levels.SILENT) {
-                    return "No console available for logging";
-                }
-            } else {
-                throw "log.setLevel() called with invalid level: " + level;
-            }
-        };
-
-        self.setDefaultLevel = function (level) {
-            if (!getPersistedLevel()) {
-                self.setLevel(level, false);
-            }
-        };
-
-        self.enableAll = function(persist) {
-            self.setLevel(self.levels.TRACE, persist);
-        };
-
-        self.disableAll = function(persist) {
-            self.setLevel(self.levels.SILENT, persist);
-        };
-
-        // Initialize with the right level
-        var initialLevel = getPersistedLevel();
-        if (initialLevel == null) {
-            initialLevel = defaultLevel == null ? "WARN" : defaultLevel;
-        }
-        self.setLevel(initialLevel, false);
+      var self = this;
+      var currentLevel;
+      var storageKey = "loglevel";
+      if (name) {
+        storageKey += ":" + name;
+      }
+
+      function persistLevelIfPossible(levelNum) {
+          var levelName = (logMethods[levelNum] || 'silent').toUpperCase();
+
+          if (typeof window === undefinedType) return;
+
+          // Use localStorage if available
+          try {
+              window.localStorage[storageKey] = levelName;
+              return;
+          } catch (ignore) {}
+
+          // Use session cookie as fallback
+          try {
+              window.document.cookie =
+                encodeURIComponent(storageKey) + "=" + levelName + ";";
+          } catch (ignore) {}
+      }
+
+      function getPersistedLevel() {
+          var storedLevel;
+
+          if (typeof window === undefinedType) return;
+
+          try {
+              storedLevel = window.localStorage[storageKey];
+          } catch (ignore) {}
+
+          // Fallback to cookies if local storage gives us nothing
+          if (typeof storedLevel === undefinedType) {
+              try {
+                  var cookie = window.document.cookie;
+                  var location = cookie.indexOf(
+                      encodeURIComponent(storageKey) + "=");
+                  if (location !== -1) {
+                      storedLevel = /^([^;]+)/.exec(cookie.slice(location))[1];
+                  }
+              } catch (ignore) {}
+          }
+
+          // If the stored level is not valid, treat it as if nothing was stored.
+          if (self.levels[storedLevel] === undefined) {
+              storedLevel = undefined;
+          }
+
+          return storedLevel;
+      }
+
+      /*
+       *
+       * Public logger API - see https://github.com/pimterry/loglevel for details
+       *
+       */
+
+      self.name = name;
+
+      self.levels = { "TRACE": 0, "DEBUG": 1, "INFO": 2, "WARN": 3,
+          "ERROR": 4, "SILENT": 5};
+
+      self.methodFactory = factory || defaultMethodFactory;
+
+      self.getLevel = function () {
+          return currentLevel;
+      };
+
+      self.setLevel = function (level, persist) {
+          if (typeof level === "string" && self.levels[level.toUpperCase()] !== undefined) {
+              level = self.levels[level.toUpperCase()];
+          }
+          if (typeof level === "number" && level >= 0 && level <= self.levels.SILENT) {
+              currentLevel = level;
+              if (persist !== false) {  // defaults to true
+                  persistLevelIfPossible(level);
+              }
+              replaceLoggingMethods.call(self, level, name);
+              if (typeof console === undefinedType && level < self.levels.SILENT) {
+                  return "No console available for logging";
+              }
+          } else {
+              throw "log.setLevel() called with invalid level: " + level;
+          }
+      };
+
+      self.setDefaultLevel = function (level) {
+          if (!getPersistedLevel()) {
+              self.setLevel(level, false);
+          }
+      };
+
+      self.enableAll = function(persist) {
+          self.setLevel(self.levels.TRACE, persist);
+      };
+
+      self.disableAll = function(persist) {
+          self.setLevel(self.levels.SILENT, persist);
+      };
+
+      // Initialize with the right level
+      var initialLevel = getPersistedLevel();
+      if (initialLevel == null) {
+          initialLevel = defaultLevel == null ? "WARN" : defaultLevel;
+      }
+      self.setLevel(initialLevel, false);
     }
 
     /*
     var _loggersByName = {};
     defaultLogger.getLogger = function getLogger(name) {
         if (typeof name !== "string" || name === "") {
-            throw new TypeError("You must supply a name when creating a logger.");
+          throw new TypeError("You must supply a name when creating a logger.");
         }
 
         var logger = _loggersByName[name];
         if (!logger) {
-            logger = _loggersByName[name] = new Logger(
-                name, defaultLogger.getLevel(), defaultLogger.methodFactory);
+          logger = _loggersByName[name] = new Logger(
+            name, defaultLogger.getLevel(), defaultLogger.methodFactory);
         }
         return logger;
     };
     var _log = (typeof window !== undefinedType) ? window.log : undefined;
     defaultLogger.noConflict = function() {
         if (typeof window !== undefinedType &&
-            window.log === defaultLogger) {
+               window.log === defaultLogger) {
             window.log = _log;
         }
 
index 573ddca..360d7d1 100644 (file)
    * Tokens that are the root node of a subtree contain two more elements: 1) an
    * array of tokens in the subtree and 2) the index in the original template at
    * which the closing tag for that section begins.
+   *
+   * Tokens for partials also contain two more elements: 1) a string value of
+   * indendation prior to that tag and 2) the index of that tag on that line -
+   * eg a value of 2 indicates the partial is the third tag on this line.
    */
   function parseTemplate (template, tags) {
     if (!template)
       return [];
-
+    var lineHasNonSpace = false;
     var sections = [];     // Stack to hold section tokens
     var tokens = [];       // Buffer to hold the tokens
     var spaces = [];       // Indices of whitespace tokens on the current line
     var hasTag = false;    // Is there a {{tag}} on the current line?
     var nonSpace = false;  // Is there a non-space char on the current line?
+    var indentation = '';  // Tracks indentation for tags that use it
+    var tagIndex = 0;      // Stores a count of number of tags encountered on a line
 
     // Strips all whitespace tokens array for the current line
     // if there was a {{#tag}} on it and otherwise only space.
 
           if (isWhitespace(chr)) {
             spaces.push(tokens.length);
+            indentation += chr;
           } else {
             nonSpace = true;
+            lineHasNonSpace = true;
+            indentation += ' ';
           }
 
           tokens.push([ 'text', chr, start, start + 1 ]);
           start += 1;
 
           // Check for whitespace on the current line.
-          if (chr === '\n')
+          if (chr === '\n') {
             stripSpace();
+            indentation = '';
+            tagIndex = 0;
+            lineHasNonSpace = false;
+          }
         }
       }
 
       if (!scanner.scan(closingTagRe))
         throw new Error('Unclosed tag at ' + scanner.pos);
 
-      token = [ type, value, start, scanner.pos ];
+      if (type == '>') {
+        token = [ type, value, start, scanner.pos, indentation, tagIndex, lineHasNonSpace ];
+      } else {
+        token = [ type, value, start, scanner.pos ];
+      }
+      tagIndex++;
       tokens.push(token);
 
       if (type === '#' || type === '^' || type === '$' || type === '<') {
       }
     }
 
+    stripSpace();
+
     // Make sure there are no open sections when we're done.
     openSection = sections.pop();
 
       return this.renderTokens(token[4], context, partials, originalTemplate);
   };
 
+  Writer.prototype.indentPartial = function indentPartial (partial, indentation, lineHasNonSpace) {
+    var filteredIndentation = indentation.replace(/[^ \t]/g, '');
+    var partialByNl = partial.split('\n');
+    for (var i = 0; i < partialByNl.length; i++) {
+      if (partialByNl[i].length && (i > 0 || !lineHasNonSpace)) {
+        partialByNl[i] = filteredIndentation + partialByNl[i];
+      }
+    }
+    return partialByNl.join('\n');
+  };
+
   Writer.prototype.renderPartial = function renderPartial (token, context, partials, tags) {
     if (!partials) return;
 
     var value = isFunction(partials) ? partials(token[1]) : partials[token[1]];
-    if (value != null)
-      return this.renderTokens(this.parse(value, tags), context, partials, value);
+    if (value != null) {
+      var lineHasNonSpace = token[6];
+      var tagIndex = token[5];
+      var indentation = token[4];
+      var indentedValue = value;
+      if (tagIndex == 0 && indentation) {
+        indentedValue = this.indentPartial(value, indentation, lineHasNonSpace);
+      }
+      return this.renderTokens(this.parse(indentedValue, tags), context, partials, indentedValue);
+    }
   };
 
   Writer.prototype.renderBlock = function renderBlock (token, context, partials, originalTemplate) {
 
     var value = isFunction(partials) ? partials(token[1]) : partials[token[1]];
     if (value != null)
-      // Ignore any wrongly set block vars before we started.
-      context.clearBlockVars();
-      // We are only rendering to record the default block variables.
-      this.renderTokens(token[4], context, partials, originalTemplate);
-      // Now we render and return the result.
-      var result = this.renderTokens(this.parse(value), context, partials, value);
-      // Don't leak the block variables outside this include.
+    // Ignore any wrongly set block vars before we started.
       context.clearBlockVars();
-      return result;
+    // We are only rendering to record the default block variables.
+    this.renderTokens(token[4], context, partials, originalTemplate);
+    // Now we render and return the result.
+    var result = this.renderTokens(this.parse(value), context, partials, value);
+    // Don't leak the block variables outside this include.
+    context.clearBlockVars();
+    return result;
   };
 
   Writer.prototype.renderBlockVariable = function renderBlockVariable (token, context, partials, originalTemplate) {
   };
 
   mustache.name = 'mustache.js';
-  mustache.version = '3.0.1';
+  mustache.version = '3.1.0';
   mustache.tags = [ '{{', '}}' ];
 
   // All high-level mustache.* functions use this writer.
index 2e1e042..bd3e8af 100644 (file)
@@ -43,6 +43,9 @@ abstract class adhoc_task extends task_base {
     /** @var integer|null $userid - Adhoc tasks may choose to run as a specific user. */
     private $userid = null;
 
+    /** @var \core\lock\lock The concurrency task lock for this task. */
+    private $concurrencylock = null;
+
     /**
      * Setter for $id.
      * @param int|null $id
@@ -107,4 +110,51 @@ abstract class adhoc_task extends task_base {
         $this->userid = $userid;
     }
 
+    /**
+     * Returns default concurrency limit for this task.
+     *
+     * @return int default concurrency limit
+     */
+    protected function get_default_concurrency_limit(): int {
+        global $CFG;
+
+        if (isset($CFG->task_concurrency_limit_default)) {
+            return (int) $CFG->task_concurrency_limit_default;
+        }
+        return 0;
+    }
+
+    /**
+     * Returns effective concurrency limit for this task.
+     *
+     * @return int effective concurrency limit for this task
+     */
+    final public function get_concurrency_limit(): int {
+        global $CFG;
+
+        $classname = get_class($this);
+
+        if (isset($CFG->task_concurrency_limit[$classname])) {
+            return (int) $CFG->task_concurrency_limit[$classname];
+        }
+        return $this->get_default_concurrency_limit();
+    }
+
+    /**
+     * Sets concurrency task lock.
+     *
+     * @param   \core\lock\lock $lock concurrency lock to be set
+     */
+    final public function set_concurrency_lock(\core\lock\lock $lock): void {
+        $this->concurrencylock = $lock;
+    }
+
+    /**
+     * Release the concurrency lock for this task type.
+     */
+    final public function release_concurrency_lock(): void {
+        if ($this->concurrencylock) {
+            $this->concurrencylock->release();
+        }
+    }
 }
index 091974b..4c573e8 100644 (file)
@@ -552,9 +552,10 @@ class manager {
      * {@link adhoc_task_failed} or {@link adhoc_task_complete} to release the lock and reschedule the task.
      *
      * @param int $timestart
+     * @param bool $checklimits Should we check limits?
      * @return \core\task\adhoc_task or null if not found
      */
-    public static function get_next_adhoc_task($timestart) {
+    public static function get_next_adhoc_task($timestart, $checklimits = true) {
         global $DB;
 
         $where = '(nextruntime IS NULL OR nextruntime < :timestart1)';
@@ -568,10 +569,16 @@ class manager {
             throw new \moodle_exception('locktimeout');
         }
 
+        $skipclasses = array();
+
         foreach ($records as $record) {
 
+            if (in_array($record->classname, $skipclasses)) {
+                // Skip the task if it can't be started due to per-task concurrency limit.
+                continue;
+            }
+
             if ($lock = $cronlockfactory->get_lock('adhoc_' . $record->id, 0)) {
-                $classname = '\\' . $record->classname;
 
                 // Safety check, see if the task has been already processed by another cron run.
                 $record = $DB->get_record('task_adhoc', array('id' => $record->id));
@@ -587,6 +594,19 @@ class manager {
                     continue;
                 }
 
+                $tasklimit = $task->get_concurrency_limit();
+                if ($checklimits && $tasklimit > 0) {
+                    if ($concurrencylock = self::get_concurrent_task_lock($task)) {
+                        $task->set_concurrency_lock($concurrencylock);
+                    } else {
+                        // Unable to obtain a concurrency lock.
+                        mtrace("Skipping $record->classname adhoc task class as the per-task limit of $tasklimit is reached.");
+                        $skipclasses[] = $record->classname;
+                        $lock->release();
+                        continue;
+                    }
+                }
+
                 $task->set_lock($lock);
                 if (!$task->is_blocking()) {
                     $cronlock->release();
@@ -691,13 +711,13 @@ class manager {
             $delay = 86400;
         }
 
-        $classname = self::get_canonical_class_name($task);
-
+        // Reschedule and then release the locks.
         $task->set_next_run_time(time() + $delay);
         $task->set_fail_delay($delay);
         $record = self::record_from_adhoc_task($task);
         $DB->update_record('task_adhoc', $record);
 
+        $task->release_concurrency_lock();
         if ($task->is_blocking()) {
             $task->get_cron_lock()->release();
         }
@@ -721,7 +741,8 @@ class manager {
         // Delete the adhoc task record - it is finished.
         $DB->delete_records('task_adhoc', array('id' => $task->get_id()));
 
-        // Reschedule and then release the locks.
+        // Release the locks.
+        $task->release_concurrency_lock();
         if ($task->is_blocking()) {
             $task->get_cron_lock()->release();
         }
@@ -858,4 +879,24 @@ class manager {
         }
         return $classname;
     }
+
+    /**
+     * Gets the concurrent lock required to run an adhoc task.
+     *
+     * @param   adhoc_task $task The task to obtain the lock for
+     * @return  \core\lock\lock The lock if one was obtained successfully
+     * @throws  \coding_exception
+     */
+    protected static function get_concurrent_task_lock(adhoc_task $task): ?\core\lock\lock {
+        $adhoclock = null;
+        $cronlockfactory = \core\lock\lock_config::get_lock_factory(get_class($task));
+
+        for ($run = 0; $run < $task->get_concurrency_limit(); $run++) {
+            if ($adhoclock = $cronlockfactory->get_lock("concurrent_run_{$run}", 0)) {
+                return $adhoclock;
+            }
+        }
+
+        return null;
+    }
 }
index db0193f..4ad08cb 100644 (file)
@@ -168,7 +168,7 @@ function cron_run_adhoc_tasks(int $timenow, $keepalive = 0, $checklimits = true)
             break;
         }
 
-        $task = \core\task\manager::get_next_adhoc_task(time());
+        $task = \core\task\manager::get_next_adhoc_task(time(), $checklimits);
 
         if ($task) {
             if ($waiting) {
index 645728a..cc81222 100644 (file)
@@ -44,7 +44,7 @@ class editor_form extends moodleform {
      */
     protected function definition() {
         $mform = $this->_form;
-        $editoroptions = $this->_customdata['editoroptions'];
+        $editoroptions = $this->_customdata['editoroptions'] ?? null;
 
         // Add header.
         $mform->addElement('header', 'myheader', 'Editor in Moodle form');
index 3dcebbf..c8e98f5 100644 (file)
@@ -2219,20 +2219,20 @@ function readfile_accel($file, $mimetype, $accelerate) {
             if ($ranges) {
                 if (is_object($file)) {
                     $handle = $file->get_content_file_handle();
+                    if ($handle === false) {
+                        throw new file_exception('storedfilecannotreadfile', $file->get_filename());
+                    }
                 } else {
                     $handle = fopen($file, 'rb');
+                    if ($handle === false) {
+                        throw new file_exception('cannotopenfile', $file);
+                    }
                 }
                 byteserving_send_file($handle, $mimetype, $ranges, $filesize);
             }
         }
     }
 
-    header('Content-Length: '.$filesize);
-
-    if (!empty($_SERVER['REQUEST_METHOD']) and $_SERVER['REQUEST_METHOD'] === 'HEAD') {
-        exit;
-    }
-
     if ($filesize > 10000000) {
         // for large files try to flush and close all buffers to conserve memory
         while(@ob_get_level()) {
@@ -2242,11 +2242,21 @@ function readfile_accel($file, $mimetype, $accelerate) {
         }
     }
 
+    // Send this header after we have flushed the buffers so that if we fail
+    // later can remove this because it wasn't sent.
+    header('Content-Length: ' . $filesize);
+
+    if (!empty($_SERVER['REQUEST_METHOD']) and $_SERVER['REQUEST_METHOD'] === 'HEAD') {
+        exit;
+    }
+
     // send the whole file content
     if (is_object($file)) {
         $file->readfile();
     } else {
-        readfile_allow_large($file, $filesize);
+        if (readfile_allow_large($file, $filesize) === false) {
+            throw new file_exception('cannotopenfile', $file);
+        }
     }
 }
 
index ae1d362..5015696 100644 (file)
@@ -63,7 +63,9 @@ abstract class file_system {
         } else {
             $path = $this->get_remote_path_from_storedfile($file);
         }
-        readfile_allow_large($path, $file->get_filesize());
+        if (readfile_allow_large($path, $file->get_filesize()) === false) {
+            throw new file_exception('storedfilecannotreadfile', $file->get_filename());
+        }
     }
 
     /**
@@ -416,11 +418,16 @@ abstract class file_system {
     protected function get_imageinfo_from_path($path) {
         $imageinfo = getimagesize($path);
 
+        if (!is_array($imageinfo)) {
+            return false; // Nothing to process, the file was not recognised as image by GD.
+        }
+
         $image = array(
                 'width'     => $imageinfo[0],
                 'height'    => $imageinfo[1],
                 'mimetype'  => image_type_to_mime_type($imageinfo[2]),
             );
+
         if (empty($image['width']) or empty($image['height']) or empty($image['mimetype'])) {
             // GD can not parse it, sorry.
             return false;
index d1c8eee..91ed768 100644 (file)
@@ -200,7 +200,7 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
                 break;
 
             case 'createElement':
-                if ($arg[2]['optional']) {
+                if (!empty($arg[2]['optional'])) {
                     $caller->disabledIf($arg[0], $arg[0] . '[enabled]');
                 }
                 $caller->setType($arg[0] . '[number]', PARAM_FLOAT);
index 9e4e3b0..9c70b5a 100644 (file)
@@ -221,6 +221,8 @@ class MoodleQuickForm_filetypes extends MoodleQuickForm_group {
      */
     public function validateSubmitValue($value) {
 
+        $value = $value ?? ['filetypes' => null]; // A null $value can arrive here. Coalesce, creating the default array.
+
         if (!$this->allowall) {
             // Assert that there is an actual list provided.
             $normalized = $this->util->normalize_file_types($value['filetypes']);
index 6bc4acf..586ce76 100644 (file)
@@ -11,3 +11,12 @@ unzip v2.12.0.zip
 cd mustache.php-2.12.0/
 mv src /path/to/moodle/lib/mustache/
 mv LICENSE /path/to/moodle/lib/mustache/
+
+Local changes:
+
+Note: All this changes need to be reviewed on every upgrade and, if they have
+been already applied upstream for the release being used, can be removed
+from the list. If still not available upstream, they will need to be re-applied.
+
+- MDL-67114: PHP 7.4 compatibility. Array operations on scalar value.
+  This corresponds to upstream https://github.com/bobthecow/mustache.php/pull/352
index c36a84a..0ec4192 100644 (file)
@@ -149,7 +149,7 @@ class Mustache_Parser
                 case Mustache_Tokenizer::T_BLOCK_VAR:
                     if ($this->pragmaBlocks) {
                         // BLOCKS pragma is enabled, let's do this!
-                        if ($parent[Mustache_Tokenizer::TYPE] === Mustache_Tokenizer::T_PARENT) {
+                        if (isset($parent) && $parent[Mustache_Tokenizer::TYPE] === Mustache_Tokenizer::T_PARENT) {
                             $token[Mustache_Tokenizer::TYPE] = Mustache_Tokenizer::T_BLOCK_ARG;
                         }
                         $this->clearStandaloneLines($nodes, $tokens);
@@ -275,7 +275,7 @@ class Mustache_Parser
      */
     private function checkIfTokenIsAllowedInParent($parent, array $token)
     {
-        if ($parent[Mustache_Tokenizer::TYPE] === Mustache_Tokenizer::T_PARENT) {
+        if (isset($parent) && $parent[Mustache_Tokenizer::TYPE] === Mustache_Tokenizer::T_PARENT) {
             throw new Mustache_Exception_SyntaxException('Illegal content in < parent tag', $token);
         }
     }
index 8f26740..75c5d67 100644 (file)
@@ -2664,11 +2664,15 @@ class Compiler
      * @param array   $value
      * @param boolean $inExp
      *
-     * @return array|\ScssPhp\ScssPhp\Node\Number
+     * @return null|array|\ScssPhp\ScssPhp\Node\Number
      */
     protected function reduce($value, $inExp = false)
     {
 
+        if (is_null($value)) {
+            return null;
+        }
+
         switch ($value[0]) {
             case Type::T_EXPRESSION:
                 list(, $op, $left, $right, $inParens) = $value;
index 592a066..f0b5311 100644 (file)
@@ -7,5 +7,14 @@ Import procedure:
 
 - Copy all the files from the folder 'src' this directory.
 - Copy the license file from the project root.
+- Review the local changes defined below, if any. Reapply
+  them if needed. If already available upstream, please remove
+  them from the list.
 
 Licensed under MIT, Copyright (c) 2015 Leaf Corcoran.
+
+Currenly using 1.0.2 plus these local changes:
+
+- MDL-67114 : Added basic compatibility with php 7.4. This corresponds to
+      upstream commit https://github.com/scssphp/scssphp/commit/66675c1553b7e9d7c480d8aaedbf7c72374647cf
+      that is available in scssphp >= 1.0.4
index f291a6d..4aa3b9a 100644 (file)
@@ -362,6 +362,10 @@ function default_exception_handler($ex) {
 
     $info = get_exception_info($ex);
 
+    // If we already tried to send the header remove it, the content length
+    // should be either empty or the length of the error page.
+    @header_remove('Content-Length');
+
     if (is_early_init($info->backtrace)) {
         echo bootstrap_renderer::early_error($info->message, $info->moreinfourl, $info->link, $info->backtrace, $info->debuginfo, $info->errorcode);
     } else {
index c5d7961..f90f238 100644 (file)
@@ -362,4 +362,38 @@ class core_adhoc_task_testcase extends advanced_testcase {
 
         $this->assertEquals($user->id, $task->get_userid());
     }
+
+    /**
+     * Test get_concurrency_limit() method to return 0 by default.
+     */
+    public function test_get_concurrency_limit() {
+        $this->resetAfterTest(true);
+        $task = new \core\task\adhoc_test_task();
+        $concurrencylimit = $task->get_concurrency_limit();
+        $this->assertEquals(0, $concurrencylimit);
+    }
+
+    /**
+     * Test get_concurrency_limit() method to return a default value set in config.
+     */
+    public function test_get_concurrency_limit_default() {
+        $this->resetAfterTest(true);
+        set_config('task_concurrency_limit_default', 10);
+        $task = new \core\task\adhoc_test_task();
+        $concurrencylimit = $task->get_concurrency_limit();
+        $this->assertEquals(10, $concurrencylimit);
+    }
+
+    /**
+     * Test get_concurrency_limit() method to return a value for specific task class.
+     */
+    public function test_get_concurrency_limit_for_task() {
+        global $CFG;
+        $this->resetAfterTest(true);
+        set_config('task_concurrency_limit_default', 10);
+        $CFG->task_concurrency_limit = array('core\task\adhoc_test_task' => 5);
+        $task = new \core\task\adhoc_test_task();
+        $concurrencylimit = $task->get_concurrency_limit();
+        $this->assertEquals(5, $concurrencylimit);
+    }
 }
diff --git a/lib/tests/fixtures/repeated_events.ics b/lib/tests/fixtures/repeated_events.ics
new file mode 100644 (file)
index 0000000..7157007
--- /dev/null
@@ -0,0 +1,34 @@
+BEGIN:VCALENDAR\r
+PRODID:QIS-LSF HIS eG\r
+VERSION:2.0\r
+BEGIN:VTIMEZONE\r
+TZID:Europe/Berlin\r
+X-LIC-LOCATION:Europe/Berlin\r
+BEGIN:DAYLIGHT\r
+TZOFFSETFROM:+0100\r
+TZOFFSETTO:+0200\r
+TZNAME:CEST\r
+DTSTART:19700329T020000\r
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3\r
+END:DAYLIGHT\r
+BEGIN:STANDARD\r
+TZOFFSETFROM:+0200\r
+TZOFFSETTO:+0100\r
+TZNAME:CET\r
+DTSTART:19701025T030000\r
+RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\r
+END:STANDARD\r
+END:VTIMEZONE\r
+METHOD:PUBLISH\r
+BEGIN:VEVENT\r
+DTSTART;TZID=Europe/Berlin:20191015T160000\r
+DTEND;TZID=Europe/Berlin:20191015T180000\r
+RRULE:FREQ=WEEKLY;UNTIL=20200211T235900Z;INTERVAL=1;BYDAY=TU\r
+LOCATION:O27 - 122\r
+DTSTAMP:20191002T151421Z\r
+UID:115808212972\r
+DESCRIPTION:\r
+SUMMARY:CS6307.000 - Introduction to Computer Science (for Non-Computer Scientists)\r
+CATEGORIES:Vorlesung/ Übung\r
+END:VEVENT\r
+END:VCALENDAR\r
index 0c71552..bbafd02 100644 (file)
@@ -835,7 +835,7 @@ class core_session_manager_testcase extends advanced_testcase {
         $SESSION->recentsessionlocks = $this->sessionlock_history();
 
         $page = \core\session\manager::get_locked_page_at($time);
-        $this->assertEquals($url, $page['url']);
+        $this->assertEquals($url, is_array($page) ? $page['url'] : null);
     }
 
     /**
index 42d5545..5630e29 100644 (file)
     <location>amd/src/loglevel.js</location>
     <name>loglevel.js</name>
     <license>MIT</license>
-    <version>1.6.2</version>
+    <version>1.6.6</version>
   </library>
   <library>
     <location>mustache</location>
     <location>amd/src/mustache.js</location>
     <name>Mustache.js</name>
     <license>MIT</license>
-    <version>3.0.1</version>
+    <version>3.1.0</version>
   </library>
   <library>
     <location>graphlib.php</location>
index 5271dc1..c6daf6f 100644 (file)
Binary files a/message/amd/build/message_drawer_view_overview_section.min.js and b/message/amd/build/message_drawer_view_overview_section.min.js differ
index 32de90f..1331bfd 100644 (file)
Binary files a/message/amd/build/message_drawer_view_overview_section.min.js.map and b/message/amd/build/message_drawer_view_overview_section.min.js.map differ
index 8fa7398..fed38be 100644 (file)
@@ -223,7 +223,10 @@ function(
                 // If that's not possible, we'll report it under the catch-all 'other media'.
                 var messagePreview = $(lastMessage.text).text();
                 if (messagePreview) {
-                    return messagePreview;
+                    // The text value of the message must have no html/script tags.
+                    if (messagePreview.indexOf('<') == -1) {
+                        return messagePreview;
+                    }
                 }
             }
 
index 4327cc0..af1ae2c 100644 (file)
@@ -295,6 +295,7 @@ class mod_feedback_responses_table extends table_sql {
         $columnscount = 0;
         $this->hasmorecolumns = max(0, count($items) - self::TABLEJOINLIMIT);
 
+        $headernamepostfix = !$this->is_downloading();
         // Add feedback response values.
         foreach ($items as $nr => $item) {
             if ($columnscount++ < self::TABLEJOINLIMIT) {
@@ -308,7 +309,7 @@ class mod_feedback_responses_table extends table_sql {
 
             $tablecolumns[] = "val{$nr}";
             $itemobj = feedback_get_item_class($item->typ);
-            $tableheaders[] = $itemobj->get_display_name($item);
+            $tableheaders[] = $itemobj->get_display_name($item, $headernamepostfix);
         }
 
         // Add 'Delete entry' column.
index 93f995b..31352c0 100644 (file)
@@ -147,6 +147,7 @@ class author extends exporter {
         $authorcontextid = $this->authorcontextid;
         $urlfactory = $this->related['urlfactory'];
         $context = $this->related['context'];
+        $forum = $this->related['forum'];
 
         if ($this->canview) {
             if ($author->is_deleted()) {
@@ -156,7 +157,7 @@ class author extends exporter {
                     'isdeleted' => true,
                     'groups' => [],
                     'urls' => [
-                        'profile' => ($urlfactory->get_author_profile_url($author))->out(false),
+                        'profile' => ($urlfactory->get_author_profile_url($author, $forum->get_course_id()))->out(false),
                         'profileimage' => ($urlfactory->get_author_profile_image_url($author, $authorcontextid))->out(false)
                     ]
                 ];
@@ -192,7 +193,7 @@ class author extends exporter {
                     'isdeleted' => false,
                     'groups' => $groups,
                     'urls' => [
-                        'profile' => ($urlfactory->get_author_profile_url($author))->out(false),
+                        'profile' => ($urlfactory->get_author_profile_url($author, $forum->get_course_id()))->out(false),
                         'profileimage' => ($urlfactory->get_author_profile_image_url($author, $authorcontextid))->out(false)
                     ]
                 ];
@@ -220,7 +221,8 @@ class author extends exporter {
     protected static function define_related() {
         return [
             'urlfactory' => 'mod_forum\local\factories\url',
-            'context' => 'context'
+            'context' => 'context',
+            'forum' => 'mod_forum\local\entities\forum',
         ];
     }
 }
index 4637cb9..13a075f 100644 (file)
@@ -140,6 +140,7 @@ class discussion_summary extends exporter {
         $related = [
             'urlfactory' => $this->related['urlfactory'],
             'context' => $this->related['forum']->get_context(),
+            'forum' => $forum,
         ];
 
         $firstpostauthor = new author(
index cfb6b4b..caae3d7 100644 (file)
@@ -394,11 +394,13 @@ class url {
      * Get the url to view an author's profile.
      *
      * @param author_entity $author The author
+     * @param int $courseid The course id
      * @return moodle_url
      */
-    public function get_author_profile_url(author_entity $author) : moodle_url {
+    public function get_author_profile_url(author_entity $author, int $courseid) : moodle_url {
         return new moodle_url('/user/view.php', [
-            'id' => $author->get_id()
+            'id' => $author->get_id(),
+            'course' => $courseid
         ]);
     }
 
index 6d80dab..23788fd 100644 (file)
@@ -1319,7 +1319,7 @@ class mod_forum_external extends external_api {
             $preferredformat = editors_get_preferred_format();
             // If the post is not HTML and the preferred format is HTML, convert to it.
             if ($params['messageformat'] != FORMAT_HTML and $preferredformat == FORMAT_HTML) {
-                $params['message'] = format_text($params['message'], $params['messageformat'], ['context' => $context]);
+                $params['message'] = format_text($params['message'], $params['messageformat'], ['filter' => false]);
             }
             $params['messageformat'] = $preferredformat;
         }
index 3b5dc57..7e85c10 100644 (file)
@@ -63,7 +63,8 @@ class mod_forum_exporters_author_testcase extends advanced_testcase {
 
         $exporter = new author_exporter($author, 1, [], true, [
             'urlfactory' => \mod_forum\local\container::get_url_factory(),
-            'context' => $context
+            'context' => $context,
+            'forum' => $forum,
         ]);
 
         $exportedauthor = $exporter->export($renderer);
@@ -104,7 +105,8 @@ class mod_forum_exporters_author_testcase extends advanced_testcase {
 
         $exporter = new author_exporter($author, 1, [$group], true, [
             'urlfactory' => \mod_forum\local\container::get_url_factory(),
-            'context' => $context
+            'context' => $context,
+            'forum' => $forum,
         ]);
 
         $exportedauthor = $exporter->export($renderer);
@@ -142,7 +144,8 @@ class mod_forum_exporters_author_testcase extends advanced_testcase {
 
         $exporter = new author_exporter($author, 1, [$group], false, [
             'urlfactory' => \mod_forum\local\container::get_url_factory(),
-            'context' => $context
+            'context' => $context,
+            'forum' => $forum,
         ]);
 
         $exportedauthor = $exporter->export($renderer);
index 1e3878d..3ba370a 100644 (file)
@@ -527,7 +527,7 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
      * Tests is similar to the get_forum_discussion_posts only utilizing the new return structure and entities
      */
     public function test_mod_forum_get_discussion_posts() {
-        global $CFG, $PAGE;
+        global $CFG;
 
         $this->resetAfterTest(true);
 
@@ -538,6 +538,9 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
         $legacyfactory = mod_forum\local\container::get_legacy_data_mapper_factory();
         $entityfactory = mod_forum\local\container::get_entity_factory();
 
+        // Create course to add the module.
+        $course1 = self::getDataGenerator()->create_course();
+
         // Create a user who can track forums.
         $record = new stdClass();
         $record->trackforums = true;
@@ -551,7 +554,7 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
             'isdeleted' => false,
             'groups' => [],
             'urls' => [
-                'profile' => $urlfactory->get_author_profile_url($user2entity),
+                'profile' => $urlfactory->get_author_profile_url($user2entity, $course1->id)->out(false),
                 'profileimage' => $urlfactory->get_author_profile_image_url($user2entity),
             ]
         ];
@@ -565,7 +568,7 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
             'groups' => [],
             'isdeleted' => false,
             'urls' => [
-                'profile' => $urlfactory->get_author_profile_url($user3entity),
+                'profile' => $urlfactory->get_author_profile_url($user3entity, $course1->id)->out(false),
                 'profileimage' => $urlfactory->get_author_profile_image_url($user3entity),
             ]
         ];
@@ -575,9 +578,6 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
         // Set the first created user to the test user.
         self::setUser($user1);
 
-        // Create course to add the module.
-        $course1 = self::getDataGenerator()->create_course();
-
         // Forum with tracking off.
         $record = new stdClass();
         $record->course = $course1->id;
@@ -652,7 +652,7 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
             'groups' => [],
             'isdeleted' => true,
             'urls' => [
-                'profile' => $urlfactory->get_author_profile_url($user3entity),
+                'profile' => $urlfactory->get_author_profile_url($user3entity, $course1->id)->out(false),
                 'profileimage' => $urlfactory->get_author_profile_image_url($user3entity),
             ]
         ];
@@ -2618,6 +2618,9 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
         $legacydatamapper = mod_forum\local\container::get_legacy_data_mapper_factory();
         $legacypostmapper = $legacydatamapper->get_post_data_mapper();
 
+        // Create course to add the module.
+        $course1 = self::getDataGenerator()->create_course();
+
         $user1 = self::getDataGenerator()->create_user();
         $user1entity = $entityfactory->get_author_from_stdclass($user1);
         $exporteduser1 = [
@@ -2625,7 +2628,7 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
             'fullname' => fullname($user1),
             'groups' => [],
             'urls' => [
-                'profile' => $urlfactory->get_author_profile_url($user1entity),
+                'profile' => $urlfactory->get_author_profile_url($user1entity, $course1->id)->out(false),
                 'profileimage' => $urlfactory->get_author_profile_image_url($user1entity),
             ],
             'isdeleted' => false,
@@ -2638,7 +2641,7 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
             'fullname' => fullname($user2),
             'groups' => [],
             'urls' => [
-                'profile' => $urlfactory->get_author_profile_url($user2entity),
+                'profile' => $urlfactory->get_author_profile_url($user2entity, $course1->id)->out(false),
                 'profileimage' => $urlfactory->get_author_profile_image_url($user2entity),
             ],
             'isdeleted' => false,
@@ -2650,9 +2653,6 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
         // Set the first created user to the test user.
         self::setUser($user1);
 
-        // Create course to add the module.
-        $course1 = self::getDataGenerator()->create_course();
-
         // Forum with tracking off.
         $record = new stdClass();
         $record->course = $course1->id;
index 2f37a14..22255c1 100644 (file)
@@ -10,7 +10,7 @@ class mod_glossary_import_form extends moodleform {
     function definition() {
         global $CFG;
         $mform =& $this->_form;
-        $cmid = $this->_customdata['id'];
+        $cmid = $this->_customdata['id'] ?? null;
 
         $mform->addElement('filepicker', 'file', get_string('filetoimport', 'glossary'));
         $mform->addHelpButton('file', 'filetoimport', 'glossary');
index fe53834..c8769f9 100644 (file)
@@ -10,8 +10,8 @@ class mod_wiki_comments_form extends moodleform {
     protected function definition() {
         $mform = $this->_form;
 
-        $current = $this->_customdata['current'];
-        $commentoptions = $this->_customdata['commentoptions'];
+        $current = $this->_customdata['current'] ?? null;
+        $commentoptions = $this->_customdata['commentoptions'] ?? null;
 
         // visible elements
         $mform->addElement('editor', 'entrycomment_editor', get_string('comment', 'glossary'), null, $commentoptions);
index 07a8df5..4fefa00 100644 (file)
@@ -38,17 +38,17 @@ class wiki_parser_proxy {
             return false;
         }
     }
-    
+
     public static function get_section(&$string, $type, $section, $all_content = false) {
         if(self::create_parser_instance($type)) {
             $content = self::$parsers[$type]->get_section($section, $string, true);
-            
+
             if($all_content) {
-               return $content;
+                return $content;
             }
             else {
-               return $content[1];
-               }
+                return is_array($content) ? $content[1] : null;
+            }
         }
         else {
             return false;
@@ -84,17 +84,17 @@ abstract class generic_parser {
     private $rulestack = array();
 
     protected $parser_status = 'Before';
-    
+
     /**
      * Dynamic return values
      */
-     
+
     protected $returnvalues = array();
 
     private $nowikiindex = array();
 
     protected $nowikitoken = "%!";
-    
+
     public function __construct() {}
 
     /**
@@ -115,7 +115,7 @@ abstract class generic_parser {
         if(method_exists($this, 'before_parsing')) {
             $this->before_parsing();
         }
-        
+
         $this->parser_status = 'Parsing';
 
         foreach($this->blockrules as $name => $block) {
@@ -129,7 +129,7 @@ abstract class generic_parser {
         if(method_exists($this, 'after_parsing')) {
             $this->after_parsing();
         }
-        
+
         return array('parsed_text' => $this->string) + $this->returnvalues;
     }
 
@@ -220,7 +220,7 @@ abstract class generic_parser {
             else {
                 $replace = parser_utils::h($rule['tag'], "$1");
             }
-            
+
             $text = preg_replace($rule['expression'], $replace, $text);
         }
     }
@@ -277,5 +277,5 @@ abstract class generic_parser {
          }
 
          return false;
-     }     
+     }
 }
index 606c30b..061df4a 100644 (file)
@@ -701,7 +701,7 @@ class question_usage_by_activity {
             // Behaviour vars should not be processed by question type, just add prefix.
             $behaviourvars = $this->get_question_attempt($slot)->get_behaviour()->get_expected_data();
             foreach (array_keys($responsedata) as $responsedatakey) {
-                if ($responsedatakey[0] === '-') {
+                if (is_string($responsedatakey) && $responsedatakey[0] === '-') {
                     $behaviourvarname = substr($responsedatakey, 1);
                     if (isset($behaviourvars[$behaviourvarname])) {
                         // Expected behaviour var found.
index 81724b5..dceee7d 100644 (file)
@@ -181,7 +181,7 @@ class dropbox extends \oauth2_client {
      * @throws  moodle_exception
      */
     protected function check_and_handle_api_errors($data) {
-        if ($this->info['http_code'] == 200) {
+        if (!is_array($this->info) or $this->info['http_code'] == 200) {
             // Dropbox only returns errors on non-200 response codes.
             return;
         }
index 8008038..2b710fc 100644 (file)
Binary files a/theme/boost/amd/build/loader.min.js and b/theme/boost/amd/build/loader.min.js differ
index 89b4410..f3c1ad4 100644 (file)
Binary files a/theme/boost/amd/build/loader.min.js.map and b/theme/boost/amd/build/loader.min.js.map differ
index ecc0d12..a81bc8d 100644 (file)
@@ -55,7 +55,8 @@ define(['jquery', './tether', 'core/event', 'core/custom_interaction_events'], f
             customEvents.events.escape,
         ]);
         jQuery('body').on(customEvents.events.escape, '[data-toggle=popover]', function() {
-            jQuery(this).popover('hide');
+            // Use "blur" instead of "popover('hide')" to prevent issue that the same tooltip can't be opened again.
+            jQuery(this).trigger('blur');
         });
 
         jQuery("html").popover({