Merge branch 'MDL-68983-recent-session-lock-ro' of https://github.com/brendanheywood...
authorJake Dallimore <jake@moodle.com>
Wed, 21 Oct 2020 04:45:36 +0000 (12:45 +0800)
committerJake Dallimore <jake@moodle.com>
Wed, 21 Oct 2020 04:45:36 +0000 (12:45 +0800)
184 files changed:
admin/classes/local/externalpage/accesscallback.php [new file with mode: 0644]
admin/modules.php
admin/plugins.php
admin/tool/customlang/classes/form/import.php [new file with mode: 0644]
admin/tool/customlang/classes/local/importer.php [new file with mode: 0644]
admin/tool/customlang/classes/local/mlang/langstring.php [new file with mode: 0644]
admin/tool/customlang/classes/local/mlang/logstatus.php [new file with mode: 0644]
admin/tool/customlang/classes/local/mlang/phpparser.php [new file with mode: 0644]
admin/tool/customlang/cli/import.php [new file with mode: 0644]
admin/tool/customlang/import.php [new file with mode: 0644]
admin/tool/customlang/index.php
admin/tool/customlang/lang/en/tool_customlang.php
admin/tool/customlang/tests/behat/import_files.feature [new file with mode: 0644]
admin/tool/customlang/tests/behat/import_mode.feature [new file with mode: 0644]
admin/tool/customlang/tests/fixtures/customlang.zip [new file with mode: 0644]
admin/tool/customlang/tests/fixtures/mod_fakecomponent.php [new file with mode: 0644]
admin/tool/customlang/tests/fixtures/moodle.php [new file with mode: 0644]
admin/tool/customlang/tests/fixtures/tool_customlang.php [new file with mode: 0644]
admin/tool/customlang/tests/local/mlang/langstring_test.php [new file with mode: 0644]
admin/tool/customlang/tests/local/mlang/phpparser_test.php [new file with mode: 0644]
admin/tool/customlang/version.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/lib.php
admin/tool/mobile/logout.php [new file with mode: 0644]
admin/tool/usertours/amd/build/filter_cssselector.min.js [new file with mode: 0644]
admin/tool/usertours/amd/build/filter_cssselector.min.js.map [new file with mode: 0644]
admin/tool/usertours/amd/build/usertours.min.js
admin/tool/usertours/amd/build/usertours.min.js.map
admin/tool/usertours/amd/src/filter_cssselector.js [new file with mode: 0644]
admin/tool/usertours/amd/src/usertours.js
admin/tool/usertours/classes/external/tour.php
admin/tool/usertours/classes/helper.php
admin/tool/usertours/classes/local/clientside_filter/clientside_filter.php [new file with mode: 0644]
admin/tool/usertours/classes/local/clientside_filter/cssselector.php [new file with mode: 0644]
admin/tool/usertours/classes/manager.php
admin/tool/usertours/classes/tour.php
admin/tool/usertours/lang/en/tool_usertours.php
admin/tool/usertours/tests/behat/tour_filter.feature
admin/tool/usertours/tests/manager_test.php
admin/tool/usertours/version.php
backup/cc/cc2moodle.php
backup/cc/entities.class.php
backup/moodle2/restore_stepslib.php
backup/util/ui/renderer.php
blocks/site_main_menu/block_site_main_menu.php
blocks/social_activities/block_social_activities.php
cache/classes/administration_helper.php
cache/tests/administration_helper_test.php
calendar/classes/local/event/strategies/raw_event_retrieval_strategy.php
calendar/export_execute.php
calendar/tests/raw_event_retrieval_strategy_test.php
config-dist.php
course/classes/local/service/content_item_service.php
course/classes/search/customfield.php
course/renderer.php
files/classes/external/delete/draft.php [new file with mode: 0644]
files/tests/externallib_test.php
grade/edit/tree/item.php
grade/edit/tree/item_form.php
grade/edit/tree/lib.php
grade/report/grader/module.js
grade/report/grader/tests/behat/ajax_grader.feature
h5p/classes/core.php
h5p/classes/editor_ajax.php
h5p/classes/editor_framework.php
h5p/classes/framework.php
h5p/classes/player.php
h5p/tests/editor_ajax_test.php
h5p/tests/framework_test.php
h5p/tests/generator/lib.php
h5p/tests/generator_test.php
h5p/tests/h5p_core_test.php
install/lang/ar/error.php
install/lang/ar/install.php
lang/en/error.php
lang/en/moodle.php
lib/adminlib.php
lib/classes/component.php
lib/classes/content.php [new file with mode: 0644]
lib/classes/content/export/exportable_item.php [new file with mode: 0644]
lib/classes/content/export/exportable_items/exportable_filearea.php [new file with mode: 0644]
lib/classes/content/export/exportable_items/exportable_stored_file.php [new file with mode: 0644]
lib/classes/content/export/exportable_items/exportable_textarea.php [new file with mode: 0644]
lib/classes/content/export/exported_item.php [new file with mode: 0644]
lib/classes/content/export/exporters/abstract_mod_exporter.php [new file with mode: 0644]
lib/classes/content/export/exporters/component_exporter.php [new file with mode: 0644]
lib/classes/content/export/exporters/course_exporter.php [new file with mode: 0644]
lib/classes/content/export/zipwriter.php [new file with mode: 0644]
lib/classes/plugininfo/base.php
lib/classes/session/redis.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/dml/moodle_database.php
lib/dml/mysqli_native_moodle_database.php
lib/dml/tests/dml_test.php
lib/form/filemanager.js
lib/form/templates/element-defaultcustom.mustache
lib/jabber/XMPP/BOSH.php
lib/jabber/XMPP/Exception.php
lib/jabber/XMPP/Log.php
lib/jabber/XMPP/README.txt [deleted file]
lib/jabber/XMPP/Roster.php
lib/jabber/XMPP/XMLObj.php
lib/jabber/XMPP/XMLStream.php
lib/jabber/XMPP/XMPP.php
lib/jabber/XMPP/XMPP_Old.php [deleted file]
lib/jabber/readme_moodle.txt
lib/moodlelib.php
lib/setup.php
lib/setuplib.php
lib/templates/content/export/course_index.mustache [new file with mode: 0644]
lib/templates/content/export/course_summary.mustache [new file with mode: 0644]
lib/templates/content/export/external_page.mustache [new file with mode: 0644]
lib/templates/content/export/module_index.mustache [new file with mode: 0644]
lib/tests/content/export/exportable_items/exportable_filearea_test.php [new file with mode: 0644]
lib/tests/content/export/exportable_items/exportable_stored_file_test.php [new file with mode: 0644]
lib/tests/content/export/exportable_items/exportable_textarea_test.php [new file with mode: 0644]
lib/tests/content/export/exporters/course_exporter_test.php [new file with mode: 0644]
lib/tests/content/export/zipwriter_test.php [new file with mode: 0644]
lib/tests/setuplib_test.php
lib/thirdpartylibs.xml
lib/upgrade.txt
lib/weblib.php
message/classes/api.php
message/externallib.php
message/output/jabber/message_output_jabber.php
message/output/popup/db/upgrade.php
mod/feedback/backup/moodle2/restore_feedback_stepslib.php
mod/feedback/tests/restore_date_test.php
mod/folder/classes/content/exporter.php [new file with mode: 0644]
mod/forum/amd/build/discussion_nested_v2.min.js
mod/forum/amd/build/discussion_nested_v2.min.js.map
mod/forum/amd/build/inpage_reply.min.js
mod/forum/amd/build/inpage_reply.min.js.map
mod/forum/amd/build/posts_list.min.js
mod/forum/amd/build/posts_list.min.js.map
mod/forum/amd/src/discussion_nested_v2.js
mod/forum/amd/src/inpage_reply.js
mod/forum/amd/src/posts_list.js
mod/forum/externallib.php
mod/forum/tests/externallib_test.php
mod/forum/upgrade.txt
mod/glossary/classes/external.php
mod/glossary/classes/external/delete_entry.php [new file with mode: 0644]
mod/glossary/classes/external/prepare_entry.php [new file with mode: 0644]
mod/glossary/classes/external/update_entry.php [new file with mode: 0644]
mod/glossary/db/services.php
mod/glossary/deleteentry.php
mod/glossary/edit.php
mod/glossary/lib.php
mod/glossary/tests/external/delete_entry.php [new file with mode: 0644]
mod/glossary/tests/external/prepare_entry.php [new file with mode: 0644]
mod/glossary/tests/external/update_entry.php [new file with mode: 0644]
mod/glossary/tests/external_test.php
mod/glossary/tests/lib_test.php
mod/glossary/upgrade.txt
mod/glossary/version.php
mod/lti/classes/external.php
mod/lti/db/install.xml
mod/lti/db/upgrade.php
mod/lti/locallib.php
mod/lti/tests/externallib_test.php
mod/lti/upgrade.txt
mod/lti/version.php
mod/page/classes/content/exporter.php [new file with mode: 0644]
mod/resource/classes/content/exporter.php [new file with mode: 0644]
question/tests/backup_test.php
question/tests/generator/lib.php
question/type/ddwtos/amd/build/ddwtos.min.js
question/type/ddwtos/amd/build/ddwtos.min.js.map
question/type/ddwtos/amd/src/ddwtos.js
question/type/ddwtos/tests/behat/behat_qtype_ddwtos.php
question/type/ddwtos/tests/behat/preview.feature
question/type/ddwtos/tests/helper.php
repository/tests/behat/behat_filepicker.php
search/tests/behat/setup_search_engine.feature [new file with mode: 0644]
theme/boost/scss/moodle/blocks.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/grade.scss
theme/boost/scss/moodle/icons.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
version.php

diff --git a/admin/classes/local/externalpage/accesscallback.php b/admin/classes/local/externalpage/accesscallback.php
new file mode 100644 (file)
index 0000000..05fb07f
--- /dev/null
@@ -0,0 +1,71 @@
+<?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/>.
+
+/**
+ * External admin page class that allows a callback to be provided to determine whether page can be accessed
+ *
+ * @package     core_admin
+ * @copyright   2019 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_admin\local\externalpage;
+
+use admin_externalpage;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once("{$CFG->libdir}/adminlib.php");
+
+/**
+ * Admin externalpage class
+ *
+ * @package     core_admin
+ * @copyright   2019 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class accesscallback extends admin_externalpage {
+
+    /** @var callable $accesscheckcallback */
+    protected $accesscheckcallback;
+
+    /**
+     * Class constructor
+     *
+     * @param string $name
+     * @param string $visiblename
+     * @param string $url
+     * @param callable $accesscheckcallback The callback method that will be executed to check whether user has access to
+     *     this page. The setting instance ($this) is passed as an argument to the callback. Should return boolean value
+     * @param bool $hidden
+     */
+    public function __construct(string $name, string $visiblename, string $url, callable $accesscheckcallback,
+            bool $hidden = false) {
+
+        $this->accesscheckcallback = $accesscheckcallback;
+
+        parent::__construct($name, $visiblename, $url, [], $hidden);
+    }
+
+    /**
+     * Determines if the current user has access to this external page based on access callback
+     *
+     * @return bool
+     */
+    public function check_access() {
+        return ($this->accesscheckcallback)($this);
+    }
+}
index 01f67a1..e76a8e1 100644 (file)
@@ -49,6 +49,7 @@
                 array($module->id));
         core_plugin_manager::reset_caches();
         admin_get_root(true, false);  // settings not required - only pages
+        redirect(new moodle_url('/admin/modules.php'));
     }
 
     if (!empty($show) and confirm_sesskey()) {
@@ -66,6 +67,7 @@
                 array($module->id));
         core_plugin_manager::reset_caches();
         admin_get_root(true, false);  // settings not required - only pages
