Merge branch 'MDL-57636_master' of https://github.com/dasistwas/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Mon, 7 Jan 2019 05:45:04 +0000 (13:45 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Mon, 7 Jan 2019 05:45:04 +0000 (13:45 +0800)
lib/amd/build/str.min.js
lib/amd/build/templates.min.js
lib/amd/src/str.js
lib/amd/src/templates.js
lib/classes/output/external.php
lib/classes/output/mustache_template_source_loader.php [new file with mode: 0644]
lib/db/services.php
lib/tests/mustache_template_source_loader_test.php [new file with mode: 0644]
lib/tests/output_external_test.php [deleted file]
mod/forum/lib.php
version.php

index d357c17..9e03a10 100644 (file)
Binary files a/lib/amd/build/str.min.js and b/lib/amd/build/str.min.js differ
index 3357ba4..b5be0b1 100644 (file)
Binary files a/lib/amd/build/templates.min.js and b/lib/amd/build/templates.min.js differ
index eac5c54..286fb48 100644 (file)
@@ -176,6 +176,42 @@ define(['jquery', 'core/ajax', 'core/localstorage'], function($, ajax, storage)
             }
 
             return deferred.promise();
+        },
+        /**
+         * Add a list of strings to the caches.
+         *
+         * @method cache_strings
+         * @param {Object[]} strings Array of { key: key, component: component, lang: lang, value: value }
+         */
+         // eslint-disable-next-line camelcase
+        cache_strings: function(strings) {
+            var defaultLang = $('html').attr('lang').replace(/-/g, '_');
+            strings.forEach(function(string) {
+                var lang = !(lang in string) ? defaultLang : string.lang;
+                var key = string.key;
+                var component = string.component;
+                var value = string.value;
+                var cacheKey = ['core_str', key, component, lang].join('/');
+
+                // Check M.str caching.
+                if (!(component in M.str) || !(key in M.str[component])) {
+                    if (!(component in M.str)) {
+                        M.str[component] = {};
+                    }
+
+                    M.str[component][key] = value;
+                }
+
+                // Check local storage.
+                if (!storage.get(cacheKey)) {
+                    storage.set(cacheKey, value);
+                }
+
+                // Check the promises cache.
+                if (!(cacheKey in promiseCache)) {
+                    promiseCache[cacheKey] = $.Deferred().resolve(value).promise();
+                }
+            });
         }
     };
 });
