Merge branch 'MDL-65017-master' of git://github.com/jleyva/moodle
authorAdrian Greeve <abgreeve@gmail.com>
Thu, 24 Oct 2019 05:53:01 +0000 (13:53 +0800)
committerAdrian Greeve <abgreeve@gmail.com>
Thu, 24 Oct 2019 05:53:01 +0000 (13:53 +0800)
278 files changed:
.eslintignore
.stylelintignore
admin/settings/subsystems.php
admin/tool/analytics/amd/build/potential-contexts.min.js [new file with mode: 0644]
admin/tool/analytics/amd/build/potential-contexts.min.js.map [new file with mode: 0644]
admin/tool/analytics/amd/src/potential-contexts.js [new file with mode: 0644]
admin/tool/analytics/classes/external.php [new file with mode: 0644]
admin/tool/analytics/classes/output/form/edit_model.php
admin/tool/analytics/classes/output/invalid_analysables.php
admin/tool/analytics/cli/evaluate_model.php
admin/tool/analytics/createmodel.php
admin/tool/analytics/db/services.php [new file with mode: 0644]
admin/tool/analytics/lang/en/tool_analytics.php
admin/tool/analytics/model.php
admin/tool/analytics/tests/external_test.php [new file with mode: 0644]
admin/tool/analytics/version.php
analytics/classes/analysis.php
analytics/classes/local/analyser/base.php
analytics/classes/local/analyser/by_course.php
analytics/classes/local/analyser/sitewide.php
analytics/classes/manager.php
analytics/classes/model.php
analytics/tests/manager_test.php
analytics/tests/prediction_test.php
analytics/upgrade.txt
backup/backup.php
blocks/activity_results/block_activity_results.php
blocks/blog_recent/block_blog_recent.php
blocks/blog_tags/block_blog_tags.php
blocks/calendar_month/tests/behat/block_calendar_month.feature
blocks/calendar_month/tests/behat/block_calendar_month_course.feature
blocks/calendar_month/tests/behat/block_calendar_month_dashboard.feature
blocks/calendar_month/tests/behat/block_calendar_month_frontpage.feature
blocks/calendar_upcoming/tests/behat/block_calendar_upcoming_course.feature
blocks/calendar_upcoming/tests/behat/block_calendar_upcoming_dashboard.feature
blocks/calendar_upcoming/tests/behat/block_calendar_upcoming_frontpage.feature
blocks/classes/external.php
blocks/course_list/block_course_list.php
blocks/glossary_random/block_glossary_random.php
blocks/html/block_html.php
blocks/mentees/block_mentees.php
blocks/moodleblock.class.php
blocks/myoverview/block_myoverview.php
blocks/myprofile/block_myprofile.php
blocks/navigation/block_navigation.php
blocks/online_users/block_online_users.php
blocks/recentlyaccessedcourses/block_recentlyaccessedcourses.php
blocks/rss_client/block_rss_client.php
blocks/section_links/block_section_links.php
blocks/settings/block_settings.php
blocks/starredcourses/block_starredcourses.php
blocks/tag_flickr/block_tag_flickr.php
blocks/tag_youtube/block_tag_youtube.php
blocks/tags/block_tags.php
blocks/tests/externallib_test.php
blocks/upgrade.txt
calendar/classes/external/event_icon_exporter.php
calendar/classes/local/event/forms/eventtype.php
calendar/classes/local/event/forms/managesubscriptions.php
calendar/export_execute.php
calendar/externallib.php
calendar/lib.php
calendar/tests/behat/category_events.feature
calendar/tests/container_test.php
calendar/tests/event_factory_test.php
calendar/tests/event_mapper_test.php
calendar/tests/helpers.php
calendar/tests/repeat_event_collection_test.php
config-dist.php
course/classes/analytics/target/course_enrolments.php
course/externallib.php
course/tests/targets_test.php
filter/displayh5p/classes/privacy/provider.php [moved from filter/h5p/classes/privacy/provider.php with 86% similarity]
filter/displayh5p/filter.php [moved from filter/h5p/filter.php with 94% similarity]
filter/displayh5p/lang/en/filter_displayh5p.php [moved from filter/h5p/lang/en/filter_h5p.php with 92% similarity]
filter/displayh5p/settings.php [moved from filter/h5p/settings.php with 76% similarity]
filter/displayh5p/tests/filter_test.php [moved from filter/h5p/tests/filter_test.php with 81% similarity]
filter/displayh5p/version.php [moved from filter/h5p/version.php with 86% similarity]
grade/report/user/externallib.php
install/lang/el/error.php
install/lang/fr/install.php
lang/en/admin.php
lang/en/analytics.php
lang/en/calendar.php
lang/en/deprecated.txt
lang/en/moodle.php
lang/en/question.php
lib/amd/build/emoji/auto_complete.min.js [new file with mode: 0644]
lib/amd/build/emoji/auto_complete.min.js.map [new file with mode: 0644]
lib/amd/build/emoji/data.min.js [new file with mode: 0644]
lib/amd/build/emoji/data.min.js.map [new file with mode: 0644]
lib/amd/build/emoji/picker.min.js [new file with mode: 0644]
lib/amd/build/emoji/picker.min.js.map [new file with mode: 0644]
lib/amd/build/form-autocomplete.min.js
lib/amd/build/form-autocomplete.min.js.map
lib/amd/build/utils.min.js [new file with mode: 0644]
lib/amd/build/utils.min.js.map [new file with mode: 0644]
lib/amd/src/emoji/auto_complete.js [new file with mode: 0644]
lib/amd/src/emoji/data.js [new file with mode: 0644]
lib/amd/src/emoji/picker.js [new file with mode: 0644]
lib/amd/src/form-autocomplete.js
lib/amd/src/utils.js [new file with mode: 0644]
lib/classes/analytics/analyser/users.php
lib/classes/output/icon_system_fontawesome.php
lib/classes/plugin_manager.php
lib/cronlib.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/emoji-data/data.js.mustache [new file with mode: 0644]
lib/emoji-data/generate_emoji_data.php [new file with mode: 0644]
lib/emoji-data/readme_moodle.txt [new file with mode: 0644]
lib/form/tests/behat/autocomplete.feature [new file with mode: 0644]
lib/gradelib.php
lib/outputcomponents.php
lib/templates/emoji/auto_complete.mustache [new file with mode: 0644]
lib/templates/emoji/emoji_row.mustache [new file with mode: 0644]
lib/templates/emoji/header_row.mustache [new file with mode: 0644]
lib/templates/emoji/picker.mustache [new file with mode: 0644]
lib/templates/form_autocomplete_selection.mustache
lib/testing/generator/data_generator.php
lib/tests/analysers_test.php
lib/tests/gradelib_test.php
lib/tests/xhprof_test.php [new file with mode: 0644]
lib/thirdpartylibs.xml
lib/upgrade.txt
lib/xhprof/readme_moodle.txt
lib/xhprof/xhprof_moodle.php
message/amd/build/message_drawer_view_conversation.min.js
message/amd/build/message_drawer_view_conversation.min.js.map
message/amd/build/message_drawer_view_conversation_constants.min.js
message/amd/build/message_drawer_view_conversation_constants.min.js.map
message/amd/build/message_drawer_view_conversation_patcher.min.js
message/amd/build/message_drawer_view_conversation_patcher.min.js.map
message/amd/build/message_drawer_view_conversation_renderer.min.js
message/amd/build/message_drawer_view_conversation_renderer.min.js.map
message/amd/build/message_drawer_view_conversation_state_manager.min.js
message/amd/build/message_drawer_view_conversation_state_manager.min.js.map
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_conversation.js
message/amd/src/message_drawer_view_conversation_constants.js
message/amd/src/message_drawer_view_conversation_patcher.js
message/amd/src/message_drawer_view_conversation_renderer.js
message/amd/src/message_drawer_view_conversation_state_manager.js
message/amd/src/message_drawer_view_overview_section.js
message/classes/helper.php
message/templates/message_drawer_view_conversation_footer_content.mustache
mod/assign/feedback/editpdf/classes/pdf.php
mod/assign/feedback/editpdf/fpdi/FpdfTpl.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/FpdfTplTrait.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/Fpdi.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/FpdiException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/FpdiTrait.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/LICENSE [deleted file]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/AbstractReader.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/CrossReference.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/CrossReferenceException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/FixedReader.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/LineReader.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/ReaderInterface.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Ascii85.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Ascii85Exception.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/AsciiHex.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/FilterException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/FilterInterface.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Flate.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/FlateException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Lzw.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/LzwException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/PdfParser.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/PdfParserException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/StreamReader.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Tokenizer.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfArray.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfBoolean.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfDictionary.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfHexString.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfIndirectObject.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfIndirectObjectReference.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfName.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfNull.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfNumeric.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfStream.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfString.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfToken.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfType.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfTypeException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfReader/DataStructure/Rectangle.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfReader/Page.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfReader/PageBoundaries.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfReader/PdfReader.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfReader/PdfReaderException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/Tcpdf/Fpdi.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/TcpdfFpdi.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/Tfpdf/FpdfTpl.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/Tfpdf/Fpdi.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/autoload.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/filters/FilterASCII85.php [deleted file]
mod/assign/feedback/editpdf/fpdi/filters/FilterASCIIHexDecode.php [deleted file]
mod/assign/feedback/editpdf/fpdi/filters/FilterLZW.php [deleted file]
mod/assign/feedback/editpdf/fpdi/fpdf_tpl.php [deleted file]
mod/assign/feedback/editpdf/fpdi/fpdi.php [deleted file]
mod/assign/feedback/editpdf/fpdi/fpdi_bridge.php [deleted file]
mod/assign/feedback/editpdf/fpdi/fpdi_pdf_parser.php [deleted file]
mod/assign/feedback/editpdf/fpdi/pdf_context.php [deleted file]
mod/assign/feedback/editpdf/fpdi/pdf_parser.php [deleted file]
mod/assign/feedback/editpdf/fpdi/readme_moodle.txt
mod/assign/feedback/editpdf/thirdpartylibs.xml
mod/assign/locallib.php
mod/assign/module.js
mod/assign/renderer.php
mod/assign/styles.css
mod/feedback/classes/completion.php
mod/feedback/lib.php
mod/forum/classes/form/export_form.php
mod/forum/classes/local/vaults/post.php
mod/forum/export.php
mod/forum/lang/en/forum.php
mod/forum/tests/vaults_post_test.php
mod/quiz/classes/output/edit_renderer.php
mod/quiz/report/attemptsreport.php
mod/quiz/report/overview/tests/behat/basic.feature
mod/quiz/report/overview/tests/report_test.php
mod/quiz/styles.css
pix/i/courseevent.png
pix/i/courseevent.svg
pix/i/emojicategoryactivities.png [new file with mode: 0644]
pix/i/emojicategoryactivities.svg [new file with mode: 0644]
pix/i/emojicategoryanimalsnature.png [new file with mode: 0644]
pix/i/emojicategoryanimalsnature.svg [new file with mode: 0644]
pix/i/emojicategoryflags.png [new file with mode: 0644]
pix/i/emojicategoryflags.svg [new file with mode: 0644]
pix/i/emojicategoryfooddrink.png [new file with mode: 0644]
pix/i/emojicategoryfooddrink.svg [new file with mode: 0644]
pix/i/emojicategoryobjects.png [new file with mode: 0644]
pix/i/emojicategoryobjects.svg [new file with mode: 0644]
pix/i/emojicategoryrecent.png [new file with mode: 0644]
pix/i/emojicategoryrecent.svg [new file with mode: 0644]
pix/i/emojicategorysmileyspeople.png [new file with mode: 0644]
pix/i/emojicategorysmileyspeople.svg [new file with mode: 0644]
pix/i/emojicategorysymbols.png [new file with mode: 0644]
pix/i/emojicategorysymbols.svg [new file with mode: 0644]
pix/i/emojicategorytravelplaces.png [new file with mode: 0644]
pix/i/emojicategorytravelplaces.svg [new file with mode: 0644]
question/classes/bank/action_column_base.php
question/classes/bank/checkbox_column.php
question/classes/bank/column_base.php
question/classes/bank/copy_action_column.php
question/classes/bank/creator_name_column.php
question/classes/bank/delete_action_column.php
question/classes/bank/edit_action_column.php
question/classes/bank/edit_menu_column.php [new file with mode: 0644]
question/classes/bank/export_xml_action_column.php [new file with mode: 0644]
question/classes/bank/menu_action_column_base.php [new file with mode: 0644]
question/classes/bank/menuable_action.php [new file with mode: 0644]
question/classes/bank/modifier_name_column.php
question/classes/bank/preview_action_column.php
question/classes/bank/question_name_column.php
question/classes/bank/question_text_row.php
question/classes/bank/question_type_column.php
question/classes/bank/row_base.php
question/classes/bank/search/category_condition.php
question/classes/bank/search/hidden_condition.php
question/classes/bank/tags_action_column.php
question/classes/bank/view.php
question/engine/bank.php
question/engine/tests/questionbank_test.php
question/tests/behat/behat_question.php
question/upgrade.txt
theme/boost/scss/moodle/bootstrap-rtl.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/forms.scss
theme/boost/scss/moodle/message.scss
theme/boost/scss/moodle/question.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
version.php

index 4689ff0..c4a9e1b 100644 (file)
@@ -62,6 +62,7 @@ lib/amd/src/popper.js
 lib/geopattern-php/
 lib/php-jwt/
 lib/babel-polyfill/
+lib/emoji-data/
 media/player/videojs/amd/src/video-lazy.js
 media/player/videojs/amd/src/Youtube-lazy.js
 media/player/videojs/videojs/
index b8cd2a8..7a0cbe5 100644 (file)
@@ -63,6 +63,7 @@ lib/amd/src/popper.js
 lib/geopattern-php/
 lib/php-jwt/
 lib/babel-polyfill/
+lib/emoji-data/
 media/player/videojs/amd/src/video-lazy.js
 media/player/videojs/amd/src/Youtube-lazy.js
 media/player/videojs/videojs/
index 597e36b..01de61c 100644 (file)
@@ -51,4 +51,27 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
 
     $optionalsubsystems->add(new admin_setting_configcheckbox('enableanalytics', new lang_string('enableanalytics', 'admin'),
         new lang_string('configenableanalytics', 'admin'), 1, 1, 0));
+
+    $fullunicodesupport = true;
+    if ($DB->get_dbfamily() == 'mysql') {
+        $collation = $DB->get_dbcollation();
+        $collationinfo = explode('_', $collation);
+        $charset = reset($collationinfo);
+        $fullunicodesupport = $charset === 'utf8mb4';
+    }
+
+    if ($fullunicodesupport) {
+        $optionalsubsystems->add(new admin_setting_configcheckbox(
+            'allowemojipicker',
+            new lang_string('allowemojipicker', 'admin'),
+            new lang_string('configallowemojipicker', 'admin'),
+            1
+        ));
+    } else {
+        $optionalsubsystems->add(new admin_setting_description(
+            'allowemojipicker',
+            new lang_string('allowemojipicker', 'admin'),
+            new lang_string('configallowemojipickerincompatible', 'admin')
+        ));
+    }
 }