+        redirect(new moodle_url('/admin/modules.php'));
     }
 
     echo $OUTPUT->header();
             $count = -1;
         }
         if ($count>0) {
-            $countlink = "<a href=\"{$CFG->wwwroot}/course/search.php?modulelist=$module->name" .
-                "&amp;sesskey=".sesskey()."\" title=\"$strshowmodulecourse\">$count</a>";
+            $countlink = $OUTPUT->action_link(new moodle_url('/course/search.php', ['modulelist' => $module->name]),
+                $count, null, ['title' => $strshowmodulecourse]);
         } else if ($count < 0) {
             $countlink = get_string('error');
         } else {
index c99ae52..e4867c1 100644 (file)
@@ -53,7 +53,6 @@ $pageurl = new moodle_url('/admin/plugins.php', $pageparams);
 $pluginman = core_plugin_manager::instance();
 
 if ($uninstall) {
-    require_sesskey();
 
     if (!$confirmed) {
         admin_externalpage_setup('pluginsoverview', '', $pageparams);
@@ -92,6 +91,7 @@ if ($uninstall) {
         exit();
 
     } else {
+        require_sesskey();
         $SESSION->pluginuninstallreturn = $pluginfo->get_return_url_after_uninstall($return);
         $progress = new progress_trace_buffer(new text_progress_trace(), false);
         $pluginman->uninstall_plugin($pluginfo->component, $progress);
diff --git a/admin/tool/customlang/classes/form/import.php b/admin/tool/customlang/classes/form/import.php
new file mode 100644 (file)
index 0000000..3d4d068
--- /dev/null
@@ -0,0 +1,68 @@
+<?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/>.
+
+/**
+ * Upload a zip of custom lang php files.
+ *
+ * @package    tool_customlang
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_customlang\form;
+
+use tool_customlang\local\importer;
+
+/**
+ * Upload a zip/php of custom lang php files.
+ *
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class import extends \moodleform {
+
+    /**
+     * Form definition.
+     */
+    public function definition() {
+        $mform = $this->_form;
+        $mform->addElement('header', 'settingsheader', get_string('import', 'tool_customlang'));
+
+        $mform->addElement('hidden', 'lng');
+        $mform->setType('lng', PARAM_LANG);
+        $mform->setDefault('lng', $this->_customdata['lng']);
+
+        $filemanageroptions = array(
+            'accepted_types' => array('.php', '.zip'),
+            'maxbytes' => 0,
+            'maxfiles' => 1,
+            'subdirs' => 0
+        );
+
+        $mform->addElement('filepicker', 'pack', get_string('langpack', 'tool_customlang'),
+            null, $filemanageroptions);
+        $mform->addRule('pack', null, 'required');
+
+        $modes = [
+            importer::IMPORTALL => get_string('import_all', 'tool_customlang'),
+            importer::IMPORTUPDATE => get_string('import_update', 'tool_customlang'),
+            importer::IMPORTNEW => get_string('import_new', 'tool_customlang'),
+        ];
+        $mform->addElement('select', 'importmode', get_string('import_mode', 'tool_customlang'), $modes);
+
+        $mform->addElement('submit', 'importcustomstrings', get_string('importfile', 'tool_customlang'));
+    }
+}
diff --git a/admin/tool/customlang/classes/local/importer.php b/admin/tool/customlang/classes/local/importer.php
new file mode 100644 (file)
index 0000000..955ec3f
--- /dev/null
@@ -0,0 +1,268 @@
+<?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/>.
+
+/**
+ * Custom lang importer.
+ *
+ * @package    tool_customlang
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_customlang\local;
+
+use tool_customlang\local\mlang\phpparser;
+use tool_customlang\local\mlang\logstatus;
+use tool_customlang\local\mlang\langstring;
+use core\output\notification;
+use stored_file;
+use coding_exception;
+use moodle_exception;
+use core_component;
+use stdClass;
+
+/**
+ * Class containing tha custom lang importer
+ *
+ * @package    tool_customlang
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class importer {
+
+    /** @var int imports will only create new customizations */
+    public const IMPORTNEW = 1;
+    /** @var int imports will only update the current customizations */
+    public const IMPORTUPDATE = 2;
+    /** @var int imports all strings */
+    public const IMPORTALL = 3;
+
+    /**
+     * @var string the language name
+     */
+    protected $lng;
+
+    /**
+     * @var int the importation mode (new, update, all)
+     */
+    protected $importmode;
+
+    /**
+     * @var string request folder path
+     */
+    private $folder;
+
+    /**
+     * @var array import log messages
+     */
+    private $log;
+
+    /**
+     * Constructor for the importer class.
+     *
+     * @param string $lng the current language to import.
+     * @param int $importmode the import method (IMPORTALL, IMPORTNEW, IMPORTUPDATE).
+     */
+    public function __construct(string $lng, int $importmode = self::IMPORTALL) {
+        $this->lng = $lng;
+        $this->importmode = $importmode;
+        $this->log = [];
+    }
+
+    /**
+     * Returns the last parse log.
+     *
+     * @return logstatus[] mlang logstatus with the messages
+     */
+    public function get_log(): array {
+        return $this->log;
+    }
+
+    /**
+     * Import customlang files.
+     *
+     * @param stored_file[] $files array of files to import
+     */
+    public function import(array $files): void {
+        // Create a temporal folder to store the files.
+        $this->folder = make_request_directory(false);
+
+        $langfiles = $this->deploy_files($files);
+
+        $this->process_files($langfiles);
+    }
+
+    /**
+     * Deploy all files into a request folder.
+     *
+     * @param stored_file[] $files array of files to deploy
+     * @return string[] of file paths
+     */
+    private function deploy_files(array $files): array {
+        $result = [];
+        // Desploy all files.
+        foreach ($files as $file) {
+            if ($file->get_mimetype() == 'application/zip') {
+                $result = array_merge($result, $this->unzip_file($file));
+            } else {
+                $path = $this->folder.'/'.$file->get_filename();
+                $file->copy_content_to($path);
+                $result = array_merge($result, [$path]);
+            }
+        }
+        return $result;
+    }
+
+    /**
+     * Unzip a file into the request folder.
+     *
+     * @param stored_file $file the zip file to unzip
+     * @return string[] of zip content paths
+     */
+    private function unzip_file(stored_file $file): array {
+        $fp = get_file_packer('application/zip');
+        $zipcontents = $fp->extract_to_pathname($file, $this->folder);
+        if (!$zipcontents) {
+            throw new moodle_exception("Error Unzipping file", 1);
+        }
+        $result = [];
+        foreach ($zipcontents as $contentname => $success) {
+            if ($success) {
+                $result[] = $this->folder.'/'.$contentname;
+            }
+        }
+        return $result;
+    }
+
+    /**
+     * Import strings from a list of langfiles.
+     *
+     * @param string[] $langfiles an array with file paths
+     */
+    private function process_files(array $langfiles): void {
+        $parser = phpparser::get_instance();
+        foreach ($langfiles as $filepath) {
+            $component = $this->component_from_filepath($filepath);
+            if ($component) {
+                $strings = $parser->parse(file_get_contents($filepath));
+                $this->import_strings($strings, $component);
+            }
+        }
+    }
+
+    /**
+     * Try to get the component from a filepath.
+     *
+     * @param string $filepath the filepath
+     * @return stdCalss|null the DB record of that component
+     */
+    private function component_from_filepath(string $filepath) {
+        global $DB;
+
+        // Get component from filename.
+        $pathparts = pathinfo($filepath);
+        if (empty($pathparts['filename'])) {
+            throw new coding_exception("Cannot get filename from $filepath", 1);
+        }
+        $filename = $pathparts['filename'];
+
+        $normalized = core_component::normalize_component($filename);
+        if (count($normalized) == 1 || empty($normalized[1])) {
+            $componentname = $normalized[0];
+        } else {
+            $componentname = implode('_', $normalized);
+        }
+
+        $result = $DB->get_record('tool_customlang_components', ['name' => $componentname]);
+
+        if (!$result) {
+            $this->log[] = new logstatus('notice_missingcomponent', notification::NOTIFY_ERROR, null, $componentname);
+            return null;
+        }
+        return $result;
+    }
+
+    /**
+     * Import an array of strings into the customlang tables.
+     *
+     * @param langstring[] $strings the langstring to set
+     * @param stdClass $component the target component
+     */
+    private function import_strings(array $strings, stdClass $component): void {
+        global $DB;
+
+        foreach ($strings as $newstring) {
+            // Check current DB entry.
+            $customlang = $DB->get_record('tool_customlang', [
+                'componentid' => $component->id,
+                'stringid' => $newstring->id,
+                'lang' => $this->lng,
+            ]);
+            if (!$customlang) {
+                $customlang = null;
+            }
+
+            if ($this->can_save_string($customlang, $newstring, $component)) {
+                $customlang->local = $newstring->text;
+                $customlang->timecustomized = $newstring->timemodified;
+                $customlang->outdated = 0;
+                $customlang->modified = 1;
+                $DB->update_record('tool_customlang', $customlang);
+            }
+        }
+    }
+
+    /**
+     * Determine if a specific string can be saved based on the current importmode.
+     *
+     * @param stdClass $customlang customlang original record
+     * @param langstring $newstring the new strign to store
+     * @param stdClass $component the component target
+     * @return bool if the string can be stored
+     */
+    private function can_save_string(?stdClass $customlang, langstring $newstring, stdClass $component): bool {
+        $result = false;
+        $message = 'notice_success';
+        if (empty($customlang)) {
+            $message = 'notice_inexitentstring';
+            $this->log[] = new logstatus($message, notification::NOTIFY_ERROR, null, $component->name, $newstring);
+            return $result;
+        }
+
+        switch ($this->importmode) {
+            case self::IMPORTNEW:
+                $result = empty($customlang->local);
+                $warningmessage = 'notice_ignoreupdate';
+                break;
+            case self::IMPORTUPDATE:
+                $result = !empty($customlang->local);
+                $warningmessage = 'notice_ignorenew';
+                break;
+            case self::IMPORTALL:
+                $result = true;
+                break;
+        }
+        if ($result) {
+            $errorlevel = notification::NOTIFY_SUCCESS;
+        } else {
+            $errorlevel = notification::NOTIFY_ERROR;
+            $message = $warningmessage;
+        }
+        $this->log[] = new logstatus($message, $errorlevel, null, $component->name, $newstring);
+
+        return $result;
+    }
+}
diff --git a/admin/tool/customlang/classes/local/mlang/langstring.php b/admin/tool/customlang/classes/local/mlang/langstring.php
new file mode 100644 (file)
index 0000000..f380cd6
--- /dev/null
@@ -0,0 +1,177 @@
+<?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/>.
+
+/**
+ * Language string based on David Mudrak langstring from local_amos.
+ *
+ * @package    tool_customlang
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_customlang\local\mlang;
+
+use moodle_exception;
+use stdclass;
+
+/**
+ * Class containing a lang string cleaned.
+ *
+ * @package    tool_customlang
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Represents a single string
+ */
+class langstring {
+
+    /** @var string identifier */
+    public $id = null;
+
+    /** @var string */
+    public $text = '';
+
+    /** @var int the time stamp when this string was saved */
+    public $timemodified = null;
+
+    /** @var bool is deleted */
+    public $deleted = false;
+
+    /** @var stdclass extra information about the string */
+    public $extra = null;
+
+    /**
+     * Class constructor.
+     *
+     * @param string $id string identifier
+     * @param string $text string text
+     * @param int $timemodified
+     * @param int $deleted
+     * @param stdclass $extra
+     */
+    public function __construct(string $id, string $text = '', int $timemodified = null,
+            int $deleted = 0, stdclass $extra = null) {
+
+        if (is_null($timemodified)) {
+            $timemodified = time();
+        }
+        $this->id           = $id;
+        $this->text         = $text;
+        $this->timemodified = $timemodified;
+        $this->deleted      = $deleted;
+        $this->extra        = $extra;
+    }
+
+    /**
+     * Given a string text, returns it being formatted properly for storing in AMOS repository.
+     *
+     * Note: This method is taken directly from local_amos as it is highly tested and robust.
+     * The Moodle 1.x part is keep on puspose to make it easier the copy paste from both codes.
+     * This could change in the future when AMOS stop suporting the 1.x langstrings.
+     *
+     * We need to know for what branch the string should be prepared due to internal changes in
+     * format required by get_string()
+     * - for get_string() in Moodle 1.6 - 1.9 use $format == 1
+     * - for get_string() in Moodle 2.0 and higher use $format == 2
+     *
+     * Typical usages of this methods:
+     *  $t = langstring::fix_syntax($t);          // sanity new translations of 2.x strings
+     *  $t = langstring::fix_syntax($t, 1);       // sanity legacy 1.x strings
+     *  $t = langstring::fix_syntax($t, 2, 1);    // convert format of 1.x strings into 2.x
+     *
+     * Backward converting 2.x format into 1.x is not supported
+     *
+     * @param string $text string text to be fixed
+     * @param int $format target get_string() format version
+     * @param int $from which format version does the text come from, defaults to the same as $format
+     * @return string
+     */
+    public static function fix_syntax(string $text, int $format = 2, ?int $from = null): string {
+        if (is_null($from)) {
+            $from = $format;
+        }
+
+        // Common filter.
+        $clean = trim($text);
+        $search = [
+            // Remove \r if it is part of \r\n.
+            '/\r(?=\n)/',
+
+            // Control characters to be replaced with \n
+            // LINE TABULATION, FORM FEED, CARRIAGE RETURN, END OF TRANSMISSION BLOCK,
+            // END OF MEDIUM, SUBSTITUTE, BREAK PERMITTED HERE, NEXT LINE, START OF STRING,
+            // STRING TERMINATOR and Unicode character categorys Zl and Zp.
+            '/[\x{0B}-\r\x{17}\x{19}\x{1A}\x{82}\x{85}\x{98}\x{9C}\p{Zl}\p{Zp}]/u',
+
+            // Control characters to be removed
+            // NULL, ENQUIRY, ACKNOWLEDGE, BELL, SHIFT {OUT,IN}, DATA LINK ESCAPE,
+            // DEVICE CONTROL {ONE,TWO,THREE,FOUR}, NEGATIVE ACKNOWLEDGE, SYNCHRONOUS IDLE, ESCAPE,
+            // DELETE, PADDING CHARACTER, HIGH OCTET PRESET, NO BREAK HERE, INDEX,
+            // {START,END} OF SELECTED AREA, CHARACTER TABULATION {SET,WITH JUSTIFICATION},
+            // LINE TABULATION SET, PARTIAL LINE {FORWARD,BACKWARD}, REVERSE LINE FEED,
+            // SINGLE SHIFT {TWO,THREE}, DEVICE CONTROL STRING, PRIVATE USE {ONE,TWO},
+            // SET TRANSMIT STATE, MESSAGE WAITING, {START,END} OF GUARDED AREA,
+            // {SINGLE {GRAPHIC,} CHARACTER,CONTROL SEQUENCE} INTRODUCER, OPERATING SYSTEM COMMAND,
+            // PRIVACY MESSAGE, APPLICATION PROGRAM COMMAND, ZERO WIDTH {,NO-BREAK} SPACE,
+            // REPLACEMENT CHARACTER.
+            '/[\0\x{05}-\x{07}\x{0E}-\x{16}\x{1B}\x{7F}\x{80}\x{81}\x{83}\x{84}\x{86}-\x{93}\x{95}-\x{97}\x{99}-\x{9B}\x{9D}-\x{9F}\x{200B}\x{FEFF}\x{FFFD}]++/u',
+
+            // Remove trailing whitespace at the end of lines in a multiline string.
+            '/[ \t]+(?=\n)/',
+        ];
+        $replace = [
+            '',
+            "\n",
+            '',
+            '',
+        ];
+        $clean = preg_replace($search, $replace, $clean);
+
+        if (($format === 2) && ($from === 2)) {
+            // Sanity translations of 2.x strings.
+            $clean = preg_replace("/\n{3,}/", "\n\n\n", $clean); // Collapse runs of blank lines.
+
+        } else if (($format === 2) && ($from === 1)) {
+            // Convert 1.x string into 2.x format.
+            $clean = preg_replace("/\n{3,}/", "\n\n\n", $clean); // Collapse runs of blank lines.
+            $clean = preg_replace('/%+/', '%', $clean); // Collapse % characters.
+            $clean = str_replace('\$', '@@@___XXX_ESCAPED_DOLLAR__@@@', $clean); // Remember for later.
+            $clean = str_replace("\\", '', $clean); // Delete all slashes.
+            $clean = preg_replace('/(^|[^{])\$a\b(\->[a-zA-Z0-9_]+)?/', '\\1{$a\\2}', $clean); // Wrap placeholders.
+            $clean = str_replace('@@@___XXX_ESCAPED_DOLLAR__@@@', '$', $clean);
+            $clean = str_replace('&#36;', '$', $clean);
+
+        } else if (($format === 1) && ($from === 1)) {
+            // Sanity legacy 1.x strings.
+            $clean = preg_replace("/\n{3,}/", "\n\n", $clean); // Collapse runs of blank lines.
+            $clean = str_replace('\$', '@@@___XXX_ESCAPED_DOLLAR__@@@', $clean);
+            $clean = str_replace("\\", '', $clean); // Delete all slashes.
+            $clean = str_replace('$', '\$', $clean); // Escape all embedded variables.
+            // Unescape placeholders: only $a and $a->something are allowed. All other $variables are left escaped.
+            $clean = preg_replace('/\\\\\$a\b(\->[a-zA-Z0-9_]+)?/', '$a\\1', $clean); // Unescape placeholders.
+            $clean = str_replace('@@@___XXX_ESCAPED_DOLLAR__@@@', '\$', $clean);
+            $clean = str_replace('"', "\\\"", $clean); // Add slashes for ".
+            $clean = preg_replace('/%+/', '%', $clean); // Collapse % characters.
+            $clean = str_replace('%', '%%', $clean); // Duplicate %.
+
+        } else {
+            throw new moodle_exception('Unknown get_string() format version');
+        }
+        return $clean;
+    }
+}
diff --git a/admin/tool/customlang/classes/local/mlang/logstatus.php b/admin/tool/customlang/classes/local/mlang/logstatus.php
new file mode 100644 (file)
index 0000000..eca3a98
--- /dev/null
@@ -0,0 +1,92 @@
+<?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/>.
+
+/**
+ * Language string based on David Mudrak langstring from local_amos.
+ *
+ * @package    tool_customlang
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_customlang\local\mlang;
+
+use moodle_exception;
+use stdclass;
+
+/**
+ * Class containing a lang string cleaned.
+ *
+ * @package    tool_customlang
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Represents a single string
+ */
+class logstatus {
+
+    /** @var langstring the current string */
+    public $langstring = null;
+
+    /** @var string the component */
+    public $component = null;
+
+    /** @var string the string ID */
+    public $stringid = null;
+
+    /** @var string the original filename */
+    public $filename = null;
+
+    /** @var int the error level */
+    public $errorlevel = null;
+
+    /** @var string the message identifier */
+    private $message;
+
+    /**
+     * Class creator.
+     *
+     * @param string $message the message identifier to display
+     * @param string $errorlevel the notice level
+     * @param string|null $filename the filename of this log
+     * @param string|null $component the component of this log
+     * @param langstring|null $langstring the langstring of this log
+     */
+    public function __construct(string $message, string $errorlevel, ?string $filename = null,
+            ?string $component = null, ?langstring $langstring = null) {
+
+        $this->filename = $filename;
+        $this->component = $component;
+        $this->langstring = $langstring;
+        $this->message = $message;
+        $this->errorlevel = $errorlevel;
+
+        if ($langstring) {
+            $this->stringid = $langstring->id;
+        }
+    }
+
+    /**
+     * Get the log message.
+     *
+     * @return string the log message.
+     */
+    public function get_message(): string {
+        return get_string($this->message, 'tool_customlang', $this);
+    }
+}
diff --git a/admin/tool/customlang/classes/local/mlang/phpparser.php b/admin/tool/customlang/classes/local/mlang/phpparser.php
new file mode 100644 (file)
index 0000000..c4c5407
--- /dev/null
@@ -0,0 +1,260 @@
+<?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/>.
+
+/**
+ * Mlang PHP based on David Mudrak phpparser for local_amos.
+ *
+ * @package    tool_customlang
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_customlang\local\mlang;
+
+use coding_exception;
+use moodle_exception;
+
+/**
+ * Parser of Moodle strings defined as associative array.
+ *
+ * Moodle core just includes this file format directly as normal PHP code. However
+ * for security reasons, we must not do this for files uploaded by anonymous users.
+ * This parser reconstructs the associative $string array without actually including
+ * the file.
+ */
+class phpparser {
+
+    /** @var holds the singleton instance of self */
+    private static $instance = null;
+
+    /**
+     * Prevents direct creation of object
+     */
+    private function __construct() {
+    }
+
+    /**
+     * Prevent from cloning the instance
+     */
+    public function __clone() {
+        throw new coding_exception('Cloning os singleton is not allowed');
+    }
+
+    /**
+     * Get the singleton instance fo this class
+     *
+     * @return phpparser singleton instance of phpparser
+     */
+    public static function get_instance(): phpparser {
+        if (is_null(self::$instance)) {
+            self::$instance = new phpparser();
+        }
+        return self::$instance;
+    }
+
+    /**
+     * Parses the given data in Moodle PHP string format
+     *
+     * Note: This method is adapted from local_amos as it is highly tested and robust.
+     * The priority is keeping it similar to the original one to make it easier to mantain.
+     *
+     * @param string $data definition of the associative array
+     * @param int $format the data format on the input, defaults to the one used since 2.0
+     * @return langstring[] array of langstrings of this file
+     */
+    public function parse(string $data, int $format = 2): array {
+        $result = [];
+        $strings = $this->extract_strings($data);
+        foreach ($strings as $id => $text) {
+            $cleaned = clean_param($id, PARAM_STRINGID);
+            if ($cleaned !== $id) {
+                continue;
+            }
+            $text = langstring::fix_syntax($text, 2, $format);
+            $result[] = new langstring($id, $text);
+        }
+        return $result;
+    }
+
+    /**
+     * Low level parsing method
+     *
+     * Note: This method is adapted from local_amos as it is highly tested and robust.
+     * The priority is keeping it similar to the original one to make it easier to mantain.
+     *
+     * @param string $data
+     * @return string[] the data strings
+     */
+    protected function extract_strings(string $data): array {
+
+        $strings = []; // To be returned.
+
+        if (empty($data)) {
+            return $strings;
+        }
+
+        // Tokenize data - we expect valid PHP code.
+        $tokens = token_get_all($data);
+
+        // Get rid of all non-relevant tokens.
+        $rubbish = [T_WHITESPACE, T_INLINE_HTML, T_COMMENT, T_DOC_COMMENT, T_OPEN_TAG, T_CLOSE_TAG];
+        foreach ($tokens as $i => $token) {
+            if (is_array($token)) {
+                if (in_array($token[0], $rubbish)) {
+                    unset($tokens[$i]);
+                }
+            }
+        }
+
+        $id = null;
+        $text = null;
+        $line = 0;
+        $expect = 'STRING_VAR'; // The first expected token is '$string'.
+
+        // Iterate over tokens and look for valid $string array assignment patterns.
+        foreach ($tokens as $token) {
+            $foundtype = null;
+            $founddata = null;
+            if (is_array($token)) {
+                $foundtype = $token[0];
+                $founddata = $token[1];
+                if (!empty($token[2])) {
+                    $line = $token[2];
+                }
+
+            } else {
+                $foundtype = 'char';
+                $founddata = $token;
+            }
+
+            if ($expect == 'STRING_VAR') {
+                if ($foundtype === T_VARIABLE and $founddata === '$string') {
+                    $expect = 'LEFT_BRACKET';
+                    continue;
+                } else {
+                    // Allow other code at the global level.
+                    continue;
+                }
+            }
+
+            if ($expect == 'LEFT_BRACKET') {
+                if ($foundtype === 'char' and $founddata === '[') {
+                    $expect = 'STRING_ID';
+                    continue;
+                } else {
+                    throw new moodle_exception('Parsing error. Expected character [ at line '.$line);
+                }
+            }
+
+            if ($expect == 'STRING_ID') {
+                if ($foundtype === T_CONSTANT_ENCAPSED_STRING) {
+                    $id = $this->decapsulate($founddata);
+                    $expect = 'RIGHT_BRACKET';
+                    continue;
+                } else {
+                    throw new moodle_exception('Parsing error. Expected T_CONSTANT_ENCAPSED_STRING array key at line '.$line);
+                }
+            }
+
+            if ($expect == 'RIGHT_BRACKET') {
+                if ($foundtype === 'char' and $founddata === ']') {
+                    $expect = 'ASSIGNMENT';
+                    continue;
+                } else {
+                    throw new moodle_exception('Parsing error. Expected character ] at line '.$line);
+                }
+            }
+
+            if ($expect == 'ASSIGNMENT') {
+                if ($foundtype === 'char' and $founddata === '=') {
+                    $expect = 'STRING_TEXT';
+                    continue;
+                } else {
+                    throw new moodle_exception('Parsing error. Expected character = at line '.$line);
+                }
+            }
+
+            if ($expect == 'STRING_TEXT') {
+                if ($foundtype === T_CONSTANT_ENCAPSED_STRING) {
+                    $text = $this->decapsulate($founddata);
+                    $expect = 'SEMICOLON';
+                    continue;
+                } else {
+                    throw new moodle_exception(
+                        'Parsing error. Expected T_CONSTANT_ENCAPSED_STRING array item value at line '.$line
+                    );
+                }
+            }
+
+            if ($expect == 'SEMICOLON') {
+                if (is_null($id) or is_null($text)) {
+                    throw new moodle_exception('Parsing error. NULL string id or value at line '.$line);
+                }
+                if ($foundtype === 'char' and $founddata === ';') {
+                    if (!empty($id)) {
+                        $strings[$id] = $text;
+                    }
+                    $id = null;
+                    $text = null;
+                    $expect = 'STRING_VAR';
+                    continue;
+                } else {
+                    throw new moodle_exception('Parsing error. Expected character ; at line '.$line);
+                }
+            }
+
+        }
+
+        return $strings;
+    }
+
+    /**
+     * Given one T_CONSTANT_ENCAPSED_STRING, return its value without quotes
+     *
+     * Also processes escaped quotes inside the text.
+     *
+     * Note: This method is taken directly from local_amos as it is highly tested and robust.
+     *
+     * @param string $text value obtained by token_get_all()
+     * @return string value without quotes
+     */
+    protected function decapsulate(string $text): string {
+
+        if (strlen($text) < 2) {
+            throw new moodle_exception('Parsing error. Expected T_CONSTANT_ENCAPSED_STRING in decapsulate()');
+        }
+
+        if (substr($text, 0, 1) == "'" and substr($text, -1) == "'") {
+            // Single quoted string.
+            $text = trim($text, "'");
+            $text = str_replace("\'", "'", $text);
+            $text = str_replace('\\\\', '\\', $text);
+            return $text;
+
+        } else if (substr($text, 0, 1) == '"' and substr($text, -1) == '"') {
+            // Double quoted string.
+            $text = trim($text, '"');
+            $text = str_replace('\"', '"', $text);
+            $text = str_replace('\\\\', '\\', $text);
+            return $text;
+
+        } else {
+            throw new moodle_exception(
+                'Parsing error. Unexpected quotation in T_CONSTANT_ENCAPSED_STRING in decapsulate(): '.$text
+            );
+        }
+    }
+}
diff --git a/admin/tool/customlang/cli/import.php b/admin/tool/customlang/cli/import.php
new file mode 100644 (file)
index 0000000..4daff05
--- /dev/null
@@ -0,0 +1,204 @@
+<?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/>.
+
+/**
+ * CLI customlang import tool.
+ *
+ * @package    tool_customlang
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use tool_customlang\local\importer;
+use core\output\notification;
+
+define('CLI_SCRIPT', true);
+
+require(__DIR__ . '/../../../../config.php');
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/customlang/locallib.php');
+require_once("$CFG->libdir/clilib.php");
+
+$usage =
+"Import lang customization.
+
+It can get a single file or a folder.
+If no lang is provided it will try to infere from the filename
+
+Options:
+--lang                  The target language (will get from filename if not provided)
+--source=path           File or folder of the custom lang files (zip or php files)
+--mode                  What string should be imported. Options are:
+                            - all: all string will be imported (default)
+                            - new: only string with no previous customisation
+                            - update: only strings already modified
+--checkin               Save strings to the language pack
+-h, --help              Print out this help
+
+Examples:
+\$ sudo -u www-data /usr/bin/php admin/tool/customlang/cli/import.php --lang=en --source=customlangs.zip
+
+\$ sudo -u www-data /usr/bin/php admin/tool/customlang/cli/import.php --source=/tmp/customlangs --checkin
+
+\$ sudo -u www-data /usr/bin/php admin/tool/customlang/cli/import.php --lang=en --source=/tmp/customlangs
+
+";
+
+list($options, $unrecognized) = cli_get_params(
+    [
+        'help' => false,
+        'lang' => false,
+        'source' => false,
+        'mode' => 'all',
+        'checkin' => false,
+    ],
+    ['h' => 'help']
+);
+
+if ($unrecognized) {
+    $unrecognized = implode("\n  ", $unrecognized);
+    cli_error(get_string('cliunknowoption', 'admin', $unrecognized));
+}
+
+if ($options['help']) {
+    cli_write($usage);
+    exit(0);
+}
+
+$source = $options['source'] ?? null;
+$lang = $options['lang'] ?? null;
+$modeparam = $options['mode'] ?? 'all';
+$checkin = $options['checkin'] ?? false;
+
+$modes = [
+    'all' => importer::IMPORTALL,
+    'update' => importer::IMPORTUPDATE,
+    'new' => importer::IMPORTNEW,
+];
+if (!isset($modes[$modeparam])) {
+    cli_error(get_string('climissingmode', 'tool_customlang'));
+}
+$mode = $modes[$modeparam];
+
+if (empty($source)) {
+    $source = $CFG->dataroot.'/temp/customlang';
+}
+
+if (!file_exists($source)) {
+    cli_error(get_string('climissingsource', 'tool_customlang'));
+}
+
+// Emulate normal session - we use admin account by default.
+cron_setup_user();
+
+// Get the file list.
+$files = [];
+$langfiles = [];
+
+if (is_file($source)) {
+    $files[] = $source;
+}
+if (is_dir($source)) {
+    $filelist = glob("$source/*");
+    foreach ($filelist as $filename) {
+        $files[] = "$filename";
+    }
+}
+
+$countfiles = 0;
+foreach ($files as $filepath) {
+    // Try to get the lang.
+    $filelang = $lang;
+    // Get component from filename.
+    $pathparts = pathinfo($filepath);
+    $filename = $pathparts['filename'];
+    $extension = $pathparts['extension'];
+    if ($extension == 'zip') {
+        if (!$filelang) {
+            // Try to get the lang from the filename.
+            if (strrpos($filename, 'customlang_') === 0) {
+                $parts = explode('_', $filename);
+                if (!empty($parts[1])) {
+                    $filelang = $parts[1];
+                }
+            }
+        }
+    } else if ($extension != 'php') {
+        // Ignore any other file extension.
+        continue;
+    }
+    if (empty($filelang)) {
+        cli_error(get_string('climissinglang', 'tool_customlang'));
+    }
+    if (!isset($langfiles[$filelang])) {
+        $langfiles[$filelang] = [];
+    }
+    $langfiles[$filelang][] = $filepath;
+    $countfiles ++;
+}
+
+if (!$countfiles) {
+    cli_error(get_string('climissingfiles', 'tool_customlang'));
+}
+
+foreach ($langfiles as $lng => $files) {
+    $importer = new importer($lng, $mode);
+    $storedfiles = [];
+    $fs = get_file_storage();
+
+    cli_heading(get_string('clifiles', 'tool_customlang', $lng));
+
+    foreach ($files as $file) {
+        // Generate a valid stored_file from this file.
+        $record = (object)[
+            'filearea' => 'draft',
+            'component' => 'user',
+            'filepath' => '/',
+            'itemid'   => file_get_unused_draft_itemid(),
+            'license'  => $CFG->sitedefaultlicense,
+            'author'   => '',
+            'filename' => clean_param(basename($file), PARAM_FILE),
+            'contextid' => \context_user::instance($USER->id)->id,
+            'userid' => $USER->id,
+        ];
+        cli_writeln($file);
+        $storedfiles[] = $fs->create_file_from_pathname($record, $file);
+    }
+    cli_writeln("");
+
+    // Import files.
+    cli_heading(get_string('cliimporting', 'tool_customlang', $modeparam));
+    $importer->import($storedfiles);
+    // Display logs.
+    $log = $importer->get_log();
+    if (empty($log)) {
+        cli_problem(get_string('clinolog', 'tool_customlang', $lng));
+    }
+    foreach ($log as $message) {
+        if ($message->errorlevel == notification::NOTIFY_ERROR) {
+            cli_problem($message->get_message());
+        } else {
+            cli_writeln($message->get_message());
+        }
+    }
+    // Do the checkin if necessary.
+    if ($checkin) {
+        tool_customlang_utils::checkin($lng);
+        cli_writeln(get_string('savecheckin', 'tool_customlang'));
+    }
+    cli_writeln("");
+}
+
+exit(0);
diff --git a/admin/tool/customlang/import.php b/admin/tool/customlang/import.php
new file mode 100644 (file)
index 0000000..628a9f8
--- /dev/null
@@ -0,0 +1,74 @@
+<?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/>.
+
+/**
+ * Import custom lang files.
+ *
+ * @package    tool_customlang
+ * @subpackage customlang
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use tool_customlang\form\import;
+use tool_customlang\local\importer;
+
+require(__DIR__ . '/../../../config.php');
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/customlang/locallib.php');
+require_once($CFG->libdir.'/adminlib.php');
+
+require_login(SITEID, false);
+require_capability('tool/customlang:edit', context_system::instance());
+
+$lng = required_param('lng', PARAM_LANG);
+
+admin_externalpage_setup('toolcustomlang', '', null,
+    new moodle_url('/admin/tool/customlang/import.php', ['lng' => $lng]));
+
+$output = $PAGE->get_renderer('tool_customlang');
+
+$form = new import(null, ['lng' => $lng]);
+if ($data = $form->get_data()) {
+    require_sesskey();
+
+    // Get the file from the users draft area.
+    $usercontext = context_user::instance($USER->id);
+    $fs = get_file_storage();
+    $files = $fs->get_area_files($usercontext->id, 'user', 'draft', $data->pack, 'id',
+        false);
+
+    // Send files to the importer.
+    $importer = new importer($data->lng, $data->importmode);
+    $importer->import($files);
+
+    echo $output->header();
+
+    // Display logs.
+    $log = $importer->get_log();
+    foreach ($log as $message) {
+        echo $output->notification($message->get_message(), $message->errorlevel);
+    }
+
+    // Show continue button.
+    echo $output->continue_button(new moodle_url('index.php', array('lng' => $lng)));
+
+} else {
+    echo $output->header();
+
+    $form->display();
+}
+
+echo $OUTPUT->footer();
index d796e8c..e57aa44 100644 (file)
@@ -132,6 +132,11 @@ if (has_capability('tool/customlang:edit', context_system::instance())) {
             'method'    => 'post',
         );
     }
+    $menu['import'] = array(
+        'title'     => get_string('import', 'tool_customlang'),
+        'url'       => new moodle_url($PAGE->url, ['action' => 'checkout', 'lng' => $lng, 'next' => 'import']),
+        'method'    => 'post',
+    );
 }
 if (has_capability('tool/customlang:export', context_system::instance())) {
     $langdir = tool_customlang_utils::get_localpack_location($lng);
index 8ac0c2a..29dd1e2 100644 (file)
@@ -37,6 +37,13 @@ $string['cliexportfilenotfoundforcomponent'] = 'File {$a->filepath} not found fo
 $string['cliexportstartexport'] = 'Exporting language "{$a}"';
 $string['cliexportzipdone'] = 'Zip created: {$a}';
 $string['cliexportzipfail'] = 'Cannot create zip {$a}';
+$string['clifiles'] = 'Files to import into {$a}';
+$string['cliimporting'] = 'Import files string (mode {$a})';
+$string['clinolog'] = 'Nothing to import into {$a}';
+$string['climissinglang'] = 'Missing language';
+$string['climissingfiles'] = 'Missing valid files';
+$string['climissingmode'] = 'Missing or invalid mode (valid is all, new or update)';
+$string['climissingsource'] = 'Missing file or folder';
 $string['confirmcheckin'] = 'You are about to save modifications to your local language pack. This will export the customised strings from the translator into your site data directory and your site will start using the modified strings. Press \'Continue\' to proceed with saving.';
 $string['customlang:edit'] = 'Edit local translation';
 $string['customlang:export'] = 'Export local translation';
@@ -55,12 +62,24 @@ $string['headingcomponent'] = 'Component';
 $string['headinglocal'] = 'Local customisation';
 $string['headingstandard'] = 'Standard text';
 $string['headingstringid'] = 'String';
+$string['import'] = 'Import custom strings';
+$string['import_mode'] = 'Import mode';
+$string['import_new'] = 'Create only strings without local customisation';
+$string['import_update'] = 'Update only strings with local customisation';
+$string['import_all'] = 'Create or update all strings from the component(s)';
+$string['importfile'] = 'Import file';
+$string['langpack'] = 'Language component(s)';
 $string['markinguptodate'] = 'Marking the customisation as up-to-date';
 $string['markinguptodate_help'] = 'The customised translation may get outdated if either the English original or the master translation has modified since the string was customised on your site. Review the customised translation. If you find it up-to-date, click the checkbox. Edit it otherwise.';
 $string['markuptodate'] = 'mark as up-to-date';
 $string['modifiedno'] = 'There are no modified strings to save.';
 $string['modifiednum'] = 'There are {$a} modified strings. Do you wish to save these changes to your local language pack?';
 $string['nolocallang'] = 'No local strings found.';
+$string['notice_ignorenew'] = 'Ignoring string {$a->component}/{$a->stringid} because it is not customised.';
+$string['notice_ignoreupdate'] = 'Ignoring string {$a->component}/{$a->stringid} because it is already defined.';
+$string['notice_inexitentstring'] = 'String {$a->component}/{$a->stringid} not found.';
+$string['notice_missingcomponent'] = 'Missing component {$a->component}.';
+$string['notice_success'] = 'String {$a->component}/{$a->stringid} updated successfully.';
 $string['nostringsfound'] = 'No strings found, please modify the filter settings';
 $string['placeholder'] = 'Placeholders';
 $string['placeholder_help'] = 'Placeholders are special statements like `{$a}` or `{$a->something}` within the string. They are replaced with a value when the string is actually printed.
diff --git a/admin/tool/customlang/tests/behat/import_files.feature b/admin/tool/customlang/tests/behat/import_files.feature
new file mode 100644 (file)
index 0000000..20df1c0
--- /dev/null
@@ -0,0 +1,48 @@
+@tool @tool_customlang @_file_upload
+Feature: Within a moodle instance, an administrator should be able to import modified langstrings.
+  In order to import modified langstrings in the adminsettings from one to another instance,
+  As an admin
+  I need to be able to import the zips and php files of the language customisation of a language.
+
+  Background:
+    Given I log in as "admin"
+    And I navigate to "Language > Language customisation" in site administration
+    And I set the field "lng" to "en"
+    And I click on "Import custom strings" "button"
+    And I press "Continue"
+
+  @javascript
+  Scenario: Import a PHP file to add a new core lang customization
+    When I upload "admin/tool/customlang/tests/fixtures/tool_customlang.php" file to "Language component(s)" filemanager
+    And I press "Import file"
+    Then I should see "String tool_customlang/pluginname updated successfully."
+    And I should see "String tool_customlang/nonexistentinvetedstring not found."
+    And I click on "Continue" "button"
+    And I should see "There are 1 modified strings."
+    And I click on "Save strings to language pack" "button"
+    And I click on "Continue" "button"
+    And I should see "An amazing import feature" in the "page-header" "region"
+
+  @javascript
+  Scenario: Try to import a PHP file from a non existent component
+    When I upload "admin/tool/customlang/tests/fixtures/mod_fakecomponent.php" file to "Language component(s)" filemanager
+    And I press "Import file"
+    Then I should see "Missing component mod_fakecomponent."
+
+  @javascript
+  Scenario: Import a zip file with some PHP files in it.
+    When I upload "admin/tool/customlang/tests/fixtures/customlang.zip" file to "Language component(s)" filemanager
+    And I press "Import file"
+    Then I should see "String core/administrationsite updated successfully."
+    And I should see "String core/language updated successfully."
+    And I should see "String core/nonexistentinvetedstring not found."
+    And I should see "String tool_customlang/pluginname updated successfully."
+    And I should see "String tool_customlang/nonexistentinvetedstring not found."
+    And I should see "Missing component mod_fakecomponent."
+    And I click on "Continue" "button"
+    And I should see "There are 3 modified strings."
+    And I click on "Save strings to language pack" "button"
+    And I click on "Continue" "button"
+    And I should see "Uploaded custom string" in the "page-header" "region"
+    And I should see "Another Uploaded string" in the "page-header" "region"
+    And I should see "An amazing import feature" in the "page-header" "region"
diff --git a/admin/tool/customlang/tests/behat/import_mode.feature b/admin/tool/customlang/tests/behat/import_mode.feature
new file mode 100644 (file)
index 0000000..2347cf2
--- /dev/null
@@ -0,0 +1,81 @@
+@tool @tool_customlang @_file_upload
+Feature: Within a moodle instance, an administrator should be able to import langstrings with several modes.
+  In order to import modified langstrings in the adminsettings from one to another instance,
+  As an admin
+  I need to be able to import only some language customisation strings depending on some conditions.
+
+  Background:
+    # Add one customization.
+    Given I log in as "admin"
+    And I navigate to "Language > Language customisation" in site administration
+    And I set the field "lng" to "en"
+    And I press "Open language pack for editing"
+    And I press "Continue"
+    And I set the field "Show strings of these components" to "moodle.php"
+    And I set the field "String identifier" to "administrationsite"
+    And I press "Show strings"
+    And I set the field "core/administrationsite" to "Custom string example"
+    And I press "Save changes to the language pack"
+    And I should see "There are 1 modified strings."
+    And I click on "Continue" "button"
+    And I should see "Custom string example" in the "page-header" "region"
+
+  @javascript
+  Scenario: Update only customized strings
+    When I set the field "lng" to "en"
+    And I click on "Import custom strings" "button"
+    And I press "Continue"
+    And I upload "admin/tool/customlang/tests/fixtures/moodle.php" file to "Language component(s)" filemanager
+    And I set the field "Import mode" to "Update only strings with local customisation"
+    And I press "Import file"
+    Then I should see "String core/administrationsite updated successfully."
+    And I should see "Ignoring string core/language because it is not customised."
+    And I should see "String core/nonexistentinvetedstring not found."
+    And I click on "Continue" "button"
+    And I should see "There are 1 modified strings."
+    And I should not see "Uploaded custom string" in the "page-header" "region"
+    And I click on "Save strings to language pack" "button"
+    And I click on "Continue" "button"
+    And I should not see "Custom string example" in the "page-header" "region"
+    And I should see "Uploaded custom string" in the "page-header" "region"
+    And I should not see "Another Uploaded string" in the "page-header" "region"
+
+  @javascript
+  Scenario: Create only new strings
+    When I set the field "lng" to "en"
+    And I click on "Import custom strings" "button"
+    And I press "Continue"
+    And I upload "admin/tool/customlang/tests/fixtures/moodle.php" file to "Language component(s)" filemanager
+    And I set the field "Import mode" to "Create only strings without local customisation"
+    And I press "Import file"
+    Then I should see "Ignoring string core/administrationsite because it is already defined."
+    And I should see "String core/language updated successfully."
+    And I should see "String core/nonexistentinvetedstring not found."
+    And I click on "Continue" "button"
+    And I should see "There are 1 modified strings."
+    And I should not see "Uploaded custom string" in the "page-header" "region"
+    And I click on "Save strings to language pack" "button"
+    And I click on "Continue" "button"
+    And I should see "Custom string example" in the "page-header" "region"
+    And I should not see "Uploaded custom string" in the "page-header" "region"
+    And I should see "Another Uploaded string" in the "page-header" "region"
+
+  @javascript
+  Scenario: Import all strings
+    When I set the field "lng" to "en"
+    And I click on "Import custom strings" "button"
+    And I press "Continue"
+    And I upload "admin/tool/customlang/tests/fixtures/moodle.php" file to "Language component(s)" filemanager
+    And I set the field "Import mode" to "Create or update all strings from the component(s)"
+    And I press "Import file"
+    Then I should see "String core/administrationsite updated successfully."
+    And I should see "String core/language updated successfully."
+    And I should see "String core/nonexistentinvetedstring not found."
+    And I click on "Continue" "button"
+    And I should see "There are 2 modified strings."
+    And I should not see "Uploaded custom string" in the "page-header" "region"
+    And I click on "Save strings to language pack" "button"
+    And I click on "Continue" "button"
+    And I should not see "Custom string example" in the "page-header" "region"
+    And I should see "Uploaded custom string" in the "page-header" "region"
+    And I should see "Another Uploaded string" in the "page-header" "region"
diff --git a/admin/tool/customlang/tests/fixtures/customlang.zip b/admin/tool/customlang/tests/fixtures/customlang.zip
new file mode 100644 (file)
index 0000000..9f61c26
Binary files /dev/null and b/admin/tool/customlang/tests/fixtures/customlang.zip differ
diff --git a/admin/tool/customlang/tests/fixtures/mod_fakecomponent.php b/admin/tool/customlang/tests/fixtures/mod_fakecomponent.php
new file mode 100644 (file)
index 0000000..780943a
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Local language pack.
+ *
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+
+$string['administrationsite'] = 'Uploaded custom string';
+$string['language'] = 'Another Uploaded string';
+$string['nonexistentinvetedstring'] = 'This should not be imported';
diff --git a/admin/tool/customlang/tests/fixtures/moodle.php b/admin/tool/customlang/tests/fixtures/moodle.php
new file mode 100644 (file)
index 0000000..b1f1866
--- /dev/null
@@ -0,0 +1,28 @@
+<?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/>.
+
+/**
+ * Local language pack.
+ *
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['administrationsite'] = 'Uploaded custom string';
+$string['language'] = 'Another Uploaded string';
+$string['nonexistentinvetedstring'] = 'This should not be imported';
diff --git a/admin/tool/customlang/tests/fixtures/tool_customlang.php b/admin/tool/customlang/tests/fixtures/tool_customlang.php
new file mode 100644 (file)
index 0000000..73559cb
--- /dev/null
@@ -0,0 +1,28 @@
+<?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/>.
+
+/**
+ * Local language pack from http://localhost/m/MDL-69583
+ *
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+
+$string['pluginname'] = 'An amazing import feature';
+$string['nonexistentinvetedstring'] = 'This string should not be imported';
diff --git a/admin/tool/customlang/tests/local/mlang/langstring_test.php b/admin/tool/customlang/tests/local/mlang/langstring_test.php
new file mode 100644 (file)
index 0000000..df5d99b
--- /dev/null
@@ -0,0 +1,1090 @@
+<?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/>.
+
+/**
+ * mlang langstring tests.
+ *
+ * Based on local_amos mlang_langstring tests.
+ *
+ * @package    tool_customlang
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_customlang\local\mlang;
+
+use advanced_testcase;
+use moodle_exception;
+
+/**
+ * Langstring tests.
+ *
+ * @package    tool_customlang
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class langstring_testcase extends advanced_testcase {
+
+    /**
+     * Sanity 1.x string
+     * - all variables but $a placeholders must be escaped because the string is eval'ed
+     * - all ' and " must be escaped
+     * - all single % must be converted into %% for backwards compatibility
+     *
+     * @dataProvider fix_syntax_data
+     * @param string $text the text to test
+     * @param int $version the lang package version (1 or 2)
+     * @param int|null $fromversion the version to convert (null for none)
+     * @param string $expected the expected result
+     *
+     */
+    public function test_fix_syntax(string $text, int $version, ?int $fromversion, string $expected): void {
+        $this->assertEquals(langstring::fix_syntax($text, $version, $fromversion), $expected);
+    }
+
+    /**
+     * Data provider for the test_parse.
+     *
+     * @return  array
+     */
+    public function fix_syntax_data() : array {
+        return [
+            // Syntax sanity v1 strings.
+            [
+                'No change', 1, null,
+                'No change'
+            ],
+            [
+                'Completed 100% of work', 1, null,
+                'Completed 100%% of work'
+            ],
+            [
+                'Completed 100%% of work', 1, null,
+                'Completed 100%% of work'
+            ],
+            [
+                "Windows\r\nsucks", 1, null,
+                "Windows\nsucks"
+            ],
+            [
+                "Linux\nsucks", 1, null,
+                "Linux\nsucks"
+            ],
+            [
+                "Mac\rsucks", 1, null,
+                "Mac\nsucks"
+            ],
+            [
+                "LINE TABULATION\x0Bnewline", 1, null,
+                "LINE TABULATION\nnewline"
+            ],
+            [
+                "FORM FEED\x0Cnewline", 1, null,
+                "FORM FEED\nnewline"
+            ],
+            [
+                "END OF TRANSMISSION BLOCK\x17newline", 1, null,
+                "END OF TRANSMISSION BLOCK\nnewline"
+            ],
+            [
+                "END OF MEDIUM\x19newline", 1, null,
+                "END OF MEDIUM\nnewline"
+            ],
+            [
+                "SUBSTITUTE\x1Anewline", 1, null,
+                "SUBSTITUTE\nnewline"
+            ],
+            [
+                "BREAK PERMITTED HERE\xC2\x82newline", 1, null,
+                "BREAK PERMITTED HERE\nnewline"
+            ],
+            [
+                "NEXT LINE\xC2\x85newline", 1, null,
+                "NEXT LINE\nnewline"
+            ],
+            [
+                "START OF STRING\xC2\x98newline", 1, null,
+                "START OF STRING\nnewline"
+            ],
+            [
+                "STRING TERMINATOR\xC2\x9Cnewline", 1, null,
+                "STRING TERMINATOR\nnewline"
+            ],
+            [
+                "Unicode Zl\xE2\x80\xA8newline", 1, null,
+                "Unicode Zl\nnewline"
+            ],
+            [
+                "Unicode Zp\xE2\x80\xA9newline", 1, null,
+                "Unicode Zp\nnewline"
+            ],
+            [
+                "Empty\n\n\n\n\n\nlines", 1, null,
+                "Empty\n\nlines"
+            ],
+            [
+                "Trailing   \n  whitespace \t \nat \nmultilines  ", 1, null,
+                "Trailing\n  whitespace\nat\nmultilines"
+            ],
+            [
+                'Escape $variable names', 1, null,
+                'Escape \$variable names'
+            ],
+            [
+                'Escape $alike names', 1, null,
+                'Escape \$alike names'
+            ],
+            [
+                'String $a placeholder', 1, null,
+                'String $a placeholder'
+            ],
+            [
+                'Escaped \$a', 1, null,
+                'Escaped \$a'
+            ],
+            [
+                'Wrapped {$a}', 1, null,
+                'Wrapped {$a}'
+            ],
+            [
+                'Trailing $a', 1, null,
+                'Trailing $a'
+            ],
+            [
+                '$a leading', 1, null,
+                '$a leading'
+            ],
+            [
+                'Hit $a-times', 1, null,
+                'Hit $a-times'
+            ], // This is placeholder.
+            [
+                'This is $a_book', 1, null,
+                'This is \$a_book'
+            ], // This is not a place holder.
+            [
+                'Bye $a, ttyl', 1, null,
+                'Bye $a, ttyl'
+            ],
+            [
+                'Object $a->foo placeholder', 1, null,
+                'Object $a->foo placeholder'
+            ],
+            [
+                'Trailing $a->bar', 1, null,
+                'Trailing $a->bar'
+            ],
+            [
+                '<strong>AMOS</strong>', 1, null,
+                '<strong>AMOS</strong>'
+            ],
+            [
+                '<a href="http://localhost">AMOS</a>', 1, null,
+                '<a href=\"http://localhost\">AMOS</a>'
+            ],
+            [
+                '<a href=\"http://localhost\">AMOS</a>', 1, null,
+                '<a href=\"http://localhost\">AMOS</a>'
+            ],
+            [
+                "'Murder!', she wrote", 1, null,
+                "'Murder!', she wrote"
+            ], // Will be escaped by var_export().
+            [
+                "\t  Trim Hunter  \t\t", 1, null,
+                'Trim Hunter'
+            ],
+            [
+                'Delete role "$a->role"?', 1, null,
+                'Delete role \"$a->role\"?'
+            ],
+            [
+                'Delete role \"$a->role\"?', 1, null,
+                'Delete role \"$a->role\"?'
+            ],
+            [
+                "Delete ASCII\0 NULL control character", 1, null,
+                'Delete ASCII NULL control character'
+            ],
+            [
+                "Delete ASCII\x05 ENQUIRY control character", 1, null,
+                'Delete ASCII ENQUIRY control character'
+            ],
+            [
+                "Delete ASCII\x06 ACKNOWLEDGE control character", 1, null,
+                'Delete ASCII ACKNOWLEDGE control character'
+            ],
+            [
+                "Delete ASCII\x07 BELL control character", 1, null,
+                'Delete ASCII BELL control character'
+            ],
+            [
+                "Delete ASCII\x0E SHIFT OUT control character", 1, null,
+                'Delete ASCII SHIFT OUT control character'
+            ],
+            [
+                "Delete ASCII\x0F SHIFT IN control character", 1, null,
+                'Delete ASCII SHIFT IN control character'
+            ],
+            [
+                "Delete ASCII\x10 DATA LINK ESCAPE control character", 1, null,
+                'Delete ASCII DATA LINK ESCAPE control character'
+            ],
+            [
+                "Delete ASCII\x11 DEVICE CONTROL ONE control character", 1, null,
+                'Delete ASCII DEVICE CONTROL ONE control character'
+            ],
+            [
+                "Delete ASCII\x12 DEVICE CONTROL TWO control character", 1, null,
+                'Delete ASCII DEVICE CONTROL TWO control character'
+            ],
+            [
+                "Delete ASCII\x13 DEVICE CONTROL THREE control character", 1, null,
+                'Delete ASCII DEVICE CONTROL THREE control character'
+            ],
+            [
+                "Delete ASCII\x14 DEVICE CONTROL FOUR control character", 1, null,
+                'Delete ASCII DEVICE CONTROL FOUR control character'
+            ],
+            [
+                "Delete ASCII\x15 NEGATIVE ACKNOWLEDGE control character", 1, null,
+                'Delete ASCII NEGATIVE ACKNOWLEDGE control character'
+            ],
+            [
+                "Delete ASCII\x16 SYNCHRONOUS IDLE control character", 1, null,
+                'Delete ASCII SYNCHRONOUS IDLE control character'
+            ],
+            [
+                "Delete ASCII\x1B ESCAPE control character", 1, null,
+                'Delete ASCII ESCAPE control character'
+            ],
+            [
+                "Delete ASCII\x7F DELETE control character", 1, null,
+                'Delete ASCII DELETE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x80 PADDING CHARACTER control character", 1, null,
+                'Delete ISO 8859 PADDING CHARACTER control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x81 HIGH OCTET PRESET control character", 1, null,
+                'Delete ISO 8859 HIGH OCTET PRESET control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x83 NO BREAK HERE control character", 1, null,
+                'Delete ISO 8859 NO BREAK HERE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x84 INDEX control character", 1, null,
+                'Delete ISO 8859 INDEX control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x86 START OF SELECTED AREA control character", 1, null,
+                'Delete ISO 8859 START OF SELECTED AREA control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x87 END OF SELECTED AREA control character", 1, null,
+                'Delete ISO 8859 END OF SELECTED AREA control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x88 CHARACTER TABULATION SET control character", 1, null,
+                'Delete ISO 8859 CHARACTER TABULATION SET control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x89 CHARACTER TABULATION WITH JUSTIFICATION control character", 1, null,
+                'Delete ISO 8859 CHARACTER TABULATION WITH JUSTIFICATION control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8A LINE TABULATION SET control character", 1, null,
+                'Delete ISO 8859 LINE TABULATION SET control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8B PARTIAL LINE FORWARD control character", 1, null,
+                'Delete ISO 8859 PARTIAL LINE FORWARD control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8C PARTIAL LINE BACKWARD control character", 1, null,
+                'Delete ISO 8859 PARTIAL LINE BACKWARD control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8D REVERSE LINE FEED control character", 1, null,
+                'Delete ISO 8859 REVERSE LINE FEED control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8E SINGLE SHIFT TWO control character", 1, null,
+                'Delete ISO 8859 SINGLE SHIFT TWO control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8F SINGLE SHIFT THREE control character", 1, null,
+                'Delete ISO 8859 SINGLE SHIFT THREE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x90 DEVICE CONTROL STRING control character", 1, null,
+                'Delete ISO 8859 DEVICE CONTROL STRING control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x91 PRIVATE USE ONE control character", 1, null,
+                'Delete ISO 8859 PRIVATE USE ONE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x92 PRIVATE USE TWO control character", 1, null,
+                'Delete ISO 8859 PRIVATE USE TWO control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x93 SET TRANSMIT STATE control character", 1, null,
+                'Delete ISO 8859 SET TRANSMIT STATE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x95 MESSAGE WAITING control character", 1, null,
+                'Delete ISO 8859 MESSAGE WAITING control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x96 START OF GUARDED AREA control character", 1, null,
+                'Delete ISO 8859 START OF GUARDED AREA control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x97 END OF GUARDED AREA control character", 1, null,
+                'Delete ISO 8859 END OF GUARDED AREA control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x99 SINGLE GRAPHIC CHARACTER INTRODUCER control character", 1, null,
+                'Delete ISO 8859 SINGLE GRAPHIC CHARACTER INTRODUCER control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x9A SINGLE CHARACTER INTRODUCER control character", 1, null,
+                'Delete ISO 8859 SINGLE CHARACTER INTRODUCER control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x9B CONTROL SEQUENCE INTRODUCER control character", 1, null,
+                'Delete ISO 8859 CONTROL SEQUENCE INTRODUCER control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x9D OPERATING SYSTEM COMMAND control character", 1, null,
+                'Delete ISO 8859 OPERATING SYSTEM COMMAND control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x9E PRIVACY MESSAGE control character", 1, null,
+                'Delete ISO 8859 PRIVACY MESSAGE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x9F APPLICATION PROGRAM COMMAND control character", 1, null,
+                'Delete ISO 8859 APPLICATION PROGRAM COMMAND control character'
+            ],
+            [
+                "Delete Unicode\xE2\x80\x8B ZERO WIDTH SPACE control character", 1, null,
+                'Delete Unicode ZERO WIDTH SPACE control character'
+            ],
+            [
+                "Delete Unicode\xEF\xBB\xBF ZERO WIDTH NO-BREAK SPACE control character", 1, null,
+                'Delete Unicode ZERO WIDTH NO-BREAK SPACE control character'
+            ],
+            [
+                "Delete Unicode\xEF\xBF\xBD REPLACEMENT CHARACTER control character", 1, null,
+                'Delete Unicode REPLACEMENT CHARACTER control character'
+            ],
+            // Syntax sanity v2 strings.
+            [
+                'No change', 2, null,
+                'No change'
+            ],
+            [
+                'Completed 100% of work', 2, null,
+                'Completed 100% of work'
+            ],
+            [
+                '%%%% HEADER %%%%', 2, null,
+                '%%%% HEADER %%%%'
+            ], // Was not possible before.
+            [
+                "Windows\r\nsucks", 2, null,
+                "Windows\nsucks"
+            ],
+            [
+                "Linux\nsucks", 2, null,
+                "Linux\nsucks"
+            ],
+            [
+                "Mac\rsucks", 2, null,
+                "Mac\nsucks"
+            ],
+            [
+                "LINE TABULATION\x0Bnewline", 2, null,
+                "LINE TABULATION\nnewline"
+            ],
+            [
+                "FORM FEED\x0Cnewline", 2, null,
+                "FORM FEED\nnewline"
+            ],
+            [
+                "END OF TRANSMISSION BLOCK\x17newline", 2, null,
+                "END OF TRANSMISSION BLOCK\nnewline"
+            ],
+            [
+                "END OF MEDIUM\x19newline", 2, null,
+                "END OF MEDIUM\nnewline"
+            ],
+            [
+                "SUBSTITUTE\x1Anewline", 2, null,
+                "SUBSTITUTE\nnewline"
+            ],
+            [
+                "BREAK PERMITTED HERE\xC2\x82newline", 2, null,
+                "BREAK PERMITTED HERE\nnewline"
+            ],
+            [
+                "NEXT LINE\xC2\x85newline", 2, null,
+                "NEXT LINE\nnewline"
+            ],
+            [
+                "START OF STRING\xC2\x98newline", 2, null,
+                "START OF STRING\nnewline"
+            ],
+            [
+                "STRING TERMINATOR\xC2\x9Cnewline", 2, null,
+                "STRING TERMINATOR\nnewline"
+            ],
+            [
+                "Unicode Zl\xE2\x80\xA8newline", 2, null,
+                "Unicode Zl\nnewline"
+            ],
+            [
+                "Unicode Zp\xE2\x80\xA9newline", 2, null,
+                "Unicode Zp\nnewline"
+            ],
+            [
+                "Empty\n\n\n\n\n\nlines", 2, null,
+                "Empty\n\n\nlines"
+            ], // Now allows up to two empty lines.
+            [
+                "Trailing   \n  whitespace\t\nat \nmultilines  ", 2, null,
+                "Trailing\n  whitespace\nat\nmultilines"
+            ],
+            [
+                'Do not escape $variable names', 2, null,
+                'Do not escape $variable names'
+            ],
+            [
+                'Do not escape $alike names', 2, null,
+                'Do not escape $alike names'
+            ],
+            [
+                'Not $a placeholder', 2, null,
+                'Not $a placeholder'
+            ],
+            [
+                'String {$a} placeholder', 2, null,
+                'String {$a} placeholder'
+            ],
+            [
+                'Trailing {$a}', 2, null,
+                'Trailing {$a}'
+            ],
+            [
+                '{$a} leading', 2, null,
+                '{$a} leading'
+            ],
+            [
+                'Trailing $a', 2, null,
+                'Trailing $a'
+            ],
+            [
+                '$a leading', 2, null,
+                '$a leading'
+            ],
+            [
+                'Not $a->foo placeholder', 2, null,
+                'Not $a->foo placeholder'
+            ],
+            [
+                'Object {$a->foo} placeholder', 2, null,
+                'Object {$a->foo} placeholder'
+            ],
+            [
+                'Trailing $a->bar', 2, null,
+                'Trailing $a->bar'
+            ],
+            [
+                'Invalid $a-> placeholder', 2, null,
+                'Invalid $a-> placeholder'
+            ],
+            [
+                '<strong>AMOS</strong>', 2, null,
+                '<strong>AMOS</strong>'
+            ],
+            [
+                "'Murder!', she wrote", 2, null,
+                "'Murder!', she wrote"
+            ], // Will be escaped by var_export().
+            [
+                "\t  Trim Hunter  \t\t", 2, null,
+                'Trim Hunter'
+            ],
+            [
+                'Delete role "$a->role"?', 2, null,
+                'Delete role "$a->role"?'
+            ],
+            [
+                'Delete role \"$a->role\"?', 2, null,
+                'Delete role \"$a->role\"?'
+            ],
+            [
+                "Delete ASCII\0 NULL control character", 2, null,
+                'Delete ASCII NULL control character'
+            ],
+            [
+                "Delete ASCII\x05 ENQUIRY control character", 2, null,
+                'Delete ASCII ENQUIRY control character'
+            ],
+            [
+                "Delete ASCII\x06 ACKNOWLEDGE control character", 2, null,
+                'Delete ASCII ACKNOWLEDGE control character'
+            ],
+            [
+                "Delete ASCII\x07 BELL control character", 2, null,
+                'Delete ASCII BELL control character'
+            ],
+            [
+                "Delete ASCII\x0E SHIFT OUT control character", 2, null,
+                'Delete ASCII SHIFT OUT control character'
+            ],
+            [
+                "Delete ASCII\x0F SHIFT IN control character", 2, null,
+                'Delete ASCII SHIFT IN control character'
+            ],
+            [
+                "Delete ASCII\x10 DATA LINK ESCAPE control character", 2, null,
+                'Delete ASCII DATA LINK ESCAPE control character'
+            ],
+            [
+                "Delete ASCII\x11 DEVICE CONTROL ONE control character", 2, null,
+                'Delete ASCII DEVICE CONTROL ONE control character'
+            ],
+            [
+                "Delete ASCII\x12 DEVICE CONTROL TWO control character", 2, null,
+                'Delete ASCII DEVICE CONTROL TWO control character'
+            ],
+            [
+                "Delete ASCII\x13 DEVICE CONTROL THREE control character", 2, null,
+                'Delete ASCII DEVICE CONTROL THREE control character'
+            ],
+            [
+                "Delete ASCII\x14 DEVICE CONTROL FOUR control character", 2, null,
+                'Delete ASCII DEVICE CONTROL FOUR control character'
+            ],
+            [
+                "Delete ASCII\x15 NEGATIVE ACKNOWLEDGE control character", 2, null,
+                'Delete ASCII NEGATIVE ACKNOWLEDGE control character'
+            ],
+            [
+                "Delete ASCII\x16 SYNCHRONOUS IDLE control character", 2, null,
+                'Delete ASCII SYNCHRONOUS IDLE control character'
+            ],
+            [
+                "Delete ASCII\x1B ESCAPE control character", 2, null,
+                'Delete ASCII ESCAPE control character'
+            ],
+            [
+                "Delete ASCII\x7F DELETE control character", 2, null,
+                'Delete ASCII DELETE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x80 PADDING CHARACTER control character", 2, null,
+                'Delete ISO 8859 PADDING CHARACTER control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x81 HIGH OCTET PRESET control character", 2, null,
+                'Delete ISO 8859 HIGH OCTET PRESET control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x83 NO BREAK HERE control character", 2, null,
+                'Delete ISO 8859 NO BREAK HERE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x84 INDEX control character", 2, null,
+                'Delete ISO 8859 INDEX control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x86 START OF SELECTED AREA control character", 2, null,
+                'Delete ISO 8859 START OF SELECTED AREA control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x87 END OF SELECTED AREA control character", 2, null,
+                'Delete ISO 8859 END OF SELECTED AREA control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x88 CHARACTER TABULATION SET control character", 2, null,
+                'Delete ISO 8859 CHARACTER TABULATION SET control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x89 CHARACTER TABULATION WITH JUSTIFICATION control character", 2, null,
+                'Delete ISO 8859 CHARACTER TABULATION WITH JUSTIFICATION control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8A LINE TABULATION SET control character", 2, null,
+                'Delete ISO 8859 LINE TABULATION SET control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8B PARTIAL LINE FORWARD control character", 2, null,
+                'Delete ISO 8859 PARTIAL LINE FORWARD control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8C PARTIAL LINE BACKWARD control character", 2, null,
+                'Delete ISO 8859 PARTIAL LINE BACKWARD control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8D REVERSE LINE FEED control character", 2, null,
+                'Delete ISO 8859 REVERSE LINE FEED control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8E SINGLE SHIFT TWO control character", 2, null,
+                'Delete ISO 8859 SINGLE SHIFT TWO control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8F SINGLE SHIFT THREE control character", 2, null,
+                'Delete ISO 8859 SINGLE SHIFT THREE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x90 DEVICE CONTROL STRING control character", 2, null,
+                'Delete ISO 8859 DEVICE CONTROL STRING control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x91 PRIVATE USE ONE control character", 2, null,
+                'Delete ISO 8859 PRIVATE USE ONE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x92 PRIVATE USE TWO control character", 2, null,
+                'Delete ISO 8859 PRIVATE USE TWO control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x93 SET TRANSMIT STATE control character", 2, null,
+                'Delete ISO 8859 SET TRANSMIT STATE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x95 MESSAGE WAITING control character", 2, null,
+                'Delete ISO 8859 MESSAGE WAITING control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x96 START OF GUARDED AREA control character", 2, null,
+                'Delete ISO 8859 START OF GUARDED AREA control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x97 END OF GUARDED AREA control character", 2, null,
+                'Delete ISO 8859 END OF GUARDED AREA control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x99 SINGLE GRAPHIC CHARACTER INTRODUCER control character", 2, null,
+                'Delete ISO 8859 SINGLE GRAPHIC CHARACTER INTRODUCER control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x9A SINGLE CHARACTER INTRODUCER control character", 2, null,
+                'Delete ISO 8859 SINGLE CHARACTER INTRODUCER control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x9B CONTROL SEQUENCE INTRODUCER control character", 2, null,
+                'Delete ISO 8859 CONTROL SEQUENCE INTRODUCER control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x9D OPERATING SYSTEM COMMAND control character", 2, null,
+                'Delete ISO 8859 OPERATING SYSTEM COMMAND control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x9E PRIVACY MESSAGE control character", 2, null,
+                'Delete ISO 8859 PRIVACY MESSAGE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x9F APPLICATION PROGRAM COMMAND control character", 2, null,
+                'Delete ISO 8859 APPLICATION PROGRAM COMMAND control character'
+            ],
+            [
+                "Delete Unicode\xE2\x80\x8B ZERO WIDTH SPACE control character", 2, null,
+                'Delete Unicode ZERO WIDTH SPACE control character'
+            ],
+            [
+                "Delete Unicode\xEF\xBB\xBF ZERO WIDTH NO-BREAK SPACE control character", 2, null,
+                'Delete Unicode ZERO WIDTH NO-BREAK SPACE control character'
+            ],
+            [
+                "Delete Unicode\xEF\xBF\xBD REPLACEMENT CHARACTER control character", 2, null,
+                'Delete Unicode REPLACEMENT CHARACTER control character'
+            ],
+            // Conterting from v1 to v2.
+            [
+                'No change', 2, 1,
+                'No change'
+            ],
+            [
+                'Completed 100% of work', 2, 1,
+                'Completed 100% of work'
+            ],
+            [
+                'Completed 100%% of work', 2, 1,
+                'Completed 100% of work'
+            ],
+            [
+                "Windows\r\nsucks", 2, 1,
+                "Windows\nsucks"
+            ],
+            [
+                "Linux\nsucks", 2, 1,
+                "Linux\nsucks"
+            ],
+            [
+                "Mac\rsucks", 2, 1,
+                "Mac\nsucks"
+            ],
+            [
+                "LINE TABULATION\x0Bnewline", 2, 1,
+                "LINE TABULATION\nnewline"
+            ],
+            [
+                "FORM FEED\x0Cnewline", 2, 1,
+                "FORM FEED\nnewline"
+            ],
+            [
+                "END OF TRANSMISSION BLOCK\x17newline", 2, 1,
+                "END OF TRANSMISSION BLOCK\nnewline"
+            ],
+            [
+                "END OF MEDIUM\x19newline", 2, 1,
+                "END OF MEDIUM\nnewline"
+            ],
+            [
+                "SUBSTITUTE\x1Anewline", 2, 1,
+                "SUBSTITUTE\nnewline"
+            ],
+            [
+                "BREAK PERMITTED HERE\xC2\x82newline", 2, 1,
+                "BREAK PERMITTED HERE\nnewline"
+            ],
+            [
+                "NEXT LINE\xC2\x85newline", 2, 1,
+                "NEXT LINE\nnewline"
+            ],
+            [
+                "START OF STRING\xC2\x98newline", 2, 1,
+                "START OF STRING\nnewline"
+            ],
+            [
+                "STRING TERMINATOR\xC2\x9Cnewline", 2, 1,
+                "STRING TERMINATOR\nnewline"
+            ],
+            [
+                "Unicode Zl\xE2\x80\xA8newline", 2, 1,
+                "Unicode Zl\nnewline"
+            ],
+            [
+                "Unicode Zp\xE2\x80\xA9newline", 2, 1,
+                "Unicode Zp\nnewline"
+            ],
+            [
+                "Empty\n\n\n\n\n\nlines", 2, 1,
+                "Empty\n\n\nlines"
+            ],
+            [
+                "Trailing   \n  whitespace\t\nat \nmultilines  ", 2, 1,
+                "Trailing\n  whitespace\nat\nmultilines"
+            ],
+            [
+                'Do not escape $variable names', 2, 1,
+                'Do not escape $variable names'
+            ],
+            [
+                'Do not escape \$variable names', 2, 1,
+                'Do not escape $variable names'
+            ],
+            [
+                'Do not escape $alike names', 2, 1,
+                'Do not escape $alike names'
+            ],
+            [
+                'Do not escape \$alike names', 2, 1,
+                'Do not escape $alike names'
+            ],
+            [
+                'Do not escape \$a names', 2, 1,
+                'Do not escape $a names'
+            ],
+            [
+                'String $a placeholder', 2, 1,
+                'String {$a} placeholder'
+            ],
+            [
+                'String {$a} placeholder', 2, 1,
+                'String {$a} placeholder'
+            ],
+            [
+                'Trailing $a', 2, 1,
+                'Trailing {$a}'
+            ],
+            [
+                '$a leading', 2, 1,
+                '{$a} leading'
+            ],
+            [
+                '$a', 2, 1,
+                '{$a}'
+            ],
+            [
+                '$a->single', 2, 1,
+                '{$a->single}'
+            ],
+            [
+                'Trailing $a->foobar', 2, 1,
+                'Trailing {$a->foobar}'
+            ],
+            [
+                'Trailing {$a}', 2, 1,
+                'Trailing {$a}'
+            ],
+            [
+                'Hit $a-times', 2, 1,
+                'Hit {$a}-times'
+            ],
+            [
+                'This is $a_book', 2, 1,
+                'This is $a_book'
+            ],
+            [
+                'Object $a->foo placeholder', 2, 1,
+                'Object {$a->foo} placeholder'
+            ],
+            [
+                'Object {$a->foo} placeholder', 2, 1,
+                'Object {$a->foo} placeholder'
+            ],
+            [
+                'Trailing $a->bar', 2, 1,
+                'Trailing {$a->bar}'
+            ],
+            [
+                'Trailing {$a->bar}', 2, 1,
+                'Trailing {$a->bar}'
+            ],
+            [
+                'Invalid $a-> placeholder', 2, 1,
+                'Invalid {$a}-> placeholder'
+                ], // Weird but BC.
+            [
+                '<strong>AMOS</strong>', 2, 1,
+                '<strong>AMOS</strong>'
+            ],
+            [
+                "'Murder!', she wrote", 2, 1,
+                "'Murder!', she wrote"
+            ], // Will be escaped by var_export().
+            [
+                "\'Murder!\', she wrote", 2, 1,
+                "'Murder!', she wrote"
+            ], // Will be escaped by var_export().
+            [
+                "\t  Trim Hunter  \t\t", 2, 1,
+                'Trim Hunter'
+            ],
+            [
+                'Delete role "$a->role"?', 2, 1,
+                'Delete role "{$a->role}"?'
+            ],
+            [
+                'Delete role \"$a->role\"?', 2, 1,
+                'Delete role "{$a->role}"?'
+            ],
+            [
+                'See &#36;CFG->foo', 2, 1,
+                'See $CFG->foo'
+            ],
+            [
+                "Delete ASCII\0 NULL control character", 2, 1,
+                'Delete ASCII NULL control character'
+            ],
+            [
+                "Delete ASCII\x05 ENQUIRY control character", 2, 1,
+                'Delete ASCII ENQUIRY control character'
+            ],
+            [
+                "Delete ASCII\x06 ACKNOWLEDGE control character", 2, 1,
+                'Delete ASCII ACKNOWLEDGE control character'
+            ],
+            [
+                "Delete ASCII\x07 BELL control character", 2, 1,
+                'Delete ASCII BELL control character'
+            ],
+            [
+                "Delete ASCII\x0E SHIFT OUT control character", 2, 1,
+                'Delete ASCII SHIFT OUT control character'
+            ],
+            [
+                "Delete ASCII\x0F SHIFT IN control character", 2, 1,
+                'Delete ASCII SHIFT IN control character'
+            ],
+            [
+                "Delete ASCII\x10 DATA LINK ESCAPE control character", 2, 1,
+                'Delete ASCII DATA LINK ESCAPE control character'
+            ],
+            [
+                "Delete ASCII\x11 DEVICE CONTROL ONE control character", 2, 1,
+                'Delete ASCII DEVICE CONTROL ONE control character'
+            ],
+            [
+                "Delete ASCII\x12 DEVICE CONTROL TWO control character", 2, 1,
+                'Delete ASCII DEVICE CONTROL TWO control character'
+            ],
+            [
+                "Delete ASCII\x13 DEVICE CONTROL THREE control character", 2, 1,
+                'Delete ASCII DEVICE CONTROL THREE control character'
+            ],
+            [
+                "Delete ASCII\x14 DEVICE CONTROL FOUR control character", 2, 1,
+                'Delete ASCII DEVICE CONTROL FOUR control character'
+            ],
+            [
+                "Delete ASCII\x15 NEGATIVE ACKNOWLEDGE control character", 2, 1,
+                'Delete ASCII NEGATIVE ACKNOWLEDGE control character'
+            ],
+            [
+                "Delete ASCII\x16 SYNCHRONOUS IDLE control character", 2, 1,
+                'Delete ASCII SYNCHRONOUS IDLE control character'
+            ],
+            [
+                "Delete ASCII\x1B ESCAPE control character", 2, 1,
+                'Delete ASCII ESCAPE control character'
+            ],
+            [
+                "Delete ASCII\x7F DELETE control character", 2, 1,
+                'Delete ASCII DELETE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x80 PADDING CHARACTER control character", 2, 1,
+                'Delete ISO 8859 PADDING CHARACTER control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x81 HIGH OCTET PRESET control character", 2, 1,
+                'Delete ISO 8859 HIGH OCTET PRESET control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x83 NO BREAK HERE control character", 2, 1,
+                'Delete ISO 8859 NO BREAK HERE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x84 INDEX control character", 2, 1,
+                'Delete ISO 8859 INDEX control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x86 START OF SELECTED AREA control character", 2, 1,
+                'Delete ISO 8859 START OF SELECTED AREA control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x87 END OF SELECTED AREA control character", 2, 1,
+                'Delete ISO 8859 END OF SELECTED AREA control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x88 CHARACTER TABULATION SET control character", 2, 1,
+                'Delete ISO 8859 CHARACTER TABULATION SET control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x89 CHARACTER TABULATION WITH JUSTIFICATION control character", 2, 1,
+                'Delete ISO 8859 CHARACTER TABULATION WITH JUSTIFICATION control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8A LINE TABULATION SET control character", 2, 1,
+                'Delete ISO 8859 LINE TABULATION SET control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8B PARTIAL LINE FORWARD control character", 2, 1,
+                'Delete ISO 8859 PARTIAL LINE FORWARD control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8C PARTIAL LINE BACKWARD control character", 2, 1,
+                'Delete ISO 8859 PARTIAL LINE BACKWARD control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8D REVERSE LINE FEED control character", 2, 1,
+                'Delete ISO 8859 REVERSE LINE FEED control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8E SINGLE SHIFT TWO control character", 2, 1,
+                'Delete ISO 8859 SINGLE SHIFT TWO control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x8F SINGLE SHIFT THREE control character", 2, 1,
+                'Delete ISO 8859 SINGLE SHIFT THREE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x90 DEVICE CONTROL STRING control character", 2, 1,
+                'Delete ISO 8859 DEVICE CONTROL STRING control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x91 PRIVATE USE ONE control character", 2, 1,
+                'Delete ISO 8859 PRIVATE USE ONE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x92 PRIVATE USE TWO control character", 2, 1,
+                'Delete ISO 8859 PRIVATE USE TWO control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x93 SET TRANSMIT STATE control character", 2, 1,
+                'Delete ISO 8859 SET TRANSMIT STATE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x95 MESSAGE WAITING control character", 2, 1,
+                'Delete ISO 8859 MESSAGE WAITING control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x96 START OF GUARDED AREA control character", 2, 1,
+                'Delete ISO 8859 START OF GUARDED AREA control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x97 END OF GUARDED AREA control character", 2, 1,
+                'Delete ISO 8859 END OF GUARDED AREA control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x99 SINGLE GRAPHIC CHARACTER INTRODUCER control character", 2, 1,
+                'Delete ISO 8859 SINGLE GRAPHIC CHARACTER INTRODUCER control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x9A SINGLE CHARACTER INTRODUCER control character", 2, 1,
+                'Delete ISO 8859 SINGLE CHARACTER INTRODUCER control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x9B CONTROL SEQUENCE INTRODUCER control character", 2, 1,
+                'Delete ISO 8859 CONTROL SEQUENCE INTRODUCER control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x9D OPERATING SYSTEM COMMAND control character", 2, 1,
+                'Delete ISO 8859 OPERATING SYSTEM COMMAND control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x9E PRIVACY MESSAGE control character", 2, 1,
+                'Delete ISO 8859 PRIVACY MESSAGE control character'
+            ],
+            [
+                "Delete ISO 8859\xC2\x9F APPLICATION PROGRAM COMMAND control character", 2, 1,
+                'Delete ISO 8859 APPLICATION PROGRAM COMMAND control character'
+            ],
+            [
+                "Delete Unicode\xE2\x80\x8B ZERO WIDTH SPACE control character", 2, 1,
+                'Delete Unicode ZERO WIDTH SPACE control character'
+            ],
+            [
+                "Delete Unicode\xEF\xBB\xBF ZERO WIDTH NO-BREAK SPACE control character", 2, 1,
+                'Delete Unicode ZERO WIDTH NO-BREAK SPACE control character'
+            ],
+            [
+                "Delete Unicode\xEF\xBF\xBD REPLACEMENT CHARACTER control character", 2, 1,
+                'Delete Unicode REPLACEMENT CHARACTER control character'
+            ],
+        ];
+    }
+}
diff --git a/admin/tool/customlang/tests/local/mlang/phpparser_test.php b/admin/tool/customlang/tests/local/mlang/phpparser_test.php
new file mode 100644 (file)
index 0000000..3dcdd93
--- /dev/null
@@ -0,0 +1,207 @@
+<?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/>.
+
+/**
+ * PHP lang parser test.
+ *
+ * @package    tool_customlang
+ * @copyright  2015 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_customlang\local\mlang;
+
+use advanced_testcase;
+use moodle_exception;
+
+/**
+ * PHP lang parser test class.
+ *
+ * @package    tool_customlang
+ * @copyright  2015 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class phpparser_testcase extends advanced_testcase {
+
+
+    /**
+     * Test get instance static method.
+     *
+     */
+    public function test_get_instance(): void {
+
+        $instance = phpparser::get_instance();
+
+        $this->assertInstanceOf('tool_customlang\local\mlang\phpparser', $instance);
+        $this->assertEquals($instance, phpparser::get_instance());
+    }
+
+    /**
+     * Test get instance parse method.
+     *
+     * @dataProvider parse_provider
+     * @param string $phpcode PHP code to test
+     * @param array $expected Expected result
+     * @param bool $exception if an exception is expected
+     */
+    public function test_parse(string $phpcode, array $expected, bool $exception): void {
+
+        $instance = phpparser::get_instance();
+
+        if ($exception) {
+            $this->expectException(moodle_exception::class);
+        }
+
+        $strings = $instance->parse($phpcode);
+
+        $this->assertEquals(count($expected), count($strings));
+        foreach ($strings as $key => $langstring) {
+            $this->assertEquals($expected[$key][0], $langstring->id);
+            $this->assertEquals($expected[$key][1], $langstring->text);
+        }
+    }
+
+    /**
+     * Data provider for the test_parse.
+     *
+     * @return  array
+     */
+    public function parse_provider() : array {
+        return [
+            'Invalid PHP code' => [
+                'No PHP code', [], false
+            ],
+            'No PHP open tag' => [
+                "\$string['example'] = 'text';\n", [], false
+            ],
+            'One string code' => [
+                "<?php \$string['example'] = 'text';\n", [['example', 'text']], false
+            ],
+            'Extra spaces' => [
+                "<?php \$string['example']   =   'text';\n", [['example', 'text']], false
+            ],
+            'Extra tabs' => [
+                "<?php \$string['example']\t=\t'text';\n", [['example', 'text']], false
+            ],
+            'Double quote string' => [
+                "<?php
+                    \$string['example'] = \"text\";
+                    \$string[\"example2\"] = 'text2';
+                    \$string[\"example3\"] = \"text3\";
+                ", [
+                    ['example', 'text'],
+                    ['example2', 'text2'],
+                    ['example3', 'text3'],
+                ], false
+            ],
+            'Multiple lines strings' => [
+                "<?php
+                    \$string['example'] = 'First line\nsecondline';
+                    \$string['example2'] = \"First line\nsecondline2\";
+                ", [
+                    ['example', "First line\nsecondline"],
+                    ['example2', "First line\nsecondline2"],
+                ], false
+            ],
+            'Two strings code' => [
+                "<?php
+                    \$string['example'] = 'text';
+                    \$string['example2'] = 'text2';
+                ", [
+                    ['example', 'text'],
+                    ['example2', 'text2'],
+                ], false
+            ],
+            'Scaped characters' => [
+                "<?php
+                    \$string['example'] = 'Thos are \\' quotes \" 1';
+                    \$string['example2'] = \"Thos are ' quotes \\\" 2\";
+                ", [
+                    ['example', "Thos are ' quotes \" 1"],
+                    ['example2', "Thos are ' quotes \" 2"],
+                ], false
+            ],
+            'PHP with single line comments' => [
+                "<?php
+                    // This is a comment.
+                    \$string['example'] = 'text';
+                    // This is another commment.
+                ", [
+                    ['example', 'text'],
+                ], false
+            ],
+            'PHP with block comments' => [
+                "<?php
+                    /* This is a block comment. */
+                    \$string['example'] = 'text';
+                    /* This is another
+                    block comment. */
+                ", [
+                    ['example', 'text'],
+                ], false
+            ],
+            'Wrong variable name' => [
+                "<?php
+                    \$stringwrong['example'] = 'text';
+                    \$wringstring['example'] = 'text';
+                ", [], false
+            ],
+            'Single line commented valid line' => [
+                "<?php
+                    // \$string['example'] = 'text';
+                ", [], false
+            ],
+            'Block commented valid line' => [
+                "<?php
+                    /*
+                    \$string['example'] = 'text';
+                    */
+                ", [], false
+            ],
+            'Syntax error 1 (double assignation)' => [
+                "<?php
+                    \$string['example'] = 'text' = 'wrong';
+                ", [], true
+            ],
+            'Syntax error 2 (no closing string)' => [
+                "<?php
+                    \$string['example'] = 'wrong;
+                ", [], true
+            ],
+            'Syntax error 3 (Array without key)' => [
+                "<?php
+                    \$string[] = 'wrong';
+                ", [], true
+            ],
+            'Syntax error 4 (Array not open)' => [
+                "<?php
+                    \$string'example'] = 'wrong';
+                ", [], true
+            ],
+            'Syntax error 5 (Array not closed)' => [
+                "<?php
+                    \$string['example' = 'wrong';
+                ", [], true
+            ],
+            'Syntax error 6 (Missing assignment)' => [
+                "<?php
+                    \$string['example'] 'wrong';
+                ", [], true
+            ],
+        ];
+    }
+
+}
index b2afe62..ccd8ad9 100644 (file)
@@ -25,6 +25,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2021052501;
+$plugin->version   = 2021052502;
 $plugin->requires  = 2021052500;
 $plugin->component = 'tool_customlang'; // Full name of the plugin (used for diagnostics)
