Merge branch 'master_MDL-47178' of https://github.com/golenkovm/moodle
authorJake Dallimore <jake@moodle.com>
Fri, 16 Oct 2020 03:52:09 +0000 (11:52 +0800)
committerJake Dallimore <jake@moodle.com>
Fri, 16 Oct 2020 03:52:09 +0000 (11:52 +0800)
271 files changed:
.travis.yml
admin/classes/local/externalpage/accesscallback.php [new file with mode: 0644]
admin/modules.php
admin/plugins.php
admin/settings/location.php
admin/tests/behat/invalid_allcountrycodes.feature [new file with mode: 0644]
admin/tool/behat/cli/util_single_run.php
admin/tool/customlang/classes/form/export.php [new file with mode: 0644]
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/classes/output/renderer.php
admin/tool/customlang/cli/export.php [new file with mode: 0644]
admin/tool/customlang/cli/import.php [new file with mode: 0644]
admin/tool/customlang/db/access.php
admin/tool/customlang/export.php [new file with mode: 0644]
admin/tool/customlang/filter_form.php
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/locallib.php
admin/tool/customlang/templates/translator.mustache
admin/tool/customlang/tests/behat/customisation_create.feature [new file with mode: 0644]
admin/tool/customlang/tests/behat/export.feature [new file with mode: 0644]
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/classes/api.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/mobile/tests/externallib_test.php
admin/tool/replace/classes/form.php
admin/tool/replace/cli/replace.php
admin/tool/replace/index.php
admin/tool/replace/lang/en/tool_replace.php
admin/tool/uploaduser/classes/cli_helper.php [new file with mode: 0644]
admin/tool/uploaduser/classes/local/cli_progress_tracker.php [new file with mode: 0644]
admin/tool/uploaduser/classes/local/text_progress_tracker.php [new file with mode: 0644]
admin/tool/uploaduser/classes/preview.php [new file with mode: 0644]
admin/tool/uploaduser/classes/process.php [new file with mode: 0644]
admin/tool/uploaduser/cli/uploaduser.php [new file with mode: 0644]
admin/tool/uploaduser/index.php
admin/tool/uploaduser/lang/en/tool_uploaduser.php
admin/tool/uploaduser/locallib.php
admin/tool/uploaduser/tests/cli_test.php [new file with mode: 0644]
admin/tool/uploaduser/user_form.php
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
availability/condition/grade/tests/behat/availability_grade.feature
backup/util/ui/renderer.php
cache/classes/administration_helper.php
cache/tests/administration_helper_test.php
calendar/export_execute.php
completion/tests/behat/restrict_activity_by_grade.feature
completion/tests/behat/restrict_section_availability.feature
composer.json
composer.lock
config-dist.php
course/classes/local/service/content_item_service.php
course/externallib.php
course/tests/externallib_test.php
course/upgrade.txt
files/classes/external/delete/draft.php [new file with mode: 0644]
files/tests/externallib_test.php
grade/grading/form/guide/tests/behat/edit_guide.feature
grade/grading/tests/behat/behat_grading.php
grade/report/singleview/tests/behat/bulk_insert_grades.feature
grade/tests/behat/grade_scales.feature
grade/tests/behat/grade_single_item_scales.feature
h5p/ajax.php
h5p/classes/core.php
h5p/classes/editor_ajax.php
h5p/classes/editor_framework.php
h5p/classes/external.php
h5p/classes/framework.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
install/lang/pt/error.php
install/lang/pt/install.php
install/lang/sv/admin.php
lang/en/error.php
lang/en/moodle.php
lib/adminlib.php
lib/amd/build/notification.min.js
lib/amd/build/notification.min.js.map
lib/amd/src/notification.js
lib/behat/classes/util.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/notification.php
lib/classes/oauth2/api.php
lib/classes/oauth2/client.php
lib/classes/plugininfo/base.php
lib/classes/privacy/provider.php
lib/classes/session/redis.php
lib/classes/string_manager_standard.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/filelib.php
lib/form/templates/element-defaultcustom.mustache
lib/form/tests/behat/modgrade_validation.feature
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/outputlib.php
lib/outputrenderers.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/adminlib_test.php
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/notification_test.php
lib/tests/setuplib_test.php
lib/tests/string_manager_standard_test.php
lib/thirdpartylibs.xml
lib/upgrade.txt
lib/weblib.php
login/token.php
message/classes/api.php
message/externallib.php
message/output/jabber/message_output_jabber.php
message/output/popup/db/upgrade.php
mod/assign/amd/build/grading_panel.min.js
mod/assign/amd/build/grading_panel.min.js.map
mod/assign/amd/src/grading_panel.js
mod/assign/feedback/editpdf/lib.php
mod/assign/feedback/editpdf/locallib.php
mod/assign/feedback/editpdf/tests/behat/annotate_pdf.feature
mod/assign/feedback/editpdf/tests/behat/group_annotations.feature
mod/assign/feedback/editpdf/tests/behat/view_previous_annotations.feature
mod/assign/feedback/file/tests/behat/feedback_file.feature
mod/assign/tests/behat/allow_another_attempt.feature
mod/assign/tests/behat/assign_hidden.feature
mod/assign/tests/behat/comment_inline.feature
mod/assign/tests/behat/display_grade.feature
mod/assign/tests/behat/edit_previous_feedback.feature
mod/assign/tests/behat/filter_by_marker.feature
mod/assign/tests/behat/grading_app_filters.feature
mod/assign/tests/behat/grading_status.feature
mod/assign/tests/behat/group_submission.feature
mod/assign/tests/behat/hide_grader.feature
mod/assign/tests/behat/outcome_grading.feature
mod/assign/tests/behat/prevent_submission_changes.feature
mod/assign/tests/behat/quickgrading.feature
mod/assign/tests/behat/relative_dates.feature
mod/assign/tests/behat/rescale_grades.feature
mod/assign/tests/behat/steps_blind_marking.feature
mod/assign/tests/behat/submission_comments.feature
mod/data/index.php
mod/data/mod_form.php
mod/data/tests/behat/create_activity.feature [new file with mode: 0644]
mod/data/tests/behat/data_activities.feature [new file with mode: 0644]
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/templates/inpage_reply.mustache
mod/forum/templates/inpage_reply_v2.mustache
mod/forum/tests/behat/behat_mod_forum.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/lesson/essay.php
mod/lesson/locallib.php
mod/lesson/mod_form.php
mod/lesson/pagetypes/essay.php
mod/lesson/pagetypes/matching.php
mod/lesson/pagetypes/multichoice.php
mod/lesson/pagetypes/numerical.php
mod/lesson/pagetypes/shortanswer.php
mod/lesson/pagetypes/truefalse.php
mod/lesson/tests/behat/lesson_navigation.feature
mod/lesson/tests/locallib_test.php
mod/lti/locallib.php
mod/lti/service/memberships/classes/local/service/memberships.php
mod/lti/tests/locallib_test.php
mod/page/classes/content/exporter.php [new file with mode: 0644]
mod/quiz/renderer.php
mod/resource/classes/content/exporter.php [new file with mode: 0644]
question/format/xml/format.php
question/format/xml/tests/fixtures/html_chars_in_idnumbers.xml [new file with mode: 0644]
question/format/xml/tests/qformat_xml_import_export_test.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/googledocs/lib.php
repository/nextcloud/lib.php
repository/onedrive/lib.php
theme/boost/scss/moodle/admin.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
version.php

index 27ee681..7b533fe 100644 (file)
@@ -2,10 +2,9 @@
 # process (which uses our internal CI system) this file is here for the benefit
 # of community developers git clones - see MDL-51458.
 
-# We currently disable Travis notifications entirely until https://github.com/travis-ci/travis-ci/issues/4976
-# is fixed.
 notifications:
-  email: false
+  email:
+    if: env(MOODLE_EMAIL) != no
 
 language: 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);
index 504cbb9..5a4cc90 100644 (file)
@@ -1,21 +1,57 @@
 <?php
+// This file is part of Moodle - https://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/>.
 
-if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
+/**
+ * Define administration settings on the Location settings page.
+ *
+ * @package     core
+ * @category    admin
+ * @copyright   2006 Martin Dougiamas <martin@moodle.com>
+ * @license     https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
 
-    // "locations" settingpage
-    $temp = new admin_settingpage('locationsettings', new lang_string('locationsettings', 'admin'));
-    $temp->add(new admin_setting_servertimezone());
-    $temp->add(new admin_setting_forcetimezone());
-    $temp->add(new admin_settings_country_select('country', new lang_string('country', 'admin'), new lang_string('configcountry', 'admin'), 0));
-    $temp->add(new admin_setting_configtext('defaultcity', new lang_string('defaultcity', 'admin'), new lang_string('defaultcity_help', 'admin'), ''));
+defined('MOODLE_INTERNAL') || die();
 
-    $temp->add(new admin_setting_heading('iplookup', new lang_string('iplookup', 'admin'), new lang_string('iplookupinfo', 'admin')));
-    $temp->add(new admin_setting_configfile('geoip2file', new lang_string('geoipfile', 'admin'),
-        new lang_string('configgeoipfile', 'admin', $CFG->dataroot.'/geoip/'), $CFG->dataroot.'/geoip/GeoLite2-City.mmdb'));
-    $temp->add(new admin_setting_configtext('googlemapkey3', new lang_string('googlemapkey3', 'admin'), new lang_string('googlemapkey3_help', 'admin'), '', PARAM_RAW, 60));
+if ($hassiteconfig) {
+    $temp = new admin_settingpage('locationsettings', new lang_string('locationsettings', 'core_admin'));
 
-    $temp->add(new admin_setting_configtext('allcountrycodes', new lang_string('allcountrycodes', 'admin'), new lang_string('configallcountrycodes', 'admin'), '', '/^(?:\w+(?:,\w+)*)?$/'));
+    if ($ADMIN->fulltree) {
+        $temp->add(new admin_setting_servertimezone());
 
-    $ADMIN->add('location', $temp);
+        $temp->add(new admin_setting_forcetimezone());
+
+        $temp->add(new admin_settings_country_select('country', new lang_string('country', 'core_admin'),
+            new lang_string('configcountry', 'core_admin'), 0));
+
+        $temp->add(new admin_setting_configtext('defaultcity', new lang_string('defaultcity', 'core_admin'),
+            new lang_string('defaultcity_help', 'core_admin'), ''));
+
+        $temp->add(new admin_setting_heading('iplookup', new lang_string('iplookup', 'core_admin'),
+            new lang_string('iplookupinfo', 'core_admin')));
 
-} // end of speedup
+        $temp->add(new admin_setting_configfile('geoip2file', new lang_string('geoipfile', 'core_admin'),
+            new lang_string('configgeoipfile', 'core_admin', $CFG->dataroot . '/geoip/'),
+            $CFG->dataroot . '/geoip/GeoLite2-City.mmdb'));
+
+        $temp->add(new admin_setting_configtext('googlemapkey3', new lang_string('googlemapkey3', 'core_admin'),
+            new lang_string('googlemapkey3_help', 'core_admin'), '', PARAM_RAW, 60));
+
+        $temp->add(new admin_setting_countrycodes('allcountrycodes', new lang_string('allcountrycodes', 'core_admin'),
+            new lang_string('configallcountrycodes', 'core_admin')));
+    }
+
+    $ADMIN->add('location', $temp);
+}
diff --git a/admin/tests/behat/invalid_allcountrycodes.feature b/admin/tests/behat/invalid_allcountrycodes.feature
new file mode 100644 (file)
index 0000000..399712f
--- /dev/null
@@ -0,0 +1,29 @@
+@core @core_admin
+Feature: Administrator is warned and when trying to set invalid allcountrycodes value.
+  In order to avoid misconfiguration of the country selector fields
+  As an admin
+  I want to be warned when I try to set an invalid country code in the allcountrycodes field
+
+  Scenario: Attempting to set allcountrycodes field with valid country codes
+    Given I log in as "admin"
+    And I navigate to "Location > Location settings" in site administration
+    When I set the following administration settings values:
+      | All country codes | CZ,BE,GB,ES |
+    Then I should not see "Invalid country code"
+
+  Scenario: Attempting to set allcountrycodes field with invalid country code
+    Given I log in as "admin"
+    And I navigate to "Location > Location settings" in site administration
+    When I set the following administration settings values:
+      | All country codes | CZ,BE,FOOBAR,GB,ES |
+    Then I should see "Invalid country code: FOOBAR"
+
+  Scenario: Attempting to unset allcountrycodes field
+    Given I log in as "admin"
+    And I navigate to "Location > Location settings" in site administration
+    And I set the following administration settings values:
+      | All country codes | CZ,BE,GB,ES |
+    And I navigate to "Location > Location settings" in site administration
+    When I set the following administration settings values:
+      | All country codes | |
+    Then I should not see "Invalid country code"
index 42b335c..868dd84 100644 (file)
@@ -187,10 +187,13 @@ if ($options['install']) {
     behat_config_manager::set_behat_run_config_value('axe', $options['axe']);
 
     // Enable test mode.
+    $timestart = microtime(true);
+    mtrace('Creating Behat configuration ...', '');
     behat_util::start_test_mode($options['add-core-features-to-theme'], $options['optimize-runs'], $parallel, $run);
+    mtrace(' done in ' . round(microtime(true) - $timestart, 2) . ' seconds.');
 
     // Themes are only built in the 'enable' command.
-    behat_util::build_themes();
+    behat_util::build_themes(true);
     mtrace("Testing environment themes built");
 
     // This is only displayed once for parallel install.
diff --git a/admin/tool/customlang/classes/form/export.php b/admin/tool/customlang/classes/form/export.php
new file mode 100644 (file)
index 0000000..843b09a
--- /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/>.
+
+/**
+ * Creates Formular for customlang file export
+ *
+ * @package    tool_customlang
+ * @copyright  2020 Thomas Wedekind <Thomas.Wedekind@univie.ac.at>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace tool_customlang\form;
+
+use tool_customlang_utils;
+
+/**
+ * Formular for customlang file export
+ *
+ * @copyright  2020 Thomas Wedekind <Thomas.Wedekind@univie.ac.at>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class export extends \moodleform {
+
+    /**
+     * Add elements to form
+     */
+    public function definition() {
+        $lng = $this->_customdata['lng'];
+        $mform = $this->_form;
+
+        $langdir = tool_customlang_utils::get_localpack_location($lng);
+
+        // The export button only appears if a local lang is present.
+        if (!check_dir_exists($langdir) || !count(glob("$langdir/*"))) {
+            print_error('nolocallang', 'tool_customlang');
+        }
+
+        $langfiles = scandir($langdir);
+        $fileoptions = [];
+        foreach ($langfiles as $file) {
+            if (substr($file, 0, 1) != '.') {
+                $fileoptions[$file] = $file;
+            }
+        }
+
+        $mform->addElement('hidden', 'lng', $lng);
+        $mform->setType('lng', PARAM_LANG);
+
+        $select = $mform->addElement('select', 'files', get_string('exportfilter', 'tool_customlang'), $fileoptions);
+        $select->setMultiple(true);
+        $mform->addRule('files', get_string('required'), 'required', null, 'client');
+        $mform->setDefault('files', $fileoptions);
+
+        $this->add_action_buttons(true, get_string('export', 'tool_customlang'));
+    }
+}
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
+            );
+        }
+    }
+}
index b183daf..9ad60c5 100644 (file)
@@ -57,8 +57,9 @@ class renderer extends \plugin_renderer_base {
     protected function render_tool_customlang_menu(\tool_customlang_menu $menu) {
         $output = '';
         foreach ($menu->get_items() as $item) {
-            $output .= $this->single_button($item->url, $item->title, $item->method);
+            $button = $this->single_button($item->url, $item->title, $item->method);
+            $output .= $this->box($button, 'menu');
         }
-        return $this->box($output, 'menu');
+        return $output;
     }
 }
