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/
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/
$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')
+ ));
+ }
}
--- /dev/null
+// 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);
+ }
+
+ };
+
+});
--- /dev/null
+<?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')
+ ])
+ );
+ }
+}
$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(
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');
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;
+ }
}
$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;
--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" .
-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.
'mode' => 'configuration',
'reuse-prev-analysed' => true,
'non-interactive' => false,
- 'filter' => false
),
array(
'h' => 'help',
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');
}
$renderer = $PAGE->get_renderer('tool_analytics');
$analyseroptions = array(
- 'filter' => $options['filter'],
'timesplitting' => $options['analysisinterval'],
'reuseprevanalysed' => $options['reuse-prev-analysed'],
'mode' => $options['mode'],
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,
'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);
$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;
--- /dev/null
+<?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)
+ ),
+);
$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';
'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);
$predictionsprocessor = false;
}
- $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
+ $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor, $data->contexts);
redirect($returnurl);
}
$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();
--- /dev/null
+<?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());
+ }
+}
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).
/**
* 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();
} else {
$action = 'prediction';
}
- $analysables = $this->analyser->get_analysables_iterator($action);
+ $analysables = $this->analyser->get_analysables_iterator($action, $contexts);
$processedanalysables = $this->get_processed_analysables();
* 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
/**
* 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();
}
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.
*
* @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';
$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];
}
* 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');
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];
+ }
+}
* 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()]);
}
$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 = [];
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;
+ }
+
}
*/
protected $indicators = null;
+ /**
+ * @var \context[]
+ */
+ protected $contexts = null;
+
/**
* Unique Model id created from site info and last model modification.
*
* @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();
$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) {
$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;
// 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)) {
// 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])) {
// 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])) {
} 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])) {
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.
*/
$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'));
+ }
}
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
*
* 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 ===
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
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,
+ ];
+ }
}
$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(),
+ ];
+ }
}
}
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) {
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 |
| 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"
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 |
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 |
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 |
| 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 |
| 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 |
| 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 |
),
'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.'
);
}
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;
}
}
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,
+ ];
+ }
}
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(),
+ ];
+ }
}
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,
+ ];
+ }
}
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(),
+ ];
+ }
}
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.
*
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,
+ ];
+ }
}
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(),
+ ];
+ }
}
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(),
+ ];
+ }
}
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,
+ ];
+ }
}
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,
+ ];
+ }
}
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,
+ ];
+ }
}
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,
+ ];
+ }
}
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(),
+ ];
+ }
}
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,
+ ];
+ }
}
}
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(),
+ ];
+ }
}
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,
+ ];
+ }
}
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(),
+ ];
+ }
}
$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);
}
/**
$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');
// 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);
=== 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.
$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));
} else {
$alttext = get_string('activityevent', 'calendar');
}
- } else if ($isglobalevent) {
+ } else if ($issiteevent) {
$key = 'i/siteevent';
$component = 'core';
$alttext = get_string('typesite', 'calendar');
$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
// 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.
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') {
"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",
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
*/
$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" />';
* @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
*/
// 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.
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) {
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);
* @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;
*/
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;
| 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 |
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;
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;
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;
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();
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();
// 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
//
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.
}
/**
- * Return structure for edit_module()
+ * Return structure for get_module()
*
* @since Moodle 3.3
* @return external_description
],
'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,
],
'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,
// 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
// 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.
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;
// 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
*/
<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.';
// 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]"));
}
// 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);
// 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';
'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'
)
)
$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'] = 'Λανθασμένη διαδρομή προορισμού (πλήρες όνομα).';
$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';
$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';
$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';
$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';
$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.';
$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';
$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';
$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';
$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';
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
$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';
$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.
--- /dev/null
+// 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
--- /dev/null
+// 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