diff --git a/admin/tool/analytics/amd/build/potential-contexts.min.js b/admin/tool/analytics/amd/build/potential-contexts.min.js
new file mode 100644 (file)
index 0000000..c93ee47
Binary files /dev/null and b/admin/tool/analytics/amd/build/potential-contexts.min.js differ
diff --git a/admin/tool/analytics/amd/build/potential-contexts.min.js.map b/admin/tool/analytics/amd/build/potential-contexts.min.js.map
new file mode 100644 (file)
index 0000000..03b6fb5
Binary files /dev/null and b/admin/tool/analytics/amd/build/potential-contexts.min.js.map differ
diff --git a/admin/tool/analytics/amd/src/potential-contexts.js b/admin/tool/analytics/amd/src/potential-contexts.js
new file mode 100644 (file)
index 0000000..eb35367
--- /dev/null
@@ -0,0 +1,63 @@
+// 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/>.
+
+/**
+ * Potential contexts selector module.
+ *
+ * @module     tool_analytics/potential-contexts
+ * @class      potential-contexts
+ * @package    tool_analytics
+ * @copyright  2019 David Monllao
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define(['jquery', 'core/ajax'], function($, Ajax) {
+
+    return /** @alias module:tool_analytics/potential-contexts */ {
+
+        processResults: function(selector, results) {
+            var contexts = [];
+            if ($.isArray(results)) {
+                $.each(results, function(index, context) {
+                    contexts.push({
+                        value: context.id,
+                        label: context.name
+                    });
+                });
+                return contexts;
+
+            } else {
+                return results;
+            }
+        },
+
+        transport: function(selector, query, success, failure) {
+            var promise;
+
+            let modelid = $(selector).attr('modelid') || null;
+            promise = Ajax.call([{
+                methodname: 'tool_analytics_potential_contexts',
+                args: {
+                    query: query,
+                    modelid: modelid
+                }
+            }]);
+
+            promise[0].then(success).fail(failure);
+        }
+
+    };
+
+});
diff --git a/admin/tool/analytics/classes/external.php b/admin/tool/analytics/classes/external.php
new file mode 100644 (file)
index 0000000..515c08a
--- /dev/null
@@ -0,0 +1,113 @@
+<?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/>.
+
+/**
+ * This is the external API for this component.
+ *
+ * @package    tool_analytics
+ * @copyright  2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_analytics;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once("$CFG->libdir/externallib.php");
+
+use external_api;
+use external_function_parameters;
+use external_value;
+use external_single_structure;
+use external_multiple_structure;
+
+/**
+ * This is the external API for this component.
+ *
+ * @copyright  2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class external extends external_api {
+
+    const MAX_CONTEXTS_RETURNED = 100;
+
+    /**
+     * potential_contexts parameters.
+     *
+     * @since  Moodle 3.8
+     * @return external_function_parameters
+     */
+    public static function potential_contexts_parameters() {
+        return new external_function_parameters(
+            array(
+                'query' => new external_value(PARAM_NOTAGS, 'The model id', VALUE_DEFAULT),
+                'modelid' => new external_value(PARAM_INT, 'The model id', VALUE_DEFAULT)
+            )
+        );
+    }
+
+    /**
+     * Return the contexts that match the provided query.
+     *
+     * @since  Moodle 3.8
+     * @param  string|null $query
+     * @param  int|null $modelid
+     * @return array an array of contexts
+     */
+    public static function potential_contexts(?string $query = null, ?int $modelid = null) {
+
+        $params = self::validate_parameters(self::potential_contexts_parameters(), ['modelid' => $modelid, 'query' => $query]);
+
+        \core_analytics\manager::check_can_manage_models();
+
+        if ($params['modelid']) {
+            $model = new \core_analytics\model($params['modelid']);
+            $contexts = ($model->get_analyser(['notimesplitting' => true]))::potential_context_restrictions($params['query']);
+        } else {
+            $contexts = \core_analytics\manager::get_potential_context_restrictions(null, $params['query']);
+        }
+
+        $contextoptions = [];
+        $i = 0;
+        foreach ($contexts as $contextid => $contextname) {
+
+            if ($i === self::MAX_CONTEXTS_RETURNED) {
+                // Limited to MAX_CONTEXTS_RETURNED items.
+                break;
+            }
+
+            $contextoptions[] = ['id' => $contextid, 'name' => $contextname];
+            $i++;
+        }
+
+        return $contextoptions;
+    }
+
+    /**
+     * potential_contexts return
+     *
+     * @since  Moodle 3.8
+     * @return external_description
+     */
+    public static function potential_contexts_returns() {
+        return new external_multiple_structure(
+            new external_single_structure([
+                'id'    => new external_value(PARAM_INT, 'ID of the context'),
+                'name'  => new external_value(PARAM_NOTAGS, 'The context name')
+            ])
+        );
+    }
+}
index c897b5c..b9515cd 100644 (file)
@@ -105,6 +105,28 @@ class edit_model extends \moodleform {
         $mform->addElement('select', 'timesplitting', get_string('timesplittingmethod', 'analytics'), $timesplittings);
         $mform->addHelpButton('timesplitting', 'timesplittingmethod', 'analytics');
 
+        // Contexts restriction.
+        if (!empty($this->_customdata['supportscontexts'])) {
+
+            $options = [
+                'ajax' => 'tool_analytics/potential-contexts',
+                'multiple' => true,
+                'noselectionstring' => get_string('all')
+            ];
+
+            if (!empty($this->_customdata['id'])) {
+                $options['modelid'] = $this->_customdata['id'];
+                $contexts = $this->load_current_contexts();
+            } else {
+                // No need to preload any selected contexts.
+                $contexts = [];
+            }
+
+            $mform->addElement('autocomplete', 'contexts', get_string('contexts', 'tool_analytics'), $contexts, $options);
+            $mform->setType('contexts', PARAM_INT);
+            $mform->addHelpButton('contexts', 'contexts', 'tool_analytics');
+        }
+
         // Predictions processor.
         if (!$this->_customdata['staticmodel']) {
             $defaultprocessor = \core_analytics\manager::get_predictions_processor_name(
@@ -146,20 +168,37 @@ class edit_model extends \moodleform {
     public function validation($data, $files) {
         $errors = parent::validation($data, $files);
 
+        $targetclass = \tool_analytics\output\helper::option_to_class($data['target']);
+        $target = \core_analytics\manager::get_target($targetclass);
+
         if (!empty($data['timesplitting'])) {
             $timesplittingclass = \tool_analytics\output\helper::option_to_class($data['timesplitting']);
             if (\core_analytics\manager::is_valid($timesplittingclass, '\core_analytics\local\time_splitting\base') === false) {
                 $errors['timesplitting'] = get_string('errorinvalidtimesplitting', 'analytics');
             }
 
-            $targetclass = \tool_analytics\output\helper::option_to_class($data['target']);
             $timesplitting = \core_analytics\manager::get_time_splitting($timesplittingclass);
-            $target = \core_analytics\manager::get_target($targetclass);
             if (!$target->can_use_timesplitting($timesplitting)) {
                 $errors['timesplitting'] = get_string('invalidtimesplitting', 'tool_analytics');
             }
         }
 
+        if (!empty($data['contexts'])) {
+
+            $analyserclass = $target->get_analyser_class();
+            if (!$potentialcontexts = $analyserclass::potential_context_restrictions()) {
+                $errors['contexts'] = get_string('errornocontextrestrictions', 'analytics');
+            } else {
+
+                // Flip the contexts array so we can just diff by key.
+                $selectedcontexts = array_flip($data['contexts']);
+                $invalidcontexts = array_diff_key($selectedcontexts, $potentialcontexts);
+                if (!empty($invalidcontexts)) {
+                    $errors['contexts'] = get_string('errorinvalidcontexts', 'analytics');
+                }
+            }
+        }
+
         if (!$this->_customdata['staticmodel']) {
             if (empty($data['indicators'])) {
                 $errors['indicators'] = get_string('errornoindicators', 'analytics');
@@ -179,4 +218,18 @@ class edit_model extends \moodleform {
 
         return $errors;
     }
+
+    /**
+     * Load the currently selected context options.
+     *
+     * @return array
+     */
+    protected function load_current_contexts() {
+        $contexts = [];
+        foreach ($this->_customdata['contexts'] as $context) {
+            $contexts[$context->id] = $context->get_context_name(true, true);
+        }
+
+        return $contexts;
+    }
 }
index fd205f1..2011754 100644 (file)
@@ -76,7 +76,8 @@ class invalid_analysables implements \renderable, \templatable {
 
         $offset = $this->page * $this->perpage;
 
-        $analysables = $this->model->get_analyser(['notimesplitting' => true])->get_analysables_iterator();
+        $contexts = $this->model->get_contexts();
+        $analysables = $this->model->get_analyser(['notimesplitting' => true])->get_analysables_iterator(null, $contexts);
 
         $skipped = 0;
         $enoughresults = false;
index 3094ffa..7d07b6f 100644 (file)
@@ -34,7 +34,6 @@ Options:
 --list                 List models
 --non-interactive      Not interactive questions
 --analysisinterval     Restrict the evaluation to 1 single analysis interval (Optional)
---filter               Analyser dependant. e.g. A courseid would evaluate the model using a single course (Optional)
 --mode                 'configuration' or 'trainedmodel'. You can only use mode=trainedmodel when the trained" .
     " model was imported" . "
 --reuse-prev-analysed  Reuse recently analysed courses instead of analysing the whole site. Set it to false while" .
@@ -42,7 +41,7 @@ Options:
 -h, --help             Print out this help
 
 Example:
-\$ php admin/tool/analytics/cli/evaluate_model.php --modelid=1 --analysisinterval='\\core\\analytics\\time_splitting\\quarters' --filter=123,321
+\$ php admin/tool/analytics/cli/evaluate_model.php --modelid=1 --analysisinterval='\\core\\analytics\\time_splitting\\quarters'
 ";
 
 // Now get cli options.
@@ -55,7 +54,6 @@ list($options, $unrecognized) = cli_get_params(
         'mode'                  => 'configuration',
         'reuse-prev-analysed'   => true,
         'non-interactive'       => false,
-        'filter'                => false
     ),
     array(
         'h' => 'help',
@@ -83,11 +81,6 @@ if ($options['modelid'] === false) {
     exit(0);
 }
 
-// Reformat them as an array.
-if ($options['filter'] !== false) {
-    $options['filter'] = explode(',', $options['filter']);
-}
-
 if ($options['mode'] !== 'configuration' && $options['mode'] !== 'trainedmodel') {
     cli_error('Error: The provided mode is not supported');
 }
@@ -110,7 +103,6 @@ if ($options['reuse-prev-analysed']) {
 $renderer = $PAGE->get_renderer('tool_analytics');
 
 $analyseroptions = array(
-    'filter' => $options['filter'],
     'timesplitting' => $options['analysisinterval'],
     'reuseprevanalysed' => $options['reuse-prev-analysed'],
     'mode' => $options['mode'],
index ad4fc83..678eec8 100644 (file)
@@ -45,6 +45,8 @@ $targets = array_filter(\core_analytics\manager::get_all_targets(), function($ta
     return (!$target->based_on_assumptions());
 });
 
+// Set 'supportscontexts' to true as at this stage we don't know if the contexts are supported by
+// the selected target.
 $customdata = array(
     'trainedmodel' => false,
     'staticmodel' => false,
@@ -52,6 +54,7 @@ $customdata = array(
     'indicators' => \core_analytics\manager::get_all_indicators(),
     'timesplittings' => \core_analytics\manager::get_all_time_splittings(),
     'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors(),
+    'supportscontexts' => true,
 );
 $mform = new \tool_analytics\output\form\edit_model(null, $customdata);
 
@@ -86,8 +89,8 @@ if ($mform->is_cancelled()) {
         $indicators = array_diff_key($indicators, $invalidindicators);
     }
 
-    // Update the model with the valid list of indicators.
-    $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
+    // Update the model with the rest of the data provided in the form.
+    $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor, $data->contexts);
 
     $message = '';
     $messagetype = \core\output\notification::NOTIFY_SUCCESS;
diff --git a/admin/tool/analytics/db/services.php b/admin/tool/analytics/db/services.php
new file mode 100644 (file)
index 0000000..fefbfb8
--- /dev/null
@@ -0,0 +1,37 @@
+<?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/>.
+
+/**
+ * Tool analytics webservice definitions.
+ *
+ * @package    tool_analytics
+ * @copyright  2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$functions = array(
+
+    'tool_analytics_potential_contexts' => array(
+        'classname'   => 'tool_analytics\external',
+        'methodname'  => 'potential_contexts',
+        'description' => 'Retrieve the list of potential contexts for a model.',
+        'type'        => 'read',
+        'ajax'          => true,
+        'services'    => array(MOODLE_OFFICIAL_MOBILE_SERVICE)
+    ),
+);
index ca3d1a2..acace09 100644 (file)
@@ -45,6 +45,8 @@ $string['component'] = 'Component';
 $string['componentcore'] = 'Core';
 $string['componentselect'] = 'Select all models provided by the component \'{$a}\'';
 $string['componentselectnone'] = 'Unselect all';
+$string['contexts'] = 'Contexts';
+$string['contexts_help'] = 'The model will be limited to this set of contexts. No context restrictions will be applied if no contexts are selected.';
 $string['createmodel'] = 'Create model';
 $string['currenttimesplitting'] = 'Current analysis interval';
 $string['delete'] = 'Delete';
index 4886ff5..6b7511b 100644 (file)
@@ -132,7 +132,9 @@ switch ($action) {
             'targetname' => $model->get_target()->get_name(),
             'indicators' => $model->get_potential_indicators(),
             'timesplittings' => $potentialtimesplittings,
-            'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors()
+            'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors(),
+            'supportscontexts' => ($model->get_analyser(['notimesplitting' => true]))::context_restriction_support(),
+            'contexts' => $model->get_contexts(),
         );
         $mform = new \tool_analytics\output\form\edit_model(null, $customdata);
 
@@ -157,7 +159,7 @@ switch ($action) {
                 $predictionsprocessor = false;
             }
 
-            $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
+            $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor, $data->contexts);
             redirect($returnurl);
         }
 
@@ -168,6 +170,9 @@ switch ($action) {
         $callable = array('\tool_analytics\output\helper', 'class_to_option');
         $modelobj->indicators = array_map($callable, json_decode($modelobj->indicators));
         $modelobj->timesplitting = \tool_analytics\output\helper::class_to_option($modelobj->timesplitting);
+        if ($modelobj->contextids) {
+            $modelobj->contexts = array_map($callable, json_decode($modelobj->contextids));
+        }
         $modelobj->predictionsprocessor = \tool_analytics\output\helper::class_to_option($modelobj->predictionsprocessor);
         $mform->set_data($modelobj);
         $mform->display();
diff --git a/admin/tool/analytics/tests/external_test.php b/admin/tool/analytics/tests/external_test.php
new file mode 100644 (file)
index 0000000..ff26c64
--- /dev/null
@@ -0,0 +1,80 @@
+<?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/>.
+
+/**
+ * Tool analytics external functions tests.
+ *
+ * @package    tool_analytics
+ * @category   external
+ * @copyright  2019 David Monllaó {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.8
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+require_once(__DIR__ . '/../../../../analytics/tests/fixtures/test_indicator_max.php');
+require_once(__DIR__ . '/../../../../analytics/tests/fixtures/test_target_course_level_shortname.php');
+
+/**
+ * Tool analytics external functions tests
+ *
+ * @package    tool_analytics
+ * @category   external
+ * @copyright  2019 David Monllaó {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.8
+ */
+class tool_analytics_external_testcase extends externallib_advanced_testcase {
+
+    /**
+     * test_potential_contexts description
+     */
+    public function test_potential_contexts() {
+        $this->resetAfterTest();
+
+        $this->setAdminUser();
+
+        // Include the all context levels so the misc. category get included.
+        $this->assertCount(1, \tool_analytics\external::potential_contexts());
+
+        // The frontpage is not included.
+        $this->assertCount(0, \tool_analytics\external::potential_contexts('PHPUnit'));
+
+        $target = \core_analytics\manager::get_target('test_target_course_level_shortname');
+        $indicators = ['test_indicator_max' => \core_analytics\manager::get_indicator('test_indicator_max')];
+        $model = \core_analytics\model::create($target, $indicators);
+
+        $this->assertCount(1, \tool_analytics\external::potential_contexts(null, $model->get_id()));
+    }
+
+    /**
+     * test_potential_contexts description
+     *
+     * @expectedException required_capability_exception
+     */
+    public function test_potential_contexts_no_manager() {
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        $this->assertCount(2, \tool_analytics\external::potential_contexts());
+    }
+}
index 03c5187..c2e2b1c 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2019052000; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2019052002; // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2019051100; // Requires this Moodle version.
 $plugin->component = 'tool_analytics'; // Full name of the plugin (used for diagnostics).
index ad39b48..f11bfae 100644 (file)
@@ -75,9 +75,10 @@ class analysis {
     /**
      * Runs the analysis.
      *
+     * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
      * @return null
      */
-    public function run() {
+    public function run(array $contexts = []) {
 
         $options = $this->analyser->get_options();
 
@@ -89,7 +90,7 @@ class analysis {
         } else {
             $action = 'prediction';
         }
-        $analysables = $this->analyser->get_analysables_iterator($action);
+        $analysables = $this->analyser->get_analysables_iterator($action, $contexts);
 
         $processedanalysables = $this->get_processed_analysables();
 
index 0065f15..99735bd 100644 (file)
@@ -131,9 +131,10 @@ abstract class base {
      * to ease to implementation of get_analysables_iterator: get_iterator_sql and order_sql.
      *
      * @param string|null $action 'prediction', 'training' or null if no specific action needed.
+     * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty.
      * @return \Iterator
      */
-    public function get_analysables_iterator(?string $action = null) {
+    public function get_analysables_iterator(?string $action = null, array $contexts = []) {
 
         debugging('Please overwrite get_analysables_iterator with your own implementation, we only keep this default
             implementation for backwards compatibility purposes with get_analysables(). note that $action param will
@@ -266,38 +267,42 @@ abstract class base {
     /**
      * Returns labelled data (training and evaluation).
      *
+     * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
      * @return \stored_file[]
      */
-    public function get_labelled_data() {
+    public function get_labelled_data(array $contexts = []) {
         // Delegates all processing to the analysis.
         $result = new \core_analytics\local\analysis\result_file($this->get_modelid(), true, $this->get_options());
         $analysis = new \core_analytics\analysis($this, true, $result);
-        $analysis->run();
+        $analysis->run($contexts);
         return $result->get();
     }
 
     /**
      * Returns unlabelled data (prediction).
      *
+     * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
      * @return \stored_file[]
      */
-    public function get_unlabelled_data() {
+    public function get_unlabelled_data(array $contexts = []) {
         // Delegates all processing to the analysis.
         $result = new \core_analytics\local\analysis\result_file($this->get_modelid(), false, $this->get_options());
         $analysis = new \core_analytics\analysis($this, false, $result);
-        $analysis->run();
+        $analysis->run($contexts);
         return $result->get();
     }
 
     /**
      * Returns indicator calculations as an array.
+     *
+     * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
      * @return array
      */
-    public function get_static_data() {
+    public function get_static_data(array $contexts = []) {
         // Delegates all processing to the analysis.
         $result = new \core_analytics\local\analysis\result_array($this->get_modelid(), false, $this->get_options());
         $analysis = new \core_analytics\analysis($this, false, $result);
-        $analysis->run();
+        $analysis->run($contexts);
         return $result->get();
     }
 
@@ -422,6 +427,34 @@ abstract class base {
         return false;
     }
 
+    /**
+     * Returns an array of context levels that can be used to restrict the contexts used during analysis.
+     *
+     * The contexts provided to self::get_analysables_iterator will match these contextlevels.
+     *
+     * @return array Array of context levels or an empty array if context restriction is not supported.
+     */
+    public static function context_restriction_support(): array {
+        return [];
+    }
+
+    /**
+     * Returns the possible contexts used by the analyser.
+     *
+     * This method uses separate logic for each context level because to iterate through
+     * the list of contexts calling get_context_name for each of them would be expensive
+     * in performance terms.
+     *
+     * This generic implementation returns all the contexts in the site for the provided context level.
+     * Overwrite it for specific restrictions in your analyser.
+     *
+     * @param  string|null $query Context name filter.
+     * @return int[]
+     */
+    public static function potential_context_restrictions(string $query = null) {
+        return \core_analytics\manager::get_potential_context_restrictions(static::context_restriction_support(), $query);
+    }
+
     /**
      * Get the sql of a default implementation of the iterator.
      *
@@ -431,9 +464,12 @@ abstract class base {
      * @param  int         $contextlevel The context level of the analysable
      * @param  string|null $action
      * @param  string|null $tablealias   The table alias
+     * @param  \context[]  $contexts     Only analysables that depend on the provided contexts. All analysables if empty.
      * @return array                     [0] => sql and [1] => params array
      */
-    protected function get_iterator_sql(string $tablename, int $contextlevel, ?string $action = null, ?string $tablealias = null) {
+    protected function get_iterator_sql(string $tablename, int $contextlevel, ?string $action = null, ?string $tablealias = null,
+            array $contexts = []) {
+        global $DB;
 
         if (!$tablealias) {
             $tablealias = 'analysable';
@@ -452,13 +488,30 @@ abstract class base {
             $params = $params + ['action' => $action];
         }
 
-        // Adding the 1 = 1 just to have the WHERE part so that all further conditions added by callers can be
-        // appended to $sql with and ' AND'.
         $sql = 'SELECT ' . $select . '
                   FROM {' . $tablename . '} ' . $tablealias . '
                   ' . $usedanalysablesjoin . '
-                  JOIN {context} ctx ON (ctx.contextlevel = :contextlevel AND ctx.instanceid = ' . $tablealias . '.id)
-                  WHERE 1 = 1';
+                  JOIN {context} ctx ON (ctx.contextlevel = :contextlevel AND ctx.instanceid = ' . $tablealias . '.id) ';
+
+        if (!$contexts) {
+            // Adding the 1 = 1 just to have the WHERE part so that all further conditions
+            // added by callers can be appended to $sql with and ' AND'.
+            $sql .= 'WHERE 1 = 1';
+        } else {
+
+            $contextsqls = [];
+            foreach ($contexts as $context) {
+                $paramkey1 = 'paramctxlike' . $context->id;
+                $paramkey2 = 'paramctxeq' . $context->id;
+                $contextsqls[] = $DB->sql_like('ctx.path', ':' . $paramkey1);
+                $contextsqls[] = 'ctx.path = :' . $paramkey2;
+
+                // This includes the context itself.
+                $params[$paramkey1] = $context->path . '/%';
+                $params[$paramkey2] = $context->path;
+            }
+            $sql .= 'WHERE (' . implode(' OR ', $contextsqls) . ')';
+        }
 
         return [$sql, $params];
     }
index c0d308b..59b6627 100644 (file)
@@ -39,24 +39,13 @@ abstract class by_course extends base {
      * Return the list of courses to analyse.
      *
      * @param string|null $action 'prediction', 'training' or null if no specific action needed.
+     * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty.
      * @return \Iterator
      */
-    public function get_analysables_iterator(?string $action = null) {
+    public function get_analysables_iterator(?string $action = null, array $contexts = []) {
         global $DB;
 
-        list($sql, $params) = $this->get_iterator_sql('course', CONTEXT_COURSE, $action, 'c');
-
-        // This will be updated to filter by context as part of MDL-64739.
-        if (!empty($this->options['filter'])) {
-            $courses = array();
-            foreach ($this->options['filter'] as $courseid) {
-                $courses[$courseid] = intval($courseid);
-            }
-
-            list($coursesql, $courseparams) = $DB->get_in_or_equal($courses, SQL_PARAMS_NAMED);
-            $sql .= " AND c.id $coursesql";
-            $params = $params + $courseparams;
-        }
+        list($sql, $params) = $this->get_iterator_sql('course', CONTEXT_COURSE, $action, 'c', $contexts);
 
         $ordersql = $this->order_sql('sortorder', 'ASC', 'c');
 
@@ -76,4 +65,13 @@ abstract class by_course extends base {
             return \core_analytics\course::instance($record, $context);
         });
     }
-}
\ No newline at end of file
+
+    /**
+     * Can be limited to course categories or specific courses.
+     *
+     * @return array
+     */
+    public static function context_restriction_support(): array {
+        return [CONTEXT_COURSE, CONTEXT_COURSECAT];
+    }
+}
index da6ea71..28a4db3 100644 (file)
@@ -39,9 +39,10 @@ abstract class sitewide extends base {
      * Return the list of analysables to analyse.
      *
      * @param string|null $action 'prediction', 'training' or null if no specific action needed.
+     * @param \context[] $contexts Ignored here.
      * @return \Iterator
      */
-    public function get_analysables_iterator(?string $action = null) {
+    public function get_analysables_iterator(?string $action = null, array $contexts = []) {
         // We can safely ignore $action as we have 1 single analysable element in this analyser.
         return new \ArrayIterator([new \core_analytics\site()]);
     }
index 60f1f45..131c782 100644 (file)
@@ -646,6 +646,8 @@ class manager {
             $usedanalysablesanalysableids = array_flip($usedanalysablesanalysableids);
 
             $analyser = $model->get_analyser(array('notimesplitting' => true));
+
+            // We do not honour the list of contexts in this model as it can contain stale records.
             $analysables = $analyser->get_analysables_iterator();
 
             $analysableids = [];
@@ -913,4 +915,79 @@ class manager {
 
         return [$target, $indicators];
     }
+
+    /**
+     * Return the context restrictions that can be applied to the provided context levels.
+     *
+     * @throws \coding_exception
+     * @param  array|null $contextlevels The list of context levels provided by the analyser. Null if all of them.
+     * @param  string|null $query
+     * @return array Associative array with contextid as key and the short version of the context name as value.
+     */
+    public static function get_potential_context_restrictions(?array $contextlevels = null, string $query = null) {
+        global $DB;
+
+        if (empty($contextlevels) && !is_null($contextlevels)) {
+            return false;
+        }
+
+        if (!is_null($contextlevels)) {
+            foreach ($contextlevels as $contextlevel) {
+                if ($contextlevel !== CONTEXT_COURSE && $contextlevel !== CONTEXT_COURSECAT) {
+                    throw new \coding_exception('Only CONTEXT_COURSE and CONTEXT_COURSECAT are supported at the moment.');
+                }
+            }
+        }
+
+        $contexts = [];
+
+        // We have a separate process for each context level for performance reasons (to iterate through mdl_context calling
+        // get_context_name() would be too slow).
+        $contextsystem = \context_system::instance();
+        if (is_null($contextlevels) || in_array(CONTEXT_COURSECAT, $contextlevels)) {
+
+            $sql = "SELECT cc.id, cc.name, ctx.id AS contextid
+                      FROM {course_categories} cc
+                      JOIN {context} ctx ON ctx.contextlevel = :ctxlevel AND ctx.instanceid = cc.id";
+            $params = ['ctxlevel' => CONTEXT_COURSECAT];
+
+            if ($query) {
+                $sql .= " WHERE " . $DB->sql_like('cc.name', ':query', false, false);
+                $params['query'] = '%' . $query . '%';
+            }
+
+            $coursecats = $DB->get_recordset_sql($sql, $params);
+            foreach ($coursecats as $record) {
+                $contexts[$record->contextid] = get_string('category') . ': ' .
+                    format_string($record->name, true, array('context' => $contextsystem));
+            }
+            $coursecats->close();
+        }
+
+        if (is_null($contextlevels) || in_array(CONTEXT_COURSE, $contextlevels)) {
+
+            $sql = "SELECT c.id, c.shortname, ctx.id AS contextid
+                      FROM {course} c
+                      JOIN {context} ctx ON ctx.contextlevel = :ctxlevel AND ctx.instanceid = c.id
+                      WHERE c.id != :siteid";
+            $params = ['ctxlevel' => CONTEXT_COURSE, 'siteid' => SITEID];
+
+            if ($query) {
+                $sql .= ' AND (' . $DB->sql_like('c.fullname', ':query1', false, false) . ' OR ' .
+                    $DB->sql_like('c.shortname', ':query2', false, false) . ')';
+                $params['query1'] = '%' . $query . '%';
+                $params['query2'] = '%' . $query . '%';
+            }
+
+            $courses = $DB->get_recordset_sql($sql, $params);
+            foreach ($courses as $record) {
+                $contexts[$record->contextid] = get_string('course') . ': ' .
+                    format_string($record->shortname, true, array('context' => $contextsystem));
+            }
+            $courses->close();
+        }
+
+        return $contexts;
+    }
+
 }
index 9a524be..cc92516 100644 (file)
@@ -120,6 +120,11 @@ class model {
      */
     protected $indicators = null;
 
+    /**
+     * @var \context[]
+     */
+    protected $contexts = null;
+
     /**
      * Unique Model id created from site info and last model modification.
      *
@@ -459,9 +464,11 @@ class model {
      * @param \core_analytics\local\indicator\base[]|false $indicators False to respect current indicators
      * @param string|false $timesplittingid False to respect current time splitting method
      * @param string|false $predictionsprocessor False to respect current predictors processor value
+     * @param int[]|false $contextids List of context ids for this model. False to respect the current list of contexts.
      * @return void
      */
-    public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false) {
+    public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false,
+            $contextids = false) {
         global $USER, $DB;
 
         \core_analytics\manager::check_can_manage_models();
@@ -486,6 +493,15 @@ class model {
             $predictionsprocessor = $this->model->predictionsprocessor;
         }
 
+        if ($contextids !== false) {
+            $contextsstr = json_encode($contextids);
+
+            // Reset the internal cache.
+            $this->contexts = null;
+        } else {
+            $contextsstr = $this->model->contextids;
+        }
+
         if ($this->model->timesplitting !== $timesplittingid ||
                 $this->model->indicators !== $indicatorsstr ||
                 $this->model->predictionsprocessor !== $predictionsprocessor) {
@@ -514,6 +530,7 @@ class model {
         $this->model->indicators = $indicatorsstr;
         $this->model->timesplitting = $timesplittingid;
         $this->model->predictionsprocessor = $predictionsprocessor;
+        $this->model->contextids = $contextsstr;
         $this->model->timemodified = $now;
         $this->model->usermodified = $USER->id;
 
@@ -603,7 +620,7 @@ class model {
         // Before get_labelled_data call so we get an early exception if it is not ready.
         $predictor = $this->get_predictions_processor();
 
-        $datasets = $this->get_analyser()->get_labelled_data();
+        $datasets = $this->get_analyser()->get_labelled_data($this->get_contexts());
 
         // No datasets generated.
         if (empty($datasets)) {
@@ -695,7 +712,7 @@ class model {
         // Before get_labelled_data call so we get an early exception if it is not ready.
         $predictor = $this->get_predictions_processor();
 
-        $datasets = $this->get_analyser()->get_labelled_data();
+        $datasets = $this->get_analyser()->get_labelled_data($this->get_contexts());
 
         // No training if no files have been provided.
         if (empty($datasets) || empty($datasets[$this->model->timesplitting])) {
@@ -766,7 +783,7 @@ class model {
             // Before get_unlabelled_data call so we get an early exception if it is not ready.
             $predictor = $this->get_predictions_processor();
 
-            $samplesdata = $this->get_analyser()->get_unlabelled_data();
+            $samplesdata = $this->get_analyser()->get_unlabelled_data($this->get_contexts());
 
             // Get the prediction samples file.
             if (empty($samplesdata) || empty($samplesdata[$this->model->timesplitting])) {
@@ -802,7 +819,7 @@ class model {
         } else {
             // Predictions based on assumptions.
 
-            $indicatorcalculations = $this->get_analyser()->get_static_data();
+            $indicatorcalculations = $this->get_analyser()->get_static_data($this->get_contexts());
             // Get the prediction samples file.
             if (empty($indicatorcalculations) || empty($indicatorcalculations[$this->model->timesplitting])) {
 
@@ -1956,6 +1973,30 @@ class model {
         return [$allsampleids, $allsamplesdata];
     }
 
+    /**
+     * Contexts where this model should be active.
+     *
+     * @return \context[] Empty array if there are no context restrictions.
+     */
+    public function get_contexts() {
+        if ($this->contexts !== null) {
+            return $this->contexts;
+        }
+
+        if (!$this->model->contextids) {
+            $this->contexts = [];
+            return $this->contexts;
+        }
+        $contextids = json_decode($this->model->contextids);
+
+        // We don't expect this list to be massive as contexts need to be selected manually using the edit model form.
+        $this->contexts = array_map(function($contextid) {
+            return \context::instance_by_id($contextid, IGNORE_MISSING);
+        }, $contextids);
+
+        return $this->contexts;
+    }
+
     /**
      * Purges the insights cache.
      */
index 02166a8..582c457 100644 (file)
@@ -487,4 +487,31 @@ class analytics_manager_testcase extends advanced_testcase {
         $this->assertNotEmpty($indicators);
         $this->assertContainsOnlyInstancesOf(\core_analytics\local\indicator\base::class, $indicators);
     }
+
+    /**
+     * test_get_potential_context_restrictions description
+     */
+    public function test_get_potential_context_restrictions() {
+        $this->resetAfterTest();
+
+        // No potential context restrictions.
+        $this->assertFalse(\core_analytics\manager::get_potential_context_restrictions([]));
+
+        // Include the all context levels so the misc. category get included.
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions());
+
+        $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->create_category();
+        $this->assertCount(3, \core_analytics\manager::get_potential_context_restrictions());
+        $this->assertCount(3, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE, CONTEXT_COURSECAT]));
+
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE]));
+        $this->assertCount(2, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT]));
+
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'Course category'));
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'Course category 1'));
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'Miscellaneous'));
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE], 'Test course 1'));
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE], 'Test course'));
+    }
 }
index 2b437f6..1fd9ac2 100644 (file)
@@ -126,6 +126,57 @@ class core_analytics_prediction_testcase extends advanced_testcase {
             array('modelid' => $model->get_id())));
     }
 
+    /**
+     * test_model_contexts
+     */
+    public function test_model_contexts() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+        $this->setAdminuser();
+
+        $misc = $DB->get_record('course_categories', ['name' => 'Miscellaneous']);
+        $miscctx = \context_coursecat::instance($misc->id);
+
+        $category = $this->getDataGenerator()->create_category();
+        $categoryctx = \context_coursecat::instance($category->id);
+
+        // One course per category.
+        $courseparams = array('shortname' => 'aaaaaa', 'fullname' => 'aaaaaa', 'visible' => 0,
+            'category' => $category->id);
+        $course1 = $this->getDataGenerator()->create_course($courseparams);
+        $course1ctx = \context_course::instance($course1->id);
+        $courseparams = array('shortname' => 'bbbbbb', 'fullname' => 'bbbbbb', 'visible' => 0,
+            'category' => $misc->id);
+        $course2 = $this->getDataGenerator()->create_course($courseparams);
+
+        $model = $this->add_perfect_model('test_static_target_shortname');
+
+        // Just 1 category.
+        $model->update(true, false, '\core\analytics\time_splitting\no_splitting', false, [$categoryctx->id]);
+        $this->assertCount(1, $model->predict()->predictions);
+
+        // Now with 2 categories.
+        $model->update(true, false, false, false, [$categoryctx->id, $miscctx->id]);
+
+        // The courses in the new category are processed.
+        $this->assertCount(1, $model->predict()->predictions);
+
+        // Clear the predictions generated by the model and predict() again.
+        $model->clear();
+        $this->assertCount(2, $model->predict()->predictions);
+
+        // Course context restriction.
+        $model->update(true, false, '\core\analytics\time_splitting\no_splitting', false, [$course1ctx->id]);
+
+        // Nothing new as the course was already analysed.
+        $result = $model->predict();
+        $this->assertTrue(empty($result->predictions));
+
+        $model->clear();
+        $this->assertCount(1, $model->predict()->predictions);
+    }
+
     /**
      * test_ml_training_and_prediction
      *
index 38395c3..4773d4a 100644 (file)
@@ -30,6 +30,10 @@ information provided here is intended especially for developers.
     * Predictions flagged as "Not useful" in models whose targets use analysers that provide multiple samples
       per analysable (e.g. students at risk or no teaching) have been updated to "Incorrectly flagged".
 * \core_analytics\predictor::delete_output_dir has a new 2nd parameter, $uniquemodelid.
+* Analyser's get_analysables_iterator and get_iterator_sql have a new $contexts parameter to limit the returned analysables to
+  the ones that depend on the provided contexts.
+* Analysers can implement a context_restriction_support() method to restrict models to a subset of the
+  contents in the site. Only CONTEXT_COURSE and CONTEXT_COURSECAT are supported.
 
 === 3.7 ===
 
index 5090f39..cfcb3ff 100644 (file)
@@ -123,6 +123,9 @@ if (!async_helper::is_async_pending($id, 'course', 'backup')) {
     if (!($bc = backup_ui::load_controller($backupid))) {
         $bc = new backup_controller($type, $id, backup::FORMAT_MOODLE,
                 backup::INTERACTIVE_YES, $backupmode, $USER->id);
+        // The backup id did not relate to a valid controller so we made a new controller.
+        // Now we need to reset the backup id to match the new controller.
+        $backupid = $bc->get_backupid();
     }
 
     // Prepare a progress bar which can display optionally during long-running
index 2890415..f62b430 100644 (file)
@@ -704,4 +704,21 @@ class block_activity_results extends block_base {
         return $scale;
 
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $instanceconfigs = !empty($this->config) ? $this->config : new stdClass();
+        $pluginconfigs = get_config('block_activity_results');
+
+        return (object) [
+            'instance' => $instanceconfigs,
+            'plugin' => $pluginconfigs,
+        ];
+    }
 }
index 80befeb..b0d875b 100644 (file)
@@ -125,4 +125,20 @@ class block_blog_recent extends block_base {
             $this->content->text .= get_string('norecentblogentries', 'block_blog_recent');
         }
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $configs = !empty($this->config) ? $this->config : new stdClass();
+
+        return (object) [
+            'instance' => $configs,
+            'plugin' => new stdClass(),
+        ];
+    }
 }
index f46bd41..f479e38 100644 (file)
@@ -206,6 +206,22 @@ class block_blog_tags extends block_base {
         }
         return $this->content;
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $configs = !empty($this->config) ? $this->config : new stdClass();
+
+        return (object) [
+            'instance' => $configs,
+            'plugin' => new stdClass(),
+        ];
+    }
 }
 
 function block_blog_tags_sort($a, $b) {
index f8f1597..b921b62 100644 (file)
@@ -26,7 +26,7 @@ Feature: Enable the calendar block in a course and test it's functionality
     Then "Calendar" "block" should exist
 
   @javascript
-  Scenario: View a global event in the calendar block
+  Scenario: View a site event in the calendar block
     Given I log in as "admin"
     And I create a calendar event with form data:
       | id_eventtype | Site |
@@ -52,7 +52,7 @@ Feature: Enable the calendar block in a course and test it's functionality
       | id_eventtype | Course |
       | id_name | Course Event |
     And I am on "Course 1" course homepage
-    And I follow "Hide global events"
+    And I follow "Hide site events"
     And I hover over today in the calendar
     Then I should not see "Site Event"
     And I should see "Course Event"
index 2360572..f0ed21f 100644 (file)
@@ -5,7 +5,7 @@ Feature: Enable the calendar block in a course
   I can add the calendar block to a course
 
   @javascript
-  Scenario: View a global event in the calendar block in a course
+  Scenario: View a site event in the calendar block in a course
     Given the following "users" exist:
       | username | firstname | lastname | email | idnumber |
       | teacher1 | Teacher | 1 | teacher1@example.com | T1 |
index aa74c43..e22b4e8 100644 (file)
@@ -5,7 +5,7 @@ Feature: View a site event on the dashboard
   I can view the event in the calendar
 
   @javascript
-  Scenario: View a global event in the calendar block on the dashboard
+  Scenario: View a site event in the calendar block on the dashboard
     Given the following "users" exist:
       | username | firstname | lastname | email | idnumber |
       | student1 | Student | 1 | student1@example.com | S1 |
index b7ef68e..e67c327 100644 (file)
@@ -5,7 +5,7 @@ Feature: Enable the calendar block on the site front page
   I can add the calendar block on the site front page
 
   @javascript
-  Scenario: View a global event in the calendar block on the front page
+  Scenario: View a site event in the calendar block on the front page
     Given the following "users" exist:
       | username | firstname | lastname | email | idnumber |
       | student1 | Student | 1 | student1@example.com | S1 |
index 9e092bf..1b4d9e0 100644 (file)
@@ -16,7 +16,7 @@ Feature: Enable the upcoming events block in a course
       | teacher1 | C1 | editingteacher |
 
   @javascript
-  Scenario: View a global event in the calendar block
+  Scenario: View a site event in the calendar block
     Given I log in as "admin"
     And I create a calendar event with form data:
       | id_eventtype | Site |
index 0b7c739..96bd216 100644 (file)
@@ -9,7 +9,7 @@ Feature: View a upcoming site event on the dashboard
       | student1 | Student | 1 | student1@example.com | S1 |
 
   @javascript
-  Scenario: View a global event in the upcoming events block on the dashboard
+  Scenario: View a site event in the upcoming events block on the dashboard
     Given I log in as "admin"
     And I create a calendar event with form data:
       | id_eventtype | Site |
index dd31e37..8336f52 100644 (file)
@@ -10,7 +10,7 @@ Feature: View a site event on the frontpage
       | teacher1 | Teacher | 1 | teacher1@example.com | T1 |
 
   @javascript
-  Scenario: View a global event in the upcoming events block on the frontpage
+  Scenario: View a site event in the upcoming events block on the frontpage
     Given I log in as "admin"
     And I create a calendar event with form data:
       | id_eventtype | Site |
index 10a10a3..a22cab2 100644 (file)
@@ -67,6 +67,16 @@ class core_block_external extends external_api {
                     ),
                     'Block contents (if required).', VALUE_OPTIONAL
                 ),
+                'configs' => new external_multiple_structure(
+                    new external_single_structure(
+                        array(
+                            'name' => new external_value(PARAM_RAW, 'Name.'),
+                            'value' => new external_value(PARAM_RAW, 'Value.'),
+                            'type' => new external_value(PARAM_ALPHA, 'Type (instance or plugin).'),
+                        )
+                    ),
+                    'Block instance and plugin configuration settings.', VALUE_OPTIONAL
+                ),
             ), 'Block information.'
         );
     }
@@ -110,6 +120,17 @@ class core_block_external extends external_api {
                 if ($returncontents) {
                     $block['contents'] = (array) $blockinstances[$bc->blockinstanceid]->get_content_for_external($OUTPUT);
                 }
+                $configs = (array) $blockinstances[$bc->blockinstanceid]->get_config_for_external();
+                foreach ($configs as $type => $data) {
+                    foreach ((array) $data as $name => $value) {
+                        $block['configs'][] = [
+                            'name' => $name,
+                            'value' => $value,
+                            'type' => $type,
+                        ];
+                    }
+                }
+
                 $allblocks[] = $block;
             }
         }
index b4aa2c1..0c4b832 100644 (file)
@@ -177,6 +177,27 @@ class block_course_list extends block_list {
     public function get_aria_role() {
         return 'navigation';
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        global $CFG;
+
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $configs = (object) [
+            'adminview' => $CFG->block_course_list_adminview,
+            'hideallcourseslink' => $CFG->block_course_list_hideallcourseslink
+        ];
+
+        return (object) [
+            'instance' => new stdClass(),
+            'plugin' => $configs,
+        ];
+    }
 }
 
 
index 5f15e8e..42a6ebc 100644 (file)
@@ -254,5 +254,21 @@ class block_glossary_random extends block_base {
 
         return $this->content;
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $configs = !empty($this->config) ? $this->config : new stdClass();
+
+        return (object) [
+            'instance' => $configs,
+            'plugin' => new stdClass(),
+        ];
+    }
 }
 
index 954a809..cb1f6a6 100644 (file)
@@ -209,4 +209,23 @@ class block_html extends block_base {
 
         return $attributes;
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        global $CFG;
+
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $instanceconfigs = !empty($this->config) ? $this->config : new stdClass();
+        $pluginconfigs = (object) ['allowcssclasses' => $CFG->block_html_allowcssclasses];
+
+        return (object) [
+            'instance' => $instanceconfigs,
+            'plugin' => $pluginconfigs,
+        ];
+    }
 }
index 0c2ff91..ff32630 100644 (file)
@@ -78,5 +78,21 @@ class block_mentees extends block_base {
     public function instance_can_be_docked() {
         return parent::instance_can_be_docked() && isset($this->config->title) && !empty($this->config->title);
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $configs = !empty($this->config) ? $this->config : new stdClass();
+
+        return (object) [
+            'instance' => $configs,
+            'plugin' => new stdClass(),
+        ];
+    }
 }
 
index b09adad..dd45eed 100644 (file)
@@ -305,6 +305,21 @@ class block_base {
         return $bc;
     }
 
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * In some cases the configs will need formatting or be returned only if the current user has some capabilities enabled.
+     *
+     * @return stdClass the configs for both the block instance and plugin (as object with name -> value)
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        return (object) [
+            'instance' => new stdClass(),
+            'plugin' => new stdClass(),
+        ];
+    }
+
     /**
      * Convert the contents of the block to HTML.
      *
index 3a7dd13..db02741 100644 (file)
@@ -82,5 +82,21 @@ class block_myoverview extends block_base {
     public function has_config() {
         return true;
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $configs = get_config('block_myoverview');
+
+        return (object) [
+            'instance' => new stdClass(),
+            'plugin' => $configs,
+        ];
+    }
 }
 
index 4b9013f..db86972 100644 (file)
@@ -129,4 +129,19 @@ class block_myprofile extends block_base {
     public function before_delete() {
     }
 
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $configs = !empty($this->config) ? $this->config : new stdClass();
+
+        return (object) [
+            'instance' => $configs,
+            'plugin' => new stdClass(),
+        ];
+    }
 }
index 76a4c3f..b8080b4 100644 (file)
@@ -326,4 +326,20 @@ class block_navigation extends block_base {
     public function get_aria_role() {
         return 'navigation';
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $configs = !empty($this->config) ? $this->config : new stdClass();
+
+        return (object) [
+            'instance' => $configs,
+            'plugin' => new stdClass(),
+        ];
+    }
 }
index 9d1fc90..dbdf802 100644 (file)
@@ -157,6 +157,27 @@ class block_online_users extends block_base {
 
         return $this->content;
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        global $CFG;
+
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $configs = (object) [
+            'timetosee' => $CFG->block_online_users_timetosee,
+            'onlinestatushiding' => $CFG->block_online_users_onlinestatushiding
+        ];
+
+        return (object) [
+            'instance' => new stdClass(),
+            'plugin' => $configs,
+        ];
+    }
 }
 
 
index 0c8c388..58241a8 100644 (file)
@@ -76,4 +76,20 @@ class block_recentlyaccessedcourses extends block_base {
     public function has_config() {
         return true;
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $configs = get_config('block_recentlyaccessedcourses');
+
+        return (object) [
+            'instance' => new stdClass(),
+            'plugin' => $configs,
+        ];
+    }
 }
index 3b33049..06052c7 100644 (file)
             return core_text::substr($title, 0, $max - 3) . '...';
         }
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        global $CFG;
+
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $instanceconfigs = !empty($this->config) ? $this->config : new stdClass();
+        $pluginconfigs = (object) [
+            'num_entries' => $CFG->block_rss_client_num_entries,
+            'timeout' => $CFG->block_rss_client_timeout
+        ];
+
+        return (object) [
+            'instance' => $instanceconfigs,
+            'plugin' => $pluginconfigs,
+        ];
+    }
 }
index 7f12683..6c638ea 100644 (file)
@@ -154,6 +154,23 @@ class block_section_links extends block_base {
     public function has_config() {
         return true;
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $instanceconfigs = !empty($this->config) ? $this->config : new stdClass();
+        $pluginconfigs = get_config('block_section_links');
+
+        return (object) [
+            'instance' => $instanceconfigs,
+            'plugin' => $pluginconfigs,
+        ];
+    }
 }
 
 
index 1ed9409..96f79f9 100644 (file)
@@ -160,4 +160,20 @@ class block_settings extends block_base {
     public function get_aria_role() {
         return 'navigation';
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $configs = !empty($this->config) ? $this->config : new stdClass();
+
+        return (object) [
+            'instance' => $configs,
+            'plugin' => new stdClass(),
+        ];
+    }
 }
index 46bfda9..365435a 100644 (file)
@@ -81,4 +81,20 @@ class block_starredcourses extends block_base {
     public function has_config() {
         return true;
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $configs = get_config('block_starredcourses');
+
+        return (object) [
+            'instance' => new stdClass(),
+            'plugin' => $configs,
+        ];
+    }
 }
index 22471f3..affb9de 100644 (file)
@@ -178,6 +178,22 @@ class block_tag_flickr extends block_base {
         }
         return $url;
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $configs = !empty($this->config) ? $this->config : new stdClass();
+
+        return (object) [
+            'instance' => $configs,
+            'plugin' => new stdClass(),
+        ];
+    }
 }
 
 
index 9c79fe8..51a33d5 100644 (file)
@@ -398,5 +398,25 @@ class block_tag_youtube extends block_base {
             return $oldcat;
         }
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        // There is a private key, only admins can see it.
+        $pluginconfigs = get_config('block_tag_youtube');
+        if (!has_capability('moodle/site:config', context_system::instance())) {
+            unset($pluginconfigs->apikey);
+        }
+        $instanceconfigs = !empty($this->config) ? $this->config : new stdClass();
+
+        return (object) [
+            'instance' => $instanceconfigs,
+            'plugin' => $pluginconfigs,
+        ];
+    }
 }
 
index f263ddf..b0a07d9 100644 (file)
@@ -109,4 +109,20 @@ class block_tags extends block_base {
 
         return $this->content;
     }
+
+    /**
+     * Return the plugin config settings for external functions.
+     *
+     * @return stdClass the configs for both the block instance and plugin
+     * @since Moodle 3.8
+     */
+    public function get_config_for_external() {
+        // Return all settings for all users since it is safe (no private keys, etc..).
+        $configs = !empty($this->config) ? $this->config : new stdClass();
+
+        return (object) [
+            'instance' => $configs,
+            'plugin' => new stdClass(),
+        ];
+    }
 }
index 1694636..6b894b9 100644 (file)
@@ -213,6 +213,19 @@ class core_block_externallib_testcase extends externallib_advanced_testcase {
         $this->assertEquals('', $result['blocks'][0]['contents']['footer']);
         $this->assertCount(1, $result['blocks'][0]['contents']['files']);
         $this->assertEquals($newblock, $result['blocks'][0]['name']);
+        $configcounts = 0;
+        foreach ($result['blocks'][0]['configs'] as $config) {
+            if ($config['type'] = 'plugin' && $config['name'] == 'allowcssclasses' && $config['value'] == 0) {
+                $configcounts++;
+            } else if ($config['type'] = 'instance' && $config['name'] == 'text' && $config['value'] == $body) {
+                $configcounts++;
+            } else if ($config['type'] = 'instance' && $config['name'] == 'title' && $config['value'] == $title) {
+                $configcounts++;
+            } else if ($config['type'] = 'instance' && $config['name'] == 'format' && $config['value'] == 0) {
+                $configcounts++;
+            }
+        }
+        $this->assertEquals(4, $configcounts);
     }
 
     /**
@@ -225,6 +238,9 @@ class core_block_externallib_testcase extends externallib_advanced_testcase {
         $user = $this->getDataGenerator()->create_user();
         $PAGE->set_url('/my/index.php');    // Need this because some internal API calls require the $PAGE url to be set.
 
+        // Force a setting change to check the returned blocks settings.
+        set_config('displaycategories', 0, 'block_recentlyaccessedcourses');
+
         // Get the expected default blocks.
         $alldefaultblocksordered = $DB->get_records_menu('block_instances',
             array('pagetypepattern' => 'my-index'), 'defaultregion, defaultweight ASC', 'id, blockname');
@@ -242,6 +258,12 @@ class core_block_externallib_testcase extends externallib_advanced_testcase {
             // Check all the returned blocks are in the expected blocks array.
             $this->assertContains($block['name'], $alldefaultblocksordered);
             $returnedblocks[] = $block['name'];
+            // Check the configuration returned for this default block.
+            if ($block['name'] == 'recentlyaccessedcourses') {
+                $this->assertEquals('displaycategories', $block['configs'][0]['name']);
+                $this->assertEquals(0, $block['configs'][0]['value']);
+                $this->assertEquals('plugin', $block['configs'][0]['type']);
+            }
         }
         // Remove lp block.
         array_shift($alldefaultblocksordered);
index 5894b64..1506c7d 100644 (file)
@@ -4,6 +4,9 @@ information provided here is intended especially for developers.
 === 3.8 ===
 * Block block_community is no longer a part of core.
 * Block block_participants is no longer a part of core.
+* Block plugins should overwrite get_config_for_external function to return the blocks settings viewable by the current user.
+  If the block plugin does not have any setting that could be considerated private (like a private/access key/token),
+  is ok to return all the settings via the get_config_for_external function.
 
 === 3.7 ===
 * The block:addinstance capability is no longer required if the block can only be added to a dashboard.
index 79d30da..1949a9e 100644 (file)
@@ -55,7 +55,7 @@ class event_icon_exporter extends exporter {
         $user = $event->get_user();
         $userid = $user ? $user->get('id') : null;
         $isactivityevent = !empty($coursemodule);
-        $isglobalevent = ($course && $courseid == SITEID);
+        $issiteevent = ($course && $courseid == SITEID);
         $iscategoryevent = ($category && !empty($categoryid));
         $iscourseevent = ($course && !empty($courseid) && $courseid != SITEID && empty($groupid));
         $isgroupevent = ($group && !empty($groupid));
@@ -70,7 +70,7 @@ class event_icon_exporter extends exporter {
             } else {
                 $alttext = get_string('activityevent', 'calendar');
             }
-        } else if ($isglobalevent) {
+        } else if ($issiteevent) {
             $key = 'i/siteevent';
             $component = 'core';
             $alttext = get_string('typesite', 'calendar');
index d615a50..8475e29 100644 (file)
@@ -57,19 +57,19 @@ trait eventtype {
         $options = [];
 
         if (!empty($eventtypes['user'])) {
-            $options['user'] = get_string('user');
+            $options['user'] = get_string('user', 'calendar');
         }
         if (!empty($eventtypes['group'])) {
-            $options['group'] = get_string('group');
+            $options['group'] = get_string('group', 'calendar');
         }
         if (!empty($eventtypes['course'])) {
-            $options['course'] = get_string('course');
+            $options['course'] = get_string('course', 'calendar');
         }
         if (!empty($eventtypes['category'])) {
-            $options['category'] = get_string('category');
+            $options['category'] = get_string('category', 'calendar');
         }
         if (!empty($eventtypes['site'])) {
-            $options['site'] = get_string('site');
+            $options['site'] = get_string('site', 'calendar');
         }
 
         // If we only have one event type and it's 'user' event then don't bother
index 61ef73f..6063f40 100644 (file)
@@ -85,7 +85,7 @@ class managesubscriptions extends \moodleform {
         // Add the select elements for the available event types.
         $this->add_event_type_elements($mform, $eventtypes);
 
-        // Eventtype: 0 = user, 1 = global, anything else = course ID.
+        // Eventtype: 0 = user, 1 = site, anything else = course ID.
         $mform->addElement('submit', 'add', get_string('add'));
 
         // Add the javascript required to enhance this mform.
index 17b5501..a06f328 100644 (file)
@@ -76,7 +76,7 @@ if(!empty($what) && !empty($time)) {
         if ($what == 'all') {
             $users = $user->id;
             $courses[SITEID] = new stdClass;
-            $courses[SITEID]->shortname = get_string('globalevents', 'calendar');
+            $courses[SITEID]->shortname = get_string('siteevents', 'calendar');
             $paramcourses[SITEID] = $courses[SITEID];
             $paramcategory = true;
         } else if ($what == 'groups') {
index 4bc9388..eccd04e 100644 (file)
@@ -147,7 +147,7 @@ class core_calendar_external extends external_api {
                                              "Set to true to return current user's user events",
                                              VALUE_DEFAULT, true, NULL_ALLOWED),
                                     'siteevents' => new external_value(PARAM_BOOL,
-                                             "Set to true to return global events",
+                                             "Set to true to return site events",
                                              VALUE_DEFAULT, true, NULL_ALLOWED),
                                     'timestart' => new external_value(PARAM_INT,
                                              "Time from which events should be returned",
index a5f772e..da878b8 100644 (file)
@@ -70,10 +70,16 @@ define('CALENDAR_TF_24', '%H:%M');
 define('CALENDAR_TF_12', '%I:%M %p');
 
 /**
- * CALENDAR_EVENT_GLOBAL - Global calendar event types
+ * CALENDAR_EVENT_GLOBAL - Site calendar event types
+ * @deprecated since 3.8
  */
 define('CALENDAR_EVENT_GLOBAL', 1);
 
+/**
+ * CALENDAR_EVENT_SITE - Site calendar event types
+ */
+define('CALENDAR_EVENT_SITE', 1);
+
 /**
  * CALENDAR_EVENT_COURSE - Course calendar event types
  */
@@ -1551,8 +1557,8 @@ function calendar_add_event_metadata($event) {
         $event->cmid = $module->id;
     } else if ($event->courseid == SITEID) { // Site event.
         $event->icon = '<img src="' . $OUTPUT->image_url('i/siteevent') . '" alt="' .
-            get_string('globalevent', 'calendar') . '" class="icon" />';
-        $event->cssclass = 'calendar_event_global';
+            get_string('siteevent', 'calendar') . '" class="icon" />';
+        $event->cssclass = 'calendar_event_site';
     } else if ($event->courseid != 0 && $event->courseid != SITEID && $event->groupid == 0) { // Course event.
         $event->icon = '<img src="' . $OUTPUT->image_url('i/courseevent') . '" alt="' .
             get_string('courseevent', 'calendar') . '" class="icon" />';
@@ -1958,7 +1964,7 @@ function calendar_sub_month($month, $year) {
  * @param int $year the number of the year
  * @param array $eventsbyday event on specific day
  * @param array $durationbyday duration of the event in days
- * @param array $typesbyday event type (eg: global, course, user, or group)
+ * @param array $typesbyday event type (eg: site, course, user, or group)
  * @param array $courses list of courses
  * @return void
  */
@@ -1995,9 +2001,9 @@ function calendar_events_by_day($events, $month, $year, &$eventsbyday, &$duratio
 
             // Mark the day as having such an event.
             if ($event->courseid == SITEID && $event->groupid == 0) {
-                $typesbyday[$eventdaystart]['startglobal'] = true;
-                // Set event class for global event.
-                $events[$event->id]->class = 'calendar_event_global';
+                $typesbyday[$eventdaystart]['startsite'] = true;
+                // Set event class for site event.
+                $events[$event->id]->class = 'calendar_event_site';
             } else if ($event->courseid != 0 && $event->courseid != SITEID && $event->groupid == 0) {
                 $typesbyday[$eventdaystart]['startcourse'] = true;
                 // Set event class for course event.
@@ -2036,7 +2042,7 @@ function calendar_events_by_day($events, $month, $year, &$eventsbyday, &$duratio
         for ($i = $lowerbound + 1; $i <= $upperbound; ++$i) {
             $durationbyday[$i][] = $event->id;
             if ($event->courseid == SITEID && $event->groupid == 0) {
-                $typesbyday[$i]['durationglobal'] = true;
+                $typesbyday[$i]['durationsite'] = true;
             } else if ($event->courseid != 0 && $event->courseid != SITEID && $event->groupid == 0) {
                 $typesbyday[$i]['durationcourse'] = true;
             } else if ($event->groupid) {
@@ -2077,7 +2083,7 @@ function calendar_set_filters(array $courseeventsfrom, $ignorefilters = false, s
     if ($ignorefilters || calendar_show_event_type(CALENDAR_EVENT_COURSE, $user)) {
         $courses = array_keys($courseeventsfrom);
     }
-    if ($ignorefilters || calendar_show_event_type(CALENDAR_EVENT_GLOBAL, $user)) {
+    if ($ignorefilters || calendar_show_event_type(CALENDAR_EVENT_SITE, $user)) {
         $courses[] = SITEID;
     }
     $courses = array_unique($courses);
@@ -2472,7 +2478,7 @@ function calendar_format_event_time($event, $now, $linkparams = null, $usecommon
  * @return bool True if the tyep should be displayed false otherwise
  */
 function calendar_show_event_type($type, $user = null) {
-    $default = CALENDAR_EVENT_GLOBAL + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP + CALENDAR_EVENT_USER;
+    $default = CALENDAR_EVENT_SITE + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP + CALENDAR_EVENT_USER;
 
     if (get_user_preferences('calendar_persistflt', 0, $user) === 0) {
         global $SESSION;
@@ -2498,7 +2504,7 @@ function calendar_show_event_type($type, $user = null) {
  */
 function calendar_set_event_type_display($type, $display = null, $user = null) {
     $persist = get_user_preferences('calendar_persistflt', 0, $user);
-    $default = CALENDAR_EVENT_GLOBAL + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP
+    $default = CALENDAR_EVENT_SITE + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP
             + CALENDAR_EVENT_USER + CALENDAR_EVENT_COURSECAT;
     if ($persist === 0) {
         global $SESSION;
index 5382c8f..021126a 100644 (file)
@@ -48,7 +48,7 @@ Feature: Course Category Events
       | student2    | B2i       | student           |
     And the following "events" exist:
       | name        | eventtype |
-      | Site event  | global    |
+      | Site event  | site    |
     And the following "events" exist:
       | name        | eventtype | course |
       | CA1i event  | course    | A1i    |
index 011464b..bc72391 100644 (file)
@@ -532,7 +532,7 @@ class core_calendar_container_testcase extends advanced_testcase {
     protected function create_event($properties = []) {
         $record = new \stdClass();
         $record->name = 'event name';
-        $record->eventtype = 'global';
+        $record->eventtype = 'site';
         $record->timestart = time();
         $record->timeduration = 0;
         $record->timesort = 0;
index abf40ea..f1c8b44 100644 (file)
@@ -467,7 +467,7 @@ class core_calendar_event_factory_testcase extends advanced_testcase {
     protected function create_event($properties = []) {
         $record = new \stdClass();
         $record->name = 'event name';
-        $record->eventtype = 'global';
+        $record->eventtype = 'site';
         $record->timestart = time();
         $record->timeduration = 0;
         $record->timesort = 0;
index d59bccc..a9a39a5 100644 (file)
@@ -141,7 +141,7 @@ class core_calendar_event_mapper_testcase extends advanced_testcase {
     protected function create_event($properties = []) {
         $record = new \stdClass();
         $record->name = 'event name';
-        $record->eventtype = 'global';
+        $record->eventtype = 'site';
         $record->timestart = time();
         $record->timeduration = 0;
         $record->timesort = 0;
index 92f2e2e..9a875f8 100644 (file)
@@ -48,7 +48,7 @@ use core_calendar\local\event\factories\event_factory_interface;
 function create_event($properties) {
     $record = new \stdClass();
     $record->name = 'event name';
-    $record->eventtype = 'global';
+    $record->eventtype = 'site';
     $record->repeat = 0;
     $record->repeats = 0;
     $record->timestart = time();
index 7d335ea..8c963bd 100644 (file)
@@ -155,7 +155,7 @@ class core_calendar_repeat_event_collection_testcase extends advanced_testcase {
     protected function create_event($properties = []) {
         $record = new \stdClass();
         $record->name = 'event name';
-        $record->eventtype = 'global';
+        $record->eventtype = 'site';
         $record->repeat = 0;
         $record->repeats = 0;
         $record->timestart = time();
index 4928d85..a16bebe 100644 (file)
@@ -639,9 +639,10 @@ $CFG->admin = 'admin';
 // to check the latest default in question/classes/bank/view.php before setting this.
 //
 //      $CFG->questionbankcolumns = 'checkbox_column,question_type_column,'
-//              . 'question_name_idnumber_tags_column,tags_action_column,edit_action_column,'
-//              . 'copy_action_column,preview_action_column,delete_action_column,'
-//              . 'creator_name_column,modifier_name_column';
+//              . 'question_name_idnumber_tags_column,'
+//              . 'tags_action_column,edit_action_column,copy_action_column,'
+//              . 'preview_action_column,delete_action_column,export_xml_action_column,'
+//              . 'creator_name_column,modifier_name_column,edit_menu_column';
 //
 // Forum summary report
 //
index 233fa09..835fe66 100644 (file)
@@ -302,8 +302,20 @@ abstract class course_enrolments extends \core_analytics\local\target\binary {
     protected function enrolment_active_during_analysis_time(int $sampleid, int $starttime, int $endtime) {
 
         $userenrol = $this->retrieve('user_enrolments', $sampleid);
-        $enrolstart = $userenrol->timestart ?? $userenrol->timecreated;
-        $enrolend = $userenrol->timeend ?? PHP_INT_MAX;
+
+        if (!empty($userenrol->timestart)) {
+            $enrolstart = $userenrol->timestart;
+        } else {
+            // This is always set.
+            $enrolstart = $userenrol->timecreated;
+        }
+
+        if (!empty($userenrol->timeend)) {
+            $enrolend = $userenrol->timeend;
+        } else {
+            // Default to tre end of the world.
+            $enrolend = PHP_INT_MAX;
+        }
 
         if ($endtime && $endtime < $enrolstart) {
             /* The enrolment starts/ed after the analysis end time.
index da5d4ee..bb2b08a 100644 (file)
@@ -3619,7 +3619,7 @@ class core_course_external extends external_api {
     }
 
     /**
-     * Return structure for edit_module()
+     * Return structure for get_module()
      *
      * @since Moodle 3.3
      * @return external_description
index 90a9710..7bd3148 100644 (file)
@@ -272,8 +272,8 @@ class core_analytics_targets_testcase extends advanced_testcase {
             ],
             'enrol-partly-active-ends-afterwards' => [
                 'starttime' => $now,
-                'endtime' => $now + (WEEKSECS * 9),
-                'timestart' => $now + (WEEKSECS * 10),
+                'endtime' => $now + (WEEKSECS * 10),
+                'timestart' => $now + (WEEKSECS * 9),
                 'timeend' => $now + (WEEKSECS * 11),
                 'nullcalculation' => true,
             ],
@@ -291,12 +291,33 @@ class core_analytics_targets_testcase extends advanced_testcase {
                 'timeend' => false,
                 'nullcalculation' => true,
             ],
-            'enrol-mostly-active-no-enrolment-end' => [
+            'enrol-mostly-active-no-enrolment-end-false' => [
                 'starttime' => $now,
                 'endtime' => $now + (WEEKSECS * 10),
                 'timestart' => $now + WEEKSECS,
                 'timeend' => false,
-                'nullcalculation' => true,
+                'nullcalculation' => false,
+            ],
+            'enrol-mostly-active-no-enrolment-end-zero' => [
+                'starttime' => $now,
+                'endtime' => $now + (WEEKSECS * 10),
+                'timestart' => $now + WEEKSECS,
+                'timeend' => 0,
+                'nullcalculation' => false,
+            ],
+            'enrol-no-enrolment-start-false' => [
+                'starttime' => $now,
+                'endtime' => $now + (WEEKSECS * 10),
+                'timestart' => false,
+                'timeend' => $now + (WEEKSECS * 9),
+                'nullcalculation' => false,
+            ],
+            'enrol-no-enrolment-start-zero' => [
+                'starttime' => $now,
+                'endtime' => $now + (WEEKSECS * 10),
+                'timestart' => 0,
+                'timeend' => $now + (WEEKSECS * 9),
+                'nullcalculation' => false,
             ],
             'no-start' => [
                 'starttime' => 0,
similarity index 86%
rename from filter/h5p/classes/privacy/provider.php
rename to filter/displayh5p/classes/privacy/provider.php
index 215facc..26a482e 100644 (file)
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Privacy Subsystem implementation for filter_h5p.
+ * Privacy Subsystem implementation for filter_displayh5p.
  *
- * @package    filter_h5p
+ * @package    filter_displayh5p
  * @copyright  2019 Victor Deniz <victor@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-namespace filter_h5p\privacy;
+namespace filter_displayh5p\privacy;
 
 defined('MOODLE_INTERNAL') || die;
 
 /**
- * Privacy Subsystem for filter_h5p implementing null_provider.
+ * Privacy Subsystem for filter_displayh5p implementing null_provider.
  *
  * @copyright  2019 Victor Deniz <victor@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
similarity index 94%
rename from filter/h5p/filter.php
rename to filter/displayh5p/filter.php
index fcb35ec..e1b0cf7 100644 (file)
@@ -14,9 +14,9 @@
 // You should have received a copy of the GNU General Public License
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 /**
- * H5P filter
+ * Display H5P filter
  *
- * @package    filter_h5p
+ * @package    filter_displayh5p
  * @copyright  2019 Victor Deniz <victor@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 defined('MOODLE_INTERNAL') || die;
 
 /**
- * H5P filter
+ * Display H5P filter
  *
  * This filter will replace any occurrence of H5P URLs with the corresponding H5P content embed code
  *
- * @package    filter_h5p
+ * @package    filter_displayh5p
  * @copyright  2019 Victor Deniz <victor@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class filter_h5p extends moodle_text_filter {
+class filter_displayh5p extends moodle_text_filter {
 
     /**
      * @var boolean $loadresizerjs This is whether to request the resize.js script.
@@ -57,7 +57,7 @@ class filter_h5p extends moodle_text_filter {
             return $text;
         }
 
-        $allowedsources = get_config('filter_h5p', 'allowedsources');
+        $allowedsources = get_config('filter_displayh5p', 'allowedsources');
         $allowedsources = array_filter(array_map('trim', explode("\n", $allowedsources)));
         if (empty($allowedsources)) {
             return $text;
similarity index 92%
rename from filter/h5p/lang/en/filter_h5p.php
rename to filter/displayh5p/lang/en/filter_displayh5p.php
index 38f4460..6317fb6 100644 (file)
@@ -15,9 +15,9 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Strings for filter_h5p
+ * Strings for filter_displayh5p
  *
- * @package    filter_h5p
+ * @package    filter_displayh5p
  * @copyright  2019 Victor Deniz <victor@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
@@ -30,5 +30,5 @@ $string['allowedsourceslistdesc'] = 'List of sources from which users can embed
 <b>[id]</b> is a placeholder for the H5P content id in the external source.
 
 <b>*</b> wildcard is supported. For example, *.example.com will embed H5P content from any subdomain of example.com, but not from the example.com domain.';
-$string['filtername'] = 'H5P';
+$string['filtername'] = 'Display H5P';
 $string['privacy:metadata'] = 'This H5P filter does not store any personal data.';
similarity index 76%
rename from filter/h5p/settings.php
rename to filter/displayh5p/settings.php
index 6102eeb..ca51990 100644 (file)
@@ -15,9 +15,9 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * H5P filter settings
+ * Display H5P filter settings
  *
- * @package    filter_h5p
+ * @package    filter_displayh5p
  * @copyright  2019 Victor Deniz <victor@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 defined('MOODLE_INTERNAL') || die;
 
 if ($ADMIN->fulltree) {
-    $settings->add(new admin_setting_configtextarea('filter_h5p/allowedsources', get_string('allowedsourceslist', 'filter_h5p'),
-            get_string('allowedsourceslistdesc', 'filter_h5p'),
+    $settings->add(new admin_setting_configtextarea(
+        'filter_displayh5p/allowedsources',
+            get_string('allowedsourceslist',
+            'filter_displayh5p'),
+            get_string('allowedsourceslistdesc', 'filter_displayh5p'),
             "https://h5p.org/h5p/embed/[id]\nhttps://*.h5p.com/content/[id]/embed\nhttps://*.h5p.com/content/[id]"));
 }
similarity index 81%
rename from filter/h5p/tests/filter_test.php
rename to filter/displayh5p/tests/filter_test.php
index 2e17c10..2ac3aae 100644 (file)
@@ -15,9 +15,9 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Unit tests for the filter_h5p
+ * Unit tests for the filter_displayh5p
  *
- * @package    filter_h5p
+ * @package    filter_displayh5p
  * @category   test
  * @copyright  2019 Victor Deniz <victor@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 defined('MOODLE_INTERNAL') || die();
 
 global $CFG;
-require_once($CFG->dirroot.'/filter/h5p/filter.php');
+require_once($CFG->dirroot.'/filter/displayh5p/filter.php');
 
 /**
- * Unit tests for the H5P filter.
+ * Unit tests for the display H5P filter.
  *
  * @copyright 2019 Victor Deniz <victor@moodle.com>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class filter_h5p_testcase extends advanced_testcase {
+class filter_displayh5p_testcase extends advanced_testcase {
 
     public function setUp() {
         parent::setUp();
 
         $this->resetAfterTest(true);
 
-        set_config('allowedsources', "https://h5p.org/h5p/embed/[id]\nhttps://*.h5p.com/content/[id]/embed\nhttps://*.h5p.com/content/[id]
-                \nhttps://generic.wordpress.soton.ac.uk/altc/wp-admin/admin-ajax.php?action=h5p_embed&id=[id]", 'filter_h5p');
-        // Enable h5p filter at top level.
-        filter_set_global_state('h5p', TEXTFILTER_ON);
+        set_config('allowedsources',
+            "https://h5p.org/h5p/embed/[id]\nhttps://*.h5p.com/content/[id]/embed\nhttps://*.h5p.com/content/[id]
+                \nhttps://generic.wordpress.soton.ac.uk/altc/wp-admin/admin-ajax.php?action=h5p_embed&id=[id]",
+            'filter_displayh5p');
+        // Enable display h5p filter at top level.
+        filter_set_global_state('displayh5p', TEXTFILTER_ON);
     }
 
     /**
      * Check that h5p tags with urls from allowed domains are filtered.
      *
      * @param string $text Original text
-     * @param string $filteredtextpattern Text pattern after H5P filter
+     * @param string $filteredtextpattern Text pattern after display H5P filter
      *
      * @dataProvider texts_provider
      */
     public function test_filter_urls($text, $filteredtextpattern) {
 
-        $filterplugin = new filter_h5p(null, array());
+        $filterplugin = new filter_displayh5p(null, array());
 
         $filteredtext = $filterplugin->filter($text);
         $this->assertRegExp($filteredtextpattern, $filteredtext);
similarity index 86%
rename from filter/h5p/version.php
rename to filter/displayh5p/version.php
index 343b5d2..59766e7 100644 (file)
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Version of filter_h5p.
+ * Version of filter_displayh5p.
  *
- * @package    filter_h5p
+ * @package    filter_displayh5p
  * @copyright  2019 Victor Deniz <victor@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
 defined('MOODLE_INTERNAL') || die;
 
-$plugin->version  = 2019092000;
+$plugin->version  = 2019101800;
 $plugin->requires = 2019092000;
-$plugin->component = 'filter_h5p';
+$plugin->component = 'filter_displayh5p';
index 8bbd6e5..da5f866 100644 (file)
@@ -514,7 +514,7 @@ class gradereport_user_external extends external_api {
                                         'numusers' => new external_value(PARAM_INT, 'Num users in course', VALUE_OPTIONAL),
                                         'averageformatted' => new external_value(PARAM_NOTAGS, 'Grade average', VALUE_OPTIONAL),
                                         'feedback' => new external_value(PARAM_RAW, 'Grade feedback', VALUE_OPTIONAL),
-                                        'feedbackformat' => new external_format_value('feedback'),
+                                        'feedbackformat' => new external_format_value('feedback', VALUE_OPTIONAL),
                                     ), 'Grade items'
                                 )
                             )
index e391454..82d92dd 100644 (file)
@@ -42,7 +42,7 @@ $string['cannotunzipfile'] = 'Αδυναμία αποσυμπίεσης αρχε
 $string['componentisuptodate'] = 'Το στοιχείο λογισμικού είναι ενημερωμένο.';
 $string['dmlexceptiononinstall'] = '<p>Παρουσιάστηκε κάποιο σφάλμα βάσης δεδομένων [{$a->errorcode}].<br />{$a->debuginfo}</p>';
 $string['downloadedfilecheckfailed'] = 'Αποτυχία ελέγχου αρχείου που έγινε λήψη';
-$string['invalidmd5'] = 'Î\9cη Î­Î³ÎºÏ\85Ï\81ο md5';
+$string['invalidmd5'] = 'Î\97 Î¼ÎµÏ\84αβληÏ\84ή ÎµÎ»Î­Î³Ï\87οÏ\85 Î®Ï\84αν Î»Î±Î½Î¸Î±Ï\83μένη - Î´Î¿ÎºÎ¹Î¼Î¬Ï\83Ï\84ε Î¾Î±Î½Î¬';
 $string['missingrequiredfield'] = 'Κάποιο απαιτούμενο πεδίο λείπει';
 $string['remotedownloaderror'] = '<p>Απέτυχε η λήψη του τμήματος στον εξυπηρετητή σας. Παρακαλούμε επιβεβαιώστε τις ρυθμίσεις του διακομιστή μεσολάβησης (proxy)· η επέκταση PHP cURL συνιστάται θερμά.</p> <p>Πρέπει να κατεβάσετε το <a href="{$a->url}">{$a->url}</a> χειροκίνητα, να το αντιγράψετε στο «{$a->dest}» στον εξυπηρετητή σας και να το αποσυμπιέσετε εκεί.</p>';
 $string['wrongdestpath'] = 'Λανθασμένη διαδρομή προορισμού (πλήρες όνομα).';
index d05ce4a..6104329 100644 (file)
@@ -44,7 +44,7 @@ $string['dataroot'] = 'Dossier de données';
 $string['datarootpermission'] = 'Droits d\'accès au dossier de données';
 $string['dbprefix'] = 'Préfixe des tables';
 $string['dirroot'] = 'Dossier Moodle';
-$string['environmenthead'] = 'Vérification de l\'environnement...';
+$string['environmenthead'] = 'Vérification de l\'environnement';
 $string['environmentsub2'] = 'Chaque version de Moodle nécessite une version minimale de certains composants PHP et des extensions de PHP obligatoires. Une vérification complète de l\'environnement est effectuée avec chaque installation et chaque mise à jour. Veuillez contacter l\'administrateur du serveur si vous ne savez pas comment installer une nouvelle version ou activer des extensions de PHP.';
 $string['errorsinenvironment'] = 'Échec de la vérification de l\'environnement !';
 $string['installation'] = 'Installation';
index b49c0a3..ad10a50 100644 (file)
@@ -53,6 +53,7 @@ $string['allowcoursethemes'] = 'Allow course themes';
 $string['allowediplist'] = 'Allowed IP list';
 $string['allowedemaildomains'] = 'Allowed email domains';
 $string['allowemailaddresses'] = 'Allowed email domains';
+$string['allowemojipicker'] = 'Enable emoji picker';
 $string['allowindexing'] = 'Allow indexing by search engines';
 $string['allowindexing_desc'] = 'This determines whether to allow search engines to index your site. "Everywhere" will allow the search engines to search everywhere including login and signup pages, which means sites with Force Login turned on are still indexed. To avoid the risk of spam involved with the signup page being searchable, use "Everywhere except login and signup pages". "Nowhere" will tell search engines not to index any page. Note this is only a tag in the header of the site. It is up to the search engine to respect the tag.';
 $string['allowindexingeverywhere'] = 'Everywhere';
@@ -153,6 +154,8 @@ $string['configallowcohortthemes'] = 'If you enable this, then themes can be set
 $string['configallowcoursethemes'] = 'If you enable this, then courses will be allowed to set their own themes.  Course themes override all other theme choices (site, user, or session themes)';
 $string['configallowedemaildomains'] = 'List email domains that are allowed to be disclosed in the "From" section of outgoing email. The default of "Empty" will use the No-reply address for all outgoing email. The use of wildcards is allowed e.g. *.example.com will allow emails sent from any subdomain of example.com, but not example.com itself. This will require separate entry.';
 $string['configallowemailaddresses'] = 'To restrict new email addresses to particular domains, list them here separated by spaces. All other domains will be rejected. To allow subdomains, add the domain with a preceding \'.\'. To allow a root domain together with its subdomains, add the domain twice - once with a preceding \'.\' and once without e.g. .ourcollege.edu.au ourcollege.edu.au.';
+$string['configallowemojipicker'] = 'If enabled, the emoji picker will be available within the site';
+$string['configallowemojipickerincompatible'] = 'Your current database configuration does not properly support emojis. In order to enable the emoji picker you will need to <a href="https://docs.moodle.org/37/en/MySQL_full_unicode_support">upgrade your database for full unicode support</a>.';
 $string['configallowguestmymoodle'] = 'If enabled guests can access Dashboard, otherwise guests are redirected to the site front page.';
 $string['configallowobjectembed'] = 'As a default security measure, normal users are not allowed to embed multimedia (like Flash) within texts using explicit EMBED and OBJECT tags in their HTML (although it can still be done safely using the mediaplugins filter).  If you wish to allow these tags then enable this option.';
 $string['configallowoverride'] = 'You can allow people with the roles on the left side to override some of the column roles';
@@ -989,11 +992,11 @@ $string['profilingautofrec_help'] = 'By configuring this setting, some request (
 $string['profilingenabled'] = 'Enable profiling';
 $string['profilingenabled_help'] = 'If you enable this setting, then profiling will be available in this site and you will be able to define its behavior by configuring the next options.';
 $string['profilingexcluded'] = 'Exclude profiling';
-$string['profilingexcluded_help'] = 'List of (comma separated, absolute skipping wwwroot, callable) URLs that will be excluded from being profiled from the ones defined by \'Profile these\' setting.';
+$string['profilingexcluded_help'] = 'List of (comma or newline separated, absolute skipping wwwroot, callable) URLs that will be excluded from being profiled from the ones defined by \'Profile these\' setting.';
 $string['profilingimportprefix'] = 'Profiling import prefix';
 $string['profilingimportprefix_desc'] = 'For easier detection, all the imported profiling runs will be prefixed with the value specified here.';
 $string['profilingincluded'] = 'Profile these';
-$string['profilingincluded_help'] = 'List of (comma separated, absolute skipping wwwroot, callable) URLs that will be automatically profiled. Examples: /index.php, /course/view.php. Also accepts the * wildchar at any position. Examples: /mod/forum/*, /mod/*/view.php.';
+$string['profilingincluded_help'] = 'List of (comma or newline separated, absolute skipping wwwroot, callable) URLs that will be automatically profiled. Examples: /index.php, /course/view.php. Also accepts the * wildchar at any position. Examples: /mod/forum/*, /mod/*/view.php.';
 $string['profilinglifetime'] = 'Keep profiling runs';
 $string['profilinglifetime_help'] = 'Specify the time you want to keep information about old profiling runs. Older ones will be pruned periodically. Note that this excludes any profiling run marked as \'reference run\'.';
 $string['profilingslow'] = 'Profile slow pages';
index 1c72cb1..c32225a 100644 (file)
@@ -46,8 +46,10 @@ $string['errorimportmissingcomponents'] = 'The provided model requires the follo
 $string['errorimportversionmismatches'] = 'The version of the following components differs from the version installed on this site: {$a}. You can use the option \'Ignore version mismatches\' to ignore these differences.';
 $string['errorimportmissingclasses'] = 'The following analytics components are not available on this site: {$a->missingclasses}.';
 $string['errorinvalidindicator'] = 'Invalid {$a} indicator';
+$string['errorinvalidcontexts'] = 'Some of the selected contexts can not be used in this target.';
 $string['errorinvalidtarget'] = 'Invalid {$a} target';
 $string['errorinvalidtimesplitting'] = 'Invalid analysis interval; please ensure you add the fully qualified class name.';
+$string['errornocontextrestrictions'] = 'The selected target does not support context restrictions';
 $string['errornoexportconfig'] = 'There was a problem exporting the model configuration.';
 $string['errornoexportconfigrequirements'] = 'Only non-static models with an analysis interval can be exported.';
 $string['errornoindicators'] = 'This model does not have any indicators.';
index 8ed97c4..cea9d7e 100644 (file)
@@ -32,6 +32,7 @@ $string['calendarheading'] = '{$a} Calendar';
 $string['calendarpreferences'] = 'Calendar preferences';
 $string['calendartypes'] = 'Calendar types';
 $string['calendarurl'] = 'Calendar URL: {$a}';
+$string['category'] = 'Category';
 $string['categoryevent'] = 'Category event';
 $string['clickhide'] = 'click to hide';
 $string['clickshow'] = 'click to show';
@@ -142,16 +143,12 @@ $string['forcecalendartype'] = 'Force calendar';
 $string['fri'] = 'Fri';
 $string['friday'] = 'Friday';
 $string['generateurlbutton'] = 'Get calendar URL';
-$string['global'] = 'Global';
-$string['globalevent'] = 'Global event';
-$string['globalevents'] = 'Global events';
 $string['gotoactivity'] = 'Go to activity';
 $string['gotocalendar'] = 'Go to calendar';
 $string['group'] = 'Group';
 $string['groupevent'] = 'Group event';
 $string['groupevents'] = 'Group events';
-$string['eventtypeglobal'] = 'global';
-$string['eventtypesite'] = 'global';
+$string['eventtypesite'] = 'site';
 $string['eventtypecategory'] = 'category';
 $string['eventtypecourse'] = 'course';
 $string['eventtypemodule'] = 'module';
@@ -225,6 +222,8 @@ $string['repeatweeksr'] = 'events';
 $string['sat'] = 'Sat';
 $string['saturday'] = 'Saturday';
 $string['shown'] = 'shown';
+$string['site'] = 'Site';
+$string['siteevent'] = 'Site event';
 $string['siteevents'] = 'Site events';
 $string['spanningevents'] = 'Events underway';
 $string['subscriptions'] = 'Subscriptions';
@@ -271,3 +270,9 @@ $string['when'] = 'When';
 $string['whendate'] = 'When: {$a}';
 $string['yesterday'] = 'Yesterday';
 $string['youcandeleteallrepeats'] = 'This event is part of a repeating event series. You can delete this event only, or all {$a} events in the series at once.';
+
+// Deprecated since Moodle 3.8.
+$string['global'] = 'Global';
+$string['globalevent'] = 'Global event';
+$string['globalevents'] = 'Global events';
+$string['eventtypeglobal'] = 'global';
index 861c15a..f343ba6 100644 (file)
@@ -116,4 +116,8 @@ registersite,core_hub
 updatesite,core_hub
 unregisterexplained,core_hub
 configrequestcategoryselection,core_admin
-requestcategoryselection,core_admin
\ No newline at end of file
+requestcategoryselection,core_admin
+global,core_calendar
+globalevent,core_calendar
+globalevents,core_calendar
+eventtypeglobal,core_calendar
index 48edfc5..4fd52e3 100644 (file)
@@ -727,6 +727,15 @@ $string['emailstop'] = 'Email stop';
 $string['emailtoprivatefiles'] = 'You can also e-mail files as attachments straight to your private files space. Simply attach your files to an e-mail and send it to {$a}';
 $string['emailtoprivatefilesdenied'] = 'Your administrator has disabled the option to upload your own private files.';
 $string['emailvia'] = '{$a->name} (via {$a->siteshortname})';
+$string['emojicategoryactivities'] = 'Activities';
+$string['emojicategoryanimalsnature'] = 'Animals & nature';
+$string['emojicategoryflags'] = 'Flags';
+$string['emojicategoryfooddrink'] = 'Food & drink';
+$string['emojicategoryobjects'] = 'Objects';
+$string['emojicategoryrecent'] = 'Recent';
+$string['emojicategorysmileyspeople'] = 'Smileys & people';
+$string['emojicategorysymbols'] = 'Symbols';
+$string['emojicategorytravelplaces'] = 'Travel & places';
 $string['emptydragdropregion'] = 'empty region';
 $string['enable'] = 'Enable';
 $string['encryptedcode'] = 'Encrypted code';
index 8da8aa4..94cc9df 100644 (file)
@@ -157,6 +157,7 @@ $string['eventquestionsexported'] = 'Questions exported';
 $string['eventquestionsimported'] = 'Questions imported';
 $string['eventquestionupdated'] = 'Question updated';
 $string['export'] = 'Export';
+$string['exportasxml'] = 'Export as Moodle XML';
 $string['exportcategory'] = 'Export category';
 $string['exportcategory_help'] = 'This setting determines the category from which the exported questions will be taken.
 
diff --git a/lib/amd/build/emoji/auto_complete.min.js b/lib/amd/build/emoji/auto_complete.min.js
new file mode 100644 (file)
index 0000000..99d9559
Binary files /dev/null and b/lib/amd/build/emoji/auto_complete.min.js differ
diff --git a/lib/amd/build/emoji/auto_complete.min.js.map b/lib/amd/build/emoji/auto_complete.min.js.map
new file mode 100644 (file)
index 0000000..cc4124a
Binary files /dev/null and b/lib/amd/build/emoji/auto_complete.min.js.map differ
diff --git a/lib/amd/build/emoji/data.min.js b/lib/amd/build/emoji/data.min.js
new file mode 100644 (file)
index 0000000..6bb156d
Binary files /dev/null and b/lib/amd/build/emoji/data.min.js differ
diff --git a/lib/amd/build/emoji/data.min.js.map b/lib/amd/build/emoji/data.min.js.map
new file mode 100644 (file)
index 0000000..4dfdda8
Binary files /dev/null and b/lib/amd/build/emoji/data.min.js.map differ
diff --git a/lib/amd/build/emoji/picker.min.js b/lib/amd/build/emoji/picker.min.js
new file mode 100644 (file)
index 0000000..03f82f0
Binary files /dev/null and b/lib/amd/build/emoji/picker.min.js differ
diff --git a/lib/amd/build/emoji/picker.min.js.map b/lib/amd/build/emoji/picker.min.js.map
new file mode 100644 (file)
index 0000000..1738e97
Binary files /dev/null and b/lib/amd/build/emoji/picker.min.js.map differ
index 1688749..45a80ae 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js and b/lib/amd/build/form-autocomplete.min.js differ
index 11e43c8..2a0d468 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js.map and b/lib/amd/build/form-autocomplete.min.js.map differ
diff --git a/lib/amd/build/utils.min.js b/lib/amd/build/utils.min.js
new file mode 100644 (file)
index 0000000..ba55c9c
Binary files /dev/null and b/lib/amd/build/utils.min.js differ
diff --git a/lib/amd/build/utils.min.js.map b/lib/amd/build/utils.min.js.map
new file mode 100644 (file)
index 0000000..5d08362
Binary files /dev/null and b/lib/amd/build/utils.min.js.map differ
diff --git a/lib/amd/src/emoji/auto_complete.js b/lib/amd/src/emoji/auto_complete.js
new file mode 100644 (file)
index 0000000..5b4c003
--- /dev/null
@@ -0,0 +1,326 @@
+// 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/>.
+
+/**
+ * Emoji auto complete.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+import * as EmojiData from 'core/emoji/data';
+import {render as renderTemplate} from 'core/templates';
+import {debounce} from 'core/utils';
+import LocalStorage from 'core/localstorage';
+import KeyCodes from 'core/key_codes';
+
+const INPUT_DEBOUNCE_TIMER = 200;
+const SUGGESTION_LIMIT = 50;
+const MAX_RECENT_COUNT = 27;
+const RECENT_EMOJIS_STORAGE_KEY = 'moodle-recent-emojis';
+
+const SELECTORS = {
+    EMOJI_BUTTON: '[data-region="emoji-button"]',
+    ACTIVE_EMOJI_BUTTON: '[data-region="emoji-button"].active',
+};
+
+/**
+ * Get the list of recent emojis data from local storage.
+ *
+ * @return {Array}
+ */
+const getRecentEmojis = () => {
+    const storedData = LocalStorage.get(RECENT_EMOJIS_STORAGE_KEY);
+    return storedData ? JSON.parse(storedData) : [];
+};
+
+/**
+ * Add an emoji data to the set of recent emojis. The new set of recent emojis are
+ * saved in local storage.
+ *
+ * @param {String} unified The char chodes for the emoji
+ * @param {String} shortName The emoji short name
+ */
+const addRecentEmoji = (unified, shortName) => {
+    const newEmoji = {
+        unified,
+        shortnames: [shortName]
+    };
+    const recentEmojis = getRecentEmojis();
+    // Add the new emoji to the start of the list of recent emojis.
+    let newRecentEmojis = [newEmoji, ...recentEmojis.filter(emoji => emoji.unified != newEmoji.unified)];
+    // Limit the number of recent emojis.
+    newRecentEmojis = newRecentEmojis.slice(0, MAX_RECENT_COUNT);
+
+    LocalStorage.set(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(newRecentEmojis));
+};
+
+/**
+ * Get the actual emoji string from the short name.
+ *
+ * @param {String} shortName Emoji short name
+ * @return {String|null}
+ */
+const getEmojiTextFromShortName = (shortName) => {
+    const unified = EmojiData.byShortName[shortName];
+
+    if (unified) {
+        const charCodes = unified.split('-').map(code => `0x${code}`);
+        return String.fromCodePoint.apply(null, charCodes);
+    } else {
+        return null;
+    }
+};
+
+/**
+ * Render the auto complete list for the given short names.
+ *
+ * @param {Element} root The root container for the emoji auto complete
+ * @param {Array} shortNames The list of short names for emoji suggestions to show
+ */
+const render = async (root, shortNames) => {
+    const renderContext = {
+        emojis: shortNames.map((shortName, index) => {
+            return {
+                active: index === 0,
+                emojitext: getEmojiTextFromShortName(shortName),
+                displayshortname: `:${shortName}:`,
+                shortname: shortName,
+                unified: EmojiData.byShortName[shortName]
+            };
+        })
+    };
+    const html = await renderTemplate('core/emoji/auto_complete', renderContext);
+    root.innerHTML = html;
+};
+
+/**
+ * Get the list of emoji short names that include the given search term. If
+ * the search term is an empty string then the list of recently used emojis
+ * will be returned.
+ *
+ * @param {String} searchTerm Text to match on
+ * @param {Number} limit Maximum number of results to return
+ * @return {Array}
+ */
+const searchEmojis = (searchTerm, limit) => {
+    if (searchTerm === '') {
+        return getRecentEmojis().map(data => data.shortnames[0]).slice(0, limit);
+    } else {
+        searchTerm = searchTerm.toLowerCase();
+        return Object.keys(EmojiData.byShortName)
+                .filter(shortName => shortName.includes(searchTerm))
+                .slice(0, limit);
+    }
+};
+
+/**
+ * Get the current word at the given position (index) within the text.
+ *
+ * @param {String} text The text to process
+ * @param {Number} position The position (index) within the text to match the word
+ * @return {String}
+ */
+const getWordFromPosition = (text, position) => {
+    const startMatches = text.slice(0, position).match(/(\S*)$/);
+    const endMatches = text.slice(position).match(/^(\S*)/);
+    let startText = '';
+    let endText = '';
+
+    if (startMatches) {
+        startText = startMatches[startMatches.length - 1];
+    }
+
+    if (endMatches) {
+        endText = endMatches[endMatches.length - 1];
+    }
+
+    return `${startText}${endText}`;
+};
+
+/**
+ * Check if the given text is a full short name, i.e. has leading and trialing colon
+ * characters.
+ *
+ * @param {String} text The text to process
+ * @return {Bool}
+ */
+const isCompleteShortName = text => /^:[^:\s]+:$/.test(text);
+
+/**
+ * Check if the given text is a partial short name, i.e. has a leading colon but no
+ * trailing colon.
+ *
+ * @param {String} text The text to process
+ * @return {Bool}
+ */
+const isPartialShortName = text => /^:[^:\s]*$/.test(text);
+
+/**
+ * Remove the colon characters from the given text.
+ *
+ * @param {String} text The text to process
+ * @return {String}
+ */
+const getShortNameFromText = text => text.replace(/:/g, '');
+
+/**
+ * Get the currently active emoji button element in the list of suggestions.
+ *
+ * @param {Element} root The emoji auto complete container element
+ * @return {Element|null}
+ */
+const getActiveEmojiSuggestion = (root) => {
+    return root.querySelector(SELECTORS.ACTIVE_EMOJI_BUTTON);
+};
+
+/**
+ * Make the previous sibling of the current active emoji active.
+ *
+ * @param {Element} root The emoji auto complete container element
+ */
+const selectPreviousEmojiSuggestion = (root) => {
+    const activeEmojiSuggestion = getActiveEmojiSuggestion(root);
+    const previousSuggestion = activeEmojiSuggestion.previousElementSibling;
+
+    if (previousSuggestion) {
+        activeEmojiSuggestion.classList.remove('active');
+        previousSuggestion.classList.add('active');
+        previousSuggestion.scrollIntoView({behaviour: 'smooth', inline: 'center'});
+    }
+};
+
+/**
+ * Make the next sibling to the current active emoji active.
+ *
+ * @param {Element} root The emoji auto complete container element
+ */
+const selectNextEmojiSuggestion = (root) => {
+    const activeEmojiSuggestion = getActiveEmojiSuggestion(root);
+    const nextSuggestion = activeEmojiSuggestion.nextElementSibling;
+
+    if (nextSuggestion) {
+        activeEmojiSuggestion.classList.remove('active');
+        nextSuggestion.classList.add('active');
+        nextSuggestion.scrollIntoView({behaviour: 'smooth', inline: 'center'});
+    }
+};
+
+/**
+ * Trigger the select callback for the given emoji button element.
+ *
+ * @param {Element} element The emoji button element
+ * @param {Function} selectCallback The callback for when the user selects an emoji
+ */
+const selectEmojiElement = (element, selectCallback) => {
+    const shortName = element.getAttribute('data-short-name');
+    const unified = element.getAttribute('data-unified');
+    addRecentEmoji(unified, shortName);
+    selectCallback(element.innerHTML.trim());
+};
+
+/**
+ * Initialise the emoji auto complete.
+ *
+ * @param {Element} root The root container element for the auto complete
+ * @param {Element} textArea The text area element to monitor for auto complete
+ * @param {Function} hasSuggestionCallback Callback for when there are auto-complete suggestions
+ * @param {Function} selectCallback Callback for when the user selects an emoji
+ */
+export default (root, textArea, hasSuggestionCallback, selectCallback) => {
+    let hasSuggestions = false;
+    let previousSearchText = '';
+
+    // Debounce the listener so that each keypress delays the execution of the handler. The
+    // handler should only run 200 milliseconds after the last keypress.
+    textArea.addEventListener('keyup', debounce(() => {
+        // This is a "keyup" listener so that it only executes after the text area value
+        // has been updated.
+        const text = textArea.value;
+        const cursorPos = textArea.selectionStart;
+        const searchText = getWordFromPosition(text, cursorPos);
+
+        if (searchText === previousSearchText) {
+            // Nothing has changed so no need to take any action.
+            return;
+        } else {
+            previousSearchText = searchText;
+        }
+
+        if (isCompleteShortName(searchText)) {
+            // If the user has entered a full short name (with leading and trialing colons)
+            // then see if we can find a match for it and auto complete it.
+            const shortName = getShortNameFromText(searchText);
+            const emojiText = getEmojiTextFromShortName(shortName);
+            hasSuggestions = false;
+            if (emojiText) {
+                addRecentEmoji(EmojiData.byShortName[shortName], shortName);
+                selectCallback(emojiText);
+            }
+        } else if (isPartialShortName(searchText)) {
+            // If the user has entered a partial short name (leading colon but no trailing) then
+            // search on the text to see if we can find some suggestions for them.
+            const suggestions = searchEmojis(getShortNameFromText(searchText), SUGGESTION_LIMIT);
+
+            if (suggestions.length) {
+                render(root, suggestions);
+                hasSuggestions = true;
+            } else {
+                hasSuggestions = false;
+            }
+        } else {
+            hasSuggestions = false;
+        }
+
+        hasSuggestionCallback(hasSuggestions);
+    }, INPUT_DEBOUNCE_TIMER));
+
+    textArea.addEventListener('keydown', (e) => {
+        if (hasSuggestions) {
+            const isModifierPressed = (e.shiftKey || e.metaKey || e.altKey || e.ctrlKey);
+            if (!isModifierPressed) {
+                switch (e.which) {
+                    case KeyCodes.escape:
+                        // Escape key closes the auto complete.
+                        hasSuggestions = false;
+                        hasSuggestionCallback(false);
+                        break;
+                    case KeyCodes.arrowLeft:
+                        // Arrow keys navigate through the list of suggetions.
+                        selectPreviousEmojiSuggestion(root);
+                        e.preventDefault();
+                        break;
+                    case KeyCodes.arrowRight:
+                        // Arrow keys navigate through the list of suggetions.
+                        selectNextEmojiSuggestion(root);
+                        e.preventDefault();
+                        break;
+                    case KeyCodes.enter:
+                        // Enter key selects the current suggestion.
+                        selectEmojiElement(getActiveEmojiSuggestion(root), selectCallback);
+                        e.preventDefault();
+                        e.stopPropagation();
+                        break;
+                }
+            }
+        }
+    });
+
+    root.addEventListener('click', (e) => {
+        const target = e.target;
+        if (target.matches(SELECTORS.EMOJI_BUTTON)) {
+            selectEmojiElement(target, selectCallback);
+        }
+    });
+};
\ No newline at end of file
diff --git a/lib/amd/src/emoji/data.js b/lib/amd/src/emoji/data.js
new file mode 100644 (file)
index 0000000..72d4c35
--- /dev/null
@@ -0,0 +1,10818 @@
+// 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/>.
+
+/**
+ * Emoji data based on the data available from https://github.com/iamcal/emoji-data.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+export const byCategory = [
+    {
+        "name": "Smileys & People",
+        "emojis": [
+            {
+                "unified": "1F600",
+                "shortnames": [
+                    "grinning"
+                ]
+            },
+            {
+                "unified": "1F601",
+                "shortnames": [
+                    "grin"
+                ]
+            },
+            {
+                "unified": "1F602",
+                "shortnames": [
+                    "joy"
+                ]
+            },
+            {
+                "unified": "1F923",
+                "shortnames": [
+                    "rolling_on_the_floor_laughing"
+                ]
+            },
+            {
+                "unified": "1F603",
+                "shortnames": [
+                    "smiley"
+                ]
+            },
+            {
+                "unified": "1F604",
+                "shortnames": [
+                    "smile"
+                ]
+            },
+            {
+                "unified": "1F605",
+                "shortnames": [
+                    "sweat_smile"
+                ]
+            },
+            {
+                "unified": "1F606",
+                "shortnames": [
+                    "laughing"
+                ]
+            },
+            {
+                "unified": "1F609",
+                "shortnames": [
+                    "wink"
+                ]
+            },
+            {
+                "unified": "1F60A",
+                "shortnames": [
+                    "blush"
+                ]
+            },
+            {
+                "unified": "1F60B",
+                "shortnames": [
+                    "yum"
+                ]
+            },
+            {
+                "unified": "1F60E",
+                "shortnames": [
+                    "sunglasses"
+                ]
+            },
+            {
+                "unified": "1F60D",
+                "shortnames": [
+                    "heart_eyes"
+                ]
+            },
+            {
+                "unified": "1F618",
+                "shortnames": [
+                    "kissing_heart"
+                ]
+            },
+            {
+                "unified": "1F617",
+                "shortnames": [
+                    "kissing"
+                ]
+            },
+            {
+                "unified": "1F619",
+                "shortnames": [
+                    "kissing_smiling_eyes"
+                ]
+            },
+            {
+                "unified": "1F61A",
+                "shortnames": [
+                    "kissing_closed_eyes"
+                ]
+            },
+            {
+                "unified": "263A-FE0F",
+                "shortnames": [
+                    "relaxed"
+                ]
+            },
+            {
+                "unified": "1F642",
+                "shortnames": [
+                    "slightly_smiling_face"
+                ]
+            },
+            {
+                "unified": "1F917",
+                "shortnames": [
+                    "hugging_face"
+                ]
+            },
+            {
+                "unified": "1F929",
+                "shortnames": [
+                    "star-struck"
+                ]
+            },
+            {
+                "unified": "1F914",
+                "shortnames": [
+                    "thinking_face"
+                ]
+            },
+            {
+                "unified": "1F928",
+                "shortnames": [
+                    "face_with_raised_eyebrow"
+                ]
+            },
+            {
+                "unified": "1F610",
+                "shortnames": [
+                    "neutral_face"
+                ]
+            },
+            {
+                "unified": "1F611",
+                "shortnames": [
+                    "expressionless"
+                ]
+            },
+            {
+                "unified": "1F636",
+                "shortnames": [
+                    "no_mouth"
+                ]
+            },
+            {
+                "unified": "1F644",
+                "shortnames": [
+                    "face_with_rolling_eyes"
+                ]
+            },
+            {
+                "unified": "1F60F",
+                "shortnames": [
+                    "smirk"
+                ]
+            },
+            {
+                "unified": "1F623",
+                "shortnames": [
+                    "persevere"
+                ]
+            },
+            {
+                "unified": "1F625",
+                "shortnames": [
+                    "disappointed_relieved"
+                ]
+            },
+            {
+                "unified": "1F62E",
+                "shortnames": [
+                    "open_mouth"
+                ]
+            },
+            {
+                "unified": "1F910",
+                "shortnames": [
+                    "zipper_mouth_face"
+                ]
+            },
+            {
+                "unified": "1F62F",
+                "shortnames": [
+                    "hushed"
+                ]
+            },
+            {
+                "unified": "1F62A",
+                "shortnames": [
+                    "sleepy"
+                ]
+            },
+            {
+                "unified": "1F62B",
+                "shortnames": [
+                    "tired_face"
+                ]
+            },
+            {
+                "unified": "1F634",
+                "shortnames": [
+                    "sleeping"
+                ]
+            },
+            {
+                "unified": "1F60C",
+                "shortnames": [
+                    "relieved"
+                ]
+            },
+            {
+                "unified": "1F61B",
+                "shortnames": [
+                    "stuck_out_tongue"
+                ]
+            },
+            {
+                "unified": "1F61C",
+                "shortnames": [
+                    "stuck_out_tongue_winking_eye"
+                ]
+            },
+            {
+                "unified": "1F61D",
+                "shortnames": [
+                    "stuck_out_tongue_closed_eyes"
+                ]
+            },
+            {
+                "unified": "1F924",
+                "shortnames": [
+                    "drooling_face"
+                ]
+            },
+            {
+                "unified": "1F612",
+                "shortnames": [
+                    "unamused"
+                ]
+            },
+            {
+                "unified": "1F613",
+                "shortnames": [
+                    "sweat"
+                ]
+            },
+            {
+                "unified": "1F614",
+                "shortnames": [
+                    "pensive"
+                ]
+            },
+            {
+                "unified": "1F615",
+                "shortnames": [
+                    "confused"
+                ]
+            },
+            {
+                "unified": "1F643",
+                "shortnames": [
+                    "upside_down_face"
+                ]
+            },
+            {
+                "unified": "1F911",
+                "shortnames": [
+                    "money_mouth_face"
+                ]
+            },
+            {
+                "unified": "1F632",
+                "shortnames": [
+                    "astonished"
+                ]
+            },
+            {
+                "unified": "2639-FE0F",
+                "shortnames": [
+                    "white_frowning_face"
+                ]
+            },
+            {
+                "unified": "1F641",
+                "shortnames": [
+                    "slightly_frowning_face"
+                ]
+            },
+            {
+                "unified": "1F616",
+                "shortnames": [
+                    "confounded"
+                ]
+            },
+            {
+                "unified": "1F61E",
+                "shortnames": [
+                    "disappointed"
+                ]
+            },
+            {
+                "unified": "1F61F",
+                "shortnames": [
+                    "worried"
+                ]
+            },
+            {
+                "unified": "1F624",
+                "shortnames": [
+                    "triumph"
+                ]
+            },
+            {
+                "unified": "1F622",
+                "shortnames": [
+                    "cry"
+                ]
+            },
+            {
+                "unified": "1F62D",
+                "shortnames": [
+                    "sob"
+                ]
+            },
+            {
+                "unified": "1F626",
+                "shortnames": [
+                    "frowning"
+                ]
+            },
+            {
+                "unified": "1F627",
+                "shortnames": [
+                    "anguished"
+                ]
+            },
+            {
+                "unified": "1F628",
+                "shortnames": [
+                    "fearful"
+                ]
+            },
+            {
+                "unified": "1F629",
+                "shortnames": [
+                    "weary"
+                ]
+            },
+            {
+                "unified": "1F92F",
+                "shortnames": [
+                    "exploding_head"
+                ]
+            },
+            {
+                "unified": "1F62C",
+                "shortnames": [
+                    "grimacing"
+                ]
+            },
+            {
+                "unified": "1F630",
+                "shortnames": [
+                    "cold_sweat"
+                ]
+            },
+            {
+                "unified": "1F631",
+                "shortnames": [
+                    "scream"
+                ]
+            },
+            {
+                "unified": "1F633",
+                "shortnames": [
+                    "flushed"
+                ]
+            },
+            {
+                "unified": "1F92A",
+                "shortnames": [
+                    "zany_face"
+                ]
+            },
+            {
+                "unified": "1F635",
+                "shortnames": [
+                    "dizzy_face"
+                ]
+            },
+            {
+                "unified": "1F621",
+                "shortnames": [
+                    "rage"
+                ]
+            },
+            {
+                "unified": "1F620",
+                "shortnames": [
+                    "angry"
+                ]
+            },
+            {
+                "unified": "1F92C",
+                "shortnames": [
+                    "face_with_symbols_on_mouth"
+                ]
+            },
+            {
+                "unified": "1F637",
+                "shortnames": [
+                    "mask"
+                ]
+            },
+            {
+                "unified": "1F912",
+                "shortnames": [
+                    "face_with_thermometer"
+                ]
+            },
+            {
+                "unified": "1F915",
+                "shortnames": [
+                    "face_with_head_bandage"
+                ]
+            },
+            {
+                "unified": "1F922",
+                "shortnames": [
+                    "nauseated_face"
+                ]
+            },
+            {
+                "unified": "1F92E",
+                "shortnames": [
+                    "face_vomiting"
+                ]
+            },
+            {
+                "unified": "1F927",
+                "shortnames": [
+                    "sneezing_face"
+                ]
+            },
+            {
+                "unified": "1F607",
+                "shortnames": [
+                    "innocent"
+                ]
+            },
+            {
+                "unified": "1F920",
+                "shortnames": [
+                    "face_with_cowboy_hat"
+                ]
+            },
+            {
+                "unified": "1F921",
+                "shortnames": [
+                    "clown_face"
+                ]
+            },
+            {
+                "unified": "1F925",
+                "shortnames": [
+                    "lying_face"
+                ]
+            },
+            {
+                "unified": "1F92B",
+                "shortnames": [
+                    "shushing_face"
+                ]
+            },
+            {
+                "unified": "1F92D",
+                "shortnames": [
+                    "face_with_hand_over_mouth"
+                ]
+            },
+            {
+                "unified": "1F9D0",
+                "shortnames": [
+                    "face_with_monocle"
+                ]
+            },
+            {
+                "unified": "1F913",
+                "shortnames": [
+                    "nerd_face"
+                ]
+            },
+            {
+                "unified": "1F608",
+                "shortnames": [
+                    "smiling_imp"
+                ]
+            },
+            {
+                "unified": "1F47F",
+                "shortnames": [
+                    "imp"
+                ]
+            },
+            {
+                "unified": "1F479",
+                "shortnames": [
+                    "japanese_ogre"
+                ]
+            },
+            {
+                "unified": "1F47A",
+                "shortnames": [
+                    "japanese_goblin"
+                ]
+            },
+            {
+                "unified": "1F480",
+                "shortnames": [
+                    "skull"
+                ]
+            },
+            {
+                "unified": "2620-FE0F",
+                "shortnames": [
+                    "skull_and_crossbones"
+                ]
+            },
+            {
+                "unified": "1F47B",
+                "shortnames": [
+                    "ghost"
+                ]
+            },
+            {
+                "unified": "1F47D",
+                "shortnames": [
+                    "alien"
+                ]
+            },
+            {
+                "unified": "1F47E",
+                "shortnames": [
+                    "space_invader"
+                ]
+            },
+            {
+                "unified": "1F916",
+                "shortnames": [
+                    "robot_face"
+                ]
+            },
+            {
+                "unified": "1F4A9",
+                "shortnames": [
+                    "hankey"
+                ]
+            },
+            {
+                "unified": "1F63A",
+                "shortnames": [
+                    "smiley_cat"
+                ]
+            },
+            {
+                "unified": "1F638",
+                "shortnames": [
+                    "smile_cat"
+                ]
+            },
+            {
+                "unified": "1F639",
+                "shortnames": [
+                    "joy_cat"
+                ]
+            },
+            {
+                "unified": "1F63B",
+                "shortnames": [
+                    "heart_eyes_cat"
+                ]
+            },
+            {
+                "unified": "1F63C",
+                "shortnames": [
+                    "smirk_cat"
+                ]
+            },
+            {
+                "unified": "1F63D",
+                "shortnames": [
+                    "kissing_cat"
+                ]
+            },
+            {
+                "unified": "1F640",
+                "shortnames": [
+                    "scream_cat"
+                ]
+            },
+            {
+                "unified": "1F63F",
+                "shortnames": [
+                    "crying_cat_face"
+                ]
+            },
+            {
+                "unified": "1F63E",
+                "shortnames": [
+                    "pouting_cat"
+                ]
+            },
+            {
+                "unified": "1F648",
+                "shortnames": [
+                    "see_no_evil"
+                ]
+            },
+            {
+                "unified": "1F649",
+                "shortnames": [
+                    "hear_no_evil"
+                ]
+            },
+            {
+                "unified": "1F64A",
+                "shortnames": [
+                    "speak_no_evil"
+                ]
+            },
+            {
+                "unified": "1F476",
+                "shortnames": [
+                    "baby"
+                ]
+            },
+            {
+                "unified": "1F9D2",
+                "shortnames": [
+                    "child"
+                ]
+            },
+            {
+                "unified": "1F466",
+                "shortnames": [
+                    "boy"
+                ]
+            },
+            {
+                "unified": "1F467",
+                "shortnames": [
+                    "girl"
+                ]
+            },
+            {
+                "unified": "1F9D1",
+                "shortnames": [
+                    "adult"
+                ]
+            },
+            {
+                "unified": "1F468",
+                "shortnames": [
+                    "man"
+                ]
+            },
+            {
+                "unified": "1F469",
+                "shortnames": [
+                    "woman"
+                ]
+            },
+            {
+                "unified": "1F9D3",
+                "shortnames": [
+                    "older_adult"
+                ]
+            },
+            {
+                "unified": "1F474",
+                "shortnames": [
+                    "older_man"
+                ]
+            },
+            {
+                "unified": "1F475",
+                "shortnames": [
+                    "older_woman"
+                ]
+            },
+            {
+                "unified": "1F468-200D-2695-FE0F",
+                "shortnames": [
+                    "male-doctor"
+                ]
+            },
+            {
+                "unified": "1F469-200D-2695-FE0F",
+                "shortnames": [
+                    "female-doctor"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F393",
+                "shortnames": [
+                    "male-student"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F393",
+                "shortnames": [
+                    "female-student"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F3EB",
+                "shortnames": [
+                    "male-teacher"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F3EB",
+                "shortnames": [
+                    "female-teacher"
+                ]
+            },
+            {
+                "unified": "1F468-200D-2696-FE0F",
+                "shortnames": [
+                    "male-judge"
+                ]
+            },
+            {
+                "unified": "1F469-200D-2696-FE0F",
+                "shortnames": [
+                    "female-judge"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F33E",
+                "shortnames": [
+                    "male-farmer"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F33E",
+                "shortnames": [
+                    "female-farmer"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F373",
+                "shortnames": [
+                    "male-cook"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F373",
+                "shortnames": [
+                    "female-cook"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F527",
+                "shortnames": [
+                    "male-mechanic"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F527",
+                "shortnames": [
+                    "female-mechanic"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F3ED",
+                "shortnames": [
+                    "male-factory-worker"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F3ED",
+                "shortnames": [
+                    "female-factory-worker"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F4BC",
+                "shortnames": [
+                    "male-office-worker"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F4BC",
+                "shortnames": [
+                    "female-office-worker"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F52C",
+                "shortnames": [
+                    "male-scientist"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F52C",
+                "shortnames": [
+                    "female-scientist"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F4BB",
+                "shortnames": [
+                    "male-technologist"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F4BB",
+                "shortnames": [
+                    "female-technologist"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F3A4",
+                "shortnames": [
+                    "male-singer"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F3A4",
+                "shortnames": [
+                    "female-singer"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F3A8",
+                "shortnames": [
+                    "male-artist"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F3A8",
+                "shortnames": [
+                    "female-artist"
+                ]
+            },
+            {
+                "unified": "1F468-200D-2708-FE0F",
+                "shortnames": [
+                    "male-pilot"
+                ]
+            },
+            {
+                "unified": "1F469-200D-2708-FE0F",
+                "shortnames": [
+                    "female-pilot"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F680",
+                "shortnames": [
+                    "male-astronaut"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F680",
+                "shortnames": [
+                    "female-astronaut"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F692",
+                "shortnames": [
+                    "male-firefighter"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F692",
+                "shortnames": [
+                    "female-firefighter"
+                ]
+            },
+            {
+                "unified": "1F46E-200D-2642-FE0F",
+                "shortnames": [
+                    "male-police-officer",
+                    "cop"
+                ]
+            },
+            {
+                "unified": "1F46E-200D-2640-FE0F",
+                "shortnames": [
+                    "female-police-officer"
+                ]
+            },
+            {
+                "unified": "1F575-FE0F-200D-2642-FE0F",
+                "shortnames": [
+                    "male-detective",
+                    "sleuth_or_spy"
+                ]
+            },
+            {
+                "unified": "1F575-FE0F-200D-2640-FE0F",
+                "shortnames": [
+                    "female-detective"
+                ]
+            },
+            {
+                "unified": "1F482-200D-2642-FE0F",
+                "shortnames": [
+                    "male-guard",
+                    "guardsman"
+                ]
+            },
+            {
+                "unified": "1F482-200D-2640-FE0F",
+                "shortnames": [
+                    "female-guard"
+                ]
+            },
+            {
+                "unified": "1F477-200D-2642-FE0F",
+                "shortnames": [
+                    "male-construction-worker",
+                    "construction_worker"
+                ]
+            },
+            {
+                "unified": "1F477-200D-2640-FE0F",
+                "shortnames": [
+                    "female-construction-worker"
+                ]
+            },
+            {
+                "unified": "1F934",
+                "shortnames": [
+                    "prince"
+                ]
+            },
+            {
+                "unified": "1F478",
+                "shortnames": [
+                    "princess"
+                ]
+            },
+            {
+                "unified": "1F473-200D-2642-FE0F",
+                "shortnames": [
+                    "man-wearing-turban",
+                    "man_with_turban"
+                ]
+            },
+            {
+                "unified": "1F473-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-wearing-turban"
+                ]
+            },
+            {
+                "unified": "1F472",
+                "shortnames": [
+                    "man_with_gua_pi_mao"
+                ]
+            },
+            {
+                "unified": "1F9D5",
+                "shortnames": [
+                    "person_with_headscarf"
+                ]
+            },
+            {
+                "unified": "1F9D4",
+                "shortnames": [
+                    "bearded_person"
+                ]
+            },
+            {
+                "unified": "1F471-200D-2642-FE0F",
+                "shortnames": [
+                    "blond-haired-man",
+                    "person_with_blond_hair"
+                ]
+            },
+            {
+                "unified": "1F471-200D-2640-FE0F",
+                "shortnames": [
+                    "blond-haired-woman"
+                ]
+            },
+            {
+                "unified": "1F935",
+                "shortnames": [
+                    "man_in_tuxedo"
+                ]
+            },
+            {
+                "unified": "1F470",
+                "shortnames": [
+                    "bride_with_veil"
+                ]
+            },
+            {
+                "unified": "1F930",
+                "shortnames": [
+                    "pregnant_woman"
+                ]
+            },
+            {
+                "unified": "1F931",
+                "shortnames": [
+                    "breast-feeding"
+                ]
+            },
+            {
+                "unified": "1F47C",
+                "shortnames": [
+                    "angel"
+                ]
+            },
+            {
+                "unified": "1F385",
+                "shortnames": [
+                    "santa"
+                ]
+            },
+            {
+                "unified": "1F936",
+                "shortnames": [
+                    "mrs_claus"
+                ]
+            },
+            {
+                "unified": "1F9D9-200D-2640-FE0F",
+                "shortnames": [
+                    "female_mage",
+                    "mage"
+                ]
+            },
+            {
+                "unified": "1F9D9-200D-2642-FE0F",
+                "shortnames": [
+                    "male_mage"
+                ]
+            },
+            {
+                "unified": "1F9DA-200D-2640-FE0F",
+                "shortnames": [
+                    "female_fairy",
+                    "fairy"
+                ]
+            },
+            {
+                "unified": "1F9DA-200D-2642-FE0F",
+                "shortnames": [
+                    "male_fairy"
+                ]
+            },
+            {
+                "unified": "1F9DB-200D-2640-FE0F",
+                "shortnames": [
+                    "female_vampire",
+                    "vampire"
+                ]
+            },
+            {
+                "unified": "1F9DB-200D-2642-FE0F",
+                "shortnames": [
+                    "male_vampire"
+                ]
+            },
+            {
+                "unified": "1F9DC-200D-2640-FE0F",
+                "shortnames": [
+                    "mermaid"
+                ]
+            },
+            {
+                "unified": "1F9DC-200D-2642-FE0F",
+                "shortnames": [
+                    "merman",
+                    "merperson"
+                ]
+            },
+            {
+                "unified": "1F9DD-200D-2640-FE0F",
+                "shortnames": [
+                    "female_elf"
+                ]
+            },
+            {
+                "unified": "1F9DD-200D-2642-FE0F",
+                "shortnames": [
+                    "male_elf",
+                    "elf"
+                ]
+            },
+            {
+                "unified": "1F9DE-200D-2640-FE0F",
+                "shortnames": [
+                    "female_genie"
+                ]
+            },
+            {
+                "unified": "1F9DE-200D-2642-FE0F",
+                "shortnames": [
+                    "male_genie",
+                    "genie"
+                ]
+            },
+            {
+                "unified": "1F9DF-200D-2640-FE0F",
+                "shortnames": [
+                    "female_zombie"
+                ]
+            },
+            {
+                "unified": "1F9DF-200D-2642-FE0F",
+                "shortnames": [
+                    "male_zombie",
+                    "zombie"
+                ]
+            },
+            {
+                "unified": "1F64D-200D-2642-FE0F",
+                "shortnames": [
+                    "man-frowning"
+                ]
+            },
+            {
+                "unified": "1F64D-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-frowning",
+                    "person_frowning"
+                ]
+            },
+            {
+                "unified": "1F64E-200D-2642-FE0F",
+                "shortnames": [
+                    "man-pouting"
+                ]
+            },
+            {
+                "unified": "1F64E-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-pouting",
+                    "person_with_pouting_face"
+                ]
+            },
+            {
+                "unified": "1F645-200D-2642-FE0F",
+                "shortnames": [
+                    "man-gesturing-no"
+                ]
+            },
+            {
+                "unified": "1F645-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-gesturing-no",
+                    "no_good"
+                ]
+            },
+            {
+                "unified": "1F646-200D-2642-FE0F",
+                "shortnames": [
+                    "man-gesturing-ok"
+                ]
+            },
+            {
+                "unified": "1F646-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-gesturing-ok",
+                    "ok_woman"
+                ]
+            },
+            {
+                "unified": "1F481-200D-2642-FE0F",
+                "shortnames": [
+                    "man-tipping-hand"
+                ]
+            },
+            {
+                "unified": "1F481-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-tipping-hand",
+                    "information_desk_person"
+                ]
+            },
+            {
+                "unified": "1F64B-200D-2642-FE0F",
+                "shortnames": [
+                    "man-raising-hand"
+                ]
+            },
+            {
+                "unified": "1F64B-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-raising-hand",
+                    "raising_hand"
+                ]
+            },
+            {
+                "unified": "1F647-200D-2642-FE0F",
+                "shortnames": [
+                    "man-bowing",
+                    "bow"
+                ]
+            },
+            {
+                "unified": "1F647-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-bowing"
+                ]
+            },
+            {
+                "unified": "1F926",
+                "shortnames": [
+                    "face_palm"
+                ]
+            },
+            {
+                "unified": "1F926-200D-2642-FE0F",
+                "shortnames": [
+                    "man-facepalming"
+                ]
+            },
+            {
+                "unified": "1F926-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-facepalming"
+                ]
+            },
+            {
+                "unified": "1F937",
+                "shortnames": [
+                    "shrug"
+                ]
+            },
+            {
+                "unified": "1F937-200D-2642-FE0F",
+                "shortnames": [
+                    "man-shrugging"
+                ]
+            },
+            {
+                "unified": "1F937-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-shrugging"
+                ]
+            },
+            {
+                "unified": "1F486-200D-2642-FE0F",
+                "shortnames": [
+                    "man-getting-massage"
+                ]
+            },
+            {
+                "unified": "1F486-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-getting-massage",
+                    "massage"
+                ]
+            },
+            {
+                "unified": "1F487-200D-2642-FE0F",
+                "shortnames": [
+                    "man-getting-haircut"
+                ]
+            },
+            {
+                "unified": "1F487-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-getting-haircut",
+                    "haircut"
+                ]
+            },
+            {
+                "unified": "1F6B6-200D-2642-FE0F",
+                "shortnames": [
+                    "man-walking",
+                    "walking"
+                ]
+            },
+            {
+                "unified": "1F6B6-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-walking"
+                ]
+            },
+            {
+                "unified": "1F3C3-200D-2642-FE0F",
+                "shortnames": [
+                    "man-running",
+                    "runner"
+                ]
+            },
+            {
+                "unified": "1F3C3-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-running"
+                ]
+            },
+            {
+                "unified": "1F483",
+                "shortnames": [
+                    "dancer"
+                ]
+            },
+            {
+                "unified": "1F57A",
+                "shortnames": [
+                    "man_dancing"
+                ]
+            },
+            {
+                "unified": "1F46F-200D-2642-FE0F",
+                "shortnames": [
+                    "man-with-bunny-ears-partying"
+                ]
+            },
+            {
+                "unified": "1F46F-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-with-bunny-ears-partying",
+                    "dancers"
+                ]
+            },
+            {
+                "unified": "1F9D6-200D-2640-FE0F",
+                "shortnames": [
+                    "woman_in_steamy_room"
+                ]
+            },
+            {
+                "unified": "1F9D6-200D-2642-FE0F",
+                "shortnames": [
+                    "man_in_steamy_room",
+                    "person_in_steamy_room"
+                ]
+            },
+            {
+                "unified": "1F9D7-200D-2640-FE0F",
+                "shortnames": [
+                    "woman_climbing",
+                    "person_climbing"
+                ]
+            },
+            {
+                "unified": "1F9D7-200D-2642-FE0F",
+                "shortnames": [
+                    "man_climbing"
+                ]
+            },
+            {
+                "unified": "1F9D8-200D-2640-FE0F",
+                "shortnames": [
+                    "woman_in_lotus_position",
+                    "person_in_lotus_position"
+                ]
+            },
+            {
+                "unified": "1F9D8-200D-2642-FE0F",
+                "shortnames": [
+                    "man_in_lotus_position"
+                ]
+            },
+            {
+                "unified": "1F6C0",
+                "shortnames": [
+                    "bath"
+                ]
+            },
+            {
+                "unified": "1F6CC",
+                "shortnames": [
+                    "sleeping_accommodation"
+                ]
+            },
+            {
+                "unified": "1F574-FE0F",
+                "shortnames": [
+                    "man_in_business_suit_levitating"
+                ]
+            },
+            {
+                "unified": "1F5E3-FE0F",
+                "shortnames": [
+                    "speaking_head_in_silhouette"
+                ]
+            },
+            {
+                "unified": "1F464",
+                "shortnames": [
+                    "bust_in_silhouette"
+                ]
+            },
+            {
+                "unified": "1F465",
+                "shortnames": [
+                    "busts_in_silhouette"
+                ]
+            },
+            {
+                "unified": "1F93A",
+                "shortnames": [
+                    "fencer"
+                ]
+            },
+            {
+                "unified": "1F3C7",
+                "shortnames": [
+                    "horse_racing"
+                ]
+            },
+            {
+                "unified": "26F7-FE0F",
+                "shortnames": [
+                    "skier"
+                ]
+            },
+            {
+                "unified": "1F3C2",
+                "shortnames": [
+                    "snowboarder"
+                ]
+            },
+            {
+                "unified": "1F3CC-FE0F-200D-2642-FE0F",
+                "shortnames": [
+                    "man-golfing",
+                    "golfer"
+                ]
+            },
+            {
+                "unified": "1F3CC-FE0F-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-golfing"
+                ]
+            },
+            {
+                "unified": "1F3C4-200D-2642-FE0F",
+                "shortnames": [
+                    "man-surfing",
+                    "surfer"
+                ]
+            },
+            {
+                "unified": "1F3C4-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-surfing"
+                ]
+            },
+            {
+                "unified": "1F6A3-200D-2642-FE0F",
+                "shortnames": [
+                    "man-rowing-boat",
+                    "rowboat"
+                ]
+            },
+            {
+                "unified": "1F6A3-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-rowing-boat"
+                ]
+            },
+            {
+                "unified": "1F3CA-200D-2642-FE0F",
+                "shortnames": [
+                    "man-swimming",
+                    "swimmer"
+                ]
+            },
+            {
+                "unified": "1F3CA-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-swimming"
+                ]
+            },
+            {
+                "unified": "26F9-FE0F-200D-2642-FE0F",
+                "shortnames": [
+                    "man-bouncing-ball",
+                    "person_with_ball"
+                ]
+            },
+            {
+                "unified": "26F9-FE0F-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-bouncing-ball"
+                ]
+            },
+            {
+                "unified": "1F3CB-FE0F-200D-2642-FE0F",
+                "shortnames": [
+                    "man-lifting-weights",
+                    "weight_lifter"
+                ]
+            },
+            {
+                "unified": "1F3CB-FE0F-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-lifting-weights"
+                ]
+            },
+            {
+                "unified": "1F6B4-200D-2642-FE0F",
+                "shortnames": [
+                    "man-biking",
+                    "bicyclist"
+                ]
+            },
+            {
+                "unified": "1F6B4-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-biking"
+                ]
+            },
+            {
+                "unified": "1F6B5-200D-2642-FE0F",
+                "shortnames": [
+                    "man-mountain-biking",
+                    "mountain_bicyclist"
+                ]
+            },
+            {
+                "unified": "1F6B5-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-mountain-biking"
+                ]
+            },
+            {
+                "unified": "1F3CE-FE0F",
+                "shortnames": [
+                    "racing_car"
+                ]
+            },
+            {
+                "unified": "1F3CD-FE0F",
+                "shortnames": [
+                    "racing_motorcycle"
+                ]
+            },
+            {
+                "unified": "1F938",
+                "shortnames": [
+                    "person_doing_cartwheel"
+                ]
+            },
+            {
+                "unified": "1F938-200D-2642-FE0F",
+                "shortnames": [
+                    "man-cartwheeling"
+                ]
+            },
+            {
+                "unified": "1F938-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-cartwheeling"
+                ]
+            },
+            {
+                "unified": "1F93C",
+                "shortnames": [
+                    "wrestlers"
+                ]
+            },
+            {
+                "unified": "1F93C-200D-2642-FE0F",
+                "shortnames": [
+                    "man-wrestling"
+                ]
+            },
+            {
+                "unified": "1F93C-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-wrestling"
+                ]
+            },
+            {
+                "unified": "1F93D",
+                "shortnames": [
+                    "water_polo"
+                ]
+            },
+            {
+                "unified": "1F93D-200D-2642-FE0F",
+                "shortnames": [
+                    "man-playing-water-polo"
+                ]
+            },
+            {
+                "unified": "1F93D-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-playing-water-polo"
+                ]
+            },
+            {
+                "unified": "1F93E",
+                "shortnames": [
+                    "handball"
+                ]
+            },
+            {
+                "unified": "1F93E-200D-2642-FE0F",
+                "shortnames": [
+                    "man-playing-handball"
+                ]
+            },
+            {
+                "unified": "1F93E-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-playing-handball"
+                ]
+            },
+            {
+                "unified": "1F939",
+                "shortnames": [
+                    "juggling"
+                ]
+            },
+            {
+                "unified": "1F939-200D-2642-FE0F",
+                "shortnames": [
+                    "man-juggling"
+                ]
+            },
+            {
+                "unified": "1F939-200D-2640-FE0F",
+                "shortnames": [
+                    "woman-juggling"
+                ]
+            },
+            {
+                "unified": "1F46B",
+                "shortnames": [
+                    "couple"
+                ]
+            },
+            {
+                "unified": "1F46C",
+                "shortnames": [
+                    "two_men_holding_hands"
+                ]
+            },
+            {
+                "unified": "1F46D",
+                "shortnames": [
+                    "two_women_holding_hands"
+                ]
+            },
+            {
+                "unified": "1F469-200D-2764-FE0F-200D-1F48B-200D-1F468",
+                "shortnames": [
+                    "woman-kiss-man",
+                    "couplekiss"
+                ]
+            },
+            {
+                "unified": "1F468-200D-2764-FE0F-200D-1F48B-200D-1F468",
+                "shortnames": [
+                    "man-kiss-man"
+                ]
+            },
+            {
+                "unified": "1F469-200D-2764-FE0F-200D-1F48B-200D-1F469",
+                "shortnames": [
+                    "woman-kiss-woman"
+                ]
+            },
+            {
+                "unified": "1F469-200D-2764-FE0F-200D-1F468",
+                "shortnames": [
+                    "woman-heart-man",
+                    "couple_with_heart"
+                ]
+            },
+            {
+                "unified": "1F468-200D-2764-FE0F-200D-1F468",
+                "shortnames": [
+                    "man-heart-man"
+                ]
+            },
+            {
+                "unified": "1F469-200D-2764-FE0F-200D-1F469",
+                "shortnames": [
+                    "woman-heart-woman"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F469-200D-1F466",
+                "shortnames": [
+                    "man-woman-boy",
+                    "family"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F469-200D-1F467",
+                "shortnames": [
+                    "man-woman-girl"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F469-200D-1F467-200D-1F466",
+                "shortnames": [
+                    "man-woman-girl-boy"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F469-200D-1F466-200D-1F466",
+                "shortnames": [
+                    "man-woman-boy-boy"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F469-200D-1F467-200D-1F467",
+                "shortnames": [
+                    "man-woman-girl-girl"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F468-200D-1F466",
+                "shortnames": [
+                    "man-man-boy"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F468-200D-1F467",
+                "shortnames": [
+                    "man-man-girl"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F468-200D-1F467-200D-1F466",
+                "shortnames": [
+                    "man-man-girl-boy"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F468-200D-1F466-200D-1F466",
+                "shortnames": [
+                    "man-man-boy-boy"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F468-200D-1F467-200D-1F467",
+                "shortnames": [
+                    "man-man-girl-girl"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F469-200D-1F466",
+                "shortnames": [
+                    "woman-woman-boy"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F469-200D-1F467",
+                "shortnames": [
+                    "woman-woman-girl"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F469-200D-1F467-200D-1F466",
+                "shortnames": [
+                    "woman-woman-girl-boy"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F469-200D-1F466-200D-1F466",
+                "shortnames": [
+                    "woman-woman-boy-boy"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F469-200D-1F467-200D-1F467",
+                "shortnames": [
+                    "woman-woman-girl-girl"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F466",
+                "shortnames": [
+                    "man-boy"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F466-200D-1F466",
+                "shortnames": [
+                    "man-boy-boy"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F467",
+                "shortnames": [
+                    "man-girl"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F467-200D-1F466",
+                "shortnames": [
+                    "man-girl-boy"
+                ]
+            },
+            {
+                "unified": "1F468-200D-1F467-200D-1F467",
+                "shortnames": [
+                    "man-girl-girl"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F466",
+                "shortnames": [
+                    "woman-boy"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F466-200D-1F466",
+                "shortnames": [
+                    "woman-boy-boy"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F467",
+                "shortnames": [
+                    "woman-girl"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F467-200D-1F466",
+                "shortnames": [
+                    "woman-girl-boy"
+                ]
+            },
+            {
+                "unified": "1F469-200D-1F467-200D-1F467",
+                "shortnames": [
+                    "woman-girl-girl"
+                ]
+            },
+            {
+                "unified": "1F933",
+                "shortnames": [
+                    "selfie"
+                ]
+            },
+            {
+                "unified": "1F4AA",
+                "shortnames": [
+                    "muscle"
+                ]
+            },
+            {
+                "unified": "1F448",
+                "shortnames": [
+                    "point_left"
+                ]
+            },
+            {
+                "unified": "1F449",
+                "shortnames": [
+                    "point_right"
+                ]
+            },
+            {
+                "unified": "261D-FE0F",
+                "shortnames": [
+                    "point_up"
+                ]
+            },
+            {
+                "unified": "1F446",
+                "shortnames": [
+                    "point_up_2"
+                ]
+            },
+            {
+                "unified": "1F595",
+                "shortnames": [
+                    "middle_finger"
+                ]
+            },
+            {
+                "unified": "1F447",
+                "shortnames": [
+                    "point_down"
+                ]
+            },
+            {
+                "unified": "270C-FE0F",
+                "shortnames": [
+                    "v"
+                ]
+            },
+            {
+                "unified": "1F91E",
+                "shortnames": [
+                    "crossed_fingers"
+                ]
+            },
+            {
+                "unified": "1F596",
+                "shortnames": [
+                    "spock-hand"
+                ]
+            },
+            {
+                "unified": "1F918",
+                "shortnames": [
+                    "the_horns"
+                ]
+            },
+            {
+                "unified": "1F919",
+                "shortnames": [
+                    "call_me_hand"
+                ]
+            },
+            {
+                "unified": "1F590-FE0F",
+                "shortnames": [
+                    "raised_hand_with_fingers_splayed"
+                ]
+            },
+            {
+                "unified": "270B",
+                "shortnames": [
+                    "hand"
+                ]
+            },
+            {
+                "unified": "1F44C",
+                "shortnames": [
+                    "ok_hand"
+                ]
+            },
+            {
+                "unified": "1F44D",
+                "shortnames": [
+                    "+1"
+                ]
+            },
+            {
+                "unified": "1F44E",
+                "shortnames": [
+                    "-1"
+                ]
+            },
+            {
+                "unified": "270A",
+                "shortnames": [
+                    "fist"
+                ]
+            },
+            {
+                "unified": "1F44A",
+                "shortnames": [
+                    "facepunch"
+                ]
+            },
+            {
+                "unified": "1F91B",
+                "shortnames": [
+                    "left-facing_fist"
+                ]
+            },
+            {
+                "unified": "1F91C",
+                "shortnames": [
+                    "right-facing_fist"
+                ]
+            },
+            {
+                "unified": "1F91A",
+                "shortnames": [
+                    "raised_back_of_hand"
+                ]
+            },
+            {
+                "unified": "1F44B",
+                "shortnames": [
+                    "wave"
+                ]
+            },
+            {
+                "unified": "1F91F",
+                "shortnames": [
+                    "i_love_you_hand_sign"
+                ]
+            },
+            {
+                "unified": "270D-FE0F",
+                "shortnames": [
+                    "writing_hand"
+                ]
+            },
+            {
+                "unified": "1F44F",
+                "shortnames": [
+                    "clap"
+                ]
+            },
+            {
+                "unified": "1F450",
+                "shortnames": [
+                    "open_hands"
+                ]
+            },
+            {
+                "unified": "1F64C",
+                "shortnames": [
+                    "raised_hands"
+                ]
+            },
+            {
+                "unified": "1F932",
+                "shortnames": [
+                    "palms_up_together"
+                ]
+            },
+            {
+          &nb