diff --git a/admin/tool/customlang/cli/export.php b/admin/tool/customlang/cli/export.php
new file mode 100644 (file)
index 0000000..29655de
--- /dev/null
@@ -0,0 +1,135 @@
+<?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/>.
+
+/**
+ * Export custom language strings to zip files.
+ *
+ * @package    tool_customlang
+ * @subpackage customlang
+ * @copyright  2020 Thomas Wedekind <thomas.wedekind@univie.ac.at>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define('CLI_SCRIPT', true);
+
+require(__DIR__ . '/../../../../config.php');
+require_once("$CFG->libdir/clilib.php");
+require_once("$CFG->dirroot/$CFG->admin/tool/customlang/locallib.php");
+
+$usage = <<<EOF
+"Export custom language files to a target folder.
+Useful for uploading custom langstrings to AMOS or importing or syncing them to other moodle instances.
+
+Options:
+-l, --lang              Comma seperated language ids to export, default: all
+-c, --components        Comma seperated components to export, default: all
+-t, --target            Target to copy the zip files to, default: $CFG->tempdir/customlang
+-o, --overwrite         Overwrite existing files in the target folder.
+                            Note: If the target is not set, the files are always overwritten!
+-h, --help              Print out this help
+
+Examples:
+Export all custom language files to the default folder:
+\$ sudo -u www-data /usr/bin/php admin/tool/customlang/cli/export.php
+
+Export just the english files of moodle core and the activity 'quiz' in a subfolder in my home folder:
+\$ sudo -u www-data /usr/bin/php admin/tool/customlang/cli/export.php --lang='en' --components='moodle,quiz' --target='~/customdir'
+
+EOF;
+
+$dafaulttarget = "$CFG->tempdir/customlang/";
+
+// Now get cli options.
+list($options, $unrecognized) = cli_get_params(
+    [
+        'lang' => '',
+        'components' => '',
+        'target' => $dafaulttarget,
+        'overwrite' => false,
+        'help' => false,
+    ],
+    ['h' => 'help', 'c' => 'components', 't' => 'target', 'o' => 'overwrite']
+);
+
+if ($unrecognized) {
+    $unrecognized = implode("\n  ", $unrecognized);
+    cli_error(get_string('cliunknowoption', 'admin', $unrecognized));
+}
+
+if ($options['help']) {
+    echo $usage;
+    die;
+}
+if (!file_exists($options['target'])) {
+    mkdir($options['target'], 0777, true);
+}
+
+cli_writeln(get_string('cliexportheading', 'tool_customlang'));
+$langs = [];
+if ($options['lang']) {
+    $langs = explode(',', $options['lang']);
+} else {
+    // No language set. We export all installed languages.
+    $langs = array_keys(get_string_manager()->get_list_of_translations(true));
+}
+
+foreach ($langs as $lang) {
+    $filename = $options['target'] . get_string('exportzipfilename', 'tool_customlang', ['lang' => $lang]);
+    // If the file exists and we are not using the temp folder it requires an ovewrite.
+    if ($options['target'] != $dafaulttarget && file_exists($filename) && !$options['overwrite']) {
+        cli_problem(get_string('cliexportfileexists', 'tool_customlang', $lang));
+        continue;
+    }
+    cli_heading(get_string('cliexportstartexport', 'tool_customlang', $lang));
+    $langdir = tool_customlang_utils::get_localpack_location($lang);
+    if (!file_exists($langdir)) {
+        // No custom files set for this language set.
+        cli_writeln(get_string('cliexportnofilefoundforlang', 'tool_customlang', ['lang' => $lang]));
+        continue;
+    }
+    $zipper = get_file_packer();
+    $tempzip = tempnam($CFG->tempdir . '/', 'tool_customlang_export');
+    $filelist = [];
+    if ($options['components']) {
+        $components = explode(',', $options['components']);
+        foreach ($components as $component) {
+            $filepath = "$langdir/$component.php";
+            if (file_exists($filepath)) {
+                $filelist["$component.php"] = $filepath;
+            } else {
+                cli_problem(
+                    get_string('cliexportfilenotfoundforcomponent', 'tool_customlang', ['lang' => $lang, 'file' => $filepath])
+                );
+            }
+        }
+    } else {
+        $langfiles = scandir($langdir);
+        foreach ($langfiles as $file) {
+            if (substr($file, 0, 1) != '.') {
+                $filelist[$file] = "$langdir/$file";
+            }
+        }
+    }
+    if (empty($filelist)) {
+        cli_problem(get_string('cliexportnofilefoundforlang', 'tool_customlang', ['lang' => $lang]));
+        continue;
+    }
+    if ($zipper->archive_to_pathname($filelist, $filename)) {
+        cli_writeln(get_string('cliexportzipdone', 'tool_customlang', $filename));
+    } else {
+        cli_problem(get_string('cliexportzipfail', 'tool_customlang', $filename));
+    }
+}
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);
index 12f0792..46f3981 100644 (file)
@@ -46,5 +46,14 @@ $capabilities = array(
             'manager' => CAP_ALLOW
         ),
     ),
+    /* allows the user to export the current language customization */
+    'tool/customlang:export' => array(
+        'riskbitmask' => RISK_CONFIG,
+        'captype' => 'read',
+        'contextlevel' => CONTEXT_SYSTEM,
+        'archetypes' => array(
+            'manager' => CAP_ALLOW
+        ),
+    ),
 
 );
diff --git a/admin/tool/customlang/export.php b/admin/tool/customlang/export.php
new file mode 100644 (file)
index 0000000..e649360
--- /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/>.
+
+/**
+ * Performs the custom lang export.
+ *
+ * @package    tool_customlang
+ * @subpackage customlang
+ * @copyright  2020 Thomas Wedekind <thomas.wedekind@univie.ac.at>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require(__DIR__ . '/../../../config.php');
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/customlang/locallib.php');
+require_once($CFG->libdir.'/adminlib.php');
+
+global $PAGE, $CFG;
+
+require_login(SITEID, false);
+require_capability('tool/customlang:export', context_system::instance());
+
+$lng = required_param('lng', PARAM_LANG);
+
+admin_externalpage_setup('toolcustomlang', '', null,
+    new moodle_url('/admin/tool/customlang/import.php', ['lng' => $lng]));
+
+$form = new \tool_customlang\form\export(null, ['lng' => $lng]);
+
+if ($form->is_cancelled()) {
+    redirect('index.php');
+    die();
+} else if ($formdata = $form->get_data()) {
+    $tempzip = tempnam($CFG->tempdir . '/', 'tool_customlang_export');
+    $filelist = [];
+    foreach ($formdata->files as $file) {
+        $filepath = tool_customlang_utils::get_localpack_location($lng). '/' . $file;
+        if (file_exists($filepath)) {
+            $filelist[$file] = $filepath;
+        }
+    }
+    $zipper = new zip_packer();
+
+    if (!empty($filelist) && $zipper->archive_to_pathname($filelist, $tempzip)) {
+        // Filename include the lang name so the file can be imported with automatic language detection.
+        send_temp_file($tempzip, "customlang_$lng.zip");
+        die();
+    }
+}
+
+$output = $PAGE->get_renderer('tool_customlang');
+
+echo $output->header();
+echo $output->heading(get_string('pluginname', 'tool_customlang'));
+$form->display();
+echo $OUTPUT->footer();
index 19d74c1..6c92e81 100644 (file)
@@ -32,7 +32,6 @@ class tool_customlang_filter_form extends moodleform {
 
     function definition() {
         $mform = $this->_form;
-        $current = $this->_customdata['current'];
 
         $mform->addElement('header', 'filtersettings', get_string('filter', 'tool_customlang'));
 
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 9031329..e57aa44 100644 (file)
@@ -35,6 +35,7 @@ require_capability('tool/customlang:view', context_system::instance());
 $action  = optional_param('action', '', PARAM_ALPHA);
 $confirm = optional_param('confirm', false, PARAM_BOOL);
 $lng     = optional_param('lng', '', PARAM_LANG);
+$next     = optional_param('next', 'edit', PARAM_ALPHA);
 
 admin_externalpage_setup('toolcustomlang');
 $langs = get_string_manager()->get_list_of_translations();
@@ -59,11 +60,10 @@ if ($action === 'checkout') {
     raise_memory_limit(MEMORY_EXTRA);
     tool_customlang_utils::checkout($lng, $progressbar);
 
-    echo $output->continue_button(new moodle_url('/admin/tool/customlang/edit.php', array('lng' => $lng)), 'get');
+    echo $output->continue_button(new moodle_url("/admin/tool/customlang/{$next}.php", array('lng' => $lng)), 'get');
     echo $output->footer();
     exit;
 }
-
 if ($action === 'checkin') {
     require_sesskey();
     require_capability('tool/customlang:edit', context_system::instance());
@@ -132,6 +132,21 @@ 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);
+    if (check_dir_exists(dirname($langdir)) && count(glob("$langdir/*"))) {
+        $menu['export'] = [
+            'title'     => get_string('export', 'tool_customlang'),
+            'url'       => new moodle_url("/admin/tool/customlang/export.php", ['lng' => $lng]),
+            'method'    => 'post',
+        ];
+    }
 }
 echo $output->render(new tool_customlang_menu($menu));
 
index a7375f1..29dd1e2 100644 (file)
@@ -30,9 +30,26 @@ $string['checkin'] = 'Save strings to language pack';
 $string['checkout'] = 'Open language pack for editing';
 $string['checkoutdone'] = 'Language pack loaded';
 $string['checkoutinprogress'] = 'Loading language pack';
+$string['cliexportfileexists'] = 'File for {$a->lang} already exists, skipping. If you want to overwrite add the --override=true option.';
+$string['cliexportheading'] = 'Starting to export lang files.';
+$string['cliexportnofilefoundforlang'] = 'No file found to export. Skipping export for this language.';
+$string['cliexportfilenotfoundforcomponent'] = 'File {$a->filepath} not found for language {$a->lang}.Skipping this file.';
+$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';
 $string['customlang:view'] = 'View local translation';
+$string['export'] = 'Export custom strings';
+$string['exportfilter'] = 'Select component(s) to export';
 $string['filter'] = 'Filter strings';
 $string['filtercomponent'] = 'Show strings of these components';
 $string['filtercustomized'] = 'Customised only';
@@ -45,11 +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.
index 2f02b45..5bbb8c2 100644 (file)
@@ -243,7 +243,7 @@ class tool_customlang_utils {
      * @param string $lang language code
      * @return string full path
      */