index 514f210..748cea5 100644 (file)
@@ -59,6 +59,191 @@ define([
     /** @var {Object} iconSystem - Object extending core/iconsystem */
     var iconSystem = {};
 
+    /** @var {Object[]} loadTemplateBuffer - List of templates to be loaded */
+    var loadTemplateBuffer = [];
+
+    /** @var {Bool} isLoadingTemplates - Whether templates are currently being loaded */
+    var isLoadingTemplates = false;
+
+    /**
+     * Search the various caches for a template promise for the given search key.
+     * The search key should be in the format <theme>/<component>/<template> e.g. boost/core/modal.
+     *
+     * If the template is found in any of the caches it will populate the other caches with
+     * the same data as well.
+     *
+     * @param {String} searchKey The template search key in the format <theme>/<component>/<template> e.g. boost/core/modal
+     * @return {Object} jQuery promise resolved with the template source
+     */
+    var getTemplatePromiseFromCache = function(searchKey) {
+        // First try the cache of promises.
+        if (searchKey in templatePromises) {
+            return templatePromises[searchKey];
+        }
+
+        // Check the module cache.
+        if (searchKey in templateCache) {
+            // Add this to the promises cache for future.
+            templatePromises[searchKey] = $.Deferred().resolve(templateCache[searchKey]).promise();
+            return templatePromises[searchKey];
+        }
+
+        // Now try local storage.
+        var cached = storage.get('core_template/' + searchKey);
+        if (cached) {
+            // Add this to the module cache for future.
+            templateCache[searchKey] = cached;
+            // Add this to the promises cache for future.
+            templatePromises[searchKey] = $.Deferred().resolve(cached).promise();
+            return templatePromises[searchKey];
+        }
+
+        return null;
+    };
+
+    /**
+     * Take all of the templates waiting in the buffer and load them from the server
+     * or from the cache.
+     *
+     * All of the templates that need to be loaded from the server will be batched up
+     * and sent in a single network request.
+     */
+    var processLoadTemplateBuffer = function() {
+        if (!loadTemplateBuffer.length) {
+            return;
+        }
+
+        if (isLoadingTemplates) {
+            return;
+        }
+
+        isLoadingTemplates = true;
+        // Grab any templates waiting in the buffer.
+        var templatesToLoad = loadTemplateBuffer.slice();
+        // This will be resolved with the list of promises for the server request.
+        var serverRequestsDeferred = $.Deferred();
+        var requests = [];
+        // Get a list of promises for each of the templates we need to load.
+        var templatePromises = templatesToLoad.map(function(templateData) {
+            var component = templateData.component;
+            var name = templateData.name;
+            var searchKey = templateData.searchKey;
+            var theme = templateData.theme;
+            var templateDeferred = templateData.deferred;
+            var promise = null;
+
+            // Double check to see if this template happened to have landed in the
+            // cache as a dependency of an earlier template.
+            var cachedPromise = getTemplatePromiseFromCache(searchKey);
+            if (cachedPromise) {
+                // We've seen this template so immediately resolve the existing promise.
+                promise = cachedPromise;
+            } else {
+                // We haven't seen this template yet so we need to request it from
+                // the server.
+                requests.push({
+                    methodname: 'core_output_load_template_with_dependencies',
+                    args: {
+                        component: component,
+                        template: name,
+                        themename: theme
+                    }
+                });
+                // Remember the index in the requests list for this template so that
+                // we can get the appropriate promise back.
+                var index = requests.length - 1;
+
+                // The server deferred will be resolved with a list of all of the promises
+                // that were sent in the order that they were added to the requests array.
+                promise = serverRequestsDeferred.promise()
+                    .then(function(promises) {
+                        // The promise for this template will be the one that matches the index
+                        // for it's entry in the requests array.
+                        //
+                        // Make sure the promise is added to the promises cache for this template
+                        // search key so that we don't request it again.
+                        templatePromises[searchKey] = promises[index].then(function(response) {
+                            var templateSource = null;
+
+                            // Process all of the template dependencies for this template and add
+                            // them to the caches so that we don't request them again later.
+                            response.templates.forEach(function(data) {
+                                // Generate the search key for this template in the response so that we
+                                // can add it to the caches.
+                                var tempSearchKey = [theme, data.component, data.name].join('/');
+                                // Cache all of the dependent templates because we'll need them to render
+                                // the requested template.
+                                templateCache[tempSearchKey] = data.value;
+                                storage.set('core_template/' + tempSearchKey, data.value);
+
+                                if (data.component == component && data.name == name) {
+                                    // This is the original template that was requested so remember it to return.
+                                    templateSource = data.value;
+                                }
+                            });
+
+                            if (response.strings.length) {
+                                // If we have strings that the template needs then warm the string cache
+                                // with them now so that we don't need to re-fetch them.
+                                str.cache_strings(response.strings.map(function(data) {
+                                    return {
+                                        component: data.component,
+                                        key: data.name,
+                                        value: data.value
+                                    };
+                                }));
+                            }
+
+                            // Return the original template source that the user requested.
+                            return templateSource;
+                        });
+
+                        return templatePromises[searchKey];
+                    });
+            }
+
+            return promise
+                .then(function(source) {
+                    // When we've successfully loaded the template then resolve the deferred
+                    // in the buffer so that all of the calling code can proceed.
+                    return templateDeferred.resolve(source);
+                })
+                .catch(function(error) {
+                    // If there was an error loading the template then reject the deferred
+                    // in the buffer so that all of the calling code can proceed.
+                    templateDeferred.reject(error);
+                    // Rethrow for anyone else listening.
+                    throw error;
+                });
+        });
+
+        if (requests.length) {
+            // We have requests to send so resolve the deferred with the promises.
+            serverRequestsDeferred.resolve(ajax.call(requests, true, false));
+        } else {
+            // Nothing to load so we can resolve our deferred.
+            serverRequestsDeferred.resolve();
+        }
+
+        // Once we've finished loading all of the templates then recurse to process
+        // any templates that may have been added to the buffer in the time that we
+        // were fetching.
+        $.when.apply(null, templatePromises)
+            .then(function() {
+                // Remove the templates we've loaded from the buffer.
+                loadTemplateBuffer.splice(0, templatesToLoad.length);
+                isLoadingTemplates = false;
+                processLoadTemplateBuffer();
+                return;
+            })
+            .catch(function() {
+                // Remove the templates we've loaded from the buffer.
+                loadTemplateBuffer.splice(0, templatesToLoad.length);
+                isLoadingTemplates = false;
+                processLoadTemplateBuffer();
+            });
+    };
+
     /**
      * Constructor
      *
@@ -85,7 +270,7 @@ define([
     Renderer.prototype.currentThemeName = '';
 
     /**
-     * Load a template from the cache or local storage or ajax request.
+     * Load a template.
      *
      * @method getTemplate
      * @private
@@ -95,44 +280,44 @@ define([
      * @return {Promise} JQuery promise object resolved when the template has been fetched.
      */
     Renderer.prototype.getTemplate = function(templateName) {
-        var parts = templateName.split('/');
-        var component = parts.shift();
-        var name = parts.shift();
-
-        var searchKey = this.currentThemeName + '/' + templateName;
+        var currentTheme = this.currentThemeName;
+        var searchKey = currentTheme + '/' + templateName;
 
-        // First try request variables.
-        if (searchKey in templatePromises) {
-            return templatePromises[searchKey];
+        // If we haven't already seen this template then buffer it.
+        var cachedPromise = getTemplatePromiseFromCache(searchKey);
+        if (cachedPromise) {
+            return cachedPromise;
         }
 
-        // Now try local storage.
-        var cached = storage.get('core_template/' + searchKey);
-
-        if (cached) {
-            templateCache[searchKey] = cached;
-            templatePromises[searchKey] = $.Deferred().resolve(cached).promise();
-            return templatePromises[searchKey];
+        // Check the buffer to seee if this template has already been added.
+        var existingBufferRecords = loadTemplateBuffer.filter(function(record) {
+            return record.searchKey == searchKey;
+        });
+        if (existingBufferRecords.length) {
+            // This template is already in the buffer so just return the existing
+            // promise. No need to add it to the buffer again.
+            return existingBufferRecords[0].deferred.promise();
         }
 
-        // Oh well - load via ajax.
-        var promises = ajax.call([{
-            methodname: 'core_output_load_template',
-            args: {
-                component: component,
-                template: name,
-                themename: this.currentThemeName
-            }
-        }], true, false);
+        // This is the first time this has been requested so let's add it to the buffer
+        // to be loaded.
+        var parts = templateName.split('/');
+        var component = parts.shift();
+        var name = parts.shift();
+        var deferred = $.Deferred();
+
+        // Add this template to the buffer to be loaded.
+        loadTemplateBuffer.push({
+            component: component,
+            name: name,
+            theme: currentTheme,
+            searchKey: searchKey,
+            deferred: deferred
+        });
 
-        templatePromises[searchKey] = promises[0].then(
-            function(templateSource) {
-                templateCache[searchKey] = templateSource;
-                storage.set('core_template/' + searchKey, templateSource);
-                return templateSource;
-            }
-        );
-        return templatePromises[searchKey];
+        // We know there is at least one thing in the buffer so kick off a processing run.
+        processLoadTemplateBuffer();
+        return deferred.promise();
     };
 
     /**
index 509ec44..cdca30c 100644 (file)
@@ -58,15 +58,6 @@ class external extends external_api {
             );
     }
 
-    /**
-     * Remove comments from mustache template.
-     * @param string $templatestr
-     * @return mixed
-     */
-    protected static function strip_template_comments($templatestr) {
-        return preg_replace('/(?={{!)(.*)(}})/sU', '', $templatestr);
-    }
-
     /**
      * Return a mustache template, and all the strings it requires.
      *
@@ -84,23 +75,14 @@ class external extends external_api {
                                                   'themename' => $themename,
                                                   'includecomments' => $includecomments));
 
-        $component = $params['component'];
-        $template = $params['template'];
-        $themename = $params['themename'];
-        $includecomments = $params['includecomments'];
-
-        $templatename = $component . '/' . $template;
-
+        $loader = new mustache_template_source_loader();
         // Will throw exceptions if the template does not exist.
-        $filename = mustache_template_finder::get_template_filepath($templatename, $themename);
-        $templatestr = file_get_contents($filename);
-
-        // Remove comments from template.
-        if (!$includecomments) {
-            $templatestr = self::strip_template_comments($templatestr);
-        }
-
-        return $templatestr;
+        return $loader->load(
+            $params['component'],
+            $params['template'],
+            $params['themename'],
+            $params['includecomments']
+        );
     }
 
     /**
@@ -112,6 +94,95 @@ class external extends external_api {
         return new external_value(PARAM_RAW, 'template');
     }
 
+    /**
+     * Returns description of load_template_with_dependencies() parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function load_template_with_dependencies_parameters() {
+        return new external_function_parameters([
+            'component' => new external_value(PARAM_COMPONENT, 'component containing the template'),
+            'template' => new external_value(PARAM_ALPHANUMEXT, 'name of the template'),
+            'themename' => new external_value(PARAM_ALPHANUMEXT, 'The current theme.'),
+            'includecomments' => new external_value(PARAM_BOOL, 'Include comments or not', VALUE_DEFAULT, false)
+        ]);
+    }
+
+    /**
+     * Return a mustache template, and all the child templates and strings it requires.
+     *
+     * @param string $component The component that holds the template.
+     * @param string $template The name of the template.
+     * @param string $themename The name of the current theme.
+     * @param bool $includecomments Whether to strip comments from the template source.
+     * @return string the template
+     */
+    public static function load_template_with_dependencies(
+        string $component,
+        string $template,
+        string $themename,
+        bool $includecomments = false
+    ) {
+        global $DB, $CFG, $PAGE;
+
+        $params = self::validate_parameters(
+            self::load_template_with_dependencies_parameters(),
+            [
+                'component' => $component,
+                'template' => $template,
+                'themename' => $themename,
+                'includecomments' => $includecomments
+            ]
+        );
+
+        $loader = new mustache_template_source_loader();
+        // Will throw exceptions if the template does not exist.
+        $dependencies = $loader->load_with_dependencies(
+            $params['component'],
+            $params['template'],
+            $params['themename'],
+            $params['includecomments']
+        );
+        $formatdependencies = function($dependency) {
+            $results = [];
+            foreach ($dependency as $dependencycomponent => $dependencyvalues) {
+                foreach ($dependencyvalues as $dependencyname => $dependencyvalue) {
+                    array_push($results, [
+                        'component' => $dependencycomponent,
+                        'name' => $dependencyname,
+                        'value' => $dependencyvalue
+                    ]);
+                }
+            }
+            return $results;
+        };
+
+        // Now we have to unpack the dependencies into a format that can be returned
+        // by external functions (because they don't support dynamic keys).
+        return [
+            'templates' => $formatdependencies($dependencies['templates']),
+            'strings' => $formatdependencies($dependencies['strings'])
+        ];
+    }
+
+    /**
+     * Returns description of load_template_with_dependencies() result value.
+     *
+     * @return external_description
+     */
+    public static function load_template_with_dependencies_returns() {
+        $resourcestructure = new external_single_structure([
+            'component' => new external_value(PARAM_COMPONENT, 'component containing the resource'),
+            'name' => new external_value(PARAM_TEXT, 'name of the resource'),
+            'value' => new external_value(PARAM_RAW, 'resource value')
+        ]);
+
+        return new external_single_structure([
+            'templates' => new external_multiple_structure($resourcestructure),
+            'strings' => new external_multiple_structure($resourcestructure)
+        ]);
+    }
+
     /**
      * Returns description of load_icon_map() parameters.
      *
diff --git a/lib/classes/output/mustache_template_source_loader.php b/lib/classes/output/mustache_template_source_loader.php
new file mode 100644 (file)
index 0000000..6be1275
--- /dev/null
@@ -0,0 +1,342 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Load template source strings.
+ *
+ * @package    core
+ * @category   output
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+use \Mustache_Tokenizer;
+
+/**
+ * Load template source strings.
+ *
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mustache_template_source_loader {
+
+    /** @var $gettemplatesource Callback function to load the template source from full name */
+    private $gettemplatesource = null;
+
+    /**
+     * Constructor that takes a callback to allow the calling code to specify how to retrieve
+     * the source for a template name.
+     *
+     * If no callback is provided then default to the load from disk implementation.
+     *
+     * @param callable|null $gettemplatesource Callback to load template source by template name
+     */
+    public function __construct(callable $gettemplatesource = null) {
+        if ($gettemplatesource) {
+            // The calling code has specified a function for retrieving the template source
+            // code by name and theme.
+            $this->gettemplatesource = $gettemplatesource;
+        } else {
+            // By default we will pull the template from disk.
+            $this->gettemplatesource = function($component, $name, $themename) {
+                $fulltemplatename = $component . '/' . $name;
+                $filename = mustache_template_finder::get_template_filepath($fulltemplatename, $themename);
+                return file_get_contents($filename);
+            };
+        }
+    }
+
+    /**
+     * Remove comments from mustache template.
+     *
+     * @param string $templatestr
+     * @return string
+     */
+    protected function strip_template_comments($templatestr) : string {
+        return preg_replace('/(?={{!)(.*)(}})/sU', '', $templatestr);
+    }
+
+    /**
+     * Load the template source from the component and template name.
+     *
+     * @param string $component The moodle component (e.g. core_message)
+     * @param string $name The template name (e.g. message_drawer)
+     * @param string $themename The theme to load the template for (e.g. boost)
+     * @param bool $includecomments If the comments should be stripped from the source before returning
+     * @return string The template source
+     */
+    public function load(
+        string $component,
+        string $name,
+        string $themename,
+        bool $includecomments = false
+    ) : string {
+        // Get the template source from the callback.
+        $source = ($this->gettemplatesource)($component, $name, $themename);
+
+        // Remove comments from template.
+        if (!$includecomments) {
+            $source = $this->strip_template_comments($source);
+        }
+
+        return $source;
+    }
+
+    /**
+     * Load a template and some of the dependencies that will be needed in order to render
+     * the template.
+     *
+     * The current implementation will return all of the templates and all of the strings in
+     * each of those templates (excluding string substitutions).
+     *
+     * The return format is an array indexed with the dependency type (e.g. templates / strings) then
+     * the component (e.g. core_message), and then the id (e.g. message_drawer).
+     *
+     * For example:
+     * * We have 3 templates in core named foo, bar, and baz.
+     * * foo includes bar and bar includes baz.
+     * * foo uses the string 'home' from core
+     * * baz uses the string 'help' from core
+     *
+     * If we load the template foo this function would return:
+     * [
+     *      'templates' => [
+     *          'core' => [
+     *              'foo' => '... template source ...',
+     *              'bar' => '... template source ...',
+     *              'baz' => '... template source ...',
+     *          ]
+     *      ],
+     *      'strings' => [
+     *          'core' => [
+     *              'home' => 'Home',
+     *              'help' => 'Help'
+     *          ]
+     *      ]
+     * ]
+     *
+     * @param string $templatecomponent The moodle component (e.g. core_message)
+     * @param string $templatename The template name (e.g. message_drawer)
+     * @param string $themename The theme to load the template for (e.g. boost)
+     * @param bool $includecomments If the comments should be stripped from the source before returning
+     * @param array $seentemplates List of templates already processed / to be skipped.
+     * @param array $seenstrings List of strings already processed / to be skipped.
+     * @return array
+     */
+    public function load_with_dependencies(
+        string $templatecomponent,
+        string $templatename,
+        string $themename,
+        bool $includecomments = false,
+        array $seentemplates = [],
+        array $seenstrings = []
+    ) : array {
+        // Initialise the return values.
+        $templates = [];
+        $strings = [];
+        $templatecomponent = trim($templatecomponent);
+        $templatename = trim($templatename);
+        // Get the requested template source.
+        $templatesource = $this->load($templatecomponent, $templatename, $themename, $includecomments);
+        // This is a helper function to save a value in one of the result arrays (either $templates or $strings).
+        $save = function(array $results, array $seenlist, string $component, string $id, $value) {
+            if (!isset($results[$component])) {
+                // If the results list doesn't already contain this component then initialise it.
+                $results[$component] = [];
+            }
+
+            // Save the value.
+            $results[$component][$id] = $value;
+            // Record that this item has been processed.
+            array_push($seenlist, "$component/$id");
+            // Return the updated results and seen list.
+            return [$results, $seenlist];
+        };
+        // This is a helper function for processing a dependency. Does stuff like ignore duplicate processing,
+        // common result formatting etc.
+        $handler = function(array $dependency, array $ignorelist, callable $processcallback) {
+            foreach ($dependency as $component => $ids) {
+                foreach ($ids as $id) {
+                    $dependencyid = "$component/$id";
+                    if (array_search($dependencyid, $ignorelist) === false) {
+                        $processcallback($component, $id);
+                        // Add this to our ignore list now that we've processed it so that we don't
+                        // process it again.
+                        array_push($ignorelist, $dependencyid);
+                    }
+                }
+            }
+
+            return $ignorelist;
+        };
+
+        // Save this template as the first result in the $templates result array.
+        list($templates, $seentemplates) = $save($templates, $seentemplates, $templatecomponent, $templatename, $templatesource);
+
+        // Check the template for any dependencies that need to be loaded.
+        $dependencies = $this->scan_template_source_for_dependencies($templatesource);
+
+        // Load all of the lang strings that this template requires and add them to the
+        // returned values.
+        $seenstrings = $handler(
+            $dependencies['strings'],
+            $seenstrings,
+            // Include $strings and $seenstrings by reference so that their values can be updated
+            // outside of this anonymous function.
+            function($component, $id) use ($save, &$strings, &$seenstrings) {
+                $string = get_string($id, $component);
+                // Save the string in the $strings results array.
+                list($strings, $seenstrings) = $save($strings, $seenstrings, $component, $id, $string);
+            }
+        );
+
+        // Load any child templates that we've found in this template and add them to
+        // the return list of dependencies.
+        $seentemplates = $handler(
+            $dependencies['templates'],
+            $seentemplates,
+            // Include $strings, $seenstrings, $templates, and $seentemplates by reference so that their values can be updated
+            // outside of this anonymous function.
+            function($component, $id) use (
+                $themename,
+                $includecomments,
+                &$seentemplates,
+                &$seenstrings,
+                &$templates,
+                &$strings,
+                $save
+            ) {
+                // We haven't seen this template yet so load it and it's dependencies.
+                $subdependencies = $this->load_with_dependencies(
+                    $component,
+                    $id,
+                    $themename,
+                    $includecomments,
+                    $seentemplates,
+                    $seenstrings
+                );
+
+                foreach ($subdependencies['templates'] as $component => $ids) {
+                    foreach ($ids as $id => $value) {
+                        // Include the child themes in our results.
+                        list($templates, $seentemplates) = $save($templates, $seentemplates, $component, $id, $value);
+                    }
+                };
+
+                foreach ($subdependencies['strings'] as $component => $ids) {
+                    foreach ($ids as $id => $value) {
+                        // Include any strings that the child templates need in our results.
+                        list($strings, $seenstrings) = $save($strings, $seenstrings, $component, $id, $value);
+                    }
+                }
+            }
+        );
+
+        return [
+            'templates' => $templates,
+            'strings' => $strings
+        ];
+    }
+
+    /**
+     * Scan over a template source string and return a list of dependencies it requires.
+     * At the moment the list will only include other templates and strings.
+     *
+     * The return format is an array indexed with the dependency type (e.g. templates / strings) then
+     * the component (e.g. core_message) with it's value being an array of the items required
+     * in that component.
+     *
+     * For example:
+     * If we have a template foo that includes 2 templates, bar and baz, and also 2 strings
+     * 'home' and 'help' from the core component then the return value would look like:
+     *
+     * [
+     *      'templates' => [
+     *          'core' => ['foo', 'bar', 'baz']
+     *      ],
+     *      'strings' => [
+     *          'core' => ['home', 'help']
+     *      ]
+     * ]
+     *
+     * @param string $source The template source
+     * @return array
+     */
+    protected function scan_template_source_for_dependencies(string $source) : array {
+        $tokenizer = new Mustache_Tokenizer();
+        $tokens = $tokenizer->scan($source);
+        $templates = [];
+        $strings = [];
+        $addtodependencies = function($dependencies, $component, $id) {
+            $id = trim($id);
+            $component = trim($component);
+
+            if (!isset($dependencies[$component])) {
+                // Initialise the component if we haven't seen it before.
+                $dependencies[$component] = [];
+            }
+
+            // Add this id to the list of dependencies.
+            array_push($dependencies[$component], $id);
+
+            return $dependencies;
+        };
+
+        foreach ($tokens as $index => $token) {
+            $type = $token['type'];
+            $name = isset($token['name']) ? $token['name'] : null;
+
+            if ($name) {
+                switch ($type) {
+                    case Mustache_Tokenizer::T_PARTIAL:
+                        list($component, $id) = explode('/', $name);
+                        $templates = $addtodependencies($templates, $component, $id);
+                        break;
+                    case Mustache_Tokenizer::T_PARENT:
+                        list($component, $id) = explode('/', $name);
+                        $templates = $addtodependencies($templates, $component, $id);
+                        break;
+                    case Mustache_Tokenizer::T_SECTION:
+                        if ($name == 'str') {
+                            // The token that containts the string identifiers (key and component) should
+                            // immediately follow the #str token.
+                            $identifiertoken = isset($tokens[$index + 1]) ? $tokens[$index + 1] : null;
+
+                            if ($identifiertoken) {
+                                // The string identifier is the key and component comma separated.
+                                $identifierstring = $identifiertoken['value'];
+                                $parts = explode(',', $identifierstring);
+                                $id = $parts[0];
+                                // Default to 'core' for the component, if not specified.
+                                $component = isset($parts[1]) ? $parts[1] : 'core';
+                                $strings = $addtodependencies($strings, $component, $id);
+                            }
+                        }
+                        break;
+                }
+            }
+        }
+
+        return [
+            'templates' => $templates,
+            'strings' => $strings
+        ];
+    }
+}
index 6d930b8..066f226 100644 (file)
@@ -1396,6 +1396,14 @@ $functions = array(
         'loginrequired' => false,
         'ajax' => true,
     ),
+    'core_output_load_template_with_dependencies' => array(
+        'classname' => 'core\output\external',
+        'methodname' => 'load_template_with_dependencies',
+        'description' => 'Load a template and its dependencies for a renderable',
+        'type' => 'read',
+        'loginrequired' => false,
+        'ajax' => true,
+    ),
     'core_output_load_fontawesome_icon_map' => array(
         'classname' => 'core\output\external',
         'methodname' => 'load_fontawesome_icon_map',
diff --git a/lib/tests/mustache_template_source_loader_test.php b/lib/tests/mustache_template_source_loader_test.php
new file mode 100644 (file)
index 0000000..1ccac41
--- /dev/null
@@ -0,0 +1,445 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for lib/classes/output/mustache_template_source_loader.php
+ *
+ * @package   core
+ * @copyright 2018 Ryan Wyllie <ryan@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\output\mustache_template_source_loader;
+
+/**
+ * Unit tests for the Mustache source loader class.
+ *
+ * @package   core
+ * @copyright 2018 Ryan Wyllie <ryan@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_output_mustache_template_source_loader_testcase extends advanced_testcase {
+    /**
+     * Ensure that stripping comments from templates does not mutilate the template body.
+     */
+    public function test_strip_template_comments() {
+
+        $templatebody = <<<'TBD'
+        <h1>{{# str }} pluginname, mod_lemmings {{/ str }}</h1>
+        <div>{{test}}</div>
+        <div>{{{unescapedtest}}}</div>
+        {{#lemmings}}
+            <div>
+                <h2>{{name}}</h2>
+                {{> mod_lemmings/lemmingprofile }}
+                {{# pix }} t/edit, core, Edit Lemming {{/ pix }}
+            </div>
+        {{/lemmings}}
+        {{^lemmings}}Sorry, no lemmings today{{/lemmings}}
+        <div id="{{ uniqid }}-tab-container">
+            {{# tabheader }}
+                <ul role="tablist" class="nav nav-tabs">
+                    {{# iconlist }}
+                        {{# icons }}
+                            {{> core/pix_icon }}
+                        {{/ icons }}
+                    {{/ iconlist }}
+                </ul>
+            {{/ tabheader }}
+            {{# tabbody }}
+                <div class="tab-content">
+                    {{# tabcontent }}
+                        {{# tabs }}
+                            {{> core/notification_info}}
+                        {{/ tabs }}
+                    {{/ tabcontent }}
+                </div>
+            {{/ tabbody }}
+        </div>
+        {{#js}}
+            require(['jquery','core/tabs'], function($, tabs) {
+
+                var container = $("#{{ uniqid }}-tab-container");
+                tabs.create(container);
+            });
+        {{/js}}
+TBD;
+        $templatewithcomment = <<<TBC
+        {{!
+            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/>.
+        }}
+        {{!
+            @template mod_lemmings/lemmings
+
+            Lemmings template.
+
+            The purpose of this template is to render a lot of lemmings.
+
+            Classes required for JS:
+            * none
+
+            Data attributes required for JS:
+            * none
+
+            Context variables required for this template:
+            * attributes Array of name / value pairs.
+
+            Example context (json):
+            {
+                "lemmings": [
+                    { "name": "Lemmy Winks", "age" : 1, "size" : "big" },
+                    { "name": "Rocky", "age" : 2, "size" : "small" }
+                ]
+            }
+
+        }}
+        $templatebody
+        {{!
+            Here's some more comment text
+            Note, there is no need to test bracketed variables inside comments as gherkin does not support that!
+            See this issue: https://github.com/mustache/spec/issues/8
+        }}
+TBC;
+
+        $loader = new mustache_template_source_loader();
+        $actual = phpunit_util::call_internal_method(
+            $loader,
+            'strip_template_comments',
+            [$templatewithcomment],
+            \core\output\mustache_template_source_loader::class
+        );
+        $this->assertEquals(trim($templatebody), trim($actual));
+    }
+
+    /**
+     * Data provider for the test_load function.
+     */
+    public function test_load_test_cases() {
+        $cache = [
+            'core' => [
+                'test' => '{{! a comment }}The rest of the template'
+            ]
+        ];
+        $loader = $this->build_loader_from_static_cache($cache);
+
+        return [
+            'with comments' => [
+                'loader' => $loader,
+                'component' => 'core',
+                'name' => 'test',
+                'includecomments' => true,
+                'expected' => '{{! a comment }}The rest of the template'
+            ],
+            'without comments' => [
+                'loader' => $loader,
+                'component' => 'core',
+                'name' => 'test',
+                'includecomments' => false,
+                'expected' => 'The rest of the template'
+            ],
+        ];
+    }
+
+    /**
+     * Test the load function.
+     *
+     * @dataProvider test_load_test_cases()
+     * @param mustache_template_source_loader $loader The loader
+     * @param string $component The moodle component
+     * @param string $name The template name
+     * @param bool $includecomments Whether to strip comments
+     * @param string $expected The expected output
+     */
+    public function test_load($loader, $component, $name, $includecomments, $expected) {
+        $this->assertEquals($expected, $loader->load($component, $name, 'boost', $includecomments));
+    }
+
+    /**
+     * Data provider for the load_with_dependencies function.
+     */
+    public function test_load_with_dependencies_test_cases() {
+        // Create a bunch of templates that include one another in various ways. There is
+        // multiple instances of recursive inclusions to test that the code doensn't get
+        // stuck in an infinite loop.
+        $foo = '{{! a comment }}{{> core/bar }}{{< test/bop }}{{/ test/bop}}{{#str}} help, core {{/str}}';
+        $foo2 = '{{! a comment }}hello';
+        $bar = '{{! a comment }}{{> core/baz }}';
+        $baz = '{{! a comment }}{{#str}} hide, core {{/str}}';
+        $bop = '{{! a comment }}{{< test/bim }}{{/ test/bim }}{{> core/foo }}';
+        $bim = '{{! a comment }}{{< core/foo }}{{/ core/foo}}{{> test/foo }}';
+        $foonocomment = '{{> core/bar }}{{< test/bop }}{{/ test/bop}}{{#str}} help, core {{/str}}';
+        $foo2nocomment = 'hello';
+        $barnocomment = '{{> core/baz }}';
+        $baznocomment = '{{#str}} hide, core {{/str}}';
+        $bopnocomment = '{{< test/bim }}{{/ test/bim }}{{> core/foo }}';
+        $bimnocomment = '{{< core/foo }}{{/ core/foo}}{{> test/foo }}';
+        $cache = [
+            'core' => [
+                'foo' => $foo,
+                'bar' => $bar,
+                'baz' => $baz,
+            ],
+            'test' => [
+                'foo' => $foo2,
+                'bop' => $bop,
+                'bim' => $bim
+            ]
+        ];
+        $loader = $this->build_loader_from_static_cache($cache);
+
+        return [
+            'no template includes w comments' => [
+                'loader' => $loader,
+                'component' => 'test',
+                'name' => 'foo',
+                'includecomments' => true,
+                'expected' => [
+                    'templates' => [
+                        'test' => [
+                            'foo' => $foo2
+                        ]
+                    ],
+                    'strings' => []
+                ]
+            ],
+            'no template includes w/o comments' => [
+                'loader' => $loader,
+                'component' => 'test',
+                'name' => 'foo',
+                'includecomments' => false,
+                'expected' => [
+                    'templates' => [
+                        'test' => [
+                            'foo' => $foo2nocomment
+                        ]
+                    ],
+                    'strings' => []
+                ]
+            ],
+            'no template includes with string w comments' => [
+                'loader' => $loader,
+                'component' => 'core',
+                'name' => 'baz',
+                'includecomments' => true,
+                'expected' => [
+                    'templates' => [
+                        'core' => [
+                            'baz' => $baz
+                        ]
+                    ],
+                    'strings' => [
+                        'core' => [
+                            'hide' => 'Hide'
+                        ]
+                    ]
+                ]
+            ],
+            'no template includes with string w/o comments' => [
+                'loader' => $loader,
+                'component' => 'core',
+                'name' => 'baz',
+                'includecomments' => false,
+                'expected' => [
+                    'templates' => [
+                        'core' => [
+                            'baz' => $baznocomment
+                        ]
+                    ],
+                    'strings' => [
+                        'core' => [
+                            'hide' => 'Hide'
+                        ]
+                    ]
+                ]
+            ],
+            'full with comments' => [
+                'loader' => $loader,
+                'component' => 'core',
+                'name' => 'foo',
+                'includecomments' => true,
+                'expected' => [
+                    'templates' => [
+                        'core' => [
+                            'foo' => $foo,
+                            'bar' => $bar,
+                            'baz' => $baz
+                        ],
+                        'test' => [
+                            'foo' => $foo2,
+                            'bop' => $bop,
+                            'bim' => $bim
+                        ]
+                    ],
+                    'strings' => [
+                        'core' => [
+                            'help' => 'Help',
+                            'hide' => 'Hide'
+                        ]
+                    ]
+                ]
+            ],
+            'full without comments' => [
+                'loader' => $loader,
+                'component' => 'core',
+                'name' => 'foo',
+                'includecomments' => false,
+                'expected' => [
+                    'templates' => [
+                        'core' => [
+                            'foo' => $foonocomment,
+                            'bar' => $barnocomment,
+                            'baz' => $baznocomment
+                        ],
+                        'test' => [
+                            'foo' => $foo2nocomment,
+                            'bop' => $bopnocomment,
+                            'bim' => $bimnocomment
+                        ]
+                    ],
+                    'strings' => [
+                        'core' => [
+                            'help' => 'Help',
+                            'hide' => 'Hide'
+                        ]
+                    ]
+                ]
+            ]
+        ];
+    }
+
+    /**
+     * Test the load_with_dependencies function.
+     *
+     * @dataProvider test_load_with_dependencies_test_cases()
+     * @param mustache_template_source_loader $loader The loader
+     * @param string $component The moodle component
+     * @param string $name The template name
+     * @param bool $includecomments Whether to strip comments
+     * @param string $expected The expected output
+     */
+    public function test_load_with_dependencies($loader, $component, $name, $includecomments, $expected) {
+        $actual = $loader->load_with_dependencies($component, $name, 'boost', $includecomments);
+        $this->assertEquals($expected, $actual);
+    }
+    /**
+     * Data provider for the test_load function.
+     */
+    public function test_scan_template_source_for_dependencies_test_cases() {
+        $foo = '{{! a comment }}{{> core/bar }}{{< test/bop }}{{/ test/bop}}{{#str}} help, core {{/str}}';
+        $bar = '{{! a comment }}{{> core/baz }}';
+        $baz = '{{! a comment }}{{#str}} hide, core {{/str}}';
+        $bop = '{{! a comment }}hello';
+        $cache = [
+            'core' => [
+                'foo' => $foo,
+                'bar' => $bar,
+                'baz' => $baz,
+                'bop' => $bop
+            ]
+        ];
+        $loader = $this->build_loader_from_static_cache($cache);
+
+        return [
+            'single template include' => [
+                'loader' => $loader,
+                'source' => $bar,
+                'expected' => [
+                    'templates' => [
+                        'core' => ['baz']
+                    ],
+                    'strings' => []
+                ]
+            ],
+            'single string include' => [
+                'loader' => $loader,
+                'source' => $baz,
+                'expected' => [
+                    'templates' => [],
+                    'strings' => [
+                        'core' => ['hide']
+                    ]
+                ]
+            ],
+            'no include' => [
+                'loader' => $loader,
+                'source' => $bop,
+                'expected' => [
+                    'templates' => [],
+                    'strings' => []
+                ]
+            ],
+            'all include' => [
+                'loader' => $loader,
+                'source' => $foo,
+                'expected' => [
+                    'templates' => [
+                        'core' => ['bar'],
+                        'test' => ['bop']
+                    ],
+                    'strings' => [
+                        'core' => ['help']
+                    ]
+                ]
+            ],
+        ];
+    }
+
+    /**
+     * Test the scan_template_source_for_dependencies function.
+     *
+     * @dataProvider test_scan_template_source_for_dependencies_test_cases()
+     * @param mustache_template_source_loader $loader The loader
+     * @param string $source The template to test
+     * @param string $expected The expected output
+     */
+    public function test_scan_template_source_for_dependencies($loader, $source, $expected) {
+        $actual = phpunit_util::call_internal_method(
+            $loader,
+            'scan_template_source_for_dependencies',
+            [$source],
+            \core\output\mustache_template_source_loader::class
+        );
+        $this->assertEquals($expected, $actual);
+    }
+
+    /**
+     * Create an instance of mustache_template_source_loader which loads its templates
+     * from the given cache rather than disk.
+     *
+     * @param array $cache A cache of templates
+     * @return mustache_template_source_loader
+     */
+    private function build_loader_from_static_cache(array $cache) : mustache_template_source_loader {
+        return new mustache_template_source_loader(function($component, $name, $themename) use ($cache) {
+            return $cache[$component][$name];
+        });
+    }
+}
diff --git a/lib/tests/output_external_test.php b/lib/tests/output_external_test.php
deleted file mode 100644 (file)
index 35acb97..0000000
+++ /dev/null
@@ -1,147 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * Unit tests for lib/classes/output/external.php
- * @author    Guy Thomas <gthomas@moodlerooms.com>
- * @copyright Copyright (c) 2017 Blackboard Inc.
- * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-use core\output\external;
-
-require_once(__DIR__.'/../../lib/externallib.php');
-require_once(__DIR__.'/../../lib/mustache/src/Mustache/Tokenizer.php');
-require_once(__DIR__.'/../../lib/mustache/src/Mustache/Parser.php');
-
-/**
- * Class core_output_external_testcase - test \core\output\external class.
- * @package   core
- * @author    Guy Thomas <gthomas@moodlerooms.com>
- * @copyright Copyright (c) 2017 Blackboard Inc.
- * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class core_output_external_testcase extends base_testcase {
-
-    /**
-     * Ensure that stripping comments from templates does not mutilate the template body.
-     */
-    public function test_strip_template_comments() {
-
-        $templatebody = <<<'TBD'
-        <h1>{{# str }} pluginname, mod_lemmings {{/ str }}</h1>
-        <div>{{test}}</div>
-        <div>{{{unescapedtest}}}</div>
-        {{#lemmings}}
-            <div>
-                <h2>{{name}}</h2>
-                {{> mod_lemmings/lemmingprofile }}
-                {{# pix }} t/edit, core, Edit Lemming {{/ pix }}
-            </div>
-        {{/lemmings}}
-        {{^lemmings}}Sorry, no lemmings today{{/lemmings}}
-        <div id="{{ uniqid }}-tab-container">
-            {{# tabheader }}
-                <ul role="tablist" class="nav nav-tabs">
-                    {{# iconlist }}
-                        {{# icons }}
-                            {{> core/pix_icon }}
-                        {{/ icons }}
-                    {{/ iconlist }}
-                </ul>
-            {{/ tabheader }}
-            {{# tabbody }}
-                <div class="tab-content">
-                    {{# tabcontent }}
-                        {{# tabs }}
-                            {{> core/notification_info}}
-                        {{/ tabs }}
-                    {{/ tabcontent }}
-                </div>
-            {{/ tabbody }}
-        </div>
-        {{#js}}
-            require(['jquery','core/tabs'], function($, tabs) {
-
-                var container = $("#{{ uniqid }}-tab-container");
-                tabs.create(container);
-            });
-        {{/js}}
-TBD;
-        $templatewithcomment = <<<TBC
-        {{!
-            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/>.
-        }}
-        {{!
-            @template mod_lemmings/lemmings
-
-            Lemmings template.
-
-            The purpose of this template is to render a lot of lemmings.
-
-            Classes required for JS:
-            * none
-
-            Data attributes required for JS:
-            * none
-
-            Context variables required for this template:
-            * attributes Array of name / value pairs.
-
-            Example context (json):
-            {
-                "lemmings": [
-                    { "name": "Lemmy Winks", "age" : 1, "size" : "big" },
-                    { "name": "Rocky", "age" : 2, "size" : "small" }
-                ]
-            }
-
-        }}
-        $templatebody
-        {{!
-            Here's some more comment text
-            Note, there is no need to test bracketed variables inside comments as gherkin does not support that!
-            See this issue: https://github.com/mustache/spec/issues/8
-        }}
-TBC;
-
-        // Ensure that the template when stripped of comments just includes the body.
-        $stripped = phpunit_util::call_internal_method(null, 'strip_template_comments',
-                [$templatewithcomment], 'core\output\external');
-        $this->assertEquals(trim($templatebody), trim($stripped));
-
-        $tokenizer = new Mustache_Tokenizer();
-        $tokens = $tokenizer->scan($templatebody);
-        $parser = new Mustache_Parser();
-        $tree = $parser->parse($tokens);
-        $this->assertNotEmpty($tree);
-    }
-}
index aaf91fb..81bf60d 100644 (file)
@@ -657,6 +657,12 @@ function forum_cron() {
                     }
                 }
 
+                $coursecontext = context_course::instance($course->id);
+                if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $coursecontext, $userto->id)) {
+                    // The course is hidden and the user does not have access to it.
+                    continue;
+                }
+
                 // Don't send email if the forum is Q&A and the user has not posted.
                 // Initial topics are still mailed.
                 if ($forum->type == 'qanda' && !forum_get_user_posted_time($discussion->id, $userto->id) && $pid != $discussion->firstpost) {
index 98db871..498a8cc 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2018122000.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2018122000.01;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.