index 6fafc5f..f3e6d7d 100644 (file)
@@ -83,6 +83,7 @@ $string['iosappid_desc'] = 'This setting may be left as default unless you have
 $string['loginintheapp'] = 'Via the app';
 $string['logininthebrowser'] = 'Via a browser window (for SSO plugins)';
 $string['loginintheembeddedbrowser'] = 'Via an embedded browser (for SSO plugins)';
+$string['logoutconfirmation'] = 'Are you sure you want to log out from the mobile app on your mobile devices? By logging out, you will then need to re-enter your username and password in the mobile app on all devices where you have the app installed.';
 $string['mainmenu'] = 'Main menu';
 $string['managefiletypes'] = 'Manage file types';
 $string['minimumversion'] = 'If an app version is specified (3.8.0 or higher), any users using an older app version will be prompted to upgrade their app before being allowed access to the site.';
index 567af8b..6b9dbf3 100644 (file)
@@ -134,15 +134,11 @@ function tool_mobile_myprofile_navigation(\core_user\output\myprofile\tree $tree
         return;
     }
 
-    if (!$iscurrentuser) {
-        return;
-    }
-
     $newnodes = [];
     $mobilesettings = get_config('tool_mobile');
 
     // Check if we should display a QR code.
-    if (!empty($mobilesettings->qrcodetype)) {
+    if ($iscurrentuser && !empty($mobilesettings->qrcodetype)) {
         $mobileqr = null;
         $qrcodeforappstr = get_string('qrcodeformobileappaccess', 'tool_mobile');
 
@@ -182,6 +178,13 @@ function tool_mobile_myprofile_navigation(\core_user\output\myprofile\tree $tree
         $mobilestrconnected = get_string('lastsiteaccess');
         if ($usertoken->lastaccess) {
             $mobilelastaccess = userdate($usertoken->lastaccess) . "&nbsp; (" . format_time(time() - $usertoken->lastaccess) . ")";
+            // Logout link.
+            $validtoken = empty($usertoken->validuntil) || time() < $usertoken->validuntil;
+            if ($iscurrentuser && $validtoken) {
+                $url = new moodle_url('/'.$CFG->admin.'/tool/mobile/logout.php', ['sesskey' => sesskey()]);
+                $logoutlink = html_writer::link($url, get_string('logout'));
+                $mobilelastaccess .= "&nbsp; ($logoutlink)";
+            }
         } else {
             // We should not reach this point.
             $mobilelastaccess = get_string("never");
diff --git a/admin/tool/mobile/logout.php b/admin/tool/mobile/logout.php
new file mode 100644 (file)
index 0000000..17a768a
--- /dev/null
@@ -0,0 +1,70 @@
+<?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/>.
+
+/**
+ * Log out a user from his external mobile devices (phones, tables, Moodle Desktop app, etc..)
+ *
+ * @package tool_mobile
+ * @copyright 2020 Juan Leyva <juan@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require(__DIR__ . '/../../../config.php');
+require_once($CFG->dirroot . '/admin/tool/mobile/lib.php');
+require_once($CFG->dirroot . '/webservice/lib.php');
+
+if (!$CFG->enablemobilewebservice) {
+    print_error('enablewsdescription', 'webservice');
+}
+
+require_login(null, false);
+
+// Require an active user: not guest, not suspended.
+core_user::require_active_user($USER);
+
+$redirecturl = new \moodle_url('/user/profile.php');
+
+if (optional_param('confirm', 0, PARAM_INT) && data_submitted()) {
+    require_sesskey();
+
+    // Get the mobile service token to be deleted.
+    $token = tool_mobile_get_token($USER->id);
+
+    if ($token) {
+        $webservicemanager = new webservice();
+        $webservicemanager->delete_user_ws_token($token->id);
+    }
+    redirect($redirecturl);
+}
+
+// Page settings.
+$title = get_string('logout');
+$context = context_system::instance();
+$PAGE->set_url(new \moodle_url('/'.$CFG->admin.'/tool/mobile/logout.php'));
+$PAGE->navbar->add($title);
+$PAGE->set_context($context);
+$PAGE->set_title($SITE->fullname. ': ' . $title);
+
+// Display the page.
+echo $OUTPUT->header();
+
+$message = get_string('logoutconfirmation', 'tool_mobile');
+$confirmurl = new \moodle_url('logout.php', ['confirm' => 1]);
+$yesbutton = new single_button($confirmurl, get_string('yes'), 'post');
+$nobutton = new single_button($redirecturl, get_string('no'));
+echo $OUTPUT->confirm($message, $yesbutton, $nobutton);
+
+echo $OUTPUT->footer();
diff --git a/admin/tool/usertours/amd/build/filter_cssselector.min.js b/admin/tool/usertours/amd/build/filter_cssselector.min.js
new file mode 100644 (file)
index 0000000..6dbb873
Binary files /dev/null and b/admin/tool/usertours/amd/build/filter_cssselector.min.js differ
diff --git a/admin/tool/usertours/amd/build/filter_cssselector.min.js.map b/admin/tool/usertours/amd/build/filter_cssselector.min.js.map
new file mode 100644 (file)
index 0000000..9ea27ca
Binary files /dev/null and b/admin/tool/usertours/amd/build/filter_cssselector.min.js.map differ
index 6b16a51..8ebe59f 100644 (file)
Binary files a/admin/tool/usertours/amd/build/usertours.min.js and b/admin/tool/usertours/amd/build/usertours.min.js differ
index 9f614ad..c5cca21 100644 (file)
Binary files a/admin/tool/usertours/amd/build/usertours.min.js.map and b/admin/tool/usertours/amd/build/usertours.min.js.map differ
diff --git a/admin/tool/usertours/amd/src/filter_cssselector.js b/admin/tool/usertours/amd/src/filter_cssselector.js
new file mode 100644 (file)
index 0000000..06e825c
--- /dev/null
@@ -0,0 +1,39 @@
+// 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/>.
+
+/**
+ * CSS selector client side filter.
+ *
+ * @module     tool_usertours/filter_cssselector
+ * @class      filter_cssselector
+ * @package    tool_usertours
+ * @copyright 2020 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Checks whether the configured CSS selector exists on this page.
+ *
+ * @param {array} tourConfig  The tour configuration.
+ * @returns {boolean}
+ */
+export const filterMatches = function(tourConfig) {
+    let filterValues = tourConfig.filtervalues.cssselector;
+    if (filterValues[0]) {
+        return !!document.querySelector(filterValues[0]);
+    }
+    // If there is no CSS selector configured, this page matches.
+    return true;
+};
index 4bb1050..a79f7f2 100644 (file)
@@ -14,36 +14,62 @@ function(ajax, BootstrapTour, $, templates, str, log, notification) {
 
         currentTour: null,
 
-        context: null,
-
         /**
          * Initialise the user tour for the current page.
          *
          * @method  init
-         * @param   {Number}    tourId      The ID of the tour to start.
-         * @param   {Bool}      startTour   Attempt to start the tour now.
-         * @param   {Number}    context     The context of the current page.
+         * @param   {Array}    tourDetails      The matching tours for this page.
+         * @param   {Array}    filters          The names of all client side filters.
          */
-        init: function(tourId, startTour, context) {
-            // Only one tour per page is allowed.
-            usertours.tourId = tourId;
+        init: function(tourDetails, filters) {
+            let requirements = [];
+            for (var req = 0; req < filters.length; req++) {
+                requirements[req] = 'tool_usertours/filter_' + filters[req];
+            }
+            require(requirements, function() {
+                // Run the client side filters to find the first matching tour.
+                let matchingTour = null;
+                for (let key in tourDetails) {
+                    let tour = tourDetails[key];
+                    for (let i = 0; i < filters.length; i++) {
+                        let filter = arguments[i];
+                        if (filter.filterMatches(tour)) {
+                            matchingTour = tour;
+                        } else {
+                            // If any filter doesn't match, move on to the next tour.
+                            matchingTour = null;
+                            break;
+                        }
+                    }
+                    // If all filters matched then use this tour.
+                    if (matchingTour) {
+                        break;
+                    }
+                }
 
-            usertours.context = context;
+                if (matchingTour === null) {
+                    return;
+                }
 
-            if (typeof startTour === 'undefined') {
-                startTour = true;
-            }
+                // Only one tour per page is allowed.
+                usertours.tourId = matchingTour.tourId;
 
-            if (startTour) {
-                // Fetch the tour configuration.
-                usertours.fetchTour(tourId);
-            }
+                let startTour = matchingTour.startTour;
+                if (typeof startTour === 'undefined') {
+                    startTour = true;
+                }
+
+                if (startTour) {
+                    // Fetch the tour configuration.
+                    usertours.fetchTour(usertours.tourId);
+                }
 
-            usertours.addResetLink();
-            // Watch for the reset link.
-            $('body').on('click', '[data-action="tool_usertours/resetpagetour"]', function(e) {
-                e.preventDefault();
-                usertours.resetTourState(usertours.tourId);
+                usertours.addResetLink();
+                // Watch for the reset link.
+                $('body').on('click', '[data-action="tool_usertours/resetpagetour"]', function(e) {
+                    e.preventDefault();
+                    usertours.resetTourState(usertours.tourId);
+                });
             });
         },
 
@@ -61,7 +87,7 @@ function(ajax, BootstrapTour, $, templates, str, log, notification) {
                         methodname: 'tool_usertours_fetch_and_start_tour',
                         args: {
                             tourid:     tourId,
-                            context:    usertours.context,
+                            context:    M.cfg.contextid,
                             pageurl:    window.location.href,
                         }
                     }
@@ -186,7 +212,7 @@ function(ajax, BootstrapTour, $, templates, str, log, notification) {
                         methodname: 'tool_usertours_step_shown',
                         args: {
                             tourid:     usertours.tourId,
-                            context:    usertours.context,
+                            context:    M.cfg.contextid,
                             pageurl:    window.location.href,
                             stepid:     stepConfig.stepid,
                             stepindex:  this.getCurrentStepNumber(),
@@ -209,7 +235,7 @@ function(ajax, BootstrapTour, $, templates, str, log, notification) {
                         methodname: 'tool_usertours_complete_tour',
                         args: {
                             tourid:     usertours.tourId,
-                            context:    usertours.context,
+                            context:    M.cfg.contextid,
                             pageurl:    window.location.href,
                             stepid:     stepConfig.stepid,
                             stepindex:  this.getCurrentStepNumber(),
@@ -232,7 +258,7 @@ function(ajax, BootstrapTour, $, templates, str, log, notification) {
                         methodname: 'tool_usertours_reset_tour',
                         args: {
                             tourid:     tourId,
-                            context:    usertours.context,
+                            context:    M.cfg.contextid,
                             pageurl:    window.location.href,
                         }
                     }
index c16c5a0..7ec9cb8 100644 (file)
@@ -131,8 +131,9 @@ class tour extends external_api {
 
         $result = [];
 
-        if ($tourinstance = \tool_usertours\manager::get_matching_tours(new \moodle_url($params['pageurl']))) {
-            if ($tour->get_id() === $tourinstance->get_id()) {
+        $matchingtours = \tool_usertours\manager::get_matching_tours(new \moodle_url($params['pageurl']));
+        foreach ($matchingtours as $match) {
+            if ($tour->get_id() === $match->get_id()) {
                 $result['startTour'] = $tour->get_id();
 
                 \tool_usertours\event\tour_reset::create([
@@ -142,7 +143,7 @@ class tour extends external_api {
                         'pageurl'   => $params['pageurl'],
                     ],
                 ])->trigger();
-
+                break;
             }
         }
 
index df04ed9..f1f8e4b 100644 (file)
@@ -24,6 +24,8 @@
 
 namespace tool_usertours;
 
+use tool_usertours\local\clientside_filter\clientside_filter;
+
 defined('MOODLE_INTERNAL') || die();
 
 /**
@@ -523,33 +525,57 @@ class helper {
         }
         self::$bootstrapped = true;
 
-        if ($tour = manager::get_current_tour()) {
+        $tours = manager::get_current_tours();
+
+        if ($tours) {
+            $filters = static::get_all_clientside_filters();
+
+            $tourdetails = array_map(function($tour) use ($filters) {
+                return [
+                        'tourId' => $tour->get_id(),
+                        'startTour' => $tour->should_show_for_user(),
+                        'filtervalues' => $tour->get_client_filter_values($filters),
+                ];
+            }, $tours);
+
+            $filternames = [];
+            foreach ($filters as $filter) {
+                    $filternames[] = $filter::get_filter_name();
+            }
+
             $PAGE->requires->js_call_amd('tool_usertours/usertours', 'init', [
-                    $tour->get_id(),
-                    $tour->should_show_for_user(),
-                    $PAGE->context->id,
-                ]);
+                    $tourdetails,
+                    $filternames,
+            ]);
         }
     }
 
     /**
-     * Add the reset link to the current page.
+     * Get a list of all possible filters.
+     *
+     * @return  array
      */
-    public static function bootstrap_reset() {
-        if (manager::get_current_tour()) {
-            echo \html_writer::link('', get_string('resettouronpage', 'tool_usertours'), [
-                    'data-action'   => 'tool_usertours/resetpagetour',
-                ]);
-        }
+    public static function get_all_filters() {
+        $filters = \core_component::get_component_classes_in_namespace('tool_usertours', 'local\filter');
+        $filters = array_keys($filters);
+
+        $filters = array_filter($filters, function($filterclass) {
+            $rc = new \ReflectionClass($filterclass);
+            return $rc->isInstantiable();
+        });
+
+        $filters = array_merge($filters, static::get_all_clientside_filters());
+
+        return $filters;
     }
 
     /**
-     * Get a list of all possible filters.
+     * Get a list of all clientside filters.
      *
      * @return  array
      */
-    public static function get_all_filters() {
-        $filters = \core_component::get_component_classes_in_namespace('tool_usertours', 'local\filter');
+    public static function get_all_clientside_filters() {
+        $filters = \core_component::get_component_classes_in_namespace('tool_usertours', 'local\clientside_filter');
         $filters = array_keys($filters);
 
         $filters = array_filter($filters, function($filterclass) {
diff --git a/admin/tool/usertours/classes/local/clientside_filter/clientside_filter.php b/admin/tool/usertours/classes/local/clientside_filter/clientside_filter.php
new file mode 100644 (file)
index 0000000..6fa403a
--- /dev/null
@@ -0,0 +1,55 @@
+<?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/>.
+
+/**
+ * Clientside filter base.
+ *
+ * @package    tool_usertours
+ * @copyright  2020 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\local\clientside_filter;
+
+defined('MOODLE_INTERNAL') || die();
+
+use stdClass;
+use tool_usertours\local\filter\base;
+use tool_usertours\tour;
+
+/**
+ * Clientside filter base.
+ *
+ * @copyright  2020 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class clientside_filter extends base {
+    /**
+     * Returns the filter values needed for client side filtering.
+     *
+     * @param   tour            $tour       The tour to find the filter values for
+     * @return  stdClass
+     */
+    public static function get_client_side_values(tour $tour): stdClass {
+        $data = (object) [];
+
+        if (is_a(static::class, clientside_filter::class, true)) {
+            $data->filterdata = $tour->get_filter_values(static::get_filter_name());
+        }
+
+        return $data;
+    }
+}
\ No newline at end of file
diff --git a/admin/tool/usertours/classes/local/clientside_filter/cssselector.php b/admin/tool/usertours/classes/local/clientside_filter/cssselector.php
new file mode 100644 (file)
index 0000000..e6d6c2d
--- /dev/null
@@ -0,0 +1,115 @@
+<?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/>.
+
+/**
+ * Selector filter.
+ *
+ * @package    tool_usertours
+ * @copyright  2020 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace tool_usertours\local\clientside_filter;
+
+use stdClass;
+use tool_usertours\tour;
+
+/**
+ * Course filter.
+ *
+ * @copyright  2020 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class cssselector extends clientside_filter {
+    /**
+     * The name of the filter.
+     *
+     * @return  string
+     */
+    public static function get_filter_name() {
+        return 'cssselector';
+    }
+
+    /**
+     * Overrides the base add form element with a selector text box.
+     *
+     * @param \MoodleQuickForm $mform
+     */
+    public static function add_filter_to_form(\MoodleQuickForm &$mform) {
+        $filtername = self::get_filter_name();
+        $key = "filter_{$filtername}";
+
+        $mform->addElement('text', $key, get_string($key, 'tool_usertours'));
+        $mform->setType($key, PARAM_RAW);
+        $mform->addHelpButton($key, $key, 'tool_usertours');
+    }
+
+    /**
+     * Prepare the filter values for the form.
+     *
+     * @param   tour            $tour       The tour to prepare values from
+     * @param   stdClass        $data       The data value
+     * @return  stdClass
+     */
+    public static function prepare_filter_values_for_form(tour $tour, \stdClass $data) {
+        $filtername = static::get_filter_name();
+
+        $key = "filter_{$filtername}";
+        $values = $tour->get_filter_values($filtername);
+        if (empty($values)) {
+            $values = [""];
+        }
+        $data->$key = $values[0];
+
+        return $data;
+    }
+
+    /**
+     * Save the filter values from the form to the tour.
+     *
+     * @param   tour            $tour       The tour to save values to
+     * @param   stdClass        $data       The data submitted in the form
+     */
+    public static function save_filter_values_from_form(tour $tour, \stdClass $data) {
+        $filtername = static::get_filter_name();
+
+        $key = "filter_{$filtername}";
+
+        $newvalue = [$data->$key];
+        if (empty($data->$key)) {
+            $newvalue = [];
+        }
+
+        $tour->set_filter_values($filtername, $newvalue);
+    }
+
+    /**
+     * Returns the filter values needed for client side filtering.
+     *
+     * @param   tour            $tour       The tour to find the filter values for
+     * @return  stdClass
+     */
+    public static function get_client_side_values(tour $tour): stdClass {
+        $filtername = static::get_filter_name();
+        $filtervalues = $tour->get_filter_values($filtername);
+
+        // Filter values might not exist for tours that were created before this filter existed.
+        if (!$filtervalues) {
+            return new stdClass;
+        }
+
+        return (object) $filtervalues;
+    }
+}
index 6741568..9d76b2e 100644 (file)
@@ -608,42 +608,46 @@ class manager {
     }
 
     /**
-     * Get the first tour matching the current page URL.
+     * Get all tours for the current page URL.
      *
-     * @param   bool        $reset      Forcibly update the current tour
-     * @return  tour
+     * @param   bool        $reset      Forcibly update the current tours
+     * @return  array
      */
-    public static function get_current_tour($reset = false) {
+    public static function get_current_tours($reset = false): array {
         global $PAGE;
 
-        static $tour = false;
+        static $tours = false;
 
-        if ($tour === false || $reset) {
-            $tour = self::get_matching_tours($PAGE->url);
+        if ($tours === false || $reset) {
+            $tours = self::get_matching_tours($PAGE->url);
         }
 
-        return $tour;
+        return $tours;
     }
 
     /**
-     * Get the first tour matching the specified URL.
+     * Get all tours matching the specified URL.
      *
      * @param   moodle_url  $pageurl        The URL to match.
-     * @return  tour
+     * @return  array
      */
-    public static function get_matching_tours(\moodle_url $pageurl) {
+    public static function get_matching_tours(\moodle_url $pageurl): array {
         global $PAGE;
 
         $tours = cache::get_matching_tourdata($pageurl);
 
-        foreach ($tours as $record) {
-            $tour = tour::load_from_record($record);
-            if ($tour->is_enabled() && $tour->matches_all_filters($PAGE->context)) {
-                return $tour;
+        $matches = [];
+        if ($tours) {
+            $filters = helper::get_all_filters();
+            foreach ($tours as $record) {
+                $tour = tour::load_from_record($record);
+                if ($tour->is_enabled() && $tour->matches_all_filters($PAGE->context, $filters)) {
+                    $matches[] = $tour;
+                }
             }
         }
 
-        return null;
+        return $matches;
     }
 
     /**
index 0765ee3..08e8279 100644 (file)
@@ -24,6 +24,8 @@
 
 namespace tool_usertours;
 
+use tool_usertours\local\clientside_filter\clientside_filter;
+
 defined('MOODLE_INTERNAL') || die();
 
 /**
@@ -769,11 +771,14 @@ class tour {
     /**
      * Check whether this tour matches all filters.
      *
-     * @param   context     $context    The context to check
+     * @param   \context     $context    The context to check.
+     * @param   array|null   $filters    Optional array of filters.
      * @return  bool
      */
-    public function matches_all_filters(\context $context) {
-        $filters = helper::get_all_filters();
+    public function matches_all_filters(\context $context, array $filters = null): bool {
+        if (!$filters) {
+            $filters = helper::get_all_filters();
+        }
 
         // All filters must match.
         // If any one filter fails to match, we return false.
@@ -785,4 +790,20 @@ class tour {
 
         return true;
     }
+
+    /**
+     * Gets all filter values for use in client side filters.
+     *
+     * @param   array     $filters    Array of clientside filters.
+     * @return  array
+     */
+    public function get_client_filter_values(array $filters): array {
+        $results = [];
+
+        foreach ($filters as $filter) {
+            $results[$filter::get_filter_name()] = $filter::get_client_side_values($this);
+        }
+
+        return $results;
+    }
 }
index 3d86e56..f142b46 100644 (file)
@@ -63,6 +63,8 @@ $string['filter_course'] = 'Courses';
 $string['filter_course_help'] = 'Show the tour on a page that is associated with the selected course.';
 $string['filter_courseformat'] = 'Course format';
 $string['filter_courseformat_help'] = 'Show the tour on a page that is associated with a course using the selected course format.';
+$string['filter_cssselector'] = 'CSS selector';
+$string['filter_cssselector_help'] = 'Only show the tour when the specified CSS selector is found on the page.';
 $string['filter_header'] = 'Tour filters';
 $string['filter_help'] = 'Select the conditions under which the tour will be shown. All of the filters must match for a tour to be shown to a user.';
 $string['filter_date_account_creation'] = 'User account creation date within';
index ac3a164..0fe72f4 100644 (file)
@@ -142,3 +142,88 @@ Feature: Apply tour filters to a tour
     When I am on "Course 2" course homepage
     And I wait until the page is ready
     Then I should not see "Welcome to your course tour."
+
+  @javascript
+  Scenario: Add tours with CSS selectors
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | 1        | student1@example.com |
+    Given the following "courses" exist:
+      | fullname | shortname | format | enablecompletion |
+      | Course 1 | C1        | topics | 1                |
+      | Course 2 | C2        | topics | 1                |
+    And I log in as "admin"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "Wiki" to section "1" and I fill the form with:
+      | Wiki name       | Test wiki name        |
+      | Description     | Test wiki description |
+      | First page name | First page            |
+      | Wiki mode       | Collaborative wiki    |
+    And I am on "Course 2" course homepage
+    And I add a "Forum" to section "1" and I fill the form with:
+      | Forum name  | Test forum name                |
+      | Forum type  | Standard forum for general use |
+      | Description | Test forum description         |
+    And I add a new user tour with:
+      | Name               | Wiki tour                |
+      | Description        | A tour with both matches |
+      | Apply to URL match | /course/view.php%        |
+      | Tour is enabled    | 1                        |
+      | CSS selector       | .modtype_wiki            |
+    And I add steps to the "Wiki tour" tour:
+      | targettype                | Title   | Content                  |
+      | Display in middle of page | Welcome | Welcome to the Wiki tour |
+    And I add a new user tour with:
+      | Name               | Forum tour               |
+      | Description        | A tour with both matches |
+      | Apply to URL match | /course/view.php%        |
+      | Tour is enabled    | 1                        |
+      | CSS selector       | .modtype_forum           |
+    And I add steps to the "Forum tour" tour:
+      | targettype                | Title   | Content                   |
+      | Display in middle of page | Welcome | Welcome to the Forum tour |
+    And I am on "Course 1" course homepage
+    Then I should see "Welcome to the Wiki tour"
+    And I am on "Course 2" course homepage
+    Then I should see "Welcome to the Forum tour"
+
+  @javascript
+  Scenario: Check filtering respects the sort order
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | 1        | student1@example.com |
+    And I log in as "admin"
+    And I add a new user tour with:
+      | Name               | First tour     |
+      | Description        | The first tour |
+      | Apply to URL match | /my/%          |
+      | Tour is enabled    | 1              |
+      | CSS selector       | #page-my-index |
+    And I add steps to the "First tour" tour:
+      | targettype                | Title   | Content                   |
+      | Display in middle of page | Welcome | Welcome to the First tour |
+    And I add a new user tour with:
+      | Name               | Second tour     |
+      | Description        | The second tour |
+      | Apply to URL match | /my/%           |
+      | Tour is enabled    | 0               |
+      | CSS selector       | #page-my-index  |
+    And I add steps to the "Second tour" tour:
+      | targettype                | Title   | Content                    |
+      | Display in middle of page | Welcome | Welcome to the Second tour |
+    And I add a new user tour with:
+      | Name               | Third tour     |
+      | Description        | The third tour |
+      | Apply to URL match | /my/%           |
+      | Tour is enabled    | 1               |
+      | CSS selector       | #page-my-index  |
+    And I add steps to the "Third tour" tour:
+      | targettype                | Title   | Content                   |
+      | Display in middle of page | Welcome | Welcome to the Third tour |
+    And I am on homepage
+    Then I should see "Welcome to the First tour"
+    And I open the User tour settings page
+    And I click on "Move tour down" "link" in the "The first tour" "table_row"
+    And I click on "Move tour down" "link" in the "The first tour" "table_row"
+    And I am on homepage
+    Then I should see "Welcome to the Third tour"
index 913c3e1..d5e4893 100644 (file)
@@ -222,6 +222,13 @@ class tool_usertours_manager_testcase extends advanced_testcase {
                     'description'   => '',
                     'configdata'    => '',
                 ],
+            [
+                    'pathmatch'     => '/my/%',
+                    'enabled'       => true,
+                    'name'          => 'My tour enabled 2',
+                    'description'   => '',
+                    'configdata'    => '',
+                ],
             [
                     'pathmatch'     => '/my/%',
                     'enabled'       => false,
@@ -277,32 +284,32 @@ class tool_usertours_manager_testcase extends advanced_testcase {
                 'No matches found' => [
                         $alltours,
                         $CFG->wwwroot . '/some/invalid/value',
-                        null,
+                        [],
                     ],
                 'Never return a disabled tour' => [
                         $alltours,
                         $CFG->wwwroot . '/my/index.php',
-                        'My tour enabled',
+                        ['My tour enabled', 'My tour enabled 2'],
                     ],
                 'My not course' => [
                         $alltours,
                         $CFG->wwwroot . '/my/index.php',
-                        'My tour enabled',
+                        ['My tour enabled', 'My tour enabled 2'],
                     ],
                 'My with params' => [
                         $alltours,
                         $CFG->wwwroot . '/my/index.php?id=42',
-                        'My tour enabled',
+                        ['My tour enabled', 'My tour enabled 2'],
                     ],
                 'Course with params' => [
                         $alltours,
                         $CFG->wwwroot . '/course/?id=42',
-                        'course tour enabled',
+                        ['course tour enabled'],
                     ],
                 'Course with params and trailing content' => [
                         $alltours,
                         $CFG->wwwroot . '/course/?id=42&foo=bar',
-                        'course tour with additional params enabled',
+                        ['course tour with additional params enabled', 'course tour enabled'],
                     ],
             ];
     }
@@ -311,11 +318,11 @@ class tool_usertours_manager_testcase extends advanced_testcase {
      * Tests for the get_matching_tours function.
      *
      * @dataProvider get_matching_tours_provider
-     * @param   array   $alltours   The list of tours to insert
-     * @param   string  $url        The URL to test
-     * @param   string  $expected   The name of the expected matching tour
+     * @param   array   $alltours   The list of tours to insert.
+     * @param   string  $url        The URL to test.
+     * @param   array   $expected   List of names of the expected matching tours.
      */
-    public function test_get_matching_tours($alltours, $url, $expected) {
+    public function test_get_matching_tours(array $alltours, string $url, array $expected) {
         $this->resetAfterTest();
 
         foreach ($alltours as $tourconfig) {
@@ -323,12 +330,10 @@ class tool_usertours_manager_testcase extends advanced_testcase {
             $this->helper_create_step((object) ['tourid' => $tour->get_id()]);
         }
 
-        $match = \tool_usertours\manager::get_matching_tours(new moodle_url($url));
-        if ($expected === null) {
-            $this->assertNull($match);
-        } else {
-            $this->assertNotNull($match);
-            $this->assertEquals($expected, $match->get_name());
+        $matches = \tool_usertours\manager::get_matching_tours(new moodle_url($url));
+        $this->assertEquals(count($expected), count($matches));
+        for ($i = 0; $i < count($matches); $i++) {
+            $this->assertEquals($expected[$i], $matches[$i]->get_name());
         }
     }
 }
index ead833e..ed8e6ee 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2021052501;            // The current module version (Date: YYYYMMDDXX).
+$plugin->version   = 2021052502;            // The current module version (Date: YYYYMMDDXX).
 $plugin->requires  = 2021052500;            // Requires this Moodle version.
 $plugin->component = 'tool_usertours';      // Full name of the plugin (used for diagnostics).
index 88895a9..245065a 100644 (file)
@@ -794,7 +794,7 @@ class cc2moodle {
         }
     }
 
-    protected function critical_error ($text) {
+    protected static function critical_error ($text) {
 
         $path_to_log = static::log_file();
 
index 4a8ef06..712b84f 100644 (file)
@@ -76,7 +76,7 @@ class entities {
         cc2moodle::log_action('Load the XML resource file: '.$path_to_file);
 
         if (!$resource->load($path_to_file)) {
-            cc2moodle::log_action('Cannot load the XML resource file: ' . $path_to_file, true);
+            cc2moodle::log_action('Cannot load the XML resource file: ' . $path_to_file, false);
         }
 
         return $resource;
index 2b28e74..69fcf19 100644 (file)
@@ -4711,10 +4711,28 @@ class restore_create_categories_and_questions extends restore_structure_step {
         }
 
         $userid = $this->get_mappingid('user', $data->createdby);
-        $data->createdby = $userid ? $userid : $this->task->get_userid();
+        if ($userid) {
+            // The question creator is included in the backup, so we can use their mapping id.
+            $data->createdby = $userid;
+        } else {
+            // Leave the question creator unchanged when we are restoring the same site.
+            // Otherwise use current user id.
+            if (!$this->task->is_samesite()) {
+                $data->createdby = $this->task->get_userid();
+            }
+        }
 
         $userid = $this->get_mappingid('user', $data->modifiedby);
-        $data->modifiedby = $userid ? $userid : $this->task->get_userid();
+        if ($userid) {
+            // The question modifier is included in the backup, so we can use their mapping id.
+            $data->modifiedby = $userid;
+        } else {
+            // Leave the question modifier unchanged when we are restoring the same site.
+            // Otherwise use current user id.
+            if (!$this->task->is_samesite()) {
+                $data->modifiedby = $this->task->get_userid();
+            }
+        }
 
         // With newitemid = 0, let's create the question
         if (!$questionmapping->newitemid) {
index 4a1f187..d404126 100644 (file)
@@ -126,8 +126,9 @@ class core_backup_renderer extends plugin_renderer_base {
 
         $html  = html_writer::start_tag('div', array('class' => 'backup-restore'));
 
-        $html .= html_writer::start_tag('div', array('class' => 'backup-section'));
-        $html .= $this->output->heading(get_string('backupdetails', 'backup'), 2, array('class' => 'header'));
+        $html .= html_writer::start_tag('div', ['class' => 'backup-section',
+            'role' => 'table', 'aria-labelledby' => 'backupdetailsheader']);
+        $html .= $this->output->heading(get_string('backupdetails', 'backup'), 2, 'header', 'backupdetailsheader');
         $html .= $this->backup_detail_pair(get_string('backuptype', 'backup'), get_string('backuptype'.$details->type, 'backup'));
         $html .= $this->backup_detail_pair(get_string('backupformat', 'backup'), get_string('backupformat'.$details->format, 'backup'));
         $html .= $this->backup_detail_pair(get_string('backupmode', 'backup'), get_string('backupmode'.$details->mode, 'backup'));
@@ -153,8 +154,9 @@ class core_backup_renderer extends plugin_renderer_base {
 
         $html .= html_writer::end_tag('div');
 
-        $html .= html_writer::start_tag('div', array('class' => 'backup-section settings-section'));
-        $html .= $this->output->heading(get_string('backupsettings', 'backup'), 2, array('class' => 'header'));
+        $html .= html_writer::start_tag('div', ['class' => 'backup-section settings-section',
+            'role' => 'table', 'aria-labelledby' => 'backupsettingsheader']);
+        $html .= $this->output->heading(get_string('backupsettings', 'backup'), 2, 'header', 'backupsettingsheader');
         foreach ($details->root_settings as $label => $value) {
             if ($label == 'filename' or $label == 'user_files') {
                 continue;
@@ -164,8 +166,9 @@ class core_backup_renderer extends plugin_renderer_base {
         $html .= html_writer::end_tag('div');
 
         if ($details->type === 'course') {
-            $html .= html_writer::start_tag('div', array('class' => 'backup-section'));
-            $html .= $this->output->heading(get_string('backupcoursedetails', 'backup'), 2, array('class' => 'header'));
+            $html .= html_writer::start_tag('div', ['class' => 'backup-section',
+                    'role' => 'table', 'aria-labelledby' => 'backupcoursedetailsheader']);
+            $html .= $this->output->heading(get_string('backupcoursedetails', 'backup'), 2, 'header', 'backupcoursedetailsheader');
             $html .= $this->backup_detail_pair(get_string('coursetitle', 'backup'), $details->course->title);
             $html .= $this->backup_detail_pair(get_string('courseid', 'backup'), $details->course->courseid);
 
@@ -200,7 +203,7 @@ class core_backup_renderer extends plugin_renderer_base {
                         $table->data = array();
                     }
                     $name = get_string('pluginname', $activity->modulename);
-                    $icon = new image_icon('icon', $name, $activity->modulename, array('class' => 'iconlarge icon-pre'));
+                    $icon = new image_icon('icon', '', $activity->modulename, ['class' => 'iconlarge icon-pre']);
                     $table->data[] = array(
                         $this->output->render($icon).$name,
                         $activity->title,
@@ -424,13 +427,25 @@ class core_backup_renderer extends plugin_renderer_base {
     protected function backup_detail_pair($label, $value) {
         static $count = 0;
         $count ++;
-        $html  = html_writer::start_tag('div', array('class' => 'detail-pair'));
-        $html .= html_writer::tag('label', $label, array('class' => 'detail-pair-label', 'for' => 'detail-pair-value-'.$count));
-        $html .= html_writer::tag('div', $value, array('class' => 'detail-pair-value pl-2', 'name' => 'detail-pair-value-'.$count));
+        $html  = html_writer::start_tag('div', ['class' => 'detail-pair', 'role' => 'row']);
+        $html .= html_writer::tag('div', $label, ['class' => 'detail-pair-label mb-2', 'role' => 'cell']);
+        $html .= html_writer::tag('div', $value, ['class' => 'detail-pair-value pl-2', 'role' => 'cell']);
         $html .= html_writer::end_tag('div');
         return $html;
     }
 
+    /**
+     * Creates a unique id string by appending an incremental number to the prefix.
+     *
+     * @param string $prefix To be used as the left part of the id string.
+     * @return string
+     */
+    protected function make_unique_id(string $prefix): string {
+        static $count = 0;
+
+        return $prefix . '-' . $count++;
+    }
+
     /**
      * Created a detailed pairing with an input
      *
@@ -448,9 +463,11 @@ class core_backup_renderer extends plugin_renderer_base {
         } else {
             $description = '';
         }
+        $id = $this->make_unique_id('detail-pair-value');
         return $this->backup_detail_pair(
-            $label,
-            html_writer::empty_tag('input', $attributes + array('name' => $name, 'type' => $type, 'value' => $value)) . $description
+            html_writer::label($label, $id),
+            html_writer::empty_tag('input', $attributes + ['id' => $id, 'name' => $name, 'type' => $type, 'value' => $value]) .
+                $description
         );
     }
 
@@ -718,8 +735,6 @@ class core_backup_renderer extends plugin_renderer_base {
      * @return string
      */
     public function render_restore_course_search(restore_course_search $component) {
-        $url = $component->get_url();
-
         $output = html_writer::start_tag('div', array('class' => 'restore-course-search mb-1'));
         $output .= html_writer::start_tag('div', array('class' => 'rcs-results table-sm w-75'));
 
@@ -733,11 +748,18 @@ class core_backup_renderer extends plugin_renderer_base {
                 if (!$course->visible) {
                     $row->attributes['class'] .= ' dimmed';
                 }
-                $row->cells = array(
-                    html_writer::empty_tag('input', array('type' => 'radio', 'name' => 'targetid', 'value' => $course->id)),
-                    format_string($course->shortname, true, array('context' => context_course::instance($course->id))),
-                    format_string($course->fullname, true, array('context' => context_course::instance($course->id)))
-                );
+                $id = $this->make_unique_id('restore-course');
+                $row->cells = [
+                    html_writer::empty_tag('input', ['type' => 'radio', 'name' => 'targetid', 'value' => $course->id,
+                        'id' => $id]),
+                    html_writer::label(
+                        format_string($course->shortname, true, ['context' => context_course::instance($course->id)]),
+                        $id,
+                        true,
+                        ['class' => 'd-block']
+                    ),
+                    format_string($course->fullname, true, ['context' => context_course::instance($course->id)])
+                ];
                 $table->data[] = $row;
             }
             if ($component->has_more_results()) {
@@ -779,8 +801,6 @@ class core_backup_renderer extends plugin_renderer_base {
      * @return string
      */
     public function render_import_course_search(import_course_search $component) {
-        $url = $component->get_url();
-
         $output = html_writer::start_tag('div', array('class' => 'import-course-search'));
         if ($component->get_count() === 0) {
             $output .= $this->output->notification(get_string('nomatchingcourses', 'backup'));
@@ -790,6 +810,8 @@ class core_backup_renderer extends plugin_renderer_base {
                 'type' => 'text',
                 'name' => restore_course_search::$VAR_SEARCH,
                 'value' => $component->get_search(),
+                'aria-label' => get_string('searchcourses'),
+                'placeholder' => get_string('searchcourses'),
                 'class' => 'form-control'
             );
             $output .= html_writer::empty_tag('input', $attrs);
@@ -825,11 +847,18 @@ class core_backup_renderer extends plugin_renderer_base {
             if (!$course->visible) {
                 $row->attributes['class'] .= ' dimmed';
             }
-            $row->cells = array(
-                html_writer::empty_tag('input', array('type' => 'radio', 'name' => 'importid', 'value' => $course->id)),
-                format_string($course->shortname, true, array('context' => context_course::instance($course->id))),
-                format_string($course->fullname, true, array('context' => context_course::instance($course->id)))
-            );
+            $id = $this->make_unique_id('import-course');
+            $row->cells = [
+                html_writer::empty_tag('input', ['type' => 'radio', 'name' => 'importid', 'value' => $course->id,
+                    'id' => $id]),
+                html_writer::label(
+                    format_string($course->shortname, true, ['context' => context_course::instance($course->id)]),
+                    $id,
+                    true,
+                    ['class' => 'd-block']
+                ),
+                format_string($course->fullname, true, ['context' => context_course::instance($course->id)])
+            ];
             $table->data[] = $row;
         }
         if ($component->has_more_results()) {
@@ -848,6 +877,8 @@ class core_backup_renderer extends plugin_renderer_base {
             'type' => 'text',
             'name' => restore_course_search::$VAR_SEARCH,
             'value' => $component->get_search(),
+            'aria-label' => get_string('searchcourses'),
+            'placeholder' => get_string('searchcourses'),
             'class' => 'form-control');
         $output .= html_writer::empty_tag('input', $attrs);
         $attrs = array(
@@ -870,8 +901,6 @@ class core_backup_renderer extends plugin_renderer_base {
      * @return string
      */
     public function render_restore_category_search(restore_category_search $component) {
-        $url = $component->get_url();
-
         $output = html_writer::start_tag('div', array('class' => 'restore-course-search mb-1'));
         $output .= html_writer::start_tag('div', array('class' => 'rcs-results table-sm w-75'));
 
@@ -887,12 +916,19 @@ class core_backup_renderer extends plugin_renderer_base {
                     $row->attributes['class'] .= ' dimmed';
                 }
                 $context = context_coursecat::instance($category->id);
-                $row->cells = array(
-                    html_writer::empty_tag('input', array('type' => 'radio', 'name' => 'targetid', 'value' => $category->id)),
-                    format_string($category->name, true, array('context' => context_coursecat::instance($category->id))),
+                $id = $this->make_unique_id('restore-category');
+                $row->cells = [
+                    html_writer::empty_tag('input', ['type' => 'radio', 'name' => 'targetid', 'value' => $category->id,
+                        'id' => $id]),
+                    html_writer::label(
+                        format_string($category->name, true, ['context' => context_coursecat::instance($category->id)]),
+                        $id,
+                        true,
+                        ['class' => 'd-block']
+                    ),
                     format_text(file_rewrite_pluginfile_urls($category->description, 'pluginfile.php', $context->id,
-                        'coursecat', 'description', null), $category->descriptionformat, array('overflowdiv' => true))
-                );
+                        'coursecat', 'description', null), $category->descriptionformat, ['overflowdiv' => true])
+                ];
                 $table->data[] = $row;
             }
             if ($component->has_more_results()) {
@@ -918,7 +954,7 @@ class core_backup_renderer extends plugin_renderer_base {
             'inform' => true,
             'extraclasses' => 'rcs-search mb-3 w-25',
             'inputname' => restore_category_search::$VAR_SEARCH,
-            'searchstring' => get_string('search'),
+            'searchstring' => get_string('searchcoursecategories'),
             'query' => $component->get_search(),
         ];
         $output .= $this->output->render_from_template('core/search_input', $data);
index c5a692f..a0de2d1 100644 (file)
@@ -88,7 +88,6 @@ class block_site_main_menu extends block_list {
         $section = $modinfo->get_section_info(0);
 
         if ($ismoving) {
-            $strmovehere = get_string('movehere');
             $strmovefull = strip_tags(get_string('movefull', '', "'$USER->activitycopyname'"));
             $strcancel= get_string('cancel');
         } else {
@@ -128,8 +127,9 @@ class block_site_main_menu extends block_list {
                         if ($mod->id == $USER->activitycopy) {
                             continue;
                         }
-                        $this->content->items[] = '<a title="'.$strmovefull.'" href="'.$CFG->wwwroot.'/course/mod.php?moveto='.$mod->id.'&amp;sesskey='.sesskey().'">'.
-                            '<img style="height:16px; width:80px; border:0px" src="'.$OUTPUT->image_url('movehere') . '" alt="'.$strmovehere.'" /></a>';
+                        $movingurl = new moodle_url('/course/mod.php', array('moveto' => $mod->id, 'sesskey' => sesskey()));
+                        $this->content->items[] = html_writer::link($movingurl, '', array('title' => $strmovefull,
+                            'class' => 'movehere'));
                         $this->content->icons[] = '';
                     }
                     if ($mod->indent > 0) {
@@ -148,8 +148,8 @@ class block_site_main_menu extends block_list {
         }
 
         if ($ismoving) {
-            $this->content->items[] = '<a title="'.$strmovefull.'" href="'.$CFG->wwwroot.'/course/mod.php?movetosection='.$section->id.'&amp;sesskey='.sesskey().'">'.
-                                      '<img style="height:16px; width:80px; border:0px" src="'.$OUTPUT->image_url('movehere') . '" alt="'.$strmovehere.'" /></a>';
+            $movingurl = new moodle_url('/course/mod.php', array('movetosection' => $section->id, 'sesskey' => sesskey()));
+            $this->content->items[] = html_writer::link($movingurl, '', array('title' => $strmovefull, 'class' => 'movehere'));
             $this->content->icons[] = '';
         }
 
index 2e5ec90..35c651b 100644 (file)
@@ -83,7 +83,6 @@ class block_social_activities extends block_list {
         $section = $modinfo->get_section_info(0);
 
         if ($ismoving) {
-            $strmovehere = get_string('movehere');
             $strmovefull = strip_tags(get_string('movefull', '', "'$USER->activitycopyname'"));
             $strcancel= get_string('cancel');
         } else {
@@ -92,7 +91,8 @@ class block_social_activities extends block_list {
 
         if ($ismoving) {
             $this->content->icons[] = '&nbsp;' . $OUTPUT->pix_icon('t/move', get_string('move'));
-            $this->content->items[] = $USER->activitycopyname.'&nbsp;(<a href="'.$CFG->wwwroot.'/course/mod.php?cancelcopy=true&amp;sesskey='.sesskey().'">'.$strcancel.'</a>)';
+            $cancelurl = new moodle_url('/course/mod.php', array('cancelcopy' => 'true', 'sesskey' => sesskey()));
+            $this->content->items[] = $USER->activitycopyname . '&nbsp;(<a href="' . $cancelurl . '">' . $strcancel . '</a>)';
         }
 
         if (!empty($modinfo->sections[0])) {
@@ -123,8 +123,9 @@ class block_social_activities extends block_list {
                         if ($mod->id == $USER->activitycopy) {
                             continue;
                         }
-                        $this->content->items[] = '<a title="'.$strmovefull.'" href="'.$CFG->wwwroot.'/course/mod.php?moveto='.$mod->id.'&amp;sesskey='.sesskey().'">'.
-                            '<img style="height:16px; width:80px; border:0px" src="'.$OUTPUT->image_url('movehere') . '" alt="'.$strmovehere.'" /></a>';
+                        $movingurl = new moodle_url('/course/mod.php', array('moveto' => $mod->id, 'sesskey' => sesskey()));
+                        $this->content->items[] = html_writer::link($movingurl, '', array('title' => $strmovefull,
+                            'class' => 'movehere'));
                         $this->content->icons[] = '';
                     }
                     if (!$mod->url) {
@@ -140,8 +141,8 @@ class block_social_activities extends block_list {
         }
 
         if ($ismoving) {
-            $this->content->items[] = '<a title="'.$strmovefull.'" href="'.$CFG->wwwroot.'/course/mod.php?movetosection='.$section->id.'&amp;sesskey='.sesskey().'">'.
-                                      '<img style="height:16px; width:80px; border:0px" src="'.$OUTPUT->image_url('movehere') . '" alt="'.$strmovehere.'" /></a>';
+            $movingurl = new moodle_url('/course/mod.php', array('movetosection' => $section->id, 'sesskey' => sesskey()));
+            $this->content->items[] = html_writer::link($movingurl, '', array('title' => $strmovefull, 'class' => 'movehere'));
             $this->content->icons[] = '';
         }
 
index 551e62c..620505c 100644 (file)
@@ -101,13 +101,31 @@ abstract class administration_helper extends cache_helper {
         ksort($default);
         $return = $return + $default;
 
-        foreach ($instance->get_definition_mappings() as $mapping) {
+        $mappings = $instance->get_definition_mappings();
+        foreach ($mappings as $mapping) {
             if (!array_key_exists($mapping['store'], $return)) {
                 continue;
             }
             $return[$mapping['store']]['mappings']++;
         }
 
+        // Now get all definitions, and if not mapped, increment the defaults for the mode.
+        $modemappings = $instance->get_mode_mappings();
+        foreach ($instance->get_definitions() as $definition) {
+            // Construct the definition name to search for.
+            $defname = $definition['component'] . '/' . $definition['area'];
+            // Skip if definition is already mapped.
+            if (array_search($defname, array_column($mappings, 'definition')) !== false) {
+                continue;
+            }
+
+            $mode = $definition['mode'];
+            // Get the store name of the default mapping from the mode.
+            $index = array_search($mode, array_column($modemappings, 'mode'));
+            $store = $modemappings[$index]['store'];
+            $return[$store]['mappings']++;
+        }
+
         return $return;
     }
 
index 865539f..146efe3 100644 (file)
@@ -92,7 +92,12 @@ class core_cache_administration_helper_testcase extends advanced_testcase {
         $this->assertEquals(0, $summary['default']);
         $this->assertEquals(1, $summary['isready']);
         $this->assertEquals(1, $summary['requirementsmet']);
-        $this->assertEquals(1, $summary['mappings']);
+
+        // Find the number of mappings to sessionstore.
+        $mappingcount = count(array_filter($config->get_definitions(), function($element) {
+            return $element['mode'] === cache_store::MODE_APPLICATION;
+        }));
+        $this->assertEquals($mappingcount, $summary['mappings']);
 
         $definitionsummaries = core_cache\administration_helper::get_definition_summaries();
         $this->assertInternalType('array', $definitionsummaries);
index f442a64..46b42d7 100644 (file)
@@ -281,7 +281,7 @@ class raw_event_retrieval_strategy implements raw_event_retrieval_strategy_inter
             $unionstartquery = "SELECT modulename, instance, eventtype, priority
                                   FROM {event} ev
                                  WHERE ";
-            $subqueryunion = $unionstartquery . implode(" UNION $unionstartquery ", $subqueryconditions);
+            $subqueryunion = '('.$unionstartquery . implode(" UNION $unionstartquery ", $subqueryconditions).')';
         } else {
             $subqueryunion = '{event}';
         }
@@ -296,7 +296,7 @@ class raw_event_retrieval_strategy implements raw_event_retrieval_strategy_inter
                             ev.instance,
                             ev.eventtype,
                             MIN(ev.priority) as priority
-                       FROM ($subqueryunion) ev
+                       FROM $subqueryunion ev
                    GROUP BY ev.modulename, ev.instance, ev.eventtype";
 
         // Build the main query.
index 66c348e..5a53e7e 100644 (file)
@@ -18,7 +18,7 @@ if (empty($CFG->enablecalendarexport)) {
 $checkuserid = !empty($userid) && $user = $DB->get_record('user', array('id' => $userid), 'id,password');
 //allowing for fallback check of old url - MDL-27542
 $checkusername = !empty($username) && $user = $DB->get_record('user', array('username' => $username), 'id,password');
-if (!$checkuserid && !$checkusername) {
+if ((!$checkuserid && !$checkusername) || !$user) {
     //No such user
     die('Invalid authentication');
 }
index 82cd7a8..b195eea 100644 (file)
@@ -447,4 +447,15 @@ class core_calendar_raw_event_retrieval_strategy_testcase extends advanced_testc
                 array_column($events, 'name'),
                 '', 0.0, 10, true);
     }
+
+    /**
+     * Test retrieval strategy with empty filters.
+     * This covers a edge case not covered elsewhere to ensure its SQL is cross
+     * db compatible. The test is ensuring we don't get a DML Exception with
+     * the filters setup this way.
+     */
+    public function test_get_raw_events_with_empty_user_and_category_lists() {
+        $retrievalstrategy = new raw_event_retrieval_strategy;
+        $retrievalstrategy->get_raw_events([], null, null, []);
+    }
 }
index 912b92e..083f217 100644 (file)
@@ -476,6 +476,7 @@ $CFG->admin = 'admin';
 //     $CFG->tempdir = '/var/www/moodle/temp';        // Directory MUST BE SHARED by all cluster nodes.
 //     $CFG->cachedir = '/var/www/moodle/cache';      // Directory MUST BE SHARED by all cluster nodes, locking required.
 //     $CFG->localcachedir = '/var/local/cache';      // Intended for local node caching.
+//     $CFG->localrequestdir = '/tmp';                // Intended for local only temporary files. The defaults uses sys_get_temp_dir().
 //
 // It is possible to specify a different backup temp directory, use local fast filesystem
 // for normal web servers. Server clusters MUST use shared filesystem for backuptempdir!
index ca60efb..832a7eb 100644 (file)
@@ -110,7 +110,12 @@ class content_item_service {
             throw new \coding_exception('The guest user does not exist in the database.');
         }
 
-        $favourites = $this->get_content_favourites(self::RECOMMENDATION_PREFIX, \context_user::instance($CFG->siteguest));
+        // Make sure the guest user context exists.
+        if (!$guestusercontext = \context_user::instance($CFG->siteguest, false)) {
+            throw new \coding_exception('The guest user context does not exist.');
+        }
+
+        $favourites = $this->get_content_favourites(self::RECOMMENDATION_PREFIX, $guestusercontext);
 
         $recommendationcache->set($CFG->siteguest, $favourites);
         return $favourites;
index 1d62aac..19fa1c2 100644 (file)
@@ -66,7 +66,7 @@ class customfield extends \core_search\base {
         if (!$fields) {
             $fields = array();
         }
-        list($fieldsql, $fieldparam) = $DB->get_in_or_equal(array_keys($fields), SQL_PARAMS_NAMED, 'fld', true, true);
+        list($fieldsql, $fieldparam) = $DB->get_in_or_equal(array_keys($fields), SQL_PARAMS_NAMED, 'fld', true, 0);
 
         // Restrict recordset to CONTEXT_COURSE (since we are implementing it to core_course\search).
         $sql = "SELECT d.*
index 89b7e0c..8d25c22 100644 (file)
@@ -1004,7 +1004,6 @@ class core_course_renderer extends plugin_renderer_base {
         // check if we are currently in the process of moving a module with JavaScript disabled
         $ismoving = $this->page->user_is_editing() && ismoving($course->id);
         if ($ismoving) {
-            $movingpix = new pix_icon('movehere', get_string('movehere'), 'moodle', array('class' => 'movetarget'));
             $strmovefull = strip_tags(get_string("movefull", "", "'$USER->activitycopyname'"));
         }
 
@@ -1032,7 +1031,7 @@ class core_course_renderer extends plugin_renderer_base {
                 if ($ismoving) {
                     $movingurl = new moodle_url('/course/mod.php', array('moveto' => $modnumber, 'sesskey' => sesskey()));
                     $sectionoutput .= html_writer::tag('li',
-                            html_writer::link($movingurl, $this->output->render($movingpix), array('title' => $strmovefull)),
+                            html_writer::link($movingurl, '', array('title' => $strmovefull, 'class' => 'movehere')),
                             array('class' => 'movehere'));
                 }
 
@@ -1042,7 +1041,7 @@ class core_course_renderer extends plugin_renderer_base {
             if ($ismoving) {
                 $movingurl = new moodle_url('/course/mod.php', array('movetosection' => $section->id, 'sesskey' => sesskey()));
                 $sectionoutput .= html_writer::tag('li',
-                        html_writer::link($movingurl, $this->output->render($movingpix), array('title' => $strmovefull)),
+                        html_writer::link($movingurl, '', array('title' => $strmovefull, 'class' => 'movehere')),
                         array('class' => 'movehere'));
             }
         }
diff --git a/files/classes/external/delete/draft.php b/files/classes/external/delete/draft.php
new file mode 100644 (file)
index 0000000..3ad88a8
--- /dev/null
@@ -0,0 +1,117 @@
+<?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 method for deleting draft files.
+ *
+ * @package    core_files
+ * @since      Moodle 3.10
+ * @copyright  2020 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_files\external\delete;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/externallib.php');
+require_once($CFG->libdir . '/filelib.php');
+
+use external_api;
+use external_function_parameters;
+use external_multiple_structure;
+use external_single_structure;
+use external_value;
+use external_warnings;
+use context_user;
+
+/**
+ * This is the external method for deleting draft files.
+ *
+ * @copyright  2020 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class draft extends external_api {
+
+    /**
+     * Describes the parameters for execute.
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.10
+     */
+    public static function execute_parameters() : external_function_parameters {
+        return new external_function_parameters (
+            [
+                'draftitemid' => new external_value(PARAM_INT, 'Item id of the draft file area'),
+                'files' => new external_multiple_structure(
+                    new external_single_structure(
+                        [
+                            'filepath'  => new external_value(PARAM_PATH, 'Path to the file or directory to delete.'),
+                            'filename'  => new external_value(PARAM_FILE, 'Name of the file to delete.'),
+                        ]
+                    ), 'Files or directories to be deleted.'
+                ),
+            ]
+        );
+    }
+
+    /**
+     * Delete the indicated files (or directories) from a user draft file area.
+     *
+     * @param  int    $draftitemid item id of the draft file area
+     * @param  array  $files       files to be deleted
+     * @return array of warnings and parent paths of the files deleted
+     * @since Moodle 3.10
+     */
+    public static function execute(int $draftitemid, array $files) : array {
+        global $CFG, $USER;
+        require_once($CFG->dirroot . '/repository/lib.php');
+
+        $params = self::validate_parameters(self::execute_parameters(), compact('draftitemid', 'files'));
+        [$draftitemid, $files] = array_values($params);
+
+        $usercontext = context_user::instance($USER->id);
+        self::validate_context($usercontext);
+
+        $files = array_map(function($file) {
+            return (object) $file;
+        }, $files);
+        $parentpaths = repository_delete_selected_files($usercontext, 'user', 'draft', $draftitemid, $files);
+
+        return [
+            'parentpaths' => array_keys($parentpaths),
+            'warnings' => [],
+        ];
+    }
+
+    /**
+     * Describes the execute return value.
+     *
+     * @return external_single_structure
+     * @since Moodle 3.10
+     */
+    public static function execute_returns() : external_single_structure {
+        return new external_single_structure(
+            [
+                'parentpaths' => new external_multiple_structure(
+                    new external_value(PARAM_PATH, 'Path to parent directory of the deleted files.')
+                ),
+                'warnings' => new external_warnings(),
+            ]
+        );
+    }
+}
index b652e6d..91a9e13 100644 (file)
@@ -293,4 +293,57 @@ class core_files_externallib_testcase extends advanced_testcase {
 
         $this->assertEquals($testfilelisting, $testdata);
     }
+
+    /**
+     * Test delete draft files
+     */
+    public function test_delete_draft_files() {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Add files to user draft area.
+        $draftitemid = file_get_unused_draft_itemid();
+        $context = context_user::instance($USER->id);
+        $filerecordinline = array(
+            'contextid' => $context->id,
+            'component' => 'user',
+            'filearea'  => 'draft',
+            'itemid'    => $draftitemid,
+            'filepath'  => '/',
+            'filename'  => 'faketxt.txt',
+        );
+        $fs = get_file_storage();
+        $fs->create_file_from_string($filerecordinline, 'fake txt contents 1.');
+
+        // Now create a folder with a file inside.
+        $fs->create_directory($context->id, 'user', 'draft', $draftitemid, '/fakefolder/');
+        $filerecordinline['filepath'] = '/fakefolder/';
+        $filerecordinline['filename'] = 'fakeimage.png';
+        $fs->create_file_from_string($filerecordinline, 'img...');
+
+        // Check two files were created (one file and one directory).
+        $files = core_files_external::get_files($context->id, 'user', 'draft', $draftitemid, '/', '');
+        $files = external_api::clean_returnvalue(core_files_external::get_files_returns(), $files);
+        $this->assertCount(2, $files['files']);
+
+        // Check the folder has one file.
+        $files = core_files_external::get_files($context->id, 'user', 'draft', $draftitemid, '/fakefolder/', '');
+        $files = external_api::clean_returnvalue(core_files_external::get_files_returns(), $files);
+        $this->assertCount(1, $files['files']);
+
+        // Delete a file and a folder.
+        $filestodelete = [
+            ['filepath' => '/', 'filename' => 'faketxt.txt'],
+            ['filepath' => '/fakefolder/', 'filename' => ''],
+        ];
+        $paths = core_files\external\delete\draft::execute($draftitemid, $filestodelete);
+        $paths = external_api::clean_returnvalue(core_files\external\delete\draft::execute_returns(), $paths);
+
+        // Check everything was deleted.
+        $files = core_files_external::get_files($context->id, 'user', 'draft', $draftitemid, '/', '');
+        $files = external_api::clean_returnvalue(core_files_external::get_files_returns(), $files);
+        $this->assertCount(0, $files['files']);
+    }
 }
index 2ece98f..64ddf7f 100644 (file)
@@ -133,8 +133,11 @@ if ($mform->is_cancelled()) {
         $data->grademin = 0;
     }
 
-    $hidden      = empty($data->hidden) ? 0: $data->hidden;
-    $hiddenuntil = empty($data->hiddenuntil) ? 0: $data->hiddenuntil;
+    $hide = empty($data->hiddenuntil) ? 0 : $data->hiddenuntil;
+    if (!$hide) {
+        $hide = empty($data->hidden) ? 0 : $data->hidden;
+    }
+
     unset($data->hidden);
     unset($data->hiddenuntil);
 
@@ -155,45 +158,43 @@ if ($mform->is_cancelled()) {
         $data->aggregationcoef2 = $defaults['aggregationcoef2'];
     }
 
-    $grade_item = new grade_item(array('id'=>$id, 'courseid'=>$courseid));
-    $oldmin = $grade_item->grademin;
-    $oldmax = $grade_item->grademax;
-    grade_item::set_properties($grade_item, $data);
-    $grade_item->outcomeid = null;
+    $gradeitem = new grade_item(array('id' => $id, 'courseid' => $courseid));
+    $oldmin = $gradeitem->grademin;
+    $oldmax = $gradeitem->grademax;
+    grade_item::set_properties($gradeitem, $data);
+    $gradeitem->outcomeid = null;
 
     // Handle null decimals value
     if (!property_exists($data, 'decimals') or $data->decimals < 0) {
-        $grade_item->decimals = null;
+        $gradeitem->decimals = null;
     }
 
-    if (empty($grade_item->id)) {
-        $grade_item->itemtype = 'manual'; // all new items to be manual only
-        $grade_item->insert();
+    if (empty($gradeitem->id)) {
+        $gradeitem->itemtype = 'manual'; // All new items to be manual only.
+        $gradeitem->insert();
 
         // set parent if needed
         if (isset($data->parentcategory)) {
-            $grade_item->set_parent($data->parentcategory, false);
+            $gradeitem->set_parent($data->parentcategory, false);
         }
 
     } else {
-        $grade_item->update();
+        $gradeitem->update();
 
         if (!empty($data->rescalegrades) && $data->rescalegrades == 'yes') {
-            $newmin = $grade_item->grademin;
-            $newmax = $grade_item->grademax;
-            $grade_item->rescale_grades_keep_percentage($oldmin, $oldmax, $newmin, $newmax, 'gradebook');
+            $newmin = $gradeitem->grademin;
+            $newmax = $gradeitem->grademax;
+            $gradeitem->rescale_grades_keep_percentage($oldmin, $oldmax, $newmin, $newmax, 'gradebook');
         }
     }
 
-    // update hiding flag
-    if ($hiddenuntil) {
-        $grade_item->set_hidden($hiddenuntil, false);
-    } else {
-        $grade_item->set_hidden($hidden, false);
+    if ($item->cancontrolvisibility) {
+        // Update hiding flag.
+        $gradeitem->set_hidden($hide, false);
     }
 
-    $grade_item->set_locktime($locktime); // locktime first - it might be removed when unlocking
-    $grade_item->set_locked($locked, false, true);
+    $gradeitem->set_locktime($locktime); // Locktime first - it might be removed when unlocking.
+    $gradeitem->set_locked($locked, false, true);
 
     redirect($returnurl);
 }
index f762cc7..fe5f58a 100644 (file)
@@ -181,10 +181,9 @@ class edit_item_form extends moodleform {
 
         /// hiding
         if ($item->cancontrolvisibility) {
-            // advcheckbox is not compatible with disabledIf!
-            $mform->addElement('checkbox', 'hidden', get_string('hidden', 'grades'));
+            $mform->addElement('advcheckbox', 'hidden', get_string('hidden', 'grades'), '', [], [0, 1]);
             $mform->addElement('date_time_selector', 'hiddenuntil', get_string('hiddenuntil', 'grades'), array('optional'=>true));
-            $mform->disabledIf('hidden', 'hiddenuntil[off]', 'notchecked');
+            $mform->disabledIf('hidden', 'hiddenuntil[enabled]', 'checked');
         } else {
             $mform->addElement('static', 'hidden', get_string('hidden', 'grades'),
                     get_string('componentcontrolsvisibility', 'grades'));
index 4216129..f34b69a 100644 (file)
@@ -233,7 +233,6 @@ class grade_edit_tree {
                 if ($this->moving && $this->moving != $child_eid) {
 
                     $strmove     = get_string('move');
-                    $strmovehere = get_string('movehere');
                     $actions = $moveaction = ''; // no action icons when moving
 
                     $aurl = new moodle_url('index.php', array('id' => $COURSE->id, 'action' => 'move', 'eid' => $this->moving, 'moveafter' => $child_eid, 'sesskey' => sesskey()));
@@ -245,8 +244,7 @@ class grade_edit_tree {
                     $cell->colspan = 12;
                     $cell->attributes['class'] = 'movehere level' . ($level + 1) . ' level' . ($level % 2 ? 'even' : 'odd');
 
-                    $icon = new pix_icon('movehere', $strmovehere, null, array('class'=>'movetarget'));
-                    $cell->text = $OUTPUT->action_icon($aurl, $icon);
+                    $cell->text = html_writer::link($aurl, '', array('title' => get_string('movehere'), 'class' => 'movehere'));
 
                     $moveto = new html_table_row(array($cell));
                 }
index 46d51db..4f60c57 100644 (file)
@@ -569,7 +569,7 @@ M.gradereport_grader.classes.ajax.prototype.submission_outcome = function(tid, o
         var p = args.properties;
         if (args.type == 'grade') {
             var oldgrade = args.values.oldgrade;
-            p.cell.one('.gradevalue').set('innerHTML',oldgrade);
+            p.cell.one('input.text').set('value', oldgrade);
         } else if (args.type == 'feedback') {
             this.report.update_feedback(p.userid, p.itemid, args.values.oldfeedback);
         }
index b7cbe9e..358f9a5 100644 (file)
@@ -234,3 +234,18 @@ Feature: Using the AJAX grading feature of Grader report to update grades and fe
       | Student 2  | 10.00 | 30.00 | 20.00 | 5.00 | 45.00 | 110.00 | 110.00 |
     And I click on student "Student 2" for grade item "Item 1"
     And the field "ajaxfeedback" matches value "Some feedback"
+
+  @javascript
+  Scenario: Teacher can see an error when an incorrect grade is given using the grader report with editing and AJAX on
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "View > Grader report" in the course gradebook
+    And I turn editing mode on
+    And I give the grade "66.00" to the user "Student 2" for the grade item "Item VU"
+    And I click away from student "Student 2" and grade item "Item VU" value
+    When I give the grade "999.00" to the user "Student 2" for the grade item "Item VU"
+    And I click away from student "Student 2" and grade item "Item VU" value
+    Then I should see "The grade entered for Item VU for Student 2 is more than the maximum allowed"
+    And I click on "The grade entered for Item VU for Student 2 is more than the maximum allowed" "text"
+    And I should not see "The grade entered for Item VU for Student 2 is more than the maximum allowed"
+    And the grade for "Student 2" in grade item "Item VU" should match "66.00"
index c3a9a88..3d80611 100644 (file)
@@ -190,6 +190,13 @@ class core extends \H5PCore {
                 'minorVersion' => $type->version->minor,
                 'patchVersion' => $type->version->patch,
             ];
+            // Add example and tutorial to the library, to store this information too.
+            if (isset($type->example)) {
+                $library['example'] = $type->example;
+            }
+            if (isset($type->tutorial)) {
+                $library['tutorial'] = $type->tutorial;
+            }
 
             $shoulddownload = true;
             if ($framework->getLibraryId($type->id, $type->version->major, $type->version->minor)) {
@@ -221,6 +228,8 @@ class core extends \H5PCore {
      * @return int|null Returns the id of the content type library installed, null otherwise.
      */
     public function fetch_content_type(array $library): ?int {
+        global $DB;
+
         $factory = new factory();
 
         // Download the latest content type from the H5P official repository.
@@ -250,6 +259,18 @@ class core extends \H5PCore {
         $librarykey = static::libraryToString($library);
         $libraryid = $factory->get_storage()->h5pC->librariesJsonData[$librarykey]["libraryId"];
 
+        // Update example and tutorial (if any of them are defined in $library).
+        $params = ['id' => $libraryid];
+        if (array_key_exists('example', $library)) {
+            $params['example'] = $library['example'];
+        }
+        if (array_key_exists('tutorial', $library)) {
+            $params['tutorial'] = $library['tutorial'];
+        }
+        if (count($params) > 1) {
+            $DB->update_record('h5p_libraries', $params);
+        }
+
         return $libraryid;
     }
 
index 6f293d0..43e4248 100644 (file)
@@ -76,8 +76,23 @@ class editor_ajax implements H5PEditorAjaxInterface {
      * @return mixed|null Returns results from querying the database
      */
     public function getContentTypeCache($machinename = null) {
-        // This is to be implemented when the Hub client is used.
-        return [];
+        global $DB;
+
+        // Added some extra fields to the result because they are expected by functions calling this. They have been
+        // taken from method getCachedLibsMap() in h5peditor.class.php.
+        $sql = "SELECT l.id, l.machinename AS machine_name, l.majorversion AS major_version,
+                       l.minorversion AS minor_version, l.patchversion AS patch_version, l.coremajor AS h5p_major_version,
+                       l.coreminor AS h5p_minor_version, l.title, l.tutorial, l.example,
+                       '' AS summary, '' AS description, '' AS icon, 0 AS created_at, 0 AS updated_at, 0 AS is_recommended,
+                       0 AS popularity, '' AS screenshots, '' AS license, '' AS owner
+                  FROM {h5p_libraries} l";
+        $params = [];
+        if (!empty($machinename)) {
+            $sql .= ' WHERE l.machinename = :machine_name';
+            $params = ['machine_name' => $machinename];
+        }
+
+        return $DB->get_records_sql($sql, $params);
     }
 
     /**
index 8f97a36..587b834 100644 (file)
@@ -228,7 +228,7 @@ class editor_framework implements H5peditorStorage {
         if ($libraries !== null) {
             // Get details for the specified libraries.
             $librariesin = [];
-            $fields = 'title, runnable, metadatasettings';
+            $fields = 'title, runnable, metadatasettings, example, tutorial';
 
             foreach ($libraries as $library) {
                 $params = [
@@ -243,11 +243,13 @@ class editor_framework implements H5peditorStorage {
                     $library->title = $details->title;
                     $library->runnable = $details->runnable;
                     $library->metadataSettings = json_decode($details->metadatasettings);
+                    $library->example = $details->example;
+                    $library->tutorial = $details->tutorial;
                     $librariesin[] = $library;
                 }
             }
         } else {
-            $fields = 'id, machinename as name, title, majorversion, minorversion, metadatasettings';
+            $fields = 'id, machinename as name, title, majorversion, minorversion, metadatasettings, example, tutorial';
             $librariesin = api::get_contenttype_libraries($fields);
         }
 
index 25d0ad9..d4b0d6a 100644 (file)
@@ -115,7 +115,16 @@ class framework implements \H5PFrameworkInterface {
      * @param string $url
      */
     public function setLibraryTutorialUrl($libraryname, $url) {
-        // Tutorial url is currently not being used or stored in libraries.
+        global $DB;
+
+        $sql = 'UPDATE {h5p_libraries}
+                   SET tutorial = :tutorial
+                 WHERE machinename = :machinename';
+        $params = [
+            'tutorial' => $url,
+            'machinename' => $libraryname,
+        ];
+        $DB->execute($sql, $params);
     }
 
     /**
index 6647594..7cc30a4 100644 (file)
@@ -353,6 +353,9 @@ class player {
         $settings['moodleLibraryPaths'] = $this->core->get_dependency_roots($this->h5pid);
         // Add also the Moodle component where the results will be tracked.
         $settings['moodleComponent'] = $this->component;
+        if (!empty($settings['moodleComponent'])) {
+            $settings['reportingIsEnabled'] = true;
+        }
 
         $cid = $this->get_cid();
         // The filterParameters function should be called before getting the dependencyfiles because it rebuild content
index c0b6734..13f46c9 100644 (file)
@@ -79,6 +79,35 @@ class editor_ajax_testcase extends \advanced_testcase {
         $this->assertEquals($expectedlibraries, array_keys($actuallibraries));
     }
 
+    /**
+     * Test that getContentTypeCache method retrieves the latest library versions that exists locally.
+     */
+    public function test_getContentTypeCache(): void {
+        $this->resetAfterTest();
+
+        $h5pgenerator = \testing_util::get_data_generator()->get_plugin_generator('core_h5p');
+
+        // Create several libraries records.
+        $lib1 = $h5pgenerator->create_library_record('Library1', 'Lib1', 1, 0, 1, '', null, 'http://tutorial.org',
+            'http://example.org');
+        $lib2 = $h5pgenerator->create_library_record('Library2', 'Lib2', 2, 0, 1, '', null, 'http://tutorial.org');
+        $lib3 = $h5pgenerator->create_library_record('Library3', 'Lib3', 3, 0);
+        $libs = [$lib1, $lib2, $lib3];
+
+        $libraries = $this->editorajax->getContentTypeCache();
+        $this->assertCount(3, $libraries);
+        foreach ($libs as $lib) {
+            $library = $libraries[$lib->id];
+            $this->assertEquals($library->id, $lib->id);
+            $this->assertEquals($library->machine_name, $lib->machinename);
+            $this->assertEquals($library->major_version, $lib->majorversion);
+            $this->assertEquals($library->tutorial, $lib->tutorial);
+            $this->assertEquals($library->example, $lib->example);
+            $this->assertEquals($library->is_recommended, 0);
+            $this->assertEquals($library->summary, '');
+        }
+    }
+
     /**
      * Test that the method getTranslations retrieves the translations of several libraries.
      *
index 1a2c18b..13883c1 100644 (file)
@@ -25,7 +25,7 @@
 
 namespace core_h5p;
 
-defined('MOODLE_INTERNAL') || die();
+use core_collator;
 
 /**
  *
@@ -174,6 +174,55 @@ class framework_testcase extends \advanced_testcase {
         $this->assertEmpty($data);
     }
 
+    /**
+     * Test the behaviour of setLibraryTutorialUrl().
+     */
+    public function test_setLibraryTutorialUrl() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
+
+        // Create several libraries records.
+        $lib1 = $generator->create_library_record('Library1', 'Lib1', 1, 0, 1, '', null, 'http://tutorial1.org',
+            'http://example.org');
+        $lib2 = $generator->create_library_record('Library2', 'Lib2', 2, 0, 1, '', null, 'http://tutorial2.org');
+        $lib3 = $generator->create_library_record('Library3', 'Lib3', 3, 0);
+
+        // Check only lib1 tutorial URL is updated.
+        $url = 'https://newtutorial.cat';
+        $this->framework->setLibraryTutorialUrl($lib1->machinename, $url);
+
+        $libraries = $DB->get_records('h5p_libraries');
+        $this->assertEquals($libraries[$lib1->id]->tutorial, $url);
+        $this->assertNotEquals($libraries[$lib2->id]->tutorial, $url);
+
+        // Check lib1 tutorial URL is set to null.
+        $this->framework->setLibraryTutorialUrl($lib1->machinename, null);
+
+        $libraries = $DB->get_records('h5p_libraries');
+        $this->assertCount(3, $libraries);
+        $this->assertNull($libraries[$lib1->id]->tutorial);
+
+        // Check no tutorial URL is set if library name doesn't exist.
+        $this->framework->setLibraryTutorialUrl('Unexisting library', $url);
+
+        $libraries = $DB->get_records('h5p_libraries');
+        $this->assertCount(3, $libraries);
+        $this->assertNull($libraries[$lib1->id]->tutorial);
+        $this->assertEquals($libraries[$lib2->id]->tutorial, 'http://tutorial2.org');
+        $this->assertNull($libraries[$lib3->id]->tutorial);
+
+        // Check tutorial is set as expected when it was null.
+        $this->framework->setLibraryTutorialUrl($lib3->machinename, $url);
+
+        $libraries = $DB->get_records('h5p_libraries');
+        $this->assertEquals($libraries[$lib3->id]->tutorial, $url);
+        $this->assertNull($libraries[$lib1->id]->tutorial);
+        $this->assertEquals($libraries[$lib2->id]->tutorial, 'http://tutorial2.org');
+    }
+
     /**
      * Test the behaviour of setErrorMessage().
      */
@@ -472,15 +521,19 @@ class framework_testcase extends \advanced_testcase {
         // The addons array should return 2 results (Library and Library1 addon).
         $this->assertCount(2, $addons);
 
+        // Ensure the addons array is consistently ordered before asserting their contents.
+        core_collator::asort_array_of_arrays_by_key($addons, 'machineName');
+        [$addonone, $addontwo] = array_values($addons);
+
         // Make sure the version 1.3 is the latest 'Library' addon version.
-        $this->assertEquals('Library', $addons[0]['machineName']);
-        $this->assertEquals(1, $addons[0]['majorVersion']);
-        $this->assertEquals(3, $addons[0]['minorVersion']);
+        $this->assertEquals('Library', $addonone['machineName']);
+        $this->assertEquals(1, $addonone['majorVersion']);
+        $this->assertEquals(3, $addonone['minorVersion']);
 
         // Make sure the version 1.2 is the latest 'Library1' addon version.
-        $this->assertEquals('Library1', $addons[1]['machineName']);
-        $this->assertEquals(1, $addons[1]['majorVersion']);
-        $this->assertEquals(2, $addons[1]['minorVersion']);
+        $this->assertEquals('Library1', $addontwo['machineName']);
+        $this->assertEquals(1, $addontwo['majorVersion']);
+        $this->assertEquals(2, $addontwo['minorVersion']);
     }
 
     /**
@@ -504,7 +557,6 @@ class framework_testcase extends \advanced_testcase {
         $this->assertEquals('1', $libraries['MainLibrary'][0]->major_version);
         $this->assertEquals('0', $libraries['MainLibrary'][0]->minor_version);
         $this->assertEquals('1', $libraries['MainLibrary'][0]->patch_version);
-        $this->assertEquals('MainLibrary', $libraries['MainLibrary'][0]->machine_name);
     }
 
     /**
index 72e8087..78c234b 100644 (file)
@@ -173,9 +173,10 @@ class core_h5p_generator extends \component_generator_base {
      */
     public function generate_h5p_data(bool $createlibraryfiles = false): stdClass {
         // Create libraries.
-        $mainlib = $libraries[] = $this->create_library_record('MainLibrary', 'Main Lib', 1, 0);
-        $lib1 = $libraries[] = $this->create_library_record('Library1', 'Lib1', 2, 0);
-        $lib2 = $libraries[] = $this->create_library_record('Library2', 'Lib2', 2, 1);
+        $mainlib = $libraries[] = $this->create_library_record('MainLibrary', 'Main Lib', 1, 0, 1, '', null,
+            'http://tutorial.org', 'http://example.org');
+        $lib1 = $libraries[] = $this->create_library_record('Library1', 'Lib1', 2, 0, 1, '', null, null,  'http://example.org');
+        $lib2 = $libraries[] = $this->create_library_record('Library2', 'Lib2', 2, 1, 1, '', null, 'http://tutorial.org');
         $lib3 = $libraries[] = $this->create_library_record('Library3', 'Lib3', 3, 2);
         $lib4 = $libraries[] = $this->create_library_record('Library4', 'Lib4', 1, 1);
         $lib5 = $libraries[] = $this->create_library_record('Library5', 'Lib5', 1, 3);
@@ -248,10 +249,13 @@ class core_h5p_generator extends \component_generator_base {
      * @param int $patchversion The library's patch version
      * @param string $semantics Json describing the content structure for the library
      * @param string $addto The plugin configuration data
+     * @param string $tutorial The tutorial URL
+     * @param string $examlpe The example URL
      * @return stdClass An object representing the added library record
      */
     public function create_library_record(string $machinename, string $title, int $majorversion = 1,
-            int $minorversion = 0, int $patchversion = 1, string $semantics = '', string $addto = null): stdClass {
+            int $minorversion = 0, int $patchversion = 1, string $semantics = '', string $addto = null,
+            string $tutorial = null, string $example = null): stdClass {
         global $DB;
 
         $content = array(
@@ -266,7 +270,9 @@ class core_h5p_generator extends \component_generator_base {
             'preloadedcss' => 'css/example.css',
             'droplibrarycss' => '',
             'semantics' => $semantics,
-            'addto' => $addto
+            'addto' => $addto,
+            'tutorial' => $tutorial,
+            'example' => $example
         );
 
         $libraryid = $DB->insert_record('h5p_libraries', $content);
index dc266f5..05be967 100644 (file)
@@ -227,7 +227,9 @@ class generator_testcase extends \advanced_testcase {
 
         $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
 
-        $data = $generator->create_library_record('Library', 'Lib', 1, 2, 3, 'Semantics example', '/regex11/');
+        $data = $generator->create_library_record(
+            'Library', 'Lib', 1, 2, 3, 'Semantics example', '/regex11/', 'http://tutorial.org/', 'http://example.org/'
+        );
         unset($data->id);
 
         $expected = (object) [
@@ -244,6 +246,8 @@ class generator_testcase extends \advanced_testcase {
             'droplibrarycss' => '',
             'semantics' => 'Semantics example',
             'addto' => '/regex11/',
+            'tutorial' => 'http://tutorial.org/',
+            'example' => 'http://example.org/',
             'coremajor' => null,
             'coreminor' => null,
             'metadatasettings' => null,
index 8d7f088..d441fdc 100644 (file)
@@ -68,8 +68,14 @@ class h5p_core_testcase extends \advanced_testcase {
 
         // Get info of latest content types versions.
         $contenttypes = $this->core->get_latest_content_types()->contentTypes;
-        // We are installing the first content type.
+        // We are installing the first content type with tutorial and example fields (or the first one if none has them).
         $librarydata = $contenttypes[0];
+        foreach ($contenttypes as $contentype) {
+            if (isset($contenttype->tutorial) && isset($contenttype->example)) {
+                $librarydata = $contenttype;
+                break;
+            }
+        }
 
         $library = [
                 'machineName' => $librarydata->id,
@@ -77,6 +83,13 @@ class h5p_core_testcase extends \advanced_testcase {
                 'minorVersion' => $librarydata->version->minor,
                 'patchVersion' => $librarydata->version->patch,
         ];
+        // Add example and tutorial to the library.
+        if (isset($librarydata->example)) {
+            $library['example'] = $librarydata->example;
+        }
+        if (isset($librarydata->tutorial)) {
+            $library['tutorial'] = $librarydata->tutorial;
+        }
 
         // Verify that the content type is not yet installed.
         $conditions['machinename'] = $library['machineName'];
@@ -92,6 +105,10 @@ class h5p_core_testcase extends \advanced_testcase {
         $this->assertEquals($librarydata->id, $typeinstalled->machinename);
         $this->assertEquals($librarydata->coreApiVersionNeeded->major, $typeinstalled->coremajor);
         $this->assertEquals($librarydata->coreApiVersionNeeded->minor, $typeinstalled->coreminor);
+        if (isset($librarydata->tutorial)) {
+            $this->assertEquals($librarydata->tutorial, $typeinstalled->tutorial);
+            $this->assertEquals($librarydata->example, $typeinstalled->example);
+        }
     }
 
     /**
index 22a7ea1..a06f575 100644 (file)
@@ -30,9 +30,9 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$string['cannotcreatedboninstall'] = '<p> لا يمكن إنشاء قاعدة البيانات. </p>
-<p> لا وجود لقاعدة البيانات المحددة والمستخدم المُعين ليس لديه إذن لإنشاء قاعدة البيانات. </ p>
-<p> المسؤول عن الموقع يجب أن يتحقق من إعدادات قاعدة بيانات. </p>';
+$string['cannotcreatedboninstall'] = '<p>لا يمكن إنشاء قاعدة البيانات.</p>
+<p>لا وجود لقاعدة البيانات المحددة والمستخدم المُعطى ليس لديه صلاحية إنشاء قاعدة البيانات.</p>
+<p>مسؤول الموقع يجب أن يتحقق من إعدادات قاعدة البيانات.</p>';
 $string['cannotcreatelangdir'] = 'لا يمكن إنشاء مجلد اللغة';
 $string['cannotcreatetempdir'] = 'لا يمكن إنشاء المجلد المؤقت';
 $string['cannotdownloadcomponents'] = 'تعذر تنزيل المُكونات';
index 1b80459..82d998d 100644 (file)
@@ -42,7 +42,7 @@ $string['databasename'] = 'اسم قاعدة البيانات';
 $string['databasetypehead'] = 'إختر مشغل قاعدة البيانات';
 $string['dataroot'] = 'مجلد البيانات';
 $string['datarootpermission'] = 'صلاحيات مجلدات البيانات';
-$string['dbprefix'] = 'مقدمة الجداول';
+$string['dbprefix'] = 'بادئة الجداول';
 $string['dirroot'] = 'مجلد مودل';
 $string['environmenthead'] = 'يتم فحص البيئة';
 $string['environmentsub2'] = 'كل إصدار من مودل يتطلب وجود إصدار معين من PHP على الأقل مع عدد من امتداداته الإجبارية.
index 7530ded..f8d5b9b 100644 (file)
@@ -390,6 +390,7 @@ $string['loginasnoenrol'] = 'You cannot use enrol or unenrol when in course "Log
 $string['loginasonecourse'] = 'You cannot enter this course.<br /> You have to terminate the "Login as" session before entering any other course.';
 $string['maxbytesfile'] = 'The file {$a->file} is too large. The maximum size you can upload is {$a->size}.';
 $string['maxareabytes'] = 'The file is larger than the space remaining in this area.';
+$string['messageundeliveredbynotificationsettings'] = 'The message could not be sent because personal messages between users (in Notification settings) has been disabled by a site administrator.';
 $string['messagingdisable'] = 'Messaging is disabled on this site';
 $string['mimetexisnotexist'] = 'Your system is not configured to run mimeTeX. You need to obtain the C source from <a href="https://www.forkosh.com/mimetex.zip">https://www.forkosh.com/mimetex.zip</a>, compile it and put the executable into your moodle/filter/tex/ directory.';
 $string['mimetexnotexecutable'] = 'Custom mimetex is not executable!';
index 41b2cee..3fe5a85 100644 (file)
@@ -277,6 +277,11 @@ $string['confirmcheckfull'] = 'Are you absolutely sure you want to confirm {$a}
 $string['confirmcoursemove'] = 'Are you sure you want to move this course ({$a->course}) into this category ({$a->category})?';
 $string['considereddigitalminor'] = 'You are too young to create an account on this site.';
 $string['content'] = 'Content';
+$string['contentexport_aboutthiscourse'] = 'Course summary';
+$string['contentexport_coursesummary'] = 'This file is part of the content downloaded from <a href="{$a->courselink}">{$a->coursename}</a>.';
+$string['contentexport_footersummary'] = 'This file is part of the content downloaded from <a href="{$a->courselink}">{$a->coursename}</a> by {$a->userfullname} on {$a->date}';
+$string['contentexport_modulesummary'] = 'This page is part of the content downloaded from <a href="{$a->modulelink}">{$a->modulename}</a> on {$a->date}. Note that some content and any files larger than {$a->maxfilesize} are not downloaded.';
+$string['contentexport_viewfilename'] = 'View the file {$a}';
 $string['contentbank'] = 'Content bank';
 $string['continue'] = 'Continue';
 $string['continuetocourse'] = 'Click here to enter your course';
@@ -1819,6 +1824,7 @@ $string['searchagain'] = 'Search again';
 $string['searchactivities'] = 'Search for activities by name or description';
 $string['searchbyemail'] = 'Search by email address';
 $string['searchbyusername'] = 'Search by username';
+$string['searchcoursecategories'] = 'Search categories';
 $string['searchcourses'] = 'Search courses';
 $string['searchoptions'] = 'Search options';
 $string['searchresults'] = 'Search results';
index bfdd787..4d55c9f 100644 (file)
@@ -11118,7 +11118,7 @@ class admin_setting_searchsetupinfo extends admin_setting {
      * @return string
      */
     public function output_html($data, $query='') {
-        global $CFG, $OUTPUT;
+        global $CFG, $OUTPUT, $ADMIN;
 
         $return = '';
         $brtag = html_writer::empty_tag('br');
@@ -11180,9 +11180,13 @@ class admin_setting_searchsetupinfo extends admin_setting {
             $row[0] = '3. ' . get_string('setupsearchengine', 'admin');
             $row[1] = html_writer::tag('span', get_string('no'), array('class' => 'badge badge-danger'));
         } else {
-            $url = new moodle_url('/admin/settings.php?section=search' . $CFG->searchengine);
-            $row[0] = '3. ' . html_writer::tag('a', get_string('setupsearchengine', 'admin'),
-                            array('href' => $url));
+            if ($ADMIN->locate('search' . $CFG->searchengine)) {
+                $url = new moodle_url('/admin/settings.php?section=search' . $CFG->searchengine);
+                $row[0] = '3. ' . html_writer::link($url, get_string('setupsearchengine', 'core_admin'));
+            } else {
+                $row[0] = '3. ' . get_string('setupsearchengine', 'core_admin');
+            }
+
             // Check the engine status.
             $searchengine = \core_search\manager::search_engine_instance();
             try {
index fa25ae4..e3366b6 100644 (file)
@@ -96,6 +96,7 @@ class core_component {
         'MoodleHQ\\RTLCSS' => 'lib/rtlcss',
         'ScssPhp\\ScssPhp' => 'lib/scssphp',
         'Box\\Spout' => 'lib/spout/src/Spout',
+        'BirknerAlex\\XMPPHP' => 'lib/jabber/XMPP',
         'MatthiasMullie\\Minify' => 'lib/minify/matthiasmullie-minify/src/',
         'MatthiasMullie\\PathConverter' => 'lib/minify/matthiasmullie-pathconverter/src/',
         'IMSGlobal\LTI' => 'lib/ltiprovider/src',
diff --git a/lib/classes/content.php b/lib/classes/content.php
new file mode 100644 (file)
index 0000000..1699213
--- /dev/null
@@ -0,0 +1,150 @@
+<?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/>.
+
+/**
+ * Content API File Area definition.
+ *
+ * @package     core_files
+ * @copyright   2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core;
+
+use coding_exception;
+use context;
+use core\content\export\exporters\course_exporter;
+use core\content\export\exporters\component_exporter;
+use core\content\export\exporters\abstract_mod_exporter;
+use core\content\export\zipwriter;
+use core_component;
+use moodle_url;
+use stdClass;
+use stored_file;
+
+/**
+ * The Content API allows all parts of Moodle to determine details about content within a component, or plugintype.
+ *
+ * This includes the description of files.
+ *
+ * @copyright   2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class content {
+
+    /**
+     * Check whether the specified user can export content for the specified context.
+     *
+     * @param   context $currentcontext
+     * @param   stdClass $user
+     * @return  bool
+     */
+    public static function can_export_context(context $currentcontext, stdClass $user): bool {
+        return true;
+    }
+
+    /**
+     * Export content for the specified context.
+     *
+     * @param   context $requestedcontext The context to be exported
+     * @param   stdClass $user The user being exported
+     * @param   zipwriter $archive The Zip Archive to export to
+     */
+    public static function export_context(context $requestedcontext, stdClass $user, zipwriter $archive): void {
+        global $USER;
+
+        if ($requestedcontext->contextlevel != CONTEXT_COURSE) {
+            throw new coding_exception('The Content Export API currently only supports the export of courses');
+        }
+
+        if ($USER->id != $user->id) {
+            throw new coding_exception('The Content Export API currently only supports export of the current user');
+        }
+
+        // Ensure that the zipwriter is aware of the requested context.
+        $archive->set_root_context($requestedcontext);
+
+        // Fetch all child contexts, indexed by path.
+        $contextlist = [
+            $requestedcontext->path => $requestedcontext,
+        ];
+        foreach ($requestedcontext->get_child_contexts() as $context) {
+            $contextlist[$context->path] = $context;
+        }
+
+        // Reverse the order by key - this ensures that child contexts are processed before their parent.
+        krsort($contextlist);
+
+        // Get the course modinfo.
+        $modinfo = get_fast_modinfo($requestedcontext->instanceid);
+
+        // Filter out any context which cannot be exported.
+        $contextlist = array_filter($contextlist, function($context) use ($user, $modinfo): bool {
+            if ($context->contextlevel == CONTEXT_COURSE) {
+                return self::can_export_context($context, $user);
+            }
+
+            if ($context->contextlevel == CONTEXT_MODULE) {
+                if (empty($modinfo->cms[$context->instanceid])) {
+                    // Unknown coursemodule in the course.
+                    return false;
+                }
+
+                $cm = $modinfo->cms[$context->instanceid];
+
+                if (!$cm->uservisible) {
+                    // This user cannot view the activity.
+                    return false;
+                }
+
+                // Defer to setting checks.
+                return self::can_export_context($context, $user);
+            }
+
+            // Only course and activities are supported at this time.
+            return false;
+        });
+
+        // Export each context.
+        $exportedcontexts = [];
+        $coursecontroller = new course_exporter($requestedcontext->get_course_context(), $user, $archive);
+        foreach ($contextlist as $context) {
+            if ($context->contextlevel === CONTEXT_MODULE) {
+                $cm = $modinfo->cms[$context->instanceid];
+                $component = "mod_{$cm->modname}";
+
+                // Check for a specific implementation for this module.
+                // This will export any content specific to this activity.
+                // For example, in mod_folder it will export the list of folders.
+                $classname = component_exporter::get_classname_for_component($component);
+                $exportables = [];
+                if (class_exists($classname) && is_a($classname, abstract_mod_exporter::class, true)) {
+                    $controller = new $classname($context, $component, $user, $archive);
+                    $exportables = $controller->get_exportables();
+                }
+
+                // Pass the exportable content to the  course controller for export.
+                $coursecontroller->export_mod_content($context, $exportables);
+
+                $exportedcontexts[$context->id] = $context;
+            } else if ($context->contextlevel === CONTEXT_COURSE) {
+                // Export the course content.
+                $coursecontroller->export_course($exportedcontexts);
+            }
+        }
+
+        $archive->finish();
+    }
+}
diff --git a/lib/classes/content/export/exportable_item.php b/lib/classes/content/export/exportable_item.php
new file mode 100644 (file)
index 0000000..4bfb5b6
--- /dev/null
@@ -0,0 +1,96 @@
+<?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/>.
+
+/**
+ * The definition of an item which can be exported.
+ *
+ * @package     core
+ * @copyright   2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types=1);
+
+namespace core\content\export;
+
+use context;
+use core\content\export\exported_item;
+use core\content\export\zipwriter;
+
+/**
+ * An object used to represent content which can be served.
+ *
+ * @copyright   2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class exportable_item {
+
+    /** @var context The context associated with this exportable item */
+    protected $context = null;
+
+    /** @var string The component being exported */
+    protected $component = null;
+
+    /** @var string The name displayed to the user */
+    protected $uservisiblename = null;
+
+    /**
+     * Create a new exportable_item instance.
+     *
+     * @param   context $context The context that this content belongs to
+     * @param   string $component The component that this content relates to
+     * @param   string $uservisiblename The name displayed in the export
+     */
+    public function __construct(context $context, string $component, string $uservisiblename) {
+        $this->context = $context;
+        $this->component = $component;
+        $this->uservisiblename = $uservisiblename;
+    }
+
+    /**
+     * Get the context that this exportable item is for.
+     *
+     * @return  context
+     */
+    public function get_context(): context {
+        return $this->context;
+    }
+
+    /**
+     * Get the component that this exportable item relates to.
+     *
+     * @return  string
+     */
+    public function get_component(): string {
+        return $this->component;
+    }
+
+    /**
+     * Get the user visible name for the exportable item.
+     *
+     * @return  string
+     */
+    public function get_user_visible_name(): string {
+        return $this->uservisiblename;
+    }
+
+    /**
+     * Add the content to the archive.
+     *
+     * @param   zipwriter $archive
+     */
+    abstract public function add_to_archive(zipwriter $archive): ?exported_item;
+}
diff --git a/lib/classes/content/export/exportable_items/exportable_filearea.php b/lib/classes/content/export/exportable_items/exportable_filearea.php
new file mode 100644 (file)
index 0000000..754903c
--- /dev/null
@@ -0,0 +1,179 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The definition of a set of files in a filearea to be exported.
+ *
+ * @package     core
+ * @copyright   2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types=1);
+
+namespace core\content\export\exportable_items;
+
+use context;
+use core\content\export\exportable_item;
+use core\content\export\exported_item;
+use core\content\export\zipwriter;
+use moodle_url;
+use stored_file;
+
+/**
+ * The definition of a set of files in a filearea to be exported.
+ *
+ * All files mustbe in a single filearea and itemid combination.
+ *
+ * @copyright   2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class exportable_filearea extends exportable_item {
+
+    /** @var string The destination path of the text content */
+    protected $folderpath;
+
+    /** @var string $filearea The file to be exported */
+    protected $filearea;
+
+    /** @var bool|int The itemid in the Files API */
+    protected $itemid;
+
+    /** @var int The itemid to use in the pluginfile URL */
+    protected $pluginfileitemid;
+
+    /**
+     * Create a new exportable_item instance.
+     *
+     * If no filearea or itemid  is specified the no attempt will be made to export files.
+     *
+     * @param   context $context The context that this content belongs to
+     * @param   string $component
+     * @param   string $uservisiblename The name displayed to the user when filtering
+     * @param   string $filearea The file area in the Files API where these files are located
+     * @param   int $itemid The itemid in the Files API where these files are located
+     * @param   null|int $pluginfileitemid The itemid as used in the Pluginfile URL
+     * @param   string $folderpath Any sub-directory to place files in
+     */
+    public function __construct(
+        context $context,
+        string $component,
+        string $uservisiblename,
+        string $filearea,
+        int $itemid,
+        ?int $pluginfileitemid = null,
+        string $folderpath = ''
+    ) {
+        parent::__construct($context, $component, $uservisiblename);
+
+        $this->filearea = $filearea;
+        $this->itemid = $itemid;
+        $this->pluginfileitemid = $pluginfileitemid;
+        $this->folderpath = $folderpath;
+    }
+
+    /**
+     * Add the content to the archive.
+     *
+     * @param   zipwriter $archive
+     */
+    public function add_to_archive(zipwriter $archive): ?exported_item {
+        $fs = get_file_storage();
+
+        $files = $fs->get_area_files($this->context->id, $this->component, $this->filearea, $this->itemid);
+
+        $exporteditem = new exported_item();
+        $exporteditem->set_title($this->get_user_visible_name());
+
+        foreach ($files as $file) {
+            if ($file->is_directory()) {
+                // Skip folders. The zipwriter cannot handle them.
+                continue;
+            }
+            // Export the content to [contextpath]/[filepath].
+            $relativefilepath = $this->get_filepath_for_file($file);
+
+            $archive->add_file_from_stored_file(
+                $this->get_context(),
+                $relativefilepath,
+                $file
+            );
+
+            if ($archive->is_file_in_archive($this->context, $relativefilepath)) {
+                // The file was successfully added to the archive.
+                $exporteditem->add_file($relativefilepath, false);
+            } else {
+                // The file was not added. Link to the live version instead.
+                $exporteditem->add_file(
+                    $relativefilepath,
+                    false,
+                    self::get_pluginfile_url_for_stored_file($file, $this->pluginfileitemid)
+                );
+            }
+        }
+
+        return $exporteditem;
+    }
+
+    /**
+     * Get the filepath for the specified stored_file.
+     *
+     * @param   stored_file $file The file to get a filepath for
+     * @return  string The generated filepath
+     */
+    protected function get_filepath_for_file(stored_file $file): string {
+        $folderpath = rtrim($this->folderpath);
+
+        if (!empty($folderpath)) {
+            $folderpath .= '/';
+        }
+        return sprintf(
+            '%s%s%s%s',
+            $folderpath,
+            $file->get_filearea(),
+            $file->get_filepath(),
+            $file->get_filename()
+        );
+    }
+
+    /**
+     * Get the pluginfile URL for a stored file.
+     *
+     * Note: The itemid in the pluginfile may be omitted in some URLs, despite an itemid being present in the database.
+     * Equally, the itemid in the URL may not match the itemid in the files table.
+     *
+     * The pluginfileitemid argument provided to this function is the variant in the URL, and not the one in the files
+     * table.
+     *
+     * @param   stored_file $file The file whose link will be generated
+     * @param   null|int $pluginfileitemid The itemid of the file in pluginfile URL.
+     *
+     */
+    protected static function get_pluginfile_url_for_stored_file(stored_file $file, ?int $pluginfileitemid): string {
+        $link = moodle_url::make_pluginfile_url(
+            $file->get_contextid(),
+            $file->get_component(),
+            $file->get_filearea(),
+            $pluginfileitemid,
+            $file->get_filepath(),
+            $file->get_filename(),
+            true,
+            true
+        );
+
+        return $link->out(false);
+    }
+}
diff --git a/lib/classes/content/export/exportable_items/exportable_stored_file.php b/lib/classes/content/export/exportable_items/exportable_stored_file.php
new file mode 100644 (file)
index 0000000..3862783
--- /dev/null
@@ -0,0 +1,202 @@
+<?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/>.
+
+/**
+ * The definition of an item which can be exported.
+ *
+ * @package     core
+ * @copyright   2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types=1);
+
+namespace core\content\export\exportable_items;
+
+use context;
+use core\content\export\exportable_item;
+use core\content\export\exported_item;
+use core\content\export\zipwriter;
+use moodle_url;
+use stored_file;
+
+/**
+ * An object used to represent content which can be served.
+ *
+ * @copyright   2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class exportable_stored_file extends exportable_item {
+
+    /** @var string The destination path of the text content */
+    protected $folderpath;
+
+    /** @var stored_file The file to be exported */
+    protected $file;
+
+    /** @var int The itemid to use in the pluginfile URL */
+    protected $pluginfileitemid;
+
+    /**
+     * Create a new exportable_item instance.
+     *
+     * If no filearea or itemid  is specified the no attempt will be made to export files.
+     *
+     * @param   context $context The context that this content belongs to
+     * @param   string $component
+     * @param   string $uservisiblename The name displayed to the user when filtering
+     * @param   stored_file $file
+     * @param   null|int $pluginfileitemid The itemid as used in the pluginfile URL.
+     *          If no itemid is used, then a null value can be provided
+     * @param   string $folderpath Any sub-directory to place files in
+     */
+    public function __construct(
+        context $context,
+        string $component,
+        string $uservisiblename,
+        stored_file $file,
+        ?int $pluginfileitemid = null,
+        string $folderpath = ''
+    ) {
+        parent::__construct($context, $component, $uservisiblename);
+
+        $this->file = $file;
+        $this->folderpath = $folderpath;
+        $this->pluginfileitemid = $pluginfileitemid;
+    }
+
+    /**
+     * Create a set of exportable_items from a set of area paramaters as passed to get_areas_files().
+     *
+     * If no filearea or itemid  is specified the no attempt will be made to export files.
+     *
+     * @param   context $context The context that this content belongs to
+     * @param   string $component
+     * @param   string $filearea
+     * @param   null|int $itemid
+     * @param   null|int $pluginfileitemid The itemid as used in the pluginfile URL.
+     *          If no itemid is used, then a null value can be provided
+     * @param   string $folderpath Any sub-directory to place files in
+     * @return  array
+     */
+    public static function create_from_area_params(
+        context $context,
+        string $component,
+        string $filearea,
+        ?int $itemid,
+        ?int $pluginfileitemid = null,
+        string $folderpath = ''
+    ): array {
+        $fs = get_file_storage();
+        if ($itemid === null) {
+            $itemid = false;
+        }
+
+        $exportables = [];
+        foreach ($fs->get_area_files($context->id, $component, $filearea, $itemid) as $file) {
+            if ($file->is_directory()) {
+                // Do not export directories.
+                // If they contain file contents the directory structure will be created in the zip file.
+                continue;
+            }
+            $filepath = $file->get_filepath() . $file->get_filename();
+            $exportables[] = new self($context, $component, $filepath, $file, $pluginfileitemid, $folderpath);
+        }
+
+        return $exportables;
+    }
+
+    /**
+     * Add the content to the archive.
+     *
+     * @param   zipwriter $archive
+     */
+    public function add_to_archive(zipwriter $archive): ?exported_item {
+        // Export the content to [contextpath]/[filepath].
+        $relativefilepath = $this->get_filepath_for_file();
+
+        $archive->add_file_from_stored_file(
+            $this->get_context(),
+            $relativefilepath,
+            $this->file
+        );
+
+        $exporteditem = new exported_item();
+        $exporteditem->set_title($this->get_user_visible_name());
+
+        if ($archive->is_file_in_archive($this->context, $relativefilepath)) {
+            // The file was successfully added to the archive.
+            $exporteditem->add_file($relativefilepath, false);
+        } else {
+            // The file was not added. Link to the live version instead.
+            $exporteditem->add_file(
+                $relativefilepath,
+                false,
+                self::get_pluginfile_url_for_stored_file($this->file, $this->pluginfileitemid)
+            );
+        }
+
+        return $exporteditem;
+    }
+
+    /**
+     * Get the filepath for the specified stored_file.
+     *
+     * @return  string
+     */
+    protected function get_filepath_for_file(): string {
+        $folderpath = rtrim($this->folderpath);
+
+        if (!empty($folderpath)) {
+            $folderpath .= '/';
+        }
+        return sprintf(
+            '%s%s%s%s',
+            $folderpath,
+            $this->file->get_filearea(),
+            $this->file->get_filepath(),
+            $this->file->get_filename()
+        );
+    }
+
+    /**
+     * Get the pluginfile URL for a stored file.
+     *
+     * Note: The itemid in the pluginfile may be omitted in some URLs, despite an itemid being present in the database.
+     * Equally, the itemid in the URL may not match the itemid in the files table.
+     *
+     * The pluginfileitemid argument provided to this function is the variant in the URL, and not the one in the files
+     * table.
+     *
+     * @param   stored_file $file The file whose link will be generated
+     * @param   null|int $pluginfileitemid The itemid of the file in pluginfile URL.
+     *
+     */
+    protected static function get_pluginfile_url_for_stored_file(stored_file $file, ?int $pluginfileitemid): string {
+        $link = moodle_url::make_pluginfile_url(
+            $file->get_contextid(),
+            $file->get_component(),
+            $file->get_filearea(),
+            $pluginfileitemid,
+            $file->get_filepath(),
+            $file->get_filename(),
+            true,
+            true
+        );
+
+        return $link->out(false);
+    }
+}
diff --git a/lib/classes/content/export/exportable_items/exportable_textarea.php b/lib/classes/content/export/exportable_items/exportable_textarea.php
new file mode 100644 (file)
index 0000000..246d478
--- /dev/null
@@ -0,0 +1,166 @@
+<?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/>.
+
+/**
+ * The definition of a text area which can be exported.
+ *
+ * @package     core
+ * @copyright   2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types=1);
+
+namespace core\content\export\exportable_items;
+
+use context;
+use core\content\export\exportable_item;
+use core\content\export\exported_item;
+use core\content\export\zipwriter;
+
+/**
+ * The definition of a text area which can be exported.
+ *
+ * @copyright   2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class exportable_textarea extends exportable_item {
+
+    /** @var string The name of the table that ha the textarea within it */
+    protected $tablename;
+
+    /** @var int The id in the table */
+    protected $id;
+
+    /** @var string The name of the text field within the table */
+    protected $textfield;
+
+    /** @var null|string The name of the format field relating to the text field */
+    protected $textformatfield;
+
+    /** @var null|string The name of a file area for this content */
+    protected $filearea;
+
+    /** @var null|int The itemid for files in this text field */
+    protected $itemid;
+
+    /** @var null|int The itemid used for constructing pluginfiles */
+    protected $pluginfileitemid;
+
+    /**
+     * Create a new exportable_item instance.
+     *
+     * If no filearea or itemid  is specified the no attempt will be made to export files.
+     *
+     * @param   context $context The context that this content belongs to
+     * @param   string $component The component that this textarea belongs to
+     * @param   string $uservisiblename The name displayed to the user when filtering
+     * @param   string $tablename The name of the table that this textarea is in
+     * @param   string $textfield The field within the tbale
+     * @param   int $id The id in the database
+     * @param   null|string $textformatfield The field in the database relating to the format field if one is present
+     * @param   null|string $filearea The name of the file area for files associated with this text area
+     * @param   null|int $itemid The itemid for files associated with this text area
+     * @param   null|int $pluginfileitemid The itemid to use when constructing the pluginfile URL
+     *          Some fileareas do not use any itemid in the URL and should therefore provide a `null` value here.
+     */
+    public function __construct(
+        context $context,
+        string $component,
+        string $uservisiblename,
+        string $tablename,
+        string $textfield,
+        int $id,
+        ?string $textformatfield = null,
+        ?string $filearea = null,
+        ?int $itemid = null,
+        ?int $pluginfileitemid = null
+    ) {
+    &nbs