-    protected static function get_localpack_location($lang) {
+    public static function get_localpack_location($lang) {
         global $CFG;
 
         return $CFG->langlocalroot.'/'.$lang.'_local';
index 400464c..a756968 100644 (file)
                         <strong>{{#str}}headinglocal, tool_customlang{{/str}}</strong>
                     </div>
                     <div class="py-2 py-md-0 px-md-3">
-                        <textarea class="form-control w-100 border-box" name="cust[{{id}}]" cols="40" rows="3">{{{ local }}}</textarea>
-
+                        <label for="{{id}}" class="sr-only sr-only-focusable">{{{ component }}}/{{{ stringid }}}</label>
+                        <textarea class="form-control w-100 border-box" id="{{id}}" name="cust[{{id}}]" cols="40" rows="3">{{{ local }}}</textarea>
                         {{#checkupdated}}
                         <div class="uptodatewrapper">
                             <div class="form-check">
diff --git a/admin/tool/customlang/tests/behat/customisation_create.feature b/admin/tool/customlang/tests/behat/customisation_create.feature
new file mode 100644 (file)
index 0000000..84f71bb
--- /dev/null
@@ -0,0 +1,30 @@
+@tool @tool_customlang
+Feature: Within a moodle instance, an administrator should be able to modify langstrings for the entire Moodle installation.
+  In order to change langstrings in the adminsettings of the instance,
+  As an admin
+  I need to be able to access and change values in the the language customisation of the language pack.
+
+  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 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"
+
+  @javascript
+  Scenario: Edit an string but don't save it to lang pack.
+    When I press "Apply changes and continue editing"
+    Then I should see "Site administration" in the "page-header" "region"
+    And I should not see "Custom string example" in the "page-header" "region"
+
+  @javascript
+  Scenario: Customize an string as admin and save it to lang pack.
+    Given I press "Save changes to the language pack"
+    And I should see "There are 1 modified strings."
+    When I click on "Continue" "button"
+    Then I should see "Custom string example" in the "page-header" "region"
+    And I should not see "Site administration" in the "page-header" "region"
diff --git a/admin/tool/customlang/tests/behat/export.feature b/admin/tool/customlang/tests/behat/export.feature
new file mode 100644 (file)
index 0000000..a42e672
--- /dev/null
@@ -0,0 +1,49 @@
+@tool @tool_customlang
+Feature: Within a moodle instance, an administrator should be able to export modified langstrings.
+  In order to export modified langstrings in the adminsettings of the instance,
+  As an admin
+  I need to be able to export the php-files of the language customisation of a language.
+
+  @javascript
+  Scenario: Export button should not appear if no customization is made
+    Given I log in as "admin"
+    And I navigate to "Language > Language customisation" in site administration
+    And I set the field "lng" to "en"
+    Then I should see "Open language pack for editing"
+    And I should not see "Export custom strings"
+
+  @javascript
+  Scenario: Export button should not appear if no customization is saved into langpack
+    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 "accept"
+    And I press "Show strings"
+    And I set the field "core/accept" to "Accept-custom_export"
+    When I press "Apply changes and continue editing"
+    And I navigate to "Language > Language customisation" in site administration
+    And I set the field "lng" to "en"
+    Then I should see "Open language pack for editing"
+    And I should see "There are 1 modified strings."
+    And I should not see "Export custom strings"
+
+  @javascript
+  Scenario: Export the php-file including a customised langstring.
+    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 "accept"
+    And I press "Show strings"
+    And I set the field "core/accept" to "Accept-custom_export"
+    When I press "Save changes to the language pack"
+    And I should see "There are 1 modified strings."
+    And I click on "Continue" "button"
+    Then I set the field "lng" to "en"
+    And I click on "Export custom strings" "button"
+    And I set the field "Select component(s) to export" to "moodle.php"
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 508df54..ccd8ad9 100644 (file)
@@ -25,6 +25,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2021052500;
+$plugin->version   = 2021052502;
 $plugin->requires  = 2021052500;
 $plugin->component = 'tool_customlang'; // Full name of the plugin (used for diagnostics)
index 0fd5f72..9ea41b0 100644 (file)
@@ -323,6 +323,12 @@ class api {
             }
         }
 
+        if (empty($section) or $section == 'supportcontact') {
+            $settings->supportname = $CFG->supportname;
+            $settings->supportemail = $CFG->supportemail;
+            $settings->supportpage = $CFG->supportpage;
+        }
+
         return $settings;
     }
 
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();
index 1a16eb7..0909aaf 100644 (file)
@@ -230,6 +230,10 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
                 'value' => get_config('core_admin', 'coursecolor' . $number)
             ];
         }
+        $expected[] = ['name' => 'supportname', 'value' => $CFG->supportname];
+        $expected[] = ['name' => 'supportemail', 'value' => $CFG->supportemail];
+        $expected[] = ['name' => 'supportpage', 'value' => $CFG->supportpage];
+
         $this->assertCount(0, $result['warnings']);
         $this->assertEquals($expected, $result['settings']);
 
index 5c60510..d42fbe2 100644 (file)
@@ -46,6 +46,13 @@ class tool_replace_form extends moodleform {
         $mform->addElement('text', 'replace', get_string('replacewith', 'tool_replace'), 'size="50"', PARAM_RAW);
         $mform->addElement('static', 'replacest', '', get_string('replacewithhelp', 'tool_replace'));
         $mform->setType('replace', PARAM_RAW);
+
+        $mform->addElement('textarea', 'additionalskiptables', get_string("additionalskiptables", "tool_replace"),
+            array('rows' => 5, 'cols' => 50));
+        $mform->addElement('static', 'additionalskiptables_desc', '', get_string('additionalskiptables_desc', 'tool_replace'));
+        $mform->setType('additionalskiptables', PARAM_RAW);
+        $mform->setDefault('additionalskiptables', '');
+
         $mform->addElement('checkbox', 'shorten', get_string('shortenoversized', 'tool_replace'));
         $mform->addRule('replace', get_string('required'), 'required', null, 'client');
 
index 1f9e74b..b4244aa 100644 (file)
@@ -34,6 +34,7 @@ $help =
 Options:
 --search=STRING       String to search for.
 --replace=STRING      String to replace with.
+--skiptables=STRING   Skip these tables (comma separated list of tables).
 --shorten             Shorten result if necessary.
 --non-interactive     Perform the replacement without confirming.
 -h, --help            Print out this help.
@@ -46,6 +47,7 @@ list($options, $unrecognized) = cli_get_params(
     array(
         'search'  => null,
         'replace' => null,
+        'skiptables' => '',
         'shorten' => false,
         'non-interactive' => false,
         'help'    => false,
@@ -71,6 +73,7 @@ if (empty($options['shorten']) && core_text::strlen($options['search']) < core_t
 try {
     $search = validate_param($options['search'], PARAM_RAW);
     $replace = validate_param($options['replace'], PARAM_RAW);
+    $skiptables = validate_param($options['skiptables'], PARAM_RAW);
 } catch (invalid_parameter_exception $e) {
     cli_error(get_string('invalidcharacter', 'tool_replace'));
 }
@@ -85,7 +88,7 @@ if (!$options['non-interactive']) {
     }
 }
 
-if (!db_replace($search, $replace)) {
+if (!db_replace($search, $replace, $skiptables)) {
     cli_heading(get_string('error'));
     exit(1);
 }
index b3013d2..e4f2900 100644 (file)
@@ -57,7 +57,7 @@ if (!$data = $form->get_data()) {
 $PAGE->requires->js_init_code("window.scrollTo(0, 5000000);");
 
 echo $OUTPUT->box_start();
-db_replace($data->search, $data->replace);
+db_replace($data->search, $data->replace, $data->additionalskiptables);
 echo $OUTPUT->box_end();
 
 // Course caches are now rebuilt on the fly.
index 6117521..e8dce48 100644 (file)
@@ -22,7 +22,8 @@
  * @copyright  2011 Petr Skoda {@link http://skodak.org}
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-
+$string['additionalskiptables'] = 'Additional skip tables';
+$string['additionalskiptables_desc'] = 'Please specify the additional tables (comma separated list) you want to skip while running DB search and replace.';
 $string['cannotfit'] = 'The replacement is longer than the original and shortening is not allowed; cannot continue.';
 $string['disclaimer'] = 'I understand the risks of this operation';
 $string['doit'] = 'Yes, do it!';
diff --git a/admin/tool/uploaduser/classes/cli_helper.php b/admin/tool/uploaduser/classes/cli_helper.php
new file mode 100644 (file)
index 0000000..e258e7d
--- /dev/null
@@ -0,0 +1,402 @@
+<?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/>.
+
+/**
+ * Class cli_helper
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_uploaduser;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_uploaduser\local\cli_progress_tracker;
+
+require_once($CFG->dirroot.'/user/profile/lib.php');
+require_once($CFG->dirroot.'/user/lib.php');
+require_once($CFG->dirroot.'/group/lib.php');
+require_once($CFG->dirroot.'/cohort/lib.php');
+require_once($CFG->libdir.'/csvlib.class.php');
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/uploaduser/locallib.php');
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/uploaduser/user_form.php');
+require_once($CFG->libdir . '/clilib.php');
+
+/**
+ * Helper method for CLI script to upload users (also has special wrappers for cli* functions for phpunit testing)
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class cli_helper {
+
+    /** @var string */
+    protected $operation;
+    /** @var array */
+    protected $clioptions;
+    /** @var array */
+    protected $unrecognized;
+    /** @var string */
+    protected $progresstrackerclass;
+
+    /** @var process */
+    protected $process;
+
+    /**
+     * cli_helper constructor.
+     *
+     * @param string|null $progresstrackerclass
+     */
+    public function __construct(?string $progresstrackerclass = null) {
+        $this->progresstrackerclass = $progresstrackerclass ?? cli_progress_tracker::class;
+        $optionsdefinitions = $this->options_definitions();
+        $longoptions = [];
+        $shortmapping = [];
+        foreach ($optionsdefinitions as $key => $option) {
+            $longoptions[$key] = $option['default'];
+            if (!empty($option['alias'])) {
+                $shortmapping[$option['alias']] = $key;
+            }
+        }
+
+        list($this->clioptions, $this->unrecognized) = cli_get_params(
+            $longoptions,
+            $shortmapping
+        );
+    }
+
+    /**
+     * Options used in this CLI script
+     *
+     * @return array
+     */
+    protected function options_definitions(): array {
+        $options = [
+            'help' => [
+                'hasvalue' => false,
+                'description' => get_string('clihelp', 'tool_uploaduser'),
+                'default' => 0,
+                'alias' => 'h',
+            ],
+            'file' => [
+                'hasvalue' => 'PATH',
+                'description' => get_string('clifile', 'tool_uploaduser'),
+                'default' => null,
+                'validation' => function($file) {
+                    if (!$file) {
+                        $this->cli_error(get_string('climissingargument', 'tool_uploaduser', 'file'));
+                    }
+                    if ($file && (!file_exists($file) || !is_readable($file))) {
+                        $this->cli_error(get_string('clifilenotreadable', 'tool_uploaduser', $file));
+                    }
+                }
+            ],
+        ];
+        $form = new \admin_uploaduser_form1();
+        [$elements, $defaults] = $form->get_form_for_cli();
+        $options += $this->prepare_form_elements_for_cli($elements, $defaults);
+        $form = new \admin_uploaduser_form2(null, ['columns' => ['type1'], 'data' => []]);
+        [$elements, $defaults] = $form->get_form_for_cli();
+        $options += $this->prepare_form_elements_for_cli($elements, $defaults);
+        return $options;
+    }
+
+    /**
+     * Print help for export
+     */
+    public function print_help(): void {
+        $this->cli_writeln(get_string('clititle', 'tool_uploaduser'));
+        $this->cli_writeln('');
+        $this->print_help_options($this->options_definitions());
+        $this->cli_writeln('');
+        $this->cli_writeln('Example:');
+        $this->cli_writeln('$sudo -u www-data /usr/bin/php admin/tool/uploaduser/cli/uploaduser.php --file=PATH');
+    }
+
+    /**
+     * Get CLI option
+     *
+     * @param string $key
+     * @return mixed|null
+     */
+    public function get_cli_option(string $key) {
+        return $this->clioptions[$key] ?? null;
+    }
+
+    /**
+     * Write a text to the given stream
+     *
+     * @param string $text text to be written
+     */
+    protected function cli_write($text): void {
+        if (PHPUNIT_TEST) {
+            echo $text;
+        } else {
+            cli_write($text);
+        }
+    }
+
+    /**
+     * Write error notification
+     * @param string $text
+     * @return void
+     */
+    protected function cli_problem($text): void {
+        if (PHPUNIT_TEST) {
+            echo $text;
+        } else {
+            cli_problem($text);
+        }
+    }
+
+    /**
+     * Write a text followed by an end of line symbol to the given stream
+     *
+     * @param string $text text to be written
+     */
+    protected function cli_writeln($text): void {
+        $this->cli_write($text . PHP_EOL);
+    }
+
+    /**
+     * Write to standard error output and exit with the given code
+     *
+     * @param string $text
+     * @param int $errorcode
+     * @return void (does not return)
+     */
+    protected function cli_error($text, $errorcode = 1): void {
+        $this->cli_problem($text);
+        $this->die($errorcode);
+    }
+
+    /**
+     * Wrapper for "die()" method so we can unittest it
+     *
+     * @param mixed $errorcode
+     * @throws \moodle_exception
+     */
+    protected function die($errorcode): void {
+        if (!PHPUNIT_TEST) {
+            die($errorcode);
+        } else {
+            throw new \moodle_exception('CLI script finished with error code '.$errorcode);
+        }
+    }
+
+    /**
+     * Display as CLI table
+     *
+     * @param array $column1
+     * @param array $column2
+     * @param int $indent
+     * @return string
+     */
+    protected function convert_to_table(array $column1, array $column2, int $indent = 0): string {
+        $maxlengthleft = 0;
+        $left = [];
+        $column1 = array_values($column1);
+        $column2 = array_values($column2);
+        foreach ($column1 as $i => $l) {
+            $left[$i] = str_repeat(' ', $indent) . $l;
+            if (strlen('' . $column2[$i])) {
+                $maxlengthleft = max($maxlengthleft, strlen($l) + $indent);
+            }
+        }
+        $maxlengthright = 80 - $maxlengthleft - 1;
+        $output = '';
+        foreach ($column2 as $i => $r) {
+            if (!strlen('' . $r)) {
+                $output .= $left[$i] . "\n";
+                continue;
+            }
+            $right = wordwrap($r, $maxlengthright, "\n");
+            $output .= str_pad($left[$i], $maxlengthleft) . ' ' .
+                str_replace("\n", PHP_EOL . str_repeat(' ', $maxlengthleft + 1), $right) . PHP_EOL;
+        }
+        return $output;
+    }
+
+    /**
+     * Display available CLI options as a table
+     *
+     * @param array $options
+     */
+    protected function print_help_options(array $options): void {
+        $left = [];
+        $right = [];
+        foreach ($options as $key => $option) {
+            if ($option['hasvalue'] !== false) {
+                $l = "--$key={$option['hasvalue']}";
+            } else if (!empty($option['alias'])) {
+                $l = "-{$option['alias']}, --$key";
+            } else {
+                $l = "--$key";
+            }
+            $left[] = $l;
+            $right[] = $option['description'];
+        }
+        $this->cli_write('Options:' . PHP_EOL . $this->convert_to_table($left, $right));
+    }
+
+    /**
+     * Process the upload
+     */
+    public function process(): void {
+        // First, validate all arguments.
+        $definitions = $this->options_definitions();
+        foreach ($this->clioptions as $key => $value) {
+            if ($validator = $definitions[$key]['validation'] ?? null) {
+                $validator($value);
+            }
+        }
+
+        // Read the CSV file.
+        $iid = \csv_import_reader::get_new_iid('uploaduser');
+        $cir = new \csv_import_reader($iid, 'uploaduser');
+        $cir->load_csv_content(file_get_contents($this->get_cli_option('file')),
+            $this->get_cli_option('encoding'), $this->get_cli_option('delimiter_name'));
+        $csvloaderror = $cir->get_error();
+
+        if (!is_null($csvloaderror)) {
+            $this->cli_error(get_string('csvloaderror', 'error', $csvloaderror), 1);
+        }
+
+        // Start upload user process.
+        $this->process = new \tool_uploaduser\process($cir, $this->progresstrackerclass);
+        $filecolumns = $this->process->get_file_columns();
+
+        $form = $this->mock_form(['columns' => $filecolumns, 'data' => ['iid' => $iid, 'previewrows' => 1]], $this->clioptions);
+
+        if (!$form->is_validated()) {
+            $errors = $form->get_validation_errors();
+            $this->cli_error(get_string('clivalidationerror', 'tool_uploaduser') . PHP_EOL .
+                $this->convert_to_table(array_keys($errors), array_values($errors), 2));
+        }
+
+        $this->process->set_form_data($form->get_data());
+        $this->process->process();
+    }
+
+    /**
+     * Mock form submission
+     *
+     * @param array $customdata
+     * @param array $submitteddata
+     * @return \admin_uploaduser_form2
+     */
+    protected function mock_form(array $customdata, array $submitteddata): \admin_uploaduser_form2 {
+        global $USER;
+        $submitteddata['description'] = ['text' => $submitteddata['description'], 'format' => FORMAT_HTML];
+
+        // Now mock the form submission.
+        $submitteddata['_qf__admin_uploaduser_form2'] = 1;
+        $oldignoresesskey = $USER->ignoresesskey ?? null;
+        $USER->ignoresesskey = true;
+        $form = new \admin_uploaduser_form2(null, $customdata, 'post', '', [], true, $submitteddata);
+        $USER->ignoresesskey = $oldignoresesskey;
+
+        $form->set_data($submitteddata);
+        return $form;
+    }
+
+    /**
+     * Prepare form elements for CLI
+     *
+     * @param \HTML_QuickForm_element[] $elements
+     * @param array $defaults
+     * @return array
+     */
+    protected function prepare_form_elements_for_cli(array $elements, array $defaults): array {
+        $options = [];
+        foreach ($elements as $element) {
+            if ($element instanceof \HTML_QuickForm_submit || $element instanceof \HTML_QuickForm_static) {
+                continue;
+            }
+            $type = $element->getType();
+            if ($type === 'html' || $type === 'hidden' || $type === 'header') {
+                continue;
+            }
+
+            $name = $element->getName();
+            if ($name === null || preg_match('/^mform_isexpanded_/', $name)
+                || preg_match('/^_qf__/', $name)) {
+                continue;
+            }
+
+            $label = $element->getLabel();
+            if (!strlen($label) && method_exists($element, 'getText')) {
+                $label = $element->getText();
+            }
+            $default = $defaults[$element->getName()] ?? null;
+
+            $postfix = '';
+            $possiblevalues = null;
+            if ($element instanceof \HTML_QuickForm_select) {
+                $selectoptions = $element->_options;
+                $possiblevalues = [];
+                foreach ($selectoptions as $option) {
+                    $possiblevalues[] = '' . $option['attr']['value'];
+                }
+                if (count($selectoptions) < 10) {
+                    $postfix .= ':';
+                    foreach ($selectoptions as $option) {
+                        $postfix .= "\n  ".$option['attr']['value']." - ".$option['text'];
+                    }
+                }
+                if (!array_key_exists($name, $defaults)) {
+                    $firstoption = reset($selectoptions);
+                    $default = $firstoption['attr']['value'];
+                }
+            }
+
+            if ($element instanceof \HTML_QuickForm_checkbox) {
+                $postfix = ":\n  0|1";
+                $possiblevalues = ['0', '1'];
+            }
+
+            if ($default !== null & $default !== '') {
+                $postfix .= "\n  ".get_string('clidefault', 'tool_uploaduser')." ".$default;
+            }
+            $options[$name] = [
+                'hasvalue' => 'VALUE',
+                'description' => $label.$postfix,
+                'default' => $default,
+            ];
+            if ($possiblevalues !== null) {
+                $options[$name]['validation'] = function($v) use ($possiblevalues, $name) {
+                    if (!in_array('' . $v, $possiblevalues)) {
+                        $this->cli_error(get_string('clierrorargument', 'tool_uploaduser',
+                            (object)['name' => $name, 'values' => join(', ', $possiblevalues)]));
+                    }
+                };
+            }
+        }
+        return $options;
+    }
+
+    /**
+     * Get process statistics.
+     *
+     * @return array
+     */
+    public function get_stats(): array {
+        return $this->process->get_stats();
+    }
+}
diff --git a/admin/tool/uploaduser/classes/local/cli_progress_tracker.php b/admin/tool/uploaduser/classes/local/cli_progress_tracker.php
new file mode 100644 (file)
index 0000000..39c5529
--- /dev/null
@@ -0,0 +1,43 @@
+<?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/>.
+
+/**
+ * Class cli_progress_tracker
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_uploaduser\local;
+
+/**
+ * Tracks the progress of the user upload and outputs it in CLI script (writes to STDOUT)
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class cli_progress_tracker extends text_progress_tracker {
+
+    /**
+     * Output one line (followed by newline)
+     * @param string $line
+     */
+    protected function output_line(string $line): void {
+        cli_writeln($line);
+    }
+}
diff --git a/admin/tool/uploaduser/classes/local/text_progress_tracker.php b/admin/tool/uploaduser/classes/local/text_progress_tracker.php
new file mode 100644 (file)
index 0000000..9e14640
--- /dev/null
@@ -0,0 +1,124 @@
+<?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/>.
+
+/**
+ * Class text_progress_tracker
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_uploaduser\local;
+
+/**
+ * Tracks the progress of the user upload and echos it in a text format
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class text_progress_tracker extends \uu_progress_tracker {
+
+    /**
+     * Print table header.
+     * @return void
+     */
+    public function start() {
+        $this->_row = null;
+    }
+
+    /**
+     * Output one line (followed by newline)
+     * @param string $line
+     */
+    protected function output_line(string $line): void {
+        echo $line . PHP_EOL;
+    }
+
+    /**
+     * Flush previous line and start a new one.
+     * @return void
+     */
+    public function flush() {
+        if (empty($this->_row) or empty($this->_row['line']['normal'])) {
+            // Nothing to print - each line has to have at least number.
+            $this->_row = array();
+            foreach ($this->columns as $col) {
+                $this->_row[$col] = ['normal' => '', 'info' => '', 'warning' => '', 'error' => ''];
+            }
+            return;
+        }
+        $this->output_line(get_string('linex', 'tool_uploaduser', $this->_row['line']['normal']));
+        $prefix = [
+            'normal' => '',
+            'info' => '',
+            'warning' => get_string('warningprefix', 'tool_uploaduser') . ' ',
+            'error' => get_string('errorprefix', 'tool_uploaduser') . ' ',
+        ];
+        foreach ($this->_row['status'] as $type => $content) {
+            if (strlen($content)) {
+                $this->output_line('  '.$prefix[$type].$content);
+            }
+        }
+
+        foreach ($this->_row as $key => $field) {
+            foreach ($field as $type => $content) {
+                if ($key !== 'status' && $type !== 'normal' && strlen($content)) {
+                    $this->output_line('  ' . $prefix[$type] . $this->headers[$key] . ': ' .
+                        str_replace("\n", "\n".str_repeat(" ", strlen($prefix[$type] . $this->headers[$key]) + 4), $content));
+                }
+            }
+        }
+        foreach ($this->columns as $col) {
+            $this->_row[$col] = ['normal' => '', 'info' => '', 'warning' => '', 'error' => ''];
+        }
+    }
+
+    /**
+     * Add tracking info
+     * @param string $col name of column
+     * @param string $msg message
+     * @param string $level 'normal', 'warning' or 'error'
+     * @param bool $merge true means add as new line, false means override all previous text of the same type
+     * @return void
+     */
+    public function track($col, $msg, $level = 'normal', $merge = true) {
+        if (empty($this->_row)) {
+            $this->flush();
+        }
+        if (!in_array($col, $this->columns)) {
+            return;
+        }
+        if ($merge) {
+            if ($this->_row[$col][$level] != '') {
+                $this->_row[$col][$level] .= "\n";
+            }
+            $this->_row[$col][$level] .= $msg;
+        } else {
+            $this->_row[$col][$level] = $msg;
+        }
+    }
+
+    /**
+     * Print the table end
+     * @return void
+     */
+    public function close() {
+        $this->flush();
+        $this->output_line(str_repeat('-', 79));
+    }
+}
diff --git a/admin/tool/uploaduser/classes/preview.php b/admin/tool/uploaduser/classes/preview.php
new file mode 100644 (file)
index 0000000..a593024
--- /dev/null
@@ -0,0 +1,158 @@
+<?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/>.
+
+/**
+ * Class preview
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_uploaduser;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_uploaduser\local\field_value_validators;
+
+require_once($CFG->libdir.'/csvlib.class.php');
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/uploaduser/locallib.php');
+
+/**
+ * Display the preview of a CSV file
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class preview extends \html_table {
+
+    /** @var \csv_import_reader  */
+    protected $cir;
+    /** @var array */
+    protected $filecolumns;
+    /** @var int */
+    protected $previewrows;
+    /** @var bool */
+    protected $noerror = true; // Keep status of any error.
+
+    /**
+     * preview constructor.
+     *
+     * @param \csv_import_reader $cir
+     * @param array $filecolumns
+     * @param int $previewrows
+     * @throws \coding_exception
+     */
+    public function __construct(\csv_import_reader $cir, array $filecolumns, int $previewrows) {
+        parent::__construct();
+        $this->cir = $cir;
+        $this->filecolumns = $filecolumns;
+        $this->previewrows = $previewrows;
+
+        $this->id = "uupreview";
+        $this->attributes['class'] = 'generaltable';
+        $this->tablealign = 'center';
+        $this->summary = get_string('uploaduserspreview', 'tool_uploaduser');
+        $this->head = array();
+        $this->data = $this->read_data();
+
+        $this->head[] = get_string('uucsvline', 'tool_uploaduser');
+        foreach ($filecolumns as $column) {
+            $this->head[] = $column;
+        }
+        $this->head[] = get_string('status');
+
+    }
+
+    /**
+     * Read data
+     *
+     * @return array
+     * @throws \coding_exception
+     * @throws \dml_exception
+     * @throws \moodle_exception
+     */
+    protected function read_data() {
+        global $DB, $CFG;
+
+        $data = array();
+        $this->cir->init();
+        $linenum = 1; // Column header is first line.
+        while ($linenum <= $this->previewrows and $fields = $this->cir->next()) {
+            $linenum++;
+            $rowcols = array();
+            $rowcols['line'] = $linenum;
+            foreach ($fields as $key => $field) {
+                $rowcols[$this->filecolumns[$key]] = s(trim($field));
+            }
+            $rowcols['status'] = array();
+
+            if (isset($rowcols['username'])) {
+                $stdusername = \core_user::clean_field($rowcols['username'], 'username');
+                if ($rowcols['username'] !== $stdusername) {
+                    $rowcols['status'][] = get_string('invalidusernameupload');
+                }
+                if ($userid = $DB->get_field('user', 'id',
+                        ['username' => $stdusername, 'mnethostid' => $CFG->mnet_localhost_id])) {
+                    $rowcols['username'] = \html_writer::link(
+                        new \moodle_url('/user/profile.php', ['id' => $userid]), $rowcols['username']);
+                }
+            } else {
+                $rowcols['status'][] = get_string('missingusername');
+            }
+
+            if (isset($rowcols['email'])) {
+                if (!validate_email($rowcols['email'])) {
+                    $rowcols['status'][] = get_string('invalidemail');
+                }
+
+                $select = $DB->sql_like('email', ':email', false, true, false, '|');
+                $params = array('email' => $DB->sql_like_escape($rowcols['email'], '|'));
+                if ($DB->record_exists_select('user', $select , $params)) {
+                    $rowcols['status'][] = get_string('useremailduplicate', 'error');
+                }
+            }
+
+            if (isset($rowcols['theme'])) {
+                list($status, $message) = field_value_validators::validate_theme($rowcols['theme']);
+                if ($status !== 'normal' && !empty($message)) {
+                    $rowcols['status'][] = $message;
+                }
+            }
+
+            // Check if rowcols have custom profile field with correct data and update error state.
+            $this->noerror = uu_check_custom_profile_data($rowcols) && $this->noerror;
+            $rowcols['status'] = implode('<br />', $rowcols['status']);
+            $data[] = $rowcols;
+        }
+        if ($fields = $this->cir->next()) {
+            $data[] = array_fill(0, count($fields) + 2, '...');
+        }
+        $this->cir->close();
+
+        return $data;
+    }
+
+    /**
+     * Getter for noerror
+     *
+     * @return bool
+     */
+    public function get_no_error() {
+        return $this->noerror;
+    }
+}
\ No newline at end of file
diff --git a/admin/tool/uploaduser/classes/process.php b/admin/tool/uploaduser/classes/process.php
new file mode 100644 (file)
index 0000000..12c2101
--- /dev/null
@@ -0,0 +1,1327 @@
+<?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/>.
+
+/**
+ * Class process
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Moodle
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_uploaduser;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_uploaduser\local\field_value_validators;
+
+require_once($CFG->dirroot.'/user/profile/lib.php');
+require_once($CFG->dirroot.'/user/lib.php');
+require_once($CFG->dirroot.'/group/lib.php');
+require_once($CFG->dirroot.'/cohort/lib.php');
+require_once($CFG->libdir.'/csvlib.class.php');
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/uploaduser/locallib.php');
+
+/**
+ * Process CSV file with users data, this will create/update users, enrol them into courses, etc
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Moodle
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class process {
+
+    /** @var \csv_import_reader  */
+    protected $cir;
+    /** @var \stdClass  */
+    protected $formdata;
+    /** @var \uu_progress_tracker  */
+    protected $upt;
+    /** @var array  */
+    protected $filecolumns = null;
+    /** @var int  */
+    protected $today;
+    /** @var \enrol_plugin|null */
+    protected $manualenrol = null;
+    /** @var array */
+    protected $standardfields = [];
+    /** @var array */
+    protected $profilefields = [];
+    /** @var array */
+    protected $allprofilefields = [];
+    /** @var string|\uu_progress_tracker|null  */
+    protected $progresstrackerclass = null;
+
+    /** @var int */
+    protected $usersnew      = 0;
+    /** @var int */
+    protected $usersupdated  = 0;
+    /** @var int /not printed yet anywhere */
+    protected $usersuptodate = 0;
+    /** @var int */
+    protected $userserrors   = 0;
+    /** @var int */
+    protected $deletes       = 0;
+    /** @var int */
+    protected $deleteerrors  = 0;
+    /** @var int */
+    protected $renames       = 0;
+    /** @var int */
+    protected $renameerrors  = 0;
+    /** @var int */
+    protected $usersskipped  = 0;
+    /** @var int */
+    protected $weakpasswords = 0;
+
+    /** @var array course cache - do not fetch all courses here, we  will not probably use them all anyway */
+    protected $ccache         = [];
+    /** @var array */
+    protected $cohorts        = [];
+    /** @var array  Course roles lookup cache. */
+    protected $rolecache      = [];
+    /** @var array System roles lookup cache. */
+    protected $sysrolecache   = [];
+    /** @var array cache of used manual enrol plugins in each course */
+    protected $manualcache    = [];
+    /** @var array officially supported plugins that are enabled */
+    protected $supportedauths = [];
+
+    /**
+     * process constructor.
+     *
+     * @param \csv_import_reader $cir
+     * @param string|null $progresstrackerclass
+     * @throws \coding_exception
+     */
+    public function __construct(\csv_import_reader $cir, string $progresstrackerclass = null) {
+        $this->cir = $cir;
+        if ($progresstrackerclass) {
+            if (!class_exists($progresstrackerclass) || !is_subclass_of($progresstrackerclass, \uu_progress_tracker::class)) {
+                throw new \coding_exception('Progress tracker class must extend \uu_progress_tracker');
+            }
+            $this->progresstrackerclass = $progresstrackerclass;
+        } else {
+            $this->progresstrackerclass = \uu_progress_tracker::class;
+        }
+
+        // Keep timestamp consistent.
+        $today = time();
+        $today = make_timestamp(date('Y', $today), date('m', $today), date('d', $today), 0, 0, 0);
+        $this->today = $today;
+
+        $this->rolecache      = uu_allowed_roles_cache(); // Course roles lookup cache.
+        $this->sysrolecache   = uu_allowed_sysroles_cache(); // System roles lookup cache.
+        $this->supportedauths = uu_supported_auths(); // Officially supported plugins that are enabled.
+
+        if (enrol_is_enabled('manual')) {
+            // We use only manual enrol plugin here, if it is disabled no enrol is done.
+            $this->manualenrol = enrol_get_plugin('manual');
+        }
+
+        $this->find_profile_fields();
+        $this->find_standard_fields();
+    }
+
+    /**
+     * Standard user fields.
+     */
+    protected function find_standard_fields(): void {
+        $this->standardfields = array('id', 'username', 'email', 'emailstop',
+            'city', 'country', 'lang', 'timezone', 'mailformat',
+            'maildisplay', 'maildigest', 'htmleditor', 'autosubscribe',
+            'institution', 'department', 'idnumber', 'skype',
+            'msn', 'aim', 'yahoo', 'icq', 'phone1', 'phone2', 'address',
+            'url', 'description', 'descriptionformat', 'password',
+            'auth',        // Watch out when changing auth type or using external auth plugins!
+            'oldusername', // Use when renaming users - this is the original username.
+            'suspended',   // 1 means suspend user account, 0 means activate user account, nothing means keep as is.
+            'theme',       // Define a theme for user when 'allowuserthemes' is enabled.
+            'deleted',     // 1 means delete user
+            'mnethostid',  // Can not be used for adding, updating or deleting of users - only for enrolments,
+                           // groups, cohorts and suspending.
+            'interests',
+        );
+        // Include all name fields.
+        $this->standardfields = array_merge($this->standardfields, get_all_user_name_fields());
+    }
+
+    /**
+     * Profile fields
+     */
+    protected function find_profile_fields(): void {
+        global $DB;
+        $this->allprofilefields = $DB->get_records('user_info_field');
+        $this->profilefields = [];
+        if ($proffields = $this->allprofilefields) {
+            foreach ($proffields as $key => $proffield) {
+                $profilefieldname = 'profile_field_'.$proffield->shortname;
+                $this->profilefields[] = $profilefieldname;
+                // Re-index $proffields with key as shortname. This will be
+                // used while checking if profile data is key and needs to be converted (eg. menu profile field).
+                $proffields[$profilefieldname] = $proffield;
+                unset($proffields[$key]);
+            }
+            $this->allprofilefields = $proffields;
+        }
+    }
+
+    /**
+     * Returns the list of columns in the file
+     *
+     * @return array
+     */
+    public function get_file_columns(): array {
+        if ($this->filecolumns === null) {
+            $returnurl = new \moodle_url('/admin/tool/uploaduser/index.php');
+            $this->filecolumns = uu_validate_user_upload_columns($this->cir,
+                $this->standardfields, $this->profilefields, $returnurl);
+        }
+        return $this->filecolumns;
+    }
+
+    /**
+     * Set data from the form (or from CLI options)
+     *
+     * @param \stdClass $formdata
+     */
+    public function set_form_data(\stdClass $formdata): void {
+        global $SESSION;
+        $this->formdata = $formdata;
+
+        // Clear bulk selection.
+        if ($this->get_bulk()) {
+            $SESSION->bulk_users = array();
+        }
+    }
+
+    /**
+     * Operation type
+     * @return int
+     */
+    protected function get_operation_type(): int {
+        return (int)$this->formdata->uutype;
+    }
+
+    /**
+     * Setting to allow deletes
+     * @return bool
+     */
+    protected function get_allow_deletes(): bool {
+        $optype = $this->get_operation_type();
+        return (!empty($this->formdata->uuallowdeletes) and $optype != UU_USER_ADDNEW and $optype != UU_USER_ADDINC);
+    }
+
+    /**
+     * Setting to allow deletes
+     * @return bool
+     */
+    protected function get_allow_renames(): bool {
+        $optype = $this->get_operation_type();
+        return (!empty($this->formdata->uuallowrenames) and $optype != UU_USER_ADDNEW and $optype != UU_USER_ADDINC);
+    }
+
+    /**
+     * Setting to select for bulk actions (not available in CLI)
+     * @return bool
+     */
+    public function get_bulk(): bool {
+        return $this->formdata->uubulk ?? false;
+    }
+
+    /**
+     * Setting for update type
+     * @return int
+     */
+    protected function get_update_type(): int {
+        return isset($this->formdata->uuupdatetype) ? $this->formdata->uuupdatetype : 0;
+    }
+
+    /**
+     * Setting to allow update passwords
+     * @return bool
+     */
+    protected function get_update_passwords(): bool {
+        return !empty($this->formdata->uupasswordold)
+            and $this->get_operation_type() != UU_USER_ADDNEW
+            and $this->get_operation_type() != UU_USER_ADDINC
+            and ($this->get_update_type() == UU_UPDATE_FILEOVERRIDE or $this->get_update_type() == UU_UPDATE_ALLOVERRIDE);
+    }
+
+    /**
+     * Setting to allow email duplicates
+     * @return bool
+     */
+    protected function get_allow_email_duplicates(): bool {
+        global $CFG;
+        return !(empty($CFG->allowaccountssameemail) ? 1 : $this->formdata->uunoemailduplicates);
+    }
+
+    /**
+     * Setting for reset password
+     * @return int UU_PWRESET_NONE, UU_PWRESET_WEAK, UU_PWRESET_ALL
+     */
+    protected function get_reset_passwords(): int {
+        return isset($this->formdata->uuforcepasswordchange) ? $this->formdata->uuforcepasswordchange : UU_PWRESET_NONE;
+    }
+
+    /**
+     * Setting to allow create passwords
+     * @return bool
+     */
+    protected function get_create_paswords(): bool {
+        return (!empty($this->formdata->uupasswordnew) and $this->get_operation_type() != UU_USER_UPDATE);
+    }
+
+    /**
+     * Setting to allow suspends
+     * @return bool
+     */
+    protected function get_allow_suspends(): bool {
+        return !empty($this->formdata->uuallowsuspends);
+    }
+
+    /**
+     * Setting to normalise user names
+     * @return bool
+     */
+    protected function get_normalise_user_names(): bool {
+        return !empty($this->formdata->uustandardusernames);
+    }
+
+    /**
+     * Helper method to return Yes/No string
+     *
+     * @param bool $value
+     * @return string
+     */
+    protected function get_string_yes_no($value): string {
+        return $value ? get_string('yes') : get_string('no');
+    }
+
+    /**
+     * Process the CSV file
+     */
+    public function process() {
+        // Init csv import helper.
+        $this->cir->init();
+
+        $classname = $this->progresstrackerclass;
+        $this->upt = new $classname();
+        $this->upt->start(); // Start table.
+
+        $linenum = 1; // Column header is first line.
+        while ($line = $this->cir->next()) {
+            $this->upt->flush();
+            $linenum++;
+
+            $this->upt->track('line', $linenum);
+            $this->process_line($line);
+        }
+
+        $this->upt->close(); // Close table.
+        $this->cir->close();
+        $this->cir->cleanup(true);
+    }
+
+    /**
+     * Prepare one line from CSV file as a user record
+     *
+     * @param array $line
+     * @return \stdClass|null
+     */
+    protected function prepare_user_record(array $line): ?\stdClass {
+        global $CFG, $USER;
+
+        $user = new \stdClass();
+
+        // Add fields to user object.
+        foreach ($line as $keynum => $value) {
+            if (!isset($this->get_file_columns()[$keynum])) {
+                // This should not happen.
+                continue;
+            }
+            $key = $this->get_file_columns()[$keynum];
+            if (strpos($key, 'profile_field_') === 0) {
+                // NOTE: bloody mega hack alert!!
+                if (isset($USER->$key) and is_array($USER->$key)) {
+                    // This must be some hacky field that is abusing arrays to store content and format.
+                    $user->$key = array();
+                    $user->{$key['text']}   = $value;
+                    $user->{$key['format']} = FORMAT_MOODLE;
+                } else {
+                    $user->$key = trim($value);
+                }
+            } else {
+                $user->$key = trim($value);
+            }
+
+            if (in_array($key, $this->upt->columns)) {
+                // Default value in progress tracking table, can be changed later.
+                $this->upt->track($key, s($value), 'normal');
+            }
+        }
+        if (!isset($user->username)) {
+            // Prevent warnings below.
+            $user->username = '';
+        }
+
+        if ($this->get_operation_type() == UU_USER_ADDNEW or $this->get_operation_type() == UU_USER_ADDINC) {
+            // User creation is a special case - the username may be constructed from templates using firstname and lastname
+            // better never try this in mixed update types.
+            $error = false;
+            if (!isset($user->firstname) or $user->firstname === '') {
+                $this->upt->track('status', get_string('missingfield', 'error', 'firstname'), 'error');
+                $this->upt->track('firstname', get_string('error'), 'error');
+                $error = true;
+            }
+            if (!isset($user->lastname) or $user->lastname === '') {
+                $this->upt->track('status', get_string('missingfield', 'error', 'lastname'), 'error');
+                $this->upt->track('lastname', get_string('error'), 'error');
+                $error = true;
+            }
+            if ($error) {
+                $this->userserrors++;
+                return null;
+            }
+            // We require username too - we might use template for it though.
+            if (empty($user->username) and !empty($this->formdata->username)) {
+                $user->username = uu_process_template($this->formdata->username, $user);
+                $this->upt->track('username', s($user->username));
+            }
+        }
+
+        // Normalize username.
+        $user->originalusername = $user->username;
+        if ($this->get_normalise_user_names()) {
+            $user->username = \core_user::clean_field($user->username, 'username');
+        }
+
+        // Make sure we really have username.
+        if (empty($user->username)) {
+            $this->upt->track('status', get_string('missingfield', 'error', 'username'), 'error');
+            $this->upt->track('username', get_string('error'), 'error');
+            $this->userserrors++;
+            return null;
+        } else if ($user->username === 'guest') {
+            $this->upt->track('status', get_string('guestnoeditprofileother', 'error'), 'error');
+            $this->userserrors++;
+            return null;
+        }
+
+        if ($user->username !== \core_user::clean_field($user->username, 'username')) {
+            $this->upt->track('status', get_string('invalidusername', 'error', 'username'), 'error');
+            $this->upt->track('username', get_string('error'), 'error');
+            $this->userserrors++;
+        }
+
+        if (empty($user->mnethostid)) {
+            $user->mnethostid = $CFG->mnet_localhost_id;
+        }
+
+        return $user;
+    }
+
+    /**
+     * Process one line from CSV file
+     *
+     * @param array $line
+     * @throws \coding_exception
+     * @throws \dml_exception
+     * @throws \moodle_exception
+     */
+    public function process_line(array $line) {
+        global $DB, $CFG, $SESSION;
+
+        if (!$user = $this->prepare_user_record($line)) {
+            return;
+        }
+
+        if ($existinguser = $DB->get_record('user', ['username' => $user->username, 'mnethostid' => $user->mnethostid])) {
+            $this->upt->track('id', $existinguser->id, 'normal', false);
+        }
+
+        if ($user->mnethostid == $CFG->mnet_localhost_id) {
+            $remoteuser = false;
+
+            // Find out if username incrementing required.
+            if ($existinguser and $this->get_operation_type() == UU_USER_ADDINC) {
+                $user->username = uu_increment_username($user->username);
+                $existinguser = false;
+            }
+
+        } else {
+            if (!$existinguser or $this->get_operation_type() == UU_USER_ADDINC) {
+                $this->upt->track('status', get_string('errormnetadd', 'tool_uploaduser'), 'error');
+                $this->userserrors++;
+                return;
+            }
+
+            $remoteuser = true;
+
+            // Make sure there are no changes of existing fields except the suspended status.
+            foreach ((array)$existinguser as $k => $v) {
+                if ($k === 'suspended') {
+                    continue;
+                }
+                if (property_exists($user, $k)) {
+                    $user->$k = $v;
+                }
+                if (in_array($k, $this->upt->columns)) {
+                    if ($k === 'password' or $k === 'oldusername' or $k === 'deleted') {
+                        $this->upt->track($k, '', 'normal', false);
+                    } else {
+                        $this->upt->track($k, s($v), 'normal', false);
+                    }
+                }
+            }
+            unset($user->oldusername);
+            unset($user->password);
+            $user->auth = $existinguser->auth;
+        }
+
+        // Notify about nay username changes.
+        if ($user->originalusername !== $user->username) {
+            $this->upt->track('username', '', 'normal', false); // Clear previous.
+            $this->upt->track('username', s($user->originalusername).'-->'.s($user->username), 'info');
+        } else {
+            $this->upt->track('username', s($user->username), 'normal', false);
+        }
+        unset($user->originalusername);
+
+        // Verify if the theme is valid and allowed to be set.
+        if (isset($user->theme)) {
+            list($status, $message) = field_value_validators::validate_theme($user->theme);
+            if ($status !== 'normal' && !empty($message)) {
+                $this->upt->track('status', $message, $status);
+                // Unset the theme when validation fails.
+                unset($user->theme);
+            }
+        }
+
+        // Add default values for remaining fields.
+        $formdefaults = array();
+        if (!$existinguser ||
+                ($this->get_update_type() != UU_UPDATE_FILEOVERRIDE && $this->get_update_type() != UU_UPDATE_NOCHANGES)) {
+            foreach ($this->standardfields as $field) {
+                if (isset($user->$field)) {
+                    continue;
+                }
+                // All validation moved to form2.
+                if (isset($this->formdata->$field)) {
+                    // Process templates.
+                    $user->$field = uu_process_template($this->formdata->$field, $user);
+                    $formdefaults[$field] = true;
+                    if (in_array($field, $this->upt->columns)) {
+                        $this->upt->track($field, s($user->$field), 'normal');
+                    }
+                }
+            }
+            $proffields = $this->allprofilefields;
+            foreach ($this->profilefields as $field) {
+                if (isset($user->$field)) {
+                    continue;
+                }
+                if (isset($this->formdata->$field)) {
+                    // Process templates.
+                    $user->$field = uu_process_template($this->formdata->$field, $user);
+
+                    // Form contains key and later code expects value.
+                    // Convert key to value for required profile fields.
+                    require_once($CFG->dirroot.'/user/profile/field/'.$proffields[$field]->datatype.'/field.class.php');
+                    $profilefieldclass = 'profile_field_'.$proffields[$field]->datatype;
+                    $profilefield = new $profilefieldclass($proffields[$field]->id);
+                    if (method_exists($profilefield, 'convert_external_data')) {
+                        $user->$field = $profilefield->edit_save_data_preprocess($user->$field, null);
+                    }
+
+                    $formdefaults[$field] = true;
+                }
+            }
+        }
+
+        // Delete user.
+        if (!empty($user->deleted)) {
+            if (!$this->get_allow_deletes() or $remoteuser) {
+                $this->usersskipped++;
+                $this->upt->track('status', get_string('usernotdeletedoff', 'error'), 'warning');
+                return;
+            }
+            if ($existinguser) {
+                if (is_siteadmin($existinguser->id)) {
+                    $this->upt->track('status', get_string('usernotdeletedadmin', 'error'), 'error');
+                    $this->deleteerrors++;
+                    return;
+                }
+                if (delete_user($existinguser)) {
+                    $this->upt->track('status', get_string('userdeleted', 'tool_uploaduser'));
+                    $this->deletes++;
+                } else {
+                    $this->upt->track('status', get_string('usernotdeletederror', 'error'), 'error');
+                    $this->deleteerrors++;
+                }
+            } else {
+                $this->upt->track('status', get_string('usernotdeletedmissing', 'error'), 'error');
+                $this->deleteerrors++;
+            }
+            return;
+        }
+        // We do not need the deleted flag anymore.
+        unset($user->deleted);
+
+        // Renaming requested?
+        if (!empty($user->oldusername) ) {
+            if (!$this->get_allow_renames()) {
+                $this->usersskipped++;
+                $this->upt->track('status', get_string('usernotrenamedoff', 'error'), 'warning');
+                return;
+            }
+
+            if ($existinguser) {
+                $this->upt->track('status', get_string('usernotrenamedexists', 'error'), 'error');
+                $this->renameerrors++;
+                return;
+            }
+
+            if ($user->username === 'guest') {
+                $this->upt->track('status', get_string('guestnoeditprofileother', 'error'), 'error');
+                $this->renameerrors++;
+                return;
+            }
+
+            if ($this->get_normalise_user_names()) {
+                $oldusername = \core_user::clean_field($user->oldusername, 'username');
+            } else {
+                $oldusername = $user->oldusername;
+            }
+
+            // No guessing when looking for old username, it must be exact match.
+            if ($olduser = $DB->get_record('user',
+                    ['username' => $oldusername, 'mnethostid' => $CFG->mnet_localhost_id])) {
+                $this->upt->track('id', $olduser->id, 'normal', false);
+                if (is_siteadmin($olduser->id)) {
+                    $this->upt->track('status', get_string('usernotrenamedadmin', 'error'), 'error');
+                    $this->renameerrors++;
+                    return;
+                }
+                $DB->set_field('user', 'username', $user->username, ['id' => $olduser->id]);
+                $this->upt->track('username', '', 'normal', false); // Clear previous.
+                $this->upt->track('username', s($oldusername).'-->'.s($user->username), 'info');
+                $this->upt->track('status', get_string('userrenamed', 'tool_uploaduser'));
+                $this->renames++;
+            } else {
+                $this->upt->track('status', get_string('usernotrenamedmissing', 'error'), 'error');
+                $this->renameerrors++;
+                return;
+            }
+            $existinguser = $olduser;
+            $existinguser->username = $user->username;
+        }
+
+        // Can we process with update or insert?
+        $skip = false;
+        switch ($this->get_operation_type()) {
+            case UU_USER_ADDNEW:
+                if ($existinguser) {
+                    $this->usersskipped++;
+                    $this->upt->track('status', get_string('usernotaddedregistered', 'error'), 'warning');
+                    $skip = true;
+                }
+                break;
+
+            case UU_USER_ADDINC:
+                if ($existinguser) {
+                    // This should not happen!
+                    $this->upt->track('status', get_string('usernotaddederror', 'error'), 'error');
+                    $this->userserrors++;
+                    $skip = true;
+                }
+                break;
+
+            case UU_USER_ADD_UPDATE:
+                break;
+
+            case UU_USER_UPDATE:
+                if (!$existinguser) {
+                    $this->usersskipped++;
+                    $this->upt->track('status', get_string('usernotupdatednotexists', 'error'), 'warning');
+                    $skip = true;
+                }
+                break;
+
+            default:
+                // Unknown type.
+                $skip = true;
+        }
+
+        if ($skip) {
+            return;
+        }
+
+        if ($existinguser) {
+            $user->id = $existinguser->id;
+
+            $this->upt->track('username', \html_writer::link(
+                new \moodle_url('/user/profile.php', ['id' => $existinguser->id]), s($existinguser->username)), 'normal', false);
+            $this->upt->track('suspended', $this->get_string_yes_no($existinguser->suspended) , 'normal', false);
+            $this->upt->track('auth', $existinguser->auth, 'normal', false);
+
+            if (is_siteadmin($user->id)) {
+                $this->upt->track('status', get_string('usernotupdatedadmin', 'error'), 'error');
+                $this->userserrors++;
+                return;
+            }
+
+            $existinguser->timemodified = time();
+            // Do NOT mess with timecreated or firstaccess here!
+
+            // Load existing profile data.
+            profile_load_data($existinguser);
+
+            $doupdate = false;
+            $dologout = false;
+
+            if ($this->get_update_type() != UU_UPDATE_NOCHANGES and !$remoteuser) {
+                if (!empty($user->auth) and $user->auth !== $existinguser->auth) {
+                    $this->upt->track('auth', s($existinguser->auth).'-->'.s($user->auth), 'info', false);
+                    $existinguser->auth = $user->auth;
+                    if (!isset($this->supportedauths[$user->auth])) {
+                        $this->upt->track('auth', get_string('userauthunsupported', 'error'), 'warning');
+                    }
+                    $doupdate = true;
+                    if ($existinguser->auth === 'nologin') {
+                        $dologout = true;
+                    }
+                }
+                $allcolumns = array_merge($this->standardfields, $this->profilefields);
+                foreach ($allcolumns as $column) {
+                    if ($column === 'username' or $column === 'password' or $column === 'auth' or $column === 'suspended') {
+                        // These can not be changed here.
+                        continue;
+                    }
+                    if (!property_exists($user, $column) or !property_exists($existinguser, $column)) {
+                        continue;
+                    }
+                    if ($this->get_update_type() == UU_UPDATE_MISSING) {
+                        if (!is_null($existinguser->$column) and $existinguser->$column !== '') {
+                            continue;
+                        }
+                    } else if ($this->get_update_type() == UU_UPDATE_ALLOVERRIDE) {
+                        // We override everything.
+                        null;
+                    } else if ($this->get_update_type() == UU_UPDATE_FILEOVERRIDE) {
+                        if (!empty($formdefaults[$column])) {
+                            // Do not override with form defaults.
+                            continue;
+                        }
+                    }
+                    if ($existinguser->$column !== $user->$column) {
+                        if ($column === 'email') {
+                            $select = $DB->sql_like('email', ':email', false, true, false, '|');
+                            $params = array('email' => $DB->sql_like_escape($user->email, '|'));
+                            if ($DB->record_exists_select('user', $select , $params)) {
+
+                                $changeincase = \core_text::strtolower($existinguser->$column) === \core_text::strtolower(
+                                        $user->$column);
+
+                                if ($changeincase) {
+                                    // If only case is different then switch to lower case and carry on.
+                                    $user->$column = \core_text::strtolower($user->$column);
+                                    continue;
+                                } else if (!$this->get_allow_email_duplicates()) {
+                                    $this->upt->track('email', get_string('useremailduplicate', 'error'), 'error');
+                                    $this->upt->track('status', get_string('usernotupdatederror', 'error'), 'error');
+                                    $this->userserrors++;
+                                    return;
+                                } else {
+                                    $this->upt->track('email', get_string('useremailduplicate', 'error'), 'warning');
+                                }
+                            }
+                            if (!validate_email($user->email)) {
+                                $this->upt->track('email', get_string('invalidemail'), 'warning');
+                            }
+                        }
+
+                        if ($column === 'lang') {
+                            if (empty($user->lang)) {
+                                // Do not change to not-set value.
+                                continue;
+                            } else if (\core_user::clean_field($user->lang, 'lang') === '') {
+                                $this->upt->track('status', get_string('cannotfindlang', 'error', $user->lang), 'warning');
+                                continue;
+                            }
+                        }
+
+                        if (in_array($column, $this->upt->columns)) {
+                            $this->upt->track($column, s($existinguser->$column).'-->'.s($user->$column), 'info', false);
+                        }
+                        $existinguser->$column = $user->$column;
+                        $doupdate = true;
+                    }
+                }
+            }
+
+            try {
+                $auth = get_auth_plugin($existinguser->auth);
+            } catch (\Exception $e) {
+                $this->upt->track('auth', get_string('userautherror', 'error', s($existinguser->auth)), 'error');
+                $this->upt->track('status', get_string('usernotupdatederror', 'error'), 'error');
+                $this->userserrors++;
+                return;
+            }
+            $isinternalauth = $auth->is_internal();
+
+            // Deal with suspending and activating of accounts.
+            if ($this->get_allow_suspends() and isset($user->suspended) and $user->suspended !== '') {
+                $user->suspended = $user->suspended ? 1 : 0;
+                if ($existinguser->suspended != $user->suspended) {
+                    $this->upt->track('suspended', '', 'normal', false);
+                    $this->upt->track('suspended',
+                        $this->get_string_yes_no($existinguser->suspended).'-->'.$this->get_string_yes_no($user->suspended),
+                        'info', false);
+                    $existinguser->suspended = $user->suspended;
+                    $doupdate = true;
+                    if ($existinguser->suspended) {
+                        $dologout = true;
+                    }
+                }
+            }
+
+            // Changing of passwords is a special case
+            // do not force password changes for external auth plugins!
+            $oldpw = $existinguser->password;
+
+            if ($remoteuser) {
+                // Do not mess with passwords of remote users.
+                null;
+            } else if (!$isinternalauth) {
+                $existinguser->password = AUTH_PASSWORD_NOT_CACHED;
+                $this->upt->track('password', '-', 'normal', false);
+                // Clean up prefs.
+                unset_user_preference('create_password', $existinguser);
+                unset_user_preference('auth_forcepasswordchange', $existinguser);
+
+            } else if (!empty($user->password)) {
+                if ($this->get_update_passwords()) {
+                    // Check for passwords that we want to force users to reset next
+                    // time they log in.
+                    $errmsg = null;
+                    $weak = !check_password_policy($user->password, $errmsg, $user);
+                    if ($this->get_reset_passwords() == UU_PWRESET_ALL or
+                            ($this->get_reset_passwords() == UU_PWRESET_WEAK and $weak)) {
+                        if ($weak) {
+                            $this->weakpasswords++;
+                            $this->upt->track('password', get_string('invalidpasswordpolicy', 'error'), 'warning');
+                        }
+                        set_user_preference('auth_forcepasswordchange', 1, $existinguser);
+                    } else {
+                        unset_user_preference('auth_forcepasswordchange', $existinguser);
+                    }
+                    unset_user_preference('create_password', $existinguser); // No need to create password any more.
+
+                    // Use a low cost factor when generating bcrypt hash otherwise
+                    // hashing would be slow when uploading lots of users. Hashes
+                    // will be automatically updated to a higher cost factor the first
+                    // time the user logs in.
+                    $existinguser->password = hash_internal_user_password($user->password, true);
+                    $this->upt->track('password', $user->password, 'normal', false);
+                } else {
+                    // Do not print password when not changed.
+                    $this->upt->track('password', '', 'normal', false);
+                }
+            }
+
+            if ($doupdate or $existinguser->password !== $oldpw) {
+                // We want only users that were really updated.
+                user_update_user($existinguser, false, false);
+
+                $this->upt->track('status', get_string('useraccountupdated', 'tool_uploaduser'));
+                $this->usersupdated++;
+
+                if (!$remoteuser) {
+                    // Pre-process custom profile menu fields data from csv file.
+                    $existinguser = uu_pre_process_custom_profile_data($existinguser);
+                    // Save custom profile fields data from csv file.
+                    profile_save_data($existinguser);
+                }
+
+                if ($this->get_bulk() == UU_BULK_UPDATED or $this->get_bulk() == UU_BULK_ALL) {
+                    if (!in_array($user->id, $SESSION->bulk_users)) {
+                        $SESSION->bulk_users[] = $user->id;
+                    }
+                }
+
+                // Trigger event.
+                \core\event\user_updated::create_from_userid($existinguser->id)->trigger();
+
+            } else {
+                // No user information changed.
+                $this->upt->track('status', get_string('useraccountuptodate', 'tool_uploaduser'));
+                $this->usersuptodate++;
+
+                if ($this->get_bulk() == UU_BULK_ALL) {
+                    if (!in_array($user->id, $SESSION->bulk_users)) {
+                        $SESSION->bulk_users[] = $user->id;
+                    }
+                }
+            }
+
+            if ($dologout) {
+                \core\session\manager::kill_user_sessions($existinguser->id);
+            }
+
+        } else {
+            // Save the new user to the database.
+            $user->confirmed    = 1;
+            $user->timemodified = time();
+            $user->timecreated  = time();
+            $user->mnethostid   = $CFG->mnet_localhost_id; // We support ONLY local accounts here, sorry.
+
+            if (!isset($user->suspended) or $user->suspended === '') {
+                $user->suspended = 0;
+            } else {
+                $user->suspended = $user->suspended ? 1 : 0;
+            }
+            $this->upt->track('suspended', $this->get_string_yes_no($user->suspended), 'normal', false);
+
+            if (empty($user->auth)) {
+                $user->auth = 'manual';
+            }
+            $this->upt->track('auth', $user->auth, 'normal', false);
+
+            // Do not insert record if new auth plugin does not exist!
+            try {
+                $auth = get_auth_plugin($user->auth);
+            } catch (\Exception $e) {
+                $this->upt->track('auth', get_string('userautherror', 'error', s($user->auth)), 'error');
+                $this->upt->track('status', get_string('usernotaddederror', 'error'), 'error');
+                $this->userserrors++;
+                return;
+            }
+            if (!isset($this->supportedauths[$user->auth])) {
+                $this->upt->track('auth', get_string('userauthunsupported', 'error'), 'warning');
+            }
+
+            $isinternalauth = $auth->is_internal();
+
+            if (empty($user->email)) {
+                $this->upt->track('email', get_string('invalidemail'), 'error');
+                $this->upt->track('status', get_string('usernotaddederror', 'error'), 'error');
+                $this->userserrors++;
+                return;
+
+            } else if ($DB->record_exists('user', ['email' => $user->email])) {
+                if (!$this->get_allow_email_duplicates()) {
+                    $this->upt->track('email', get_string('useremailduplicate', 'error'), 'error');
+                    $this->upt->track('status', get_string('usernotaddederror', 'error'), 'error');
+                    $this->userserrors++;
+                    return;
+                } else {
+                    $this->upt->track('email', get_string('useremailduplicate', 'error'), 'warning');
+                }
+            }
+            if (!validate_email($user->email)) {
+                $this->upt->track('email', get_string('invalidemail'), 'warning');
+            }
+
+            if (empty($user->lang)) {
+                $user->lang = '';
+            } else if (\core_user::clean_field($user->lang, 'lang') === '') {
+                $this->upt->track('status', get_string('cannotfindlang', 'error', $user->lang), 'warning');
+                $user->lang = '';
+            }
+
+            $forcechangepassword = false;
+
+            if ($isinternalauth) {
+                if (empty($user->password)) {
+                    if ($this->get_create_paswords()) {
+                        $user->password = 'to be generated';
+                        $this->upt->track('password', '', 'normal', false);
+                        $this->upt->track('password', get_string('uupasswordcron', 'tool_uploaduser'), 'warning', false);
+                    } else {
+                        $this->upt->track('password', '', 'normal', false);
+                        $this->upt->track('password', get_string('missingfield', 'error', 'password'), 'error');
+                        $this->upt->track('status', get_string('usernotaddederror', 'error'), 'error');
+                        $this->userserrors++;
+                        return;
+                    }
+                } else {
+                    $errmsg = null;
+                    $weak = !check_password_policy($user->password, $errmsg, $user);
+                    if ($this->get_reset_passwords() == UU_PWRESET_ALL or
+                            ($this->get_reset_passwords() == UU_PWRESET_WEAK and $weak)) {
+                        if ($weak) {
+                            $this->weakpasswords++;
+                            $this->upt->track('password', get_string('invalidpasswordpolicy', 'error'), 'warning');
+                        }
+                        $forcechangepassword = true;
+                    }
+                    // Use a low cost factor when generating bcrypt hash otherwise
+                    // hashing would be slow when uploading lots of users. Hashes
+                    // will be automatically updated to a higher cost factor the first
+                    // time the user logs in.
+                    $user->password = hash_internal_user_password($user->password, true);
+                }
+            } else {
+                $user->password = AUTH_PASSWORD_NOT_CACHED;
+                $this->upt->track('password', '-', 'normal', false);
+            }
+
+            $user->id = user_create_user($user, false, false);
+            $this->upt->track('username', \html_writer::link(
+                new \moodle_url('/user/profile.php', ['id' => $user->id]), s($user->username)), 'normal', false);
+
+            // Pre-process custom profile menu fields data from csv file.
+            $user = uu_pre_process_custom_profile_data($user);
+            // Save custom profile fields data.
+            profile_save_data($user);
+
+            if ($forcechangepassword) {
+                set_user_preference('auth_forcepasswordchange', 1, $user);
+            }
+            if ($user->password === 'to be generated') {
+                set_user_preference('create_password', 1, $user);
+            }
+
+            // Trigger event.
+            \core\event\user_created::create_from_userid($user->id)->trigger();
+
+            $this->upt->track('status', get_string('newuser'));
+            $this->upt->track('id', $user->id, 'normal', false);
+            $this->usersnew++;
+
+            // Make sure user context exists.
+            \context_user::instance($user->id);
+
+            if ($this->get_bulk() == UU_BULK_NEW or $this->get_bulk() == UU_BULK_ALL) {
+                if (!in_array($user->id, $SESSION->bulk_users)) {
+                    $SESSION->bulk_users[] = $user->id;
+                }
+            }
+        }
+
+        // Update user interests.
+        if (isset($user->interests) && strval($user->interests) !== '') {
+            useredit_update_interests($user, preg_split('/\s*,\s*/', $user->interests, -1, PREG_SPLIT_NO_EMPTY));
+        }
+
+        // Add to cohort first, it might trigger enrolments indirectly - do NOT create cohorts here!
+        foreach ($this->get_file_columns() as $column) {
+            if (!preg_match('/^cohort\d+$/', $column)) {
+                continue;
+            }
+
+            if (!empty($user->$column)) {
+                $addcohort = $user->$column;
+                if (!isset($this->cohorts[$addcohort])) {
+                    if (is_number($addcohort)) {
+                        // Only non-numeric idnumbers!
+                        $cohort = $DB->get_record('cohort', ['id' => $addcohort]);
+                    } else {
+                        $cohort = $DB->get_record('cohort', ['idnumber' => $addcohort]);
+                        if (empty($cohort) && has_capability('moodle/cohort:manage', \context_system::instance())) {
+                            // Cohort was not found. Create a new one.
+                            $cohortid = cohort_add_cohort((object)array(
+                                'idnumber' => $addcohort,
+                                'name' => $addcohort,
+                                'contextid' => \context_system::instance()->id
+                            ));
+                            $cohort = $DB->get_record('cohort', ['id' => $cohortid]);
+                        }
+                    }
+
+                    if (empty($cohort)) {
+                        $this->cohorts[$addcohort] = get_string('unknowncohort', 'core_cohort', s($addcohort));
+                    } else if (!empty($cohort->component)) {
+                        // Cohorts synchronised with external sources must not be modified!
+                        $this->cohorts[$addcohort] = get_string('external', 'core_cohort');
+                    } else {
+                        $this->cohorts[$addcohort] = $cohort;
+                    }
+                }
+
+                if (is_object($this->cohorts[$addcohort])) {
+                    $cohort = $this->cohorts[$addcohort];
+                    if (!$DB->record_exists('cohort_members', ['cohortid' => $cohort->id, 'userid' => $user->id])) {
+                        cohort_add_member($cohort->id, $user->id);
+                        // We might add special column later, for now let's abuse enrolments.
+                        $this->upt->track('enrolments', get_string('useradded', 'core_cohort', s($cohort->name)), 'info');
+                    }
+                } else {
+                    // Error message.
+                    $this->upt->track('enrolments', $this->cohorts[$addcohort], 'error');
+                }
+            }
+        }
+
+        // Find course enrolments, groups, roles/types and enrol periods
+        // this is again a special case, we always do this for any updated or created users.
+        foreach ($this->get_file_columns() as $column) {
+            if (preg_match('/^sysrole\d+$/', $column)) {
+
+                if (!empty($user->$column)) {
+                    $sysrolename = $user->$column;
+                    if ($sysrolename[0] == '-') {
+                        $removing = true;
+                        $sysrolename = substr($sysrolename, 1);
+                    } else {
+                        $removing = false;
+                    }
+
+                    if (array_key_exists($sysrolename, $this->sysrolecache)) {
+                        $sysroleid = $this->sysrolecache[$sysrolename]->id;
+                    } else {
+                        $this->upt->track('enrolments', get_string('unknownrole', 'error', s($sysrolename)), 'error');
+                        continue;
+                    }
+
+                    if ($removing) {
+                        if (user_has_role_assignment($user->id, $sysroleid, SYSCONTEXTID)) {
+                            role_unassign($sysroleid, $user->id, SYSCONTEXTID);
+                            $this->upt->track('enrolments', get_string('unassignedsysrole',
+                                'tool_uploaduser', $this->sysrolecache[$sysroleid]->name), 'info');
+                        }
+                    } else {
+                        if (!user_has_role_assignment($user->id, $sysroleid, SYSCONTEXTID)) {
+                            role_assign($sysroleid, $user->id, SYSCONTEXTID);
+                            $this->upt->track('enrolments', get_string('assignedsysrole',
+                                'tool_uploaduser', $this->sysrolecache[$sysroleid]->name), 'info');
+                        }
+                    }
+                }
+
+                continue;
+            }
+            if (!preg_match('/^course\d+$/', $column)) {
+                continue;
+            }
+            $i = substr($column, 6);
+
+            if (empty($user->{'course'.$i})) {
+                continue;
+            }
+            $shortname = $user->{'course'.$i};
+            if (!array_key_exists($shortname, $this->ccache)) {
+                if (!$course = $DB->get_record('course', ['shortname' => $shortname], 'id, shortname')) {
+                    $this->upt->track('enrolments', get_string('unknowncourse', 'error', s($shortname)), 'error');
+                    continue;
+                }
+                $ccache[$shortname] = $course;
+                $ccache[$shortname]->groups = null;
+            }
+            $courseid      = $ccache[$shortname]->id;
+            $coursecontext = \context_course::instance($courseid);
+            if (!isset($this->manualcache[$courseid])) {
+                $this->manualcache[$courseid] = false;
+                if ($this->manualenrol) {
+                    if ($instances = enrol_get_instances($courseid, false)) {
+                        foreach ($instances as $instance) {
+                            if ($instance->enrol === 'manual') {
+                                $this->manualcache[$courseid] = $instance;
+                                break;
+                            }
+                        }
+                    }
+                }
+            }
+
+            if ($courseid == SITEID) {
+                // Technically frontpage does not have enrolments, but only role assignments,
+                // let's not invent new lang strings here for this rarely used feature.
+
+                if (!empty($user->{'role'.$i})) {
+                    $rolename = $user->{'role'.$i};
+                    if (array_key_exists($rolename, $this->rolecache)) {
+                        $roleid = $this->rolecache[$rolename]->id;
+                    } else {
+                        $this->upt->track('enrolments', get_string('unknownrole', 'error', s($rolename)), 'error');
+                        continue;
+                    }
+
+                    role_assign($roleid, $user->id, \context_course::instance($courseid));
+
+                    $a = new \stdClass();
+                    $a->course = $shortname;
+                    $a->role   = $this->rolecache[$roleid]->name;
+                    $this->upt->track('enrolments', get_string('enrolledincourserole', 'enrol_manual', $a), 'info');
+                }
+
+            } else if ($this->manualenrol and $this->manualcache[$courseid]) {
+
+                // Find role.
+                $roleid = false;
+                if (!empty($user->{'role'.$i})) {
+                    $rolename = $user->{'role'.$i};
+                    if (array_key_exists($rolename, $this->rolecache)) {
+                        $roleid = $this->rolecache[$rolename]->id;
+                    } else {
+                        $this->upt->track('enrolments', get_string('unknownrole', 'error', s($rolename)), 'error');
+                        continue;
+                    }
+
+                } else if (!empty($user->{'type'.$i})) {
+                    // If no role, then find "old" enrolment type.
+                    $addtype = $user->{'type'.$i};
+                    if ($addtype < 1 or $addtype > 3) {
+                        $this->upt->track('enrolments', get_string('error').': typeN = 1|2|3', 'error');
+                        continue;
+                    } else if (empty($this->formdata->{'uulegacy'.$addtype})) {
+                        continue;
+                    } else {
+                        $roleid = $this->formdata->{'uulegacy'.$addtype};
+                    }
+                } else {
+                    // No role specified, use the default from manual enrol plugin.
+                    $roleid = $this->manualcache[$courseid]->roleid;
+                }
+
+                if ($roleid) {
+                    // Find duration and/or enrol status.
+                    $timeend = 0;
+                    $timestart = $this->today;
+                    $status = null;
+
+                    if (isset($user->{'enrolstatus'.$i})) {
+                        $enrolstatus = $user->{'enrolstatus'.$i};
+                        if ($enrolstatus == '') {
+                            $status = null;
+                        } else if ($enrolstatus === (string)ENROL_USER_ACTIVE) {
+                            $status = ENROL_USER_ACTIVE;
+                        } else if ($enrolstatus === (string)ENROL_USER_SUSPENDED) {
+                            $status = ENROL_USER_SUSPENDED;
+                        } else {
+                            debugging('Unknown enrolment status.');
+                        }
+                    }
+
+                    if (!empty($user->{'enroltimestart'.$i})) {
+                        $parsedtimestart = strtotime($user->{'enroltimestart'.$i});
+                        if ($parsedtimestart !== false) {
+                            $timestart = $parsedtimestart;
+                        }
+                    }
+
+                    if (!empty($user->{'enrolperiod'.$i})) {
+                        $duration = (int)$user->{'enrolperiod'.$i} * 60 * 60 * 24; // Convert days to seconds.
+                        if ($duration > 0) { // Sanity check.
+                            $timeend = $timestart + $duration;
+                        }
+                    } else if ($this->manualcache[$courseid]->enrolperiod > 0) {
+                        $timeend = $timestart + $this->manualcache[$courseid]->enrolperiod;
+                    }
+
+                    $this->manualenrol->enrol_user($this->manualcache[$courseid], $user->id, $roleid,
+                        $timestart, $timeend, $status);
+
+                    $a = new \stdClass();
+                    $a->course = $shortname;
+                    $a->role   = $this->rolecache[$roleid]->name;
+                    $this->upt->track('enrolments', get_string('enrolledincourserole', 'enrol_manual', $a), 'info');
+                }
+            }
+
+            // Find group to add to.
+            if (!empty($user->{'group'.$i})) {
+                // Make sure user is enrolled into course before adding into groups.
+                if (!is_enrolled($coursecontext, $user->id)) {
+                    $this->upt->track('enrolments', get_string('addedtogroupnotenrolled', '', $user->{'group'.$i}), 'error');
+                    continue;
+                }
+                // Build group cache.
+                if (is_null($ccache[$shortname]->groups)) {
+                    $ccache[$shortname]->groups = array();
+                    if ($groups = groups_get_all_groups($courseid)) {
+                        foreach ($groups as $gid => $group) {
+                            $ccache[$shortname]->groups[$gid] = new \stdClass();
+                            $ccache[$shortname]->groups[$gid]->id   = $gid;
+                            $ccache[$shortname]->groups[$gid]->name = $group->name;
+                            if (!is_numeric($group->name)) { // Only non-numeric names are supported!!!
+                                $ccache[$shortname]->groups[$group->name] = new \stdClass();
+                                $ccache[$shortname]->groups[$group->name]->id   = $gid;
+                                $ccache[$shortname]->groups[$group->name]->name = $group->name;
+                            }
+                        }
+                    }
+                }
+                // Group exists?
+                $addgroup = $user->{'group'.$i};
+                if (!array_key_exists($addgroup, $ccache[$shortname]->groups)) {
+                    // If group doesn't exist,  create it.
+                    $newgroupdata = new \stdClass();
+                    $newgroupdata->name = $addgroup;
+                    $newgroupdata->courseid = $ccache[$shortname]->id;
+                    $newgroupdata->description = '';
+                    $gid = groups_create_group($newgroupdata);
+                    if ($gid) {
+                        $ccache[$shortname]->groups[$addgroup] = new \stdClass();
+                        $ccache[$shortname]->groups[$addgroup]->id   = $gid;
+                        $ccache[$shortname]->groups[$addgroup]->name = $newgroupdata->name;
+                    } else {
+                        $this->upt->track('enrolments', get_string('unknowngroup', 'error', s($addgroup)), 'error');
+                        continue;
+                    }
+                }
+                $gid   = $ccache[$shortname]->groups[$addgroup]->id;
+                $gname = $ccache[$shortname]->groups[$addgroup]->name;
+
+                try {
+                    if (groups_add_member($gid, $user->id)) {
+                        $this->upt->track('enrolments', get_string('addedtogroup', '', s($gname)), 'info');
+                    } else {
+                        $this->upt->track('enrolments', get_string('addedtogroupnot', '', s($gname)), 'error');
+                    }
+                } catch (\moodle_exception $e) {
+                    $this->upt->track('enrolments', get_string('addedtogroupnot', '', s($gname)), 'error');
+                    continue;
+                }
+            }
+        }
+        if (($invalid = \core_user::validate($user)) !== true) {
+            $this->upt->track('status', get_string('invaliduserdata', 'tool_uploaduser', s($user->username)), 'warning');
+        }
+    }
+
+    /**
+     * Summary about the whole process (how many users created, skipped, updated, etc)
+     *
+     * @return array
+     */
+    public function get_stats() {
+        $lines = [];
+
+        if ($this->get_operation_type() != UU_USER_UPDATE) {
+            $lines[] = get_string('userscreated', 'tool_uploaduser').': '.$this->usersnew;
+        }
+        if ($this->get_operation_type() == UU_USER_UPDATE or $this->get_operation_type() == UU_USER_ADD_UPDATE) {
+            $lines[] = get_string('usersupdated', 'tool_uploaduser').': '.$this->usersupdated;
+        }
+        if ($this->get_allow_deletes()) {
+            $lines[] = get_string('usersdeleted', 'tool_uploaduser').': '.$this->deletes;
+            $lines[] = get_string('deleteerrors', 'tool_uploaduser').': '.$this->deleteerrors;
+        }
+        if ($this->get_allow_renames()) {
+            $lines[] = get_string('usersrenamed', 'tool_uploaduser').': '.$this->renames;
+            $lines[] = get_string('renameerrors', 'tool_uploaduser').': '.$this->renameerrors;
+        }
+        if ($usersskipped = $this->usersskipped) {
+            $lines[] = get_string('usersskipped', 'tool_uploaduser').': '.$usersskipped;
+        }
+        $lines[] = get_string('usersweakpassword', 'tool_uploaduser').': '.$this->weakpasswords;
+        $lines[] = get_string('errors', 'tool_uploaduser').': '.$this->userserrors;
+
+        return $lines;
+    }
+}
diff --git a/admin/tool/uploaduser/cli/uploaduser.php b/admin/tool/uploaduser/cli/uploaduser.php
new file mode 100644 (file)
index 0000000..5db9e02
--- /dev/null
@@ -0,0 +1,53 @@
+<?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 script to upload users
+ *
+ * @package     tool_uploaduser
+ * @copyright   2020 Marina Glancy
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define('CLI_SCRIPT', true);
+
+require_once(__DIR__ . '/../../../../config.php');
+require_once($CFG->libdir . '/clilib.php');
+
+if (moodle_needs_upgrading()) {
+    cli_error("Moodle upgrade pending, export execution suspended.");
+}
+