Merge branch 'MDL-43156' of https://github.com/stronk7/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Fri, 1 May 2020 02:16:37 +0000 (10:16 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Fri, 1 May 2020 02:16:37 +0000 (10:16 +0800)
197 files changed:
.eslintignore
.stylelintignore
admin/settings/development.php
admin/tool/messageinbound/lang/en/tool_messageinbound.php
admin/tool/task/lang/en/tool_task.php
admin/tool/uploadcourse/classes/course.php
admin/tool/uploadcourse/classes/helper.php
admin/tool/uploadcourse/classes/step2_form.php
admin/tool/uploadcourse/index.php
admin/tool/uploadcourse/lang/en/tool_uploadcourse.php
admin/tool/uploadcourse/tests/behat/create.feature
admin/tool/uploadcourse/tests/behat/update.feature
admin/tool/uploadcourse/tests/course_test.php
admin/tool/uploadcourse/tests/fixtures/courses_custom_fields.csv [new file with mode: 0644]
admin/tool/uploadcourse/tests/helper_test.php
admin/tool/xmldb/lang/en/tool_xmldb.php
blocks/site_main_menu/tests/behat/add_url.feature
cache/classes/helper.php
cache/classes/loaders.php
cache/classes/store.php
cache/tests/cache_test.php
cache/upgrade.txt
composer.json
composer.lock
config-dist.php
contentbank/contenttype/h5p/lang/en/contenttype_h5p.php
contentbank/contenttype/h5p/tests/behat/admin_upload_content.feature
contentbank/tests/behat/delete_content.feature
course/tests/behat/activity_chooser.feature
customfield/classes/data_controller.php
customfield/classes/field_controller.php
customfield/classes/handler.php
customfield/field/date/classes/field_controller.php
customfield/field/date/tests/plugin_test.php
customfield/field/select/classes/field_controller.php
customfield/field/select/tests/plugin_test.php
customfield/field/textarea/classes/data_controller.php
grade/grading/form/guide/lang/en/gradingform_guide.php
grade/grading/form/rubric/renderer.php
grade/grading/form/rubric/styles.css
h5p/h5plib/v124/lang/en/h5plib_v124.php
lang/en/admin.php
lang/en/cache.php
lang/en/contentbank.php
lang/en/course.php
lang/en/enrol.php
lang/en/error.php
lang/en/h5p.php
lang/en/moodle.php
lang/en/repository.php
lang/en/role.php
lib/amd/build/custom_interaction_events.min.js
lib/amd/build/custom_interaction_events.min.js.map
lib/amd/src/custom_interaction_events.js
lib/antivirus/clamav/lang/en/antivirus_clamav.php
lib/behat/lib.php
lib/classes/component.php
lib/classes/ip_utils.php
lib/classes/plugin_manager.php
lib/classes/session/redis.php
lib/classes/task/manager.php
lib/classes/task/messaging_cleanup_task.php
lib/db/upgrade.php
lib/moodlelib.php
lib/outputcomponents.php
lib/plist/LICENSE [new file with mode: 0644]
lib/plist/README.md [new file with mode: 0644]
lib/plist/classes/CFPropertyList/CFBinaryPropertyList.php [new file with mode: 0644]
lib/plist/classes/CFPropertyList/CFPropertyList.php [new file with mode: 0644]
lib/plist/classes/CFPropertyList/CFType.php [new file with mode: 0644]
lib/plist/classes/CFPropertyList/CFTypeDetector.php [new file with mode: 0644]
lib/plist/classes/CFPropertyList/IOException.php [new file with mode: 0644]
lib/plist/classes/CFPropertyList/PListException.php [new file with mode: 0644]
lib/plist/readme_moodle.txt [new file with mode: 0644]
lib/table/amd/build/dynamic.min.js
lib/table/amd/build/dynamic.min.js.map
lib/table/amd/build/local/dynamic/repository.min.js
lib/table/amd/build/local/dynamic/repository.min.js.map
lib/table/amd/build/local/dynamic/selectors.min.js
lib/table/amd/build/local/dynamic/selectors.min.js.map
lib/table/amd/src/dynamic.js
lib/table/amd/src/local/dynamic/repository.js
lib/table/amd/src/local/dynamic/selectors.js
lib/table/classes/external/dynamic/fetch.php
lib/table/tests/external/dynamic/fetch_test.php
lib/tablelib.php
lib/templates/single_select.mustache
lib/templates/url_select.mustache
lib/thirdpartylibs.xml
lib/upgrade.txt
message/amd/build/message_preferences.min.js [new file with mode: 0644]
message/amd/build/message_preferences.min.js.map [new file with mode: 0644]
message/amd/src/message_preferences.js [new file with mode: 0644]
message/output/airnotifier/lang/en/message_airnotifier.php
message/output/lib.php
message/output/popup/db/upgrade.php
message/output/popup/message_output_popup.php
message/output/popup/tests/messaging_cleanup_test.php [new file with mode: 0644]
message/output/popup/version.php
message/templates/message_preferences.mustache [new file with mode: 0644]
message/templates/message_preferences_component.mustache [new file with mode: 0644]
message/templates/message_preferences_notification_processor.mustache [new file with mode: 0644]
message/tests/behat/message_preferences.feature [new file with mode: 0644]
message/upgrade.txt
mod/assign/lang/en/assign.php
mod/forum/report/summary/templates/bulk_action_menu.mustache
mod/h5pactivity/lang/en/h5pactivity.php
mod/h5pactivity/tests/behat/add_h5pactivity.feature
mod/h5pactivity/tests/behat/sending_attempt.feature
mod/h5pactivity/tests/privacy_test.php
mod/lesson/lang/en/lesson.php
mod/lti/lang/en/lti.php
mod/lti/mod_form.php
mod/quiz/accessrule/safebrowser/rule.php [deleted file]
mod/quiz/accessrule/safebrowser/tests/rule_test.php [deleted file]
mod/quiz/accessrule/seb/amd/build/managetemplates.min.js [new file with mode: 0644]
mod/quiz/accessrule/seb/amd/build/managetemplates.min.js.map [new file with mode: 0644]
mod/quiz/accessrule/seb/amd/src/managetemplates.js [new file with mode: 0644]
mod/quiz/accessrule/seb/backup/moodle2/backup_quizaccess_seb_subplugin.class.php [new file with mode: 0644]
mod/quiz/accessrule/seb/backup/moodle2/restore_quizaccess_seb_subplugin.class.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/access_manager.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/config_key.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/event/access_prevented.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/event/template_created.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/event/template_deleted.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/event/template_disabled.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/event/template_enabled.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/event/template_updated.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/helper.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/hideif_rule.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/link_generator.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/local/form/template.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/local/table/template_list.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/privacy/provider.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/property_list.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/quiz_settings.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/settings_provider.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/template.php [new file with mode: 0644]
mod/quiz/accessrule/seb/classes/template_controller.php [new file with mode: 0644]
mod/quiz/accessrule/seb/config.php [new file with mode: 0644]
mod/quiz/accessrule/seb/db/access.php [new file with mode: 0644]
mod/quiz/accessrule/seb/db/caches.php [new file with mode: 0644]
mod/quiz/accessrule/seb/db/install.php [new file with mode: 0644]
mod/quiz/accessrule/seb/db/install.xml [new file with mode: 0644]
mod/quiz/accessrule/seb/db/upgrade.php [moved from mod/quiz/accessrule/safebrowser/lang/en/quizaccess_safebrowser.php with 53% similarity]
mod/quiz/accessrule/seb/lang/en/quizaccess_seb.php [new file with mode: 0644]
mod/quiz/accessrule/seb/lib.php [new file with mode: 0644]
mod/quiz/accessrule/seb/rule.php [new file with mode: 0644]
mod/quiz/accessrule/seb/settings.php [new file with mode: 0644]
mod/quiz/accessrule/seb/template.php [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/behat/edit_form.feature [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/generator/behat_quizaccess_seb_generator.php [moved from mod/quiz/accessrule/safebrowser/classes/privacy/provider.php with 53% similarity]
mod/quiz/accessrule/seb/tests/generator/lib.php [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/access_manager_test.php [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/backup_restore_test.php [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/base.php [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/config_key_test.php [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/event_test.php [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/helper_test.php [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/hideif_rule_test.php [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/link_generator_test.php [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/privacy_provider_test.php [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/property_list_test.php [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/quiz_settings_test.php [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/rule_test.php [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/sample_data/JSON_unencrypted_mac_001.txt [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/sample_data/encrypted.seb [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/sample_data/simpleunencrypted.seb [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/sample_data/simpleunencryptedwithoutoriginator.seb [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/sample_data/unencrypted.seb [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/sample_data/unencrypted_mac_001.seb [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/sample_data/unencrypted_win_223.seb [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/settings_provider_test.php [new file with mode: 0644]
mod/quiz/accessrule/seb/tests/phpunit/template_test.php [new file with mode: 0644]
mod/quiz/accessrule/seb/version.php [moved from mod/quiz/accessrule/safebrowser/version.php with 68% similarity]
mod/quiz/accessrule/timelimit/lang/en/quizaccess_timelimit.php
mod/quiz/backup/moodle2/restore_quiz_stepslib.php
mod/quiz/lang/en/quiz.php
mod/quiz/tests/behat/attempt_begin.feature
mod/workshop/tests/behat/file_type_restriction.feature
question/type/ddimageortext/lang/en/qtype_ddimageortext.php
question/type/ddmarker/lang/en/qtype_ddmarker.php
theme/boost/scss/moodle/grade.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
user/amd/build/participants.min.js
user/amd/build/participants.min.js.map
user/amd/build/repository.min.js [new file with mode: 0644]
user/amd/build/repository.min.js.map [new file with mode: 0644]
user/amd/build/status_field.min.js
user/amd/build/status_field.min.js.map
user/amd/src/participants.js
user/amd/src/repository.js [new file with mode: 0644]
user/amd/src/status_field.js
user/classes/table/participants.php
user/tests/behat/course_preference.feature
version.php

index 2ef8a38..b9c0b6a 100644 (file)
@@ -65,6 +65,7 @@ lib/php-jwt/
 lib/babel-polyfill/
 lib/polyfills/
 lib/emoji-data/
+lib/plist/
 media/player/videojs/amd/src/video-lazy.js
 media/player/videojs/amd/src/Youtube-lazy.js
 media/player/videojs/videojs/
index f00e696..5d9e5c1 100644 (file)
@@ -66,6 +66,7 @@ lib/php-jwt/
 lib/babel-polyfill/
 lib/polyfills/
 lib/emoji-data/
+lib/plist/
 media/player/videojs/amd/src/video-lazy.js
 media/player/videojs/amd/src/Youtube-lazy.js
 media/player/videojs/videojs/
index f8a5ceb..46206fc 100644 (file)
@@ -10,7 +10,6 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
     $temp = new admin_settingpage('experimentalsettings', new lang_string('experimentalsettings', 'admin'));
     //TODO: Re-enable cc-import once re-implemented in 2.0.x
     //$temp->add(new admin_setting_configcheckbox('enableimsccimport', new lang_string('enable_cc_import', 'imscc'), new lang_string('enable_cc_import_description', 'imscc'), 0));
-    $temp->add(new admin_setting_configcheckbox('enablesafebrowserintegration', new lang_string('enablesafebrowserintegration', 'admin'), new lang_string('configenablesafebrowserintegration', 'admin'), 0));
 
     $temp->add(new admin_setting_configcheckbox('dndallowtextandlinks', new lang_string('dndallowtextandlinks', 'admin'), new lang_string('configdndallowtextandlinks', 'admin'), 0));
 
index 8daab15..fd0b465 100644 (file)
@@ -24,7 +24,7 @@
 
 $string['classname'] = 'Class name';
 $string['component'] = 'Component';
-$string['configmessageinboundhost'] = 'The address of the server that Moodle should check mail against. To specify a non-default port, use [server]:[port], for example mail.example.com:587. If a port isn\'t specified, the default port for the type of mail server will be used.';
+$string['configmessageinboundhost'] = 'The address of the server that Moodle should check mail against. To specify a non-default port, use [server]:[port], for example mail.example.com:993. If a port isn\'t specified, the default port for the type of mail server will be used.';
 $string['defaultexpiration'] = 'Default address expiry period';
 $string['defaultexpiration_help'] = 'When an email address is generated by the handler, it can be set to automatically expire after a period of time, so that it can no longer be used. It is advisable to set an expiry period.';
 $string['description'] = 'Description';
index 9ccd292..299603f 100644 (file)
  */
 
 $string['asap'] = 'ASAP';
-$string['adhocempty'] = 'Adhoc task queue is empty';
-$string['adhocqueuesize'] = 'Adhoc task queue has {$a} tasks';
+$string['adhocempty'] = 'Ad hoc task queue is empty';
+$string['adhocqueuesize'] = 'Ad hoc task queue has {$a} tasks';
 $string['adhocqueueold'] = 'Oldest task is {$a->age} which is more than {$a->max}';
 $string['backtoscheduledtasks'] = 'Back to scheduled tasks';
 $string['blocking'] = 'Blocking';
 $string['cannotfindthepathtothecli'] = 'Cannot find the path to the PHP CLI executable so task execution aborted. Set the \'Path to PHP CLI\' setting in Site administration / Server / System paths.';
-$string['checkadhocqueue'] = 'Adhoc task queue';
+$string['checkadhocqueue'] = 'Ad hoc task queue';
 $string['checkcronrunning'] = 'Cron running';
 $string['checkmaxfaildelay'] = 'Tasks max fail delay';
 $string['clearfaildelay_confirm'] = 'Are you sure you want to clear the fail delay for task \'{$a}\'? After clearing the delay, the task will run according to its normal schedule.';
@@ -58,7 +58,7 @@ $string['runpattern'] = 'Run pattern';
 $string['scheduledtasks'] = 'Scheduled tasks';
 $string['scheduledtaskchangesdisabled'] = 'Modifications to the list of scheduled tasks have been prevented in Moodle configuration';
 $string['taskdisabled'] = 'Task disabled';
-$string['taskfailures'] = 'There are {$a} task(s) failing';
+$string['taskfailures'] = '{$a} task(s) failing';
 $string['tasklogs'] = 'Task logs';
 $string['tasknofailures'] = 'There are no tasks failing';
 $string['taskscheduleday'] = 'Day';
index 8ae7a8d..88cd888 100644 (file)
@@ -284,6 +284,15 @@ class tool_uploadcourse_course {
         return $this->errors;
     }
 
+    /**
+     * Return array of valid fields for default values
+     *
+     * @return array
+     */
+    protected function get_valid_fields() {
+        return array_merge(self::$validfields, \tool_uploadcourse_helper::get_custom_course_field_names());
+    }
+
     /**
      * Assemble the course data based on defaults.
      *
@@ -293,7 +302,7 @@ class tool_uploadcourse_course {
      * @return array
      */
     protected function get_final_create_data($data) {
-        foreach (self::$validfields as $field) {
+        foreach ($this->get_valid_fields() as $field) {
             if (!isset($data[$field]) && isset($this->defaults[$field])) {
                 $data[$field] = $this->defaults[$field];
             }
@@ -316,9 +325,9 @@ class tool_uploadcourse_course {
         global $DB;
         $newdata = array();
         $existingdata = $DB->get_record('course', array('shortname' => $this->shortname));
-        foreach (self::$validfields as $field) {
+        foreach ($this->get_valid_fields() as $field) {
             if ($missingonly) {
-                if (!is_null($existingdata->$field) and $existingdata->$field !== '') {
+                if (isset($existingdata->$field) and $existingdata->$field !== '') {
                     continue;
                 }
             }
@@ -699,6 +708,27 @@ class tool_uploadcourse_course {
             $coursedata[$rolekey] = $rolename;
         }
 
+        // Custom fields. If the course already exists and mode isn't set to force creation, we can use its context.
+        if ($exists && $mode !== tool_uploadcourse_processor::MODE_CREATE_ALL) {
+            $context = context_course::instance($coursedata['id']);
+        } else {
+            // The category ID is taken from the defaults if it exists, otherwise from course data.
+            $context = context_coursecat::instance($this->defaults['category'] ?? $coursedata['category']);
+        }
+        $customfielddata = tool_uploadcourse_helper::get_custom_course_field_data($this->rawdata, $this->defaults, $context,
+            $errors);
+        if (!empty($errors)) {
+            foreach ($errors as $key => $message) {
+                $this->error($key, $message);
+            }
+
+            return false;
+        }
+
+        foreach ($customfielddata as $name => $value) {
+            $coursedata[$name] = $value;
+        }
+
         // Some validation.
         if (!empty($coursedata['format']) && !in_array($coursedata['format'], tool_uploadcourse_helper::get_course_formats())) {
             $this->error('invalidcourseformat', new lang_string('invalidcourseformat', 'tool_uploadcourse'));
index 2325c9c..1011c37 100644 (file)
@@ -337,6 +337,103 @@ class tool_uploadcourse_helper {
         return $rolenames;
     }
 
+    /**
+     * Return array of all custom course fields indexed by their shortname
+     *
+     * @return \core_customfield\field_controller[]
+     */
+    public static function get_custom_course_fields(): array {
+        $result = [];
+
+        $fields = \core_course\customfield\course_handler::create()->get_fields();
+        foreach ($fields as $field) {
+            $result[$field->get('shortname')] = $field;
+        }
+
+        return $result;
+    }
+
+    /**
+     * Return array of custom field element names
+     *
+     * @return string[]
+     */
+    public static function get_custom_course_field_names(): array {
+        $result = [];
+
+        $fields = self::get_custom_course_fields();
+        foreach ($fields as $field) {
+            $controller = \core_customfield\data_controller::create(0, null, $field);
+            $result[] = $controller->get_form_element_name();
+        }
+
+        return $result;
+    }
+
+    /**
+     * Return any elements from passed $data whose key matches one of the custom course fields defined for the site
+     *
+     * @param array $data
+     * @param array $defaults
+     * @param context $context
+     * @param array $errors Will be populated with any errors
+     * @return array
+     */
+    public static function get_custom_course_field_data(array $data, array $defaults, context $context,
+            array &$errors = []): array {
+
+        $fields = self::get_custom_course_fields();
+        $result = [];
+
+        $canchangelockedfields = guess_if_creator_will_have_course_capability('moodle/course:changelockedcustomfields', $context);
+
+        foreach ($data as $name => $originalvalue) {
+            if (preg_match('/^customfield_(?<name>.*)?$/', $name, $matches)
+                    && isset($fields[$matches['name']])) {
+
+                $fieldname = $matches['name'];
+                $field = $fields[$fieldname];
+
+                // Skip field if it's locked and user doesn't have capability to change locked fields.
+                if ($field->get_configdata_property('locked') && !$canchangelockedfields) {
+                    continue;
+                }
+
+                // Create field data controller.
+                $controller = \core_customfield\data_controller::create(0, null, $field);
+                $controller->set('id', 1);
+
+                $defaultvalue = $defaults["customfield_{$fieldname}"] ?? $controller->get_default_value();
+                $value = (empty($originalvalue) ? $defaultvalue : $field->parse_value($originalvalue));
+
+                // If we initially had a value, but now don't, then reset it to the default.
+                if (!empty($originalvalue) && empty($value)) {
+                    $value = $defaultvalue;
+                }
+
+                // Validate data with controller.
+                $fieldformdata = [$controller->get_form_element_name() => $value];
+                $validationerrors = $controller->instance_form_validation($fieldformdata, []);
+                if (count($validationerrors) > 0) {
+                    $errors['customfieldinvalid'] = new lang_string('customfieldinvalid', 'tool_uploadcourse',
+                        $field->get_formatted_name());
+
+                    continue;
+                }
+
+                $controller->set($controller->datafield(), $value);
+
+                // Pass an empty object to the data controller, which will transform it to a correct name/value pair.
+                $instance = new stdClass();
+                $controller->instance_form_before_set_data($instance);
+
+                $result = array_merge($result, (array) $instance);
+            }
+        }
+
+        return $result;
+    }
+
     /**
      * Helper to increment an ID number.
      *
@@ -493,5 +590,4 @@ class tool_uploadcourse_helper {
         }
         return $id;
     }
-
-}
+}
\ No newline at end of file
index 58c39d4..8854abb 100644 (file)
@@ -173,6 +173,10 @@ class tool_uploadcourse_step2_form extends tool_uploadcourse_base_form {
             $mform->addHelpButton('defaults[enablecompletion]', 'enablecompletion', 'completion');
         }
 
+        // Add custom fields to the form.
+        $handler = \core_course\customfield\course_handler::create();
+        $handler->instance_form_definition($mform, 0, 'defaultvaluescustomfieldcategory', 'tool_uploadcourse');
+
         // Hidden fields.
         $mform->addElement('hidden', 'importid');
         $mform->setType('importid', PARAM_INT);
@@ -182,6 +186,10 @@ class tool_uploadcourse_step2_form extends tool_uploadcourse_base_form {
 
         $this->add_action_buttons(true, get_string('uploadcourses', 'tool_uploadcourse'));
 
+        // Prepare custom fields data.
+        $data = (object) $data;
+        $handler->instance_form_before_set_data($data);
+
         $this->set_data($data);
     }
 
@@ -219,6 +227,9 @@ class tool_uploadcourse_step2_form extends tool_uploadcourse_base_form {
             $enddate = $format->get_default_course_enddate($mform, array('startdate' => 'defaults[startdate]'));
             $mform->setDefault('defaults[enddate]', $enddate);
         }
+
+        // Tweak the form with values provided by custom fields in use.
+        \core_course\customfield\course_handler::create()->instance_form_definition_after_data($mform);
     }
 
     /**
@@ -237,6 +248,9 @@ class tool_uploadcourse_step2_form extends tool_uploadcourse_base_form {
             $errors['defaults[enddate]'] = get_string($errorcode, 'error');
         }
 
+        // Custom fields validation.
+        array_merge($errors, \core_course\customfield\course_handler::create()->instance_form_validation($data, $files));
+
         return $errors;
     }
 }
index 69f4410..8c2ccfd 100644 (file)
@@ -78,6 +78,12 @@ if ($form2data = $mform2->is_cancelled()) {
     $options = (array) $form2data->options;
     $defaults = (array) $form2data->defaults;
 
+    // Custom field defaults.
+    $customfields = tool_uploadcourse_helper::get_custom_course_field_names();
+    foreach ($customfields as $customfield) {
+        $defaults[$customfield] = $form2data->{$customfield};
+    }
+
     // Restorefile deserves its own logic because formslib does not really appreciate
     // when the name of a filepicker is an array...
     $options['restorefile'] = '';
index 9a0e2c6..b44b4f3 100644 (file)
@@ -75,6 +75,7 @@ $string['csvdelimiter_help'] = 'CSV delimiter of the CSV file.';
 $string['csvfileerror'] = 'There is something wrong with the format of the CSV file. Please check the number of headings and columns match, and that the delimiter and file encoding are correct: {$a}';
 $string['csvline'] = 'Line';
 $string['defaultvalues'] = 'Default course values';
+$string['defaultvaluescustomfieldcategory'] = 'Default values for \'{$a}\'';
 $string['encoding'] = 'Encoding';
 $string['encoding_help'] = 'Encoding of the CSV file.';
 $string['errorwhilerestoringcourse'] = 'Error while restoring the course';
@@ -102,6 +103,7 @@ $string['mode_help'] = 'This allows you to specify if courses can be created and
 $string['nochanges'] = 'No changes';
 $string['pluginname'] = 'Course upload';
 $string['preview'] = 'Preview';
+$string['customfieldinvalid'] = 'Custom field \'{$a}\' is empty or contains invalid data';
 $string['reset'] = 'Reset course after upload';
 $string['reset_help'] = 'Whether to reset the course after creating/updating it.';
 $string['result'] = 'Result';
index 9fbfa01..a17fc2f 100644 (file)
@@ -42,3 +42,66 @@ Feature: An admin can create courses using a CSV file
     And I should see "Course 1"
     And I should see "Course 2"
     And I should see "Course 3"
+
+  @javascript
+  Scenario: Creation of new courses with custom fields
+    Given the following "custom field categories" exist:
+      | name  | component   | area   | itemid |
+      | Other | core_course | course | 0      |
+    And the following "custom fields" exist:
+      | name    | category | type     | shortname | configdata            |
+      | Field 1 | Other    | checkbox | checkbox  |                       |
+      | Field 2 | Other    | date     | date      |                       |
+      | Field 3 | Other    | select   | select    | {"options":"a\nb\nc"} |
+      | Field 4 | Other    | text     | text      |                       |
+      | Field 5 | Other    | textarea | textarea  |                       |
+    When I upload "admin/tool/uploadcourse/tests/fixtures/courses_custom_fields.csv" file to "File" filemanager
+    And I set the field "Upload mode" to "Create new courses only, skip existing ones"
+    And I click on "Preview" "button"
+    And I click on "Upload courses" "button"
+    Then I should see "Course created"
+    And I should see "Courses created: 1"
+    And I am on site homepage
+    And I should see "Course fields 1"
+    And I should see "Field 1: Yes"
+    And I should see "Field 2: Tuesday, 1 October 2019, 2:00"
+    And I should see "Field 3: b"
+    And I should see "Field 4: Hello"
+    And I should see "Field 5: Goodbye"
+
+  @javascript
+  Scenario: Creation of new courses with custom fields using defaults
+    Given the following "custom field categories" exist:
+      | name  | component   | area   | itemid |
+      | Other | core_course | course | 0      |
+    And the following "custom fields" exist:
+      | name    | category | type     | shortname | configdata                                          |
+      | Field 1 | Other    | checkbox | checkbox  | {"checkbydefault":1}                                |
+      | Field 2 | Other    | date     | date      | {"includetime":0}                                   |
+      | Field 3 | Other    | select   | select    | {"options":"a\nb\nc","defaultvalue":"b"}            |
+      | Field 4 | Other    | text     | text      | {"defaultvalue":"Hello"}                            |
+      | Field 5 | Other    | textarea | textarea  | {"defaultvalue":"Some text","defaultvalueformat":1} |
+    When I upload "admin/tool/uploadcourse/tests/fixtures/courses.csv" file to "File" filemanager
+    And I set the field "Upload mode" to "Create all, increment shortname if needed"
+    And I click on "Preview" "button"
+    And I expand all fieldsets
+    And the field "Field 1" matches value "1"
+    And the field "Field 3" matches value "b"
+    And the field "Field 4" matches value "Hello"
+    And the field "Field 5" matches value "Some text"
+    # We have to enable the date field manually.
+    And I set the following fields to these values:
+      | customfield_date[enabled] | 1    |
+      | customfield_date[day]     | 1    |
+      | customfield_date[month]   | June |
+      | customfield_date[year]    | 2020 |
+    And I click on "Upload courses" "button"
+    Then I should see "Course created"
+    And I should see "Courses created: 3"
+    And I am on site homepage
+    And I should see "Course 1"
+    And I should see "Field 1: Yes"
+    And I should see "Field 2: 1 June 2020"
+    And I should see "Field 3: b"
+    And I should see "Field 4: Hello"
+    And I should see "Field 5: Some text"
\ No newline at end of file
index dbdbef5..dbca1f4 100644 (file)
@@ -7,7 +7,8 @@ Feature: An admin can update courses using a CSV file
   Background:
     Given the following "courses" exist:
       | fullname | shortname | category |
-      | Some random name | C1 | 0 |
+      | Some random name | C1  | 0 |
+      | Another course   | CF1 | 0 |
     And I log in as "admin"
     And I navigate to "Courses > Upload courses" in site administration
 
@@ -28,3 +29,31 @@ Feature: An admin can update courses using a CSV file
     And I should see "Course 1"
     And I should not see "Course 2"
     And I should not see "Course 3"
+
+  @javascript
+  Scenario: Updating a course with custom fields
+    Given the following "custom field categories" exist:
+      | name  | component   | area   | itemid |
+      | Other | core_course | course | 0      |
+    And the following "custom fields" exist:
+      | name    | category | type     | shortname | configdata            |
+      | Field 1 | Other    | checkbox | checkbox  |                       |
+      | Field 2 | Other    | date     | date      |                       |
+      | Field 3 | Other    | select   | select    | {"options":"a\nb\nc"} |
+      | Field 4 | Other    | text     | text      |                       |
+      | Field 5 | Other    | textarea | textarea  |                       |
+    When I upload "admin/tool/uploadcourse/tests/fixtures/courses_custom_fields.csv" file to "File" filemanager
+    And I set the following fields to these values:
+      | Upload mode | Only update existing courses |
+      | Update mode | Update with CSV data only    |
+    And I click on "Preview" "button"
+    And I click on "Upload courses" "button"
+    Then I should see "Course updated"
+    And I should see "Courses updated: 1"
+    And I am on site homepage
+    And I should see "Course fields 1"
+    And I should see "Field 1: Yes"
+    And I should see "Field 2: Tuesday, 1 October 2019, 2:00"
+    And I should see "Field 3: b"
+    And I should see "Field 4: Hello"
+    And I should see "Field 5: Goodbye"
\ No newline at end of file
index 0add18e..0af3d93 100644 (file)
@@ -1081,6 +1081,136 @@ class tool_uploadcourse_course_testcase extends advanced_testcase {
         $this->assertEquals(strtotime('12th July 2013'), $enroldata['manual']->enrolenddate);
     }
 
+    /**
+     * Test upload processing of course custom fields
+     */
+    public function test_custom_fields_data() {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course(['shortname' => 'C1']);
+
+        // Create our custom fields.
+        $category = $this->get_customfield_generator()->create_category();
+        $this->create_custom_field($category, 'date', 'mydatefield');
+        $this->create_custom_field($category, 'text', 'mytextfield');
+        $this->create_custom_field($category, 'textarea', 'mytextareafield');
+
+        // Perform upload.
+        $mode = tool_uploadcourse_processor::MODE_UPDATE_ONLY;
+        $updatemode = tool_uploadcourse_processor::UPDATE_ALL_WITH_DATA_ONLY;
+        $dataupload = [
+            'shortname' => $course->shortname,
+            'customfield_mydatefield' => '2020-04-01 16:00',
+            'customfield_mytextfield' => 'Hello',
+            'customfield_mytextareafield' => 'Is it me you\'re looking for?',
+        ];
+
+        $uploader = new tool_uploadcourse_course($mode, $updatemode, $dataupload);
+        $this->assertTrue($uploader->prepare());
+        $uploader->proceed();
+
+        // Confirm presence of course custom fields.
+        $data = \core_course\customfield\course_handler::create()->export_instance_data_object($course->id);
+        $this->assertEquals('Wednesday, 1 April 2020, 4:00 PM', $data->mydatefield);
+        $this->assertEquals($dataupload['customfield_mytextfield'], $data->mytextfield);
+        $this->assertContains($dataupload['customfield_mytextareafield'], $data->mytextareafield);
+    }
+
+    /**
+     * Test upload processing of course custom field that is required but empty
+     */
+    public function test_custom_fields_data_required() {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course(['shortname' => 'C1']);
+
+        // Create our custom field.
+        $category = $this->get_customfield_generator()->create_category();
+        $this->create_custom_field($category, 'select', 'myselect', ['required' => true, 'options' => "Cat\nDog"]);
+
+        $mode = tool_uploadcourse_processor::MODE_UPDATE_ONLY;
+        $updatemode = tool_uploadcourse_processor::UPDATE_ALL_WITH_DATA_ONLY;
+        $dataupload = [
+            'shortname' => $course->shortname,
+            'customfield_myselect' => null,
+        ];
+
+        $uploader = new tool_uploadcourse_course($mode, $updatemode, $dataupload);
+        $this->assertFalse($uploader->prepare());
+        $this->assertArrayHasKey('customfieldinvalid', $uploader->get_errors());
+
+        // Try again with a default value.
+        $defaults = [
+            'customfield_myselect' => 2, // Our second option: Dog.
+        ];
+
+        $uploader = new tool_uploadcourse_course($mode, $updatemode, $dataupload, $defaults);
+        $this->assertTrue($uploader->prepare());
+        $uploader->proceed();
+
+        // Confirm presence of course custom fields.
+        $data = \core_course\customfield\course_handler::create()->export_instance_data_object($course->id);
+        $this->assertEquals('Dog', $data->myselect);
+    }
+
+    /**
+     * Test upload processing of course custom field with an invalid select option
+     */
+    public function test_custom_fields_data_invalid_select_option() {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course(['shortname' => 'C1']);
+
+        // Create our custom field.
+        $category = $this->get_customfield_generator()->create_category();
+        $this->create_custom_field($category, 'select', 'myselect',
+            ['required' => true, 'options' => "Cat\nDog", 'defaultvalue' => 'Cat']);
+
+        $mode = tool_uploadcourse_processor::MODE_UPDATE_ONLY;
+        $updatemode = tool_uploadcourse_processor::UPDATE_ALL_WITH_DATA_ONLY;
+        $dataupload = [
+            'shortname' => $course->shortname,
+            'customfield_myselect' => 'Fish', // No, invalid.
+        ];
+
+        $uploader = new tool_uploadcourse_course($mode, $updatemode, $dataupload);
+        $this->assertTrue($uploader->prepare());
+        $uploader->proceed();
+
+        // Confirm presence of course custom fields.
+        $data = \core_course\customfield\course_handler::create()->export_instance_data_object($course->id);
+        $this->assertEquals('Cat', $data->myselect);
+    }
+
+    /**
+     * Test upload processing of course custom field with an out of range date
+     */
+    public function test_custom_fields_data_invalid_date() {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course(['shortname' => 'C1']);
+
+        // Create our custom field.
+        $category = $this->get_customfield_generator()->create_category();
+        $this->create_custom_field($category, 'date', 'mydate',
+            ['mindate' => strtotime('2020-04-01'), 'maxdate' => '2020-04-30']);
+
+        $mode = tool_uploadcourse_processor::MODE_UPDATE_ONLY;
+        $updatemode = tool_uploadcourse_processor::UPDATE_ALL_WITH_DATA_ONLY;
+        $dataupload = [
+            'shortname' => $course->shortname,
+            'customfield_mydate' => '2020-05-06', // Out of range.
+        ];
+
+        $uploader = new tool_uploadcourse_course($mode, $updatemode, $dataupload);
+        $this->assertFalse($uploader->prepare());
+        $this->assertArrayHasKey('customfieldinvalid', $uploader->get_errors());
+    }
+
     public function test_idnumber_problems() {
         $this->resetAfterTest(true);
 
@@ -1224,7 +1354,34 @@ class tool_uploadcourse_course_testcase extends advanced_testcase {
         $co = new tool_uploadcourse_course($mode, $updatemode, $data, array(), $importoptions);
         $this->assertFalse($co->prepare());
         $this->assertArrayHasKey('cannotrenameshortnamealreadyinuse', $co->get_errors());
+    }
 
+    /**
+     * Get custom field plugin generator
+     *
+     * @return core_customfield_generator
+     */
+    protected function get_customfield_generator() : core_customfield_generator {
+        return $this->getDataGenerator()->get_plugin_generator('core_customfield');
     }
 
-}
+    /**
+     * Helper method to create custom course field
+     *
+     * @param \core_customfield\category_controller $category
+     * @param string $type
+     * @param string $shortname
+     * @param array $configdata
+     * @return \core_customfield\field_controller
+     */
+    protected function create_custom_field(\core_customfield\category_controller $category, string $type, string $shortname,
+            array $configdata = []) : \core_customfield\field_controller {
+
+        return $this->get_customfield_generator()->create_field([
+            'categoryid' => $category->get('id'),
+            'type' => $type,
+            'shortname' => $shortname,
+            'configdata' => $configdata,
+        ]);
+    }
+}
\ No newline at end of file
diff --git a/admin/tool/uploadcourse/tests/fixtures/courses_custom_fields.csv b/admin/tool/uploadcourse/tests/fixtures/courses_custom_fields.csv
new file mode 100644 (file)
index 0000000..f949599
--- /dev/null
@@ -0,0 +1,2 @@
+shortname,fullname,summary,category,customfield_checkbox,customfield_date,customfield_select,customfield_text,customfield_textarea
+CF1,Course fields 1,Testing course fields,1,1,2019-10-01 14:00,b,Hello,Goodbye
\ No newline at end of file
index 6768507..feff463 100644 (file)
@@ -250,6 +250,81 @@ class tool_uploadcourse_helper_testcase extends advanced_testcase {
         $this->assertArrayHasKey('invalidroles', $errors);
     }
 
+    /**
+     * Test custom field data processing
+     */
+    public function test_get_custom_course_field_data() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        // Create all the fields!
+        $category = $this->get_customfield_generator()->create_category();
+
+        $checkboxfield = $this->create_custom_field($category, 'checkbox', 'mycheckbox');
+        $datefield = $this->create_custom_field($category, 'date', 'mydate');
+        $selectfield = $this->create_custom_field($category, 'select', 'myselect', ['options' => "Red\nGreen\nBlue"]);
+        $textfield = $this->create_custom_field($category, 'text', 'mytext', ['locked' => 1]);
+        $textareafield = $this->create_custom_field($category, 'textarea', 'mytextarea');
+
+        $fields = tool_uploadcourse_helper::get_custom_course_fields();
+        $this->assertCount(5, $fields);
+
+        $this->assertArrayHasKey($checkboxfield->get('shortname'), $fields);
+        $this->assertInstanceOf(customfield_checkbox\field_controller::class, $fields[$checkboxfield->get('shortname')]);
+
+        $this->assertArrayHasKey($datefield->get('shortname'), $fields);
+        $this->assertInstanceOf(customfield_date\field_controller::class, $fields[$datefield->get('shortname')]);
+
+        $this->assertArrayHasKey($selectfield->get('shortname'), $fields);
+        $this->assertInstanceOf(customfield_select\field_controller::class, $fields[$selectfield->get('shortname')]);
+
+        $this->assertArrayHasKey($textfield->get('shortname'), $fields);
+        $this->assertInstanceOf(customfield_text\field_controller::class, $fields[$textfield->get('shortname')]);
+
+        $this->assertArrayHasKey($textareafield->get('shortname'), $fields);
+        $this->assertInstanceOf(customfield_textarea\field_controller::class, $fields[$textareafield->get('shortname')]);
+
+        $data = [
+            'customfield_mycheckbox' => '1',
+            'customfield_mydate' => '2019-10-01',
+            'customfield_myselect' => 'Green',
+            'customfield_mytext' => 'Hello',
+            'customfield_myunknownfield' => 'Goodbye',
+        ];
+
+        $expected = [
+            'customfield_mycheckbox' => '1',
+            'customfield_mydate' => strtotime('2019-10-01'),
+            'customfield_myselect' => 2,
+            'customfield_mytext' => 'Hello',
+        ];
+
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'manager');
+        $this->setUser($user);
+
+        $context = context_course::instance($course->id);
+
+        $this->assertEquals($expected, tool_uploadcourse_helper::get_custom_course_field_data($data, [], $context));
+
+        // Now add our custom textarea field (separately because the value of it's 'itemid' element is unknown).
+        $data['customfield_mytextarea'] = 'Something';
+        $fields = tool_uploadcourse_helper::get_custom_course_field_data($data, [], $context);
+        $this->assertArrayHasKey('customfield_mytextarea_editor', $fields);
+        $this->assertArrayHasKey('text', $fields['customfield_mytextarea_editor']);
+        $this->assertEquals('Something', $fields['customfield_mytextarea_editor']['text']);
+
+        // Now prohibit the capability to change locked fields for the manager role.
+        $managerrole = $DB->get_record('role', ['shortname' => 'manager']);
+        role_change_permission($managerrole->id, $context, 'moodle/course:changelockedcustomfields', CAP_PROHIBIT);
+
+        // The locked 'mytext' custom field should not be returned.
+        $fields = tool_uploadcourse_helper::get_custom_course_field_data($data, [], $context);
+        $this->assertCount(4, $fields);
+        $this->assertArrayNotHasKey('customfield_mytext', $fields);
+    }
+
     public function test_increment_idnumber() {
         $this->resetAfterTest(true);
 
@@ -394,4 +469,33 @@ class tool_uploadcourse_helper_testcase extends advanced_testcase {
         $this->assertEquals($cat3_fakedouble->id, tool_uploadcourse_helper::resolve_category_by_path($path));
         $this->assertEquals($cat3_fakedouble->id, tool_uploadcourse_helper::resolve_category_by_path($path));
     }
-}
+
+    /**
+     * Get custom field plugin generator
+     *
+     * @return core_customfield_generator
+     */
+    protected function get_customfield_generator() : core_customfield_generator {
+        return $this->getDataGenerator()->get_plugin_generator('core_customfield');
+    }
+
+    /**
+     * Helper method to create custom course field
+     *
+     * @param \core_customfield\category_controller $category
+     * @param string $type
+     * @param string $shortname
+     * @param array $configdata
+     * @return \core_customfield\field_controller
+     */
+    protected function create_custom_field(\core_customfield\category_controller $category, string $type, string $shortname,
+            array $configdata = []) : \core_customfield\field_controller {
+
+        return $this->get_customfield_generator()->create_field([
+            'categoryid' => $category->get('id'),
+            'type' => $type,
+            'shortname' => $shortname,
+            'configdata' => $configdata,
+        ]);
+    }
+}
\ No newline at end of file
index 77ababc..e879835 100644 (file)
@@ -158,7 +158,7 @@ $string['newtablefrommysql'] = 'New table from MySQL';
 $string['new_table_from_mysql'] = 'New table from MySQL';
 $string['nofieldsspecified'] = 'No fields specified';
 $string['nomasterprimaryuniquefound'] = 'The column(s) that your foreign key references must be included in a primary or unique KEY in the referenced table. Note that the column being in a UNIQUE INDEX is not good enough.';
-$string['nomissingorextraindexesfound'] = 'No missing or extra indexes have been found, your DB doesn\'t need further actions.';
+$string['nomissingorextraindexesfound'] = 'No missing or extra indexes have been found, so no further action is required.';
 $string['noreffieldsspecified'] = 'No reference fields specified';
 $string['noreftablespecified'] = 'Specified reference table not found';
 $string['noviolatedforeignkeysfound'] = 'No violated foreign keys found';
index 1b2a7df..ac42715 100644 (file)
@@ -16,4 +16,4 @@ Feature: Add URL to main menu block
       | External URL | http://www.google.com |
       | id_display | In pop-up |
     Then "google" "link" should exist in the "Main menu" "block"
-    And "Add an activity or resource" "button" should exist in the "Main menu" "block"
+    And "Add an activity" "button" should exist in the "Main menu" "block"
index 2776339..dc4821b 100644 (file)
@@ -361,20 +361,23 @@ class cache_helper {
     /**
      * Ensure that the stats array is ready to collect information for the given store and definition.
      * @param string $store
+     * @param string $storeclass
      * @param string $definition A string that identifies the definition.
      * @param int $mode One of cache_store::MODE_*. Since 2.9.
      */
-    protected static function ensure_ready_for_stats($store, $definition, $mode = cache_store::MODE_APPLICATION) {
+    protected static function ensure_ready_for_stats($store, $storeclass, $definition, $mode = cache_store::MODE_APPLICATION) {
         // This function is performance-sensitive, so exit as quickly as possible
         // if we do not need to do anything.
         if (isset(self::$stats[$definition]['stores'][$store])) {
             return;
         }
+
         if (!array_key_exists($definition, self::$stats)) {
             self::$stats[$definition] = array(
                 'mode' => $mode,
                 'stores' => array(
                     $store => array(
+                        'class' => $storeclass,
                         'hits' => 0,
                         'misses' => 0,
                         'sets' => 0,
@@ -383,6 +386,7 @@ class cache_helper {
             );
         } else if (!array_key_exists($store, self::$stats[$definition]['stores'])) {
             self::$stats[$definition]['stores'][$store] = array(
+                'class' => $storeclass,
                 'hits' => 0,
                 'misses' => 0,
                 'sets' => 0,
@@ -418,15 +422,22 @@ class cache_helper {
      * In Moodle 2.9 the $definition argument changed from accepting only a string to accepting a string or a
      * cache_definition instance. It is preferable to pass a cache definition instance.
      *
+     * In Moodle 3.9 the first argument changed to also accept a cache_store.
+     *
      * @internal
-     * @param cache_definition $store
+     * @param string|cache_store $store
      * @param cache_definition $definition You used to be able to pass a string here, however that is deprecated please pass the
      *      actual cache_definition object now.
      * @param int $hits The number of hits to record (by default 1)
      */
     public static function record_cache_hit($store, $definition, $hits = 1) {
+        $storeclass = '';
+        if ($store instanceof cache_store) {
+            $storeclass = get_class($store);
+            $store = $store->my_name();
+        }
         list($definitionstr, $mode) = self::get_definition_stat_id_and_mode($definition);
-        self::ensure_ready_for_stats($store, $definitionstr, $mode);
+        self::ensure_ready_for_stats($store, $storeclass, $definitionstr, $mode);
         self::$stats[$definitionstr]['stores'][$store]['hits'] += $hits;
     }
 
@@ -436,15 +447,22 @@ class cache_helper {
      * In Moodle 2.9 the $definition argument changed from accepting only a string to accepting a string or a
      * cache_definition instance. It is preferable to pass a cache definition instance.
      *
+     * In Moodle 3.9 the first argument changed to also accept a cache_store.
+     *
      * @internal
-     * @param string $store
+     * @param string|cache_store $store
      * @param cache_definition $definition You used to be able to pass a string here, however that is deprecated please pass the
      *      actual cache_definition object now.
      * @param int $misses The number of misses to record (by default 1)
      */
     public static function record_cache_miss($store, $definition, $misses = 1) {
+        $storeclass = '';
+        if ($store instanceof cache_store) {
+            $storeclass = get_class($store);
+            $store = $store->my_name();
+        }
         list($definitionstr, $mode) = self::get_definition_stat_id_and_mode($definition);
-        self::ensure_ready_for_stats($store, $definitionstr, $mode);
+        self::ensure_ready_for_stats($store, $storeclass, $definitionstr, $mode);
         self::$stats[$definitionstr]['stores'][$store]['misses'] += $misses;
     }
 
@@ -454,15 +472,22 @@ class cache_helper {
      * In Moodle 2.9 the $definition argument changed from accepting only a string to accepting a string or a
      * cache_definition instance. It is preferable to pass a cache definition instance.
      *
+     * In Moodle 3.9 the first argument changed to also accept a cache_store.
+     *
      * @internal
-     * @param string $store
+     * @param string|cache_store $store
      * @param cache_definition $definition You used to be able to pass a string here, however that is deprecated please pass the
      *      actual cache_definition object now.
      * @param int $sets The number of sets to record (by default 1)
      */
     public static function record_cache_set($store, $definition, $sets = 1) {
+        $storeclass = '';
+        if ($store instanceof cache_store) {
+            $storeclass = get_class($store);
+            $store = $store->my_name();
+        }
         list($definitionstr, $mode) = self::get_definition_stat_id_and_mode($definition);
-        self::ensure_ready_for_stats($store, $definitionstr, $mode);
+        self::ensure_ready_for_stats($store, $storeclass, $definitionstr, $mode);
         self::$stats[$definitionstr]['stores'][$store]['sets'] += $sets;
     }
 
index 8cd7914..6236cb0 100644 (file)
@@ -414,7 +414,7 @@ class cache implements cache_loader {
         $setaftervalidation = false;
         if ($result === false) {
             if ($this->perfdebug) {
-                cache_helper::record_cache_miss($this->storetype, $this->definition);
+                cache_helper::record_cache_miss($this->store, $this->definition);
             }
             if ($this->loader !== false) {
                 // We must pass the original (unparsed) key to the next loader in the chain.
@@ -426,7 +426,7 @@ class cache implements cache_loader {
             }
             $setaftervalidation = ($result !== false);
         } else if ($this->perfdebug) {
-            cache_helper::record_cache_hit($this->storetype, $this->definition);
+            cache_helper::record_cache_hit($this->store, $this->definition);
         }
         // 5. Validate strictness.
         if ($strictness === MUST_EXIST && $result === false) {
@@ -580,8 +580,8 @@ class cache implements cache_loader {
                     $hits++;
                 }
             }
-            cache_helper::record_cache_hit($this->storetype, $this->definition, $hits);
-            cache_helper::record_cache_miss($this->storetype, $this->definition, $misses);
+            cache_helper::record_cache_hit($this->store, $this->definition, $hits);
+            cache_helper::record_cache_miss($this->store, $this->definition, $misses);
         }
 
         // Return the result. Phew!
@@ -607,7 +607,7 @@ class cache implements cache_loader {
      */
     public function set($key, $data) {
         if ($this->perfdebug) {
-            cache_helper::record_cache_set($this->storetype, $this->definition);
+            cache_helper::record_cache_set($this->store, $this->definition);
         }
         if ($this->loader !== false) {
             // We have a loader available set it there as well.
@@ -762,7 +762,7 @@ class cache implements cache_loader {
         }
         $successfullyset = $this->store->set_many($data);
         if ($this->perfdebug && $successfullyset) {
-            cache_helper::record_cache_set($this->storetype, $this->definition, $successfullyset);
+            cache_helper::record_cache_set($this->store, $this->definition, $successfullyset);
         }
         return $successfullyset;
     }
@@ -1112,7 +1112,7 @@ class cache implements cache_loader {
         }
         if ($result !== false) {
             if ($this->perfdebug) {
-                cache_helper::record_cache_hit('** static acceleration **', $this->definition);
+                cache_helper::record_cache_hit(cache_store::STATIC_ACCEL, $this->definition);
             }
             if ($this->staticaccelerationsize > 1 && $this->staticaccelerationcount > 1) {
                 // Check to see if this is the last item on the static acceleration keys array.
@@ -1126,7 +1126,7 @@ class cache implements cache_loader {
             return $result;
         } else {
             if ($this->perfdebug) {
-                cache_helper::record_cache_miss('** static acceleration **', $this->definition);
+                cache_helper::record_cache_miss(cache_store::STATIC_ACCEL, $this->definition);
             }
             return false;
         }
@@ -1830,7 +1830,7 @@ class cache_session extends cache {
         // 4. Load if from the loader/datasource if we don't already have it.
         if ($result === false) {
             if ($this->perfdebug) {
-                cache_helper::record_cache_miss($this->storetype, $this->get_definition());
+                cache_helper::record_cache_miss($this->get_store(), $this->get_definition());
             }
             if ($this->get_loader() !== false) {
                 // We must pass the original (unparsed) key to the next loader in the chain.
@@ -1845,7 +1845,7 @@ class cache_session extends cache {
                 $this->set($key, $result);
             }
         } else if ($this->perfdebug) {
-            cache_helper::record_cache_hit($this->storetype, $this->get_definition());
+            cache_helper::record_cache_hit($this->get_store(), $this->get_definition());
         }
         // 5. Validate strictness.
         if ($strictness === MUST_EXIST && $result === false) {
@@ -1889,7 +1889,7 @@ class cache_session extends cache {
             $loader->set($key, $data);
         }
         if ($this->perfdebug) {
-            cache_helper::record_cache_set($this->storetype, $this->get_definition());
+            cache_helper::record_cache_set($this->get_store(), $this->get_definition());
         }
         if (is_object($data) && $data instanceof cacheable_object) {
             $data = new cache_cached_object($data);
@@ -2019,8 +2019,8 @@ class cache_session extends cache {
                     $hits++;
                 }
             }
-            cache_helper::record_cache_hit($this->storetype, $this->get_definition(), $hits);
-            cache_helper::record_cache_miss($this->storetype, $this->get_definition(), $misses);
+            cache_helper::record_cache_hit($this->get_store(), $this->get_definition(), $hits);
+            cache_helper::record_cache_miss($this->get_store(), $this->get_definition(), $misses);
         }
         return $return;
 
@@ -2097,7 +2097,7 @@ class cache_session extends cache {
         }
         $successfullyset = $this->get_store()->set_many($data);
         if ($this->perfdebug && $successfullyset) {
-            cache_helper::record_cache_set($this->storetype, $this->get_definition(), $successfullyset);
+            cache_helper::record_cache_set($this->store, $this->get_definition(), $successfullyset);
         }
         return $successfullyset;
     }
index 4fcb03f..a2cfe3e 100644 (file)
@@ -144,6 +144,10 @@ abstract class cache_store implements cache_store_interface {
      * Request caches. Static caches really.
      */
     const MODE_REQUEST = 4;
+    /**
+     * Static caches.
+     */
+    const STATIC_ACCEL = '** static accel. **';
 
     /**
      * Constructs an instance of the cache store.
index 9c0f1d9..0e6b203 100644 (file)
@@ -2092,15 +2092,15 @@ class core_cache_testcase extends advanced_testcase {
         $this->assertFalse($request->get('missMe'));
 
         $endstats = cache_helper::get_stats();
-        $this->assertEquals(2, $endstats[$applicationid]['stores']['cachestore_file']['misses']);
-        $this->assertEquals(0, $endstats[$applicationid]['stores']['cachestore_file']['hits']);
-        $this->assertEquals(0, $endstats[$applicationid]['stores']['cachestore_file']['sets']);
-        $this->assertEquals(3, $endstats[$sessionid]['stores']['cachestore_session']['misses']);
-        $this->assertEquals(0, $endstats[$sessionid]['stores']['cachestore_session']['hits']);
-        $this->assertEquals(1, $endstats[$sessionid]['stores']['cachestore_session']['sets']);
-        $this->assertEquals(4, $endstats[$requestid]['stores']['cachestore_static']['misses']);
-        $this->assertEquals(0, $endstats[$requestid]['stores']['cachestore_static']['hits']);
-        $this->assertEquals(0, $endstats[$requestid]['stores']['cachestore_static']['sets']);
+        $this->assertEquals(2, $endstats[$applicationid]['stores']['default_application']['misses']);
+        $this->assertEquals(0, $endstats[$applicationid]['stores']['default_application']['hits']);
+        $this->assertEquals(0, $endstats[$applicationid]['stores']['default_application']['sets']);
+        $this->assertEquals(3, $endstats[$sessionid]['stores']['default_session']['misses']);
+        $this->assertEquals(0, $endstats[$sessionid]['stores']['default_session']['hits']);
+        $this->assertEquals(1, $endstats[$sessionid]['stores']['default_session']['sets']);
+        $this->assertEquals(4, $endstats[$requestid]['stores']['default_request']['misses']);
+        $this->assertEquals(0, $endstats[$requestid]['stores']['default_request']['hits']);
+        $this->assertEquals(0, $endstats[$requestid]['stores']['default_request']['sets']);
 
         $startstats = cache_helper::get_stats();
 
@@ -2116,24 +2116,24 @@ class core_cache_testcase extends advanced_testcase {
         $this->assertTrue($request->set('setMe4', 4));
 
         $endstats = cache_helper::get_stats();
-        $this->assertEquals(0, $endstats[$applicationid]['stores']['cachestore_file']['misses'] -
-            $startstats[$applicationid]['stores']['cachestore_file']['misses']);
-        $this->assertEquals(0, $endstats[$applicationid]['stores']['cachestore_file']['hits'] -
-            $startstats[$applicationid]['stores']['cachestore_file']['hits']);
-        $this->assertEquals(2, $endstats[$applicationid]['stores']['cachestore_file']['sets'] -
-            $startstats[$applicationid]['stores']['cachestore_file']['sets']);
-        $this->assertEquals(0, $endstats[$sessionid]['stores']['cachestore_session']['misses'] -
-            $startstats[$sessionid]['stores']['cachestore_session']['misses']);
-        $this->assertEquals(0, $endstats[$sessionid]['stores']['cachestore_session']['hits'] -
-            $startstats[$sessionid]['stores']['cachestore_session']['hits']);
-        $this->assertEquals(3, $endstats[$sessionid]['stores']['cachestore_session']['sets'] -
-            $startstats[$sessionid]['stores']['cachestore_session']['sets']);
-        $this->assertEquals(0, $endstats[$requestid]['stores']['cachestore_static']['misses'] -
-            $startstats[$requestid]['stores']['cachestore_static']['misses']);
-        $this->assertEquals(0, $endstats[$requestid]['stores']['cachestore_static']['hits'] -
-            $startstats[$requestid]['stores']['cachestore_static']['hits']);
-        $this->assertEquals(4, $endstats[$requestid]['stores']['cachestore_static']['sets'] -
-            $startstats[$requestid]['stores']['cachestore_static']['sets']);
+        $this->assertEquals(0, $endstats[$applicationid]['stores']['default_application']['misses'] -
+                             $startstats[$applicationid]['stores']['default_application']['misses']);
+        $this->assertEquals(0, $endstats[$applicationid]['stores']['default_application']['hits'] -
+                             $startstats[$applicationid]['stores']['default_application']['hits']);
+        $this->assertEquals(2, $endstats[$applicationid]['stores']['default_application']['sets'] -
+                             $startstats[$applicationid]['stores']['default_application']['sets']);
+        $this->assertEquals(0, $endstats[$sessionid]['stores']['default_session']['misses'] -
+                             $startstats[$sessionid]['stores']['default_session']['misses']);
+        $this->assertEquals(0, $endstats[$sessionid]['stores']['default_session']['hits'] -
+                             $startstats[$sessionid]['stores']['default_session']['hits']);
+        $this->assertEquals(3, $endstats[$sessionid]['stores']['default_session']['sets'] -
+                             $startstats[$sessionid]['stores']['default_session']['sets']);
+        $this->assertEquals(0, $endstats[$requestid]['stores']['default_request']['misses'] -
+                             $startstats[$requestid]['stores']['default_request']['misses']);
+        $this->assertEquals(0, $endstats[$requestid]['stores']['default_request']['hits'] -
+                             $startstats[$requestid]['stores']['default_request']['hits']);
+        $this->assertEquals(4, $endstats[$requestid]['stores']['default_request']['sets'] -
+                             $startstats[$requestid]['stores']['default_request']['sets']);
 
         $startstats = cache_helper::get_stats();
 
@@ -2149,24 +2149,24 @@ class core_cache_testcase extends advanced_testcase {
         $this->assertEquals($request->get('setMe4'), 4);
 
         $endstats = cache_helper::get_stats();
-        $this->assertEquals(0, $endstats[$applicationid]['stores']['cachestore_file']['misses'] -
-            $startstats[$applicationid]['stores']['cachestore_file']['misses']);
-        $this->assertEquals(2, $endstats[$applicationid]['stores']['cachestore_file']['hits'] -
-            $startstats[$applicationid]['stores']['cachestore_file']['hits']);
-        $this->assertEquals(0, $endstats[$applicationid]['stores']['cachestore_file']['sets'] -
-            $startstats[$applicationid]['stores']['cachestore_file']['sets']);
-        $this->assertEquals(0, $endstats[$sessionid]['stores']['cachestore_session']['misses'] -
-            $startstats[$sessionid]['stores']['cachestore_session']['misses']);
-        $this->assertEquals(3, $endstats[$sessionid]['stores']['cachestore_session']['hits'] -
-            $startstats[$sessionid]['stores']['cachestore_session']['hits']);
-        $this->assertEquals(0, $endstats[$sessionid]['stores']['cachestore_session']['sets'] -
-            $startstats[$sessionid]['stores']['cachestore_session']['sets']);
-        $this->assertEquals(0, $endstats[$requestid]['stores']['cachestore_static']['misses'] -
-            $startstats[$requestid]['stores']['cachestore_static']['misses']);
-        $this->assertEquals(4, $endstats[$requestid]['stores']['cachestore_static']['hits'] -
-            $startstats[$requestid]['stores']['cachestore_static']['hits']);
-        $this->assertEquals(0, $endstats[$requestid]['stores']['cachestore_static']['sets'] -
-            $startstats[$requestid]['stores']['cachestore_static']['sets']);
+        $this->assertEquals(0, $endstats[$applicationid]['stores']['default_application']['misses'] -
+                             $startstats[$applicationid]['stores']['default_application']['misses']);
+        $this->assertEquals(2, $endstats[$applicationid]['stores']['default_application']['hits'] -
+                             $startstats[$applicationid]['stores']['default_application']['hits']);
+        $this->assertEquals(0, $endstats[$applicationid]['stores']['default_application']['sets'] -
+                             $startstats[$applicationid]['stores']['default_application']['sets']);
+        $this->assertEquals(0, $endstats[$sessionid]['stores']['default_session']['misses'] -
+                             $startstats[$sessionid]['stores']['default_session']['misses']);
+        $this->assertEquals(3, $endstats[$sessionid]['stores']['default_session']['hits'] -
+                             $startstats[$sessionid]['stores']['default_session']['hits']);
+        $this->assertEquals(0, $endstats[$sessionid]['stores']['default_session']['sets'] -
+                             $startstats[$sessionid]['stores']['default_session']['sets']);
+        $this->assertEquals(0, $endstats[$requestid]['stores']['default_request']['misses'] -
+                             $startstats[$requestid]['stores']['default_request']['misses']);
+        $this->assertEquals(4, $endstats[$requestid]['stores']['default_request']['hits'] -
+                             $startstats[$requestid]['stores']['default_request']['hits']);
+        $this->assertEquals(0, $endstats[$requestid]['stores']['default_request']['sets'] -
+                             $startstats[$requestid]['stores']['default_request']['sets']);
 
         $startstats = cache_helper::get_stats();
 
@@ -2176,24 +2176,24 @@ class core_cache_testcase extends advanced_testcase {
         $request->get_many(array('setMe1', 'setMe2', 'setMe3', 'setMe4'));
 
         $endstats = cache_helper::get_stats();
-        $this->assertEquals(0, $endstats[$applicationid]['stores']['cachestore_file']['misses'] -
-            $startstats[$applicationid]['stores']['cachestore_file']['misses']);
-        $this->assertEquals(2, $endstats[$applicationid]['stores']['cachestore_file']['hits'] -
-            $startstats[$applicationid]['stores']['cachestore_file']['hits']);
-        $this->assertEquals(0, $endstats[$applicationid]['stores']['cachestore_file']['sets'] -
-            $startstats[$applicationid]['stores']['cachestore_file']['sets']);
-        $this->assertEquals(0, $endstats[$sessionid]['stores']['cachestore_session']['misses'] -
-            $startstats[$sessionid]['stores']['cachestore_session']['misses']);
-        $this->assertEquals(3, $endstats[$sessionid]['stores']['cachestore_session']['hits'] -
-            $startstats[$sessionid]['stores']['cachestore_session']['hits']);
-        $this->assertEquals(0, $endstats[$sessionid]['stores']['cachestore_session']['sets'] -
-            $startstats[$sessionid]['stores']['cachestore_session']['sets']);
-        $this->assertEquals(0, $endstats[$requestid]['stores']['cachestore_static']['misses'] -
-            $startstats[$requestid]['stores']['cachestore_static']['misses']);
-        $this->assertEquals(4, $endstats[$requestid]['stores']['cachestore_static']['hits'] -
-            $startstats[$requestid]['stores']['cachestore_static']['hits']);
-        $this->assertEquals(0, $endstats[$requestid]['stores']['cachestore_static']['sets'] -
-            $startstats[$requestid]['stores']['cachestore_static']['sets']);
+        $this->assertEquals(0, $endstats[$applicationid]['stores']['default_application']['misses'] -
+                             $startstats[$applicationid]['stores']['default_application']['misses']);
+        $this->assertEquals(2, $endstats[$applicationid]['stores']['default_application']['hits'] -
+                             $startstats[$applicationid]['stores']['default_application']['hits']);
+        $this->assertEquals(0, $endstats[$applicationid]['stores']['default_application']['sets'] -
+                             $startstats[$applicationid]['stores']['default_application']['sets']);
+        $this->assertEquals(0, $endstats[$sessionid]['stores']['default_session']['misses'] -
+                             $startstats[$sessionid]['stores']['default_session']['misses']);
+        $this->assertEquals(3, $endstats[$sessionid]['stores']['default_session']['hits'] -
+                             $startstats[$sessionid]['stores']['default_session']['hits']);
+        $this->assertEquals(0, $endstats[$sessionid]['stores']['default_session']['sets'] -
+                             $startstats[$sessionid]['stores']['default_session']['sets']);
+        $this->assertEquals(0, $endstats[$requestid]['stores']['default_request']['misses'] -
+                             $startstats[$requestid]['stores']['default_request']['misses']);
+        $this->assertEquals(4, $endstats[$requestid]['stores']['default_request']['hits'] -
+                             $startstats[$requestid]['stores']['default_request']['hits']);
+        $this->assertEquals(0, $endstats[$requestid]['stores']['default_request']['sets'] -
+                             $startstats[$requestid]['stores']['default_request']['sets']);
     }
 
     public function test_static_cache() {
@@ -2225,8 +2225,8 @@ class core_cache_testcase extends advanced_testcase {
 
         // Check that the static acceleration worked, even on empty arrays and the number 0.
         $endstats = cache_helper::get_stats();
-        $this->assertEquals(0, $endstats[$applicationid]['stores']['** static acceleration **']['misses']);
-        $this->assertEquals(3, $endstats[$applicationid]['stores']['** static acceleration **']['hits']);
+        $this->assertEquals(0, $endstats[$applicationid]['stores']['** static accel. **']['misses']);
+        $this->assertEquals(3, $endstats[$applicationid]['stores']['** static accel. **']['hits']);
     }
 
     public function test_performance_debug_off() {
index eaf6344..076bd53 100644 (file)
@@ -1,6 +1,9 @@
 This files describes API changes in /cache/stores/* - cache store plugins.
 Information provided here is intended especially for developers.
 
+=== 3.9 ===
+* The record_cache_hit/miss/set methods now take a cache_store instead of a cache_definition object
+
 === 3.8 ===
 * The Redis cache store can now make use of the Zstandard compression algorithm (see MDL-66428).
 
index 9a3f1d0..3b08693 100644 (file)
@@ -13,7 +13,7 @@
     "require-dev": {
         "phpunit/phpunit": "7.5.*",
         "phpunit/dbunit": "4.0.*",
-        "moodlehq/behat-extension": "3.39.2",
+        "moodlehq/behat-extension": "3.39.3",
         "mikey179/vfsstream": "^1.6",
         "instaclick/php-webdriver": "dev-local as 1.x-dev"
     }
index 7fd4234..11dbff6 100644 (file)
@@ -4,42 +4,44 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "a2329ee2d14a351b74f99322f42722da",
+    "content-hash": "b1953ceec577434625a7aee12f650daa",
     "packages": [],
     "packages-dev": [
         {
             "name": "behat/behat",
-            "version": "v3.5.0",
+            "version": "v3.6.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Behat/Behat.git",
-                "reference": "e4bce688be0c2029dc1700e46058d86428c63cab"
+                "reference": "9bfe195b4745c32e068af03fa4df9558b4916d30"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Behat/Behat/zipball/e4bce688be0c2029dc1700e46058d86428c63cab",
-                "reference": "e4bce688be0c2029dc1700e46058d86428c63cab",
+                "url": "https://api.github.com/repos/Behat/Behat/zipball/9bfe195b4745c32e068af03fa4df9558b4916d30",
+                "reference": "9bfe195b4745c32e068af03fa4df9558b4916d30",
                 "shasum": ""
             },
             "require": {
-                "behat/gherkin": "^4.5.1",
+                "behat/gherkin": "^4.6.0",
                 "behat/transliterator": "^1.2",
                 "container-interop/container-interop": "^1.2",
                 "ext-mbstring": "*",
                 "php": ">=5.3.3",
                 "psr/container": "^1.0",
-                "symfony/class-loader": "~2.1||~3.0",
-                "symfony/config": "~2.3||~3.0||~4.0",
-                "symfony/console": "~2.7.40||^2.8.33||~3.3.15||^3.4.3||^4.0.3",
-                "symfony/dependency-injection": "~2.1||~3.0||~4.0",
-                "symfony/event-dispatcher": "~2.1||~3.0||~4.0",
-                "symfony/translation": "~2.3||~3.0||~4.0",
-                "symfony/yaml": "~2.1||~3.0||~4.0"
+                "symfony/config": "^2.7.51 || ^3.0 || ^4.0 || ^5.0",
+                "symfony/console": "^2.7.51 || ^2.8.33 || ^3.3.15 || ^3.4.3 || ^4.0.3 || ^5.0",
+                "symfony/dependency-injection": "^2.7.51 || ^3.0 || ^4.0 || ^5.0",
+                "symfony/event-dispatcher": "^2.7.51 || ^3.0 || ^4.0 || ^5.0",
+                "symfony/translation": "^2.7.51 || ^3.0 || ^4.0 || ^5.0",
+                "symfony/yaml": "^2.7.51 || ^3.0 || ^4.0 || ^5.0"
             },
             "require-dev": {
                 "herrera-io/box": "~1.6.1",
-                "phpunit/phpunit": "^4.8.36|^6.3",
-                "symfony/process": "~2.5|~3.0|~4.0"
+                "phpunit/phpunit": "^4.8.36 || ^6.3",
+                "symfony/process": "~2.5 || ^3.0 || ^4.0 || ^5.0"
+            },
+            "suggest": {
+                "ext-dom": "Needed to output test results in JUnit format."
             },
             "bin": [
                 "bin/behat"
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.5.x-dev"
+                    "dev-master": "3.6.x-dev"
                 }
             },
             "autoload": {
-                "psr-0": {
-                    "Behat\\Behat": "src/",
-                    "Behat\\Testwork": "src/"
+                "psr-4": {
+                    "Behat\\Behat\\": "src/Behat/Behat/",
+                    "Behat\\Testwork\\": "src/Behat/Testwork/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
@@ -83,7 +85,7 @@
                 "symfony",
                 "testing"
             ],
-            "time": "2018-08-10T18:56:51+00:00"
+            "time": "2020-02-06T09:54:48+00:00"
         },
         {
             "name": "behat/gherkin",
         },
         {
             "name": "guzzlehttp/guzzle",
-            "version": "6.5.2",
+            "version": "6.5.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/guzzle.git",
-                "reference": "43ece0e75098b7ecd8d13918293029e555a50f82"
+                "reference": "aab4ebd862aa7d04f01a4b51849d657db56d882e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/43ece0e75098b7ecd8d13918293029e555a50f82",
-                "reference": "43ece0e75098b7ecd8d13918293029e555a50f82",
+                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/aab4ebd862aa7d04f01a4b51849d657db56d882e",
+                "reference": "aab4ebd862aa7d04f01a4b51849d657db56d882e",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
                 "guzzlehttp/promises": "^1.0",
                 "guzzlehttp/psr7": "^1.6.1",
-                "php": ">=5.5"
+                "php": ">=5.5",
+                "symfony/polyfill-intl-idn": "^1.11"
             },
             "require-dev": {
                 "ext-curl": "*",
                 "psr/log": "^1.1"
             },
             "suggest": {
-                "ext-intl": "Required for Internationalized Domain Name (IDN) support",
                 "psr/log": "Required for using the Log middleware"
             },
             "type": "library",
                 "rest",
                 "web service"
             ],
-            "time": "2019-12-23T11:57:10+00:00"
+            "time": "2020-04-18T10:38:46+00:00"
         },
         {
             "name": "guzzlehttp/promises",
         },
         {
             "name": "moodlehq/behat-extension",
-            "version": "v3.39.2",
+            "version": "v3.39.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/moodlehq/moodle-behat-extension.git",
-                "reference": "7a2df2124ba8a85ccf21e517d18c78f932bdbbce"
+                "reference": "d05ea443ff24f90edb9b31c92e4dfe67c58a0b4b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/moodlehq/moodle-behat-extension/zipball/7a2df2124ba8a85ccf21e517d18c78f932bdbbce",
-                "reference": "7a2df2124ba8a85ccf21e517d18c78f932bdbbce",
+                "url": "https://api.github.com/repos/moodlehq/moodle-behat-extension/zipball/d05ea443ff24f90edb9b31c92e4dfe67c58a0b4b",
+                "reference": "d05ea443ff24f90edb9b31c92e4dfe67c58a0b4b",
                 "shasum": ""
             },
             "require": {
-                "behat/behat": "3.5.*",
+                "behat/behat": "3.6.*",
                 "behat/mink": "~1.8",
-                "behat/mink-extension": "~2.2",
+                "behat/mink-extension": "~2.3",
                 "behat/mink-goutte-driver": "~1.2",
-                "behat/mink-selenium2-driver": "~1.3",
+                "behat/mink-selenium2-driver": "~1.4",
                 "php": ">=7.2.0",
-                "symfony/process": "2.8.*"
+                "symfony/process": "^4.0 || ^5.0"
             },
             "type": "library",
             "autoload": {
                 "Behat",
                 "moodle"
             ],
-            "time": "2020-04-09T16:06:14+00:00"
+            "time": "2020-04-20T09:32:44+00:00"
         },
         {
             "name": "myclabs/deep-copy",
             "time": "2017-02-14T16:28:37+00:00"
         },
         {
-            "name": "psr/http-message",
-            "version": "1.0.1",
+            "name": "psr/event-dispatcher",
+            "version": "1.0.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/php-fig/http-message.git",
-                "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363"
+                "url": "https://github.com/php-fig/event-dispatcher.git",
+                "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363",
-                "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363",
+                "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
+                "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.0"
+                "php": ">=7.2.0"
             },
             "type": "library",
             "extra": {
             },
             "autoload": {
                 "psr-4": {
-                    "Psr\\Http\\Message\\": "src/"
+                    "Psr\\EventDispatcher\\": "src/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
                     "homepage": "http://www.php-fig.org/"
                 }
             ],
-            "description": "Common interface for HTTP messages",
-            "homepage": "https://github.com/php-fig/http-message",
+            "description": "Standard interfaces for event handling.",
             "keywords": [
-                "http",
-                "http-message",
+                "events",
                 "psr",
-                "psr-7",
-                "request",
-                "response"
+                "psr-14"
             ],
-            "time": "2016-08-06T14:39:51+00:00"
+            "time": "2019-01-08T18:20:26+00:00"
         },
         {
-            "name": "psr/log",
-            "version": "1.1.3",
+            "name": "psr/http-message",
+            "version": "1.0.1",
             "source": {
                 "type": "git",
-                "url": "https://github.com/php-fig/log.git",
-                "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc"
+                "url": "https://github.com/php-fig/http-message.git",
+                "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc",
-                "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc",
+                "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363",
+                "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.1.x-dev"
+                    "dev-master": "1.0.x-dev"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "Psr\\Log\\": "Psr/Log/"
+                    "Psr\\Http\\Message\\": "src/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
                     "homepage": "http://www.php-fig.org/"
                 }
             ],
-            "description": "Common interface for logging libraries",
-            "homepage": "https://github.com/php-fig/log",
+            "description": "Common interface for HTTP messages",
+            "homepage": "https://github.com/php-fig/http-message",
             "keywords": [
-                "log",
+                "http",
+                "http-message",
                 "psr",
-                "psr-3"
+                "psr-7",
+                "request",
+                "response"
             ],
-            "time": "2020-03-23T09:12:05+00:00"
+            "time": "2016-08-06T14:39:51+00:00"
         },
         {
             "name": "ralouphie/getallheaders",
             ],
             "time": "2020-03-28T10:15:50+00:00"
         },
-        {
-            "name": "symfony/class-loader",
-            "version": "v3.4.39",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/class-loader.git",
-                "reference": "e4636a4f23f157278a19e5db160c63de0da297d8"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/class-loader/zipball/e4636a4f23f157278a19e5db160c63de0da297d8",
-                "reference": "e4636a4f23f157278a19e5db160c63de0da297d8",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^5.5.9|>=7.0.8"
-            },
-            "require-dev": {
-                "symfony/finder": "~2.8|~3.0|~4.0",
-                "symfony/polyfill-apcu": "~1.1"
-            },
-            "suggest": {
-                "symfony/polyfill-apcu": "For using ApcClassLoader on HHVM"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.4-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\ClassLoader\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Symfony ClassLoader Component",
-            "homepage": "https://symfony.com",
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2020-03-15T09:38:08+00:00"
-        },
         {
             "name": "symfony/config",
             "version": "v4.4.7",
             ],
             "description": "Symfony Config Component",
             "homepage": "https://symfony.com",
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-03-27T16:54:36+00:00"
         },
         {
             "name": "symfony/console",
-            "version": "v3.3.18",
+            "version": "v5.0.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "af7ec995de93671c03cc1b4e3176c8588bc79dcc"
+                "reference": "5fa1caadc8cdaa17bcfb25219f3b53fe294a9935"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/af7ec995de93671c03cc1b4e3176c8588bc79dcc",
-                "reference": "af7ec995de93671c03cc1b4e3176c8588bc79dcc",
+                "url": "https://api.github.com/repos/symfony/console/zipball/5fa1caadc8cdaa17bcfb25219f3b53fe294a9935",
+                "reference": "5fa1caadc8cdaa17bcfb25219f3b53fe294a9935",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.5.9|>=7.0.8",
-                "symfony/debug": "~2.8|~3.0",
-                "symfony/polyfill-mbstring": "~1.0"
+                "php": "^7.2.5",
+                "symfony/polyfill-mbstring": "~1.0",
+                "symfony/polyfill-php73": "^1.8",
+                "symfony/service-contracts": "^1.1|^2"
             },
             "conflict": {
-                "symfony/dependency-injection": "<3.3"
+                "symfony/dependency-injection": "<4.4",
+                "symfony/event-dispatcher": "<4.4",
+                "symfony/lock": "<4.4",
+                "symfony/process": "<4.4"
+            },
+            "provide": {
+                "psr/log-implementation": "1.0"
             },
             "require-dev": {
                 "psr/log": "~1.0",
-                "symfony/config": "~3.3",
-                "symfony/dependency-injection": "~3.3",
-                "symfony/event-dispatcher": "~2.8|~3.0",
-                "symfony/filesystem": "~2.8|~3.0",
-                "symfony/process": "~2.8|~3.0"
+                "symfony/config": "^4.4|^5.0",
+                "symfony/dependency-injection": "^4.4|^5.0",
+                "symfony/event-dispatcher": "^4.4|^5.0",
+                "symfony/lock": "^4.4|^5.0",
+                "symfony/process": "^4.4|^5.0",
+                "symfony/var-dumper": "^4.4|^5.0"
             },
             "suggest": {
                 "psr/log": "For using the console logger",
                 "symfony/event-dispatcher": "",
-                "symfony/filesystem": "",
+                "symfony/lock": "",
                 "symfony/process": ""
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.3-dev"
+                    "dev-master": "5.0-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Symfony Console Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-29T09:02:23+00:00"
+            "time": "2020-03-30T11:42:42+00:00"
         },
         {
             "name": "symfony/css-selector",
             ],
             "time": "2020-03-27T16:56:45+00:00"
         },
-        {
-            "name": "symfony/debug",
-            "version": "v3.4.39",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/debug.git",
-                "reference": "ce9f3b5e8e1c50f849fded59b3a1b6bc3562ec29"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/debug/zipball/ce9f3b5e8e1c50f849fded59b3a1b6bc3562ec29",
-                "reference": "ce9f3b5e8e1c50f849fded59b3a1b6bc3562ec29",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^5.5.9|>=7.0.8",
-                "psr/log": "~1.0"
-            },
-            "conflict": {
-                "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2"
-            },
-            "require-dev": {
-                "symfony/http-kernel": "~2.8|~3.0|~4.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.4-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\Debug\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Symfony Debug Component",
-            "homepage": "https://symfony.com",
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2020-03-23T10:22:40+00:00"
-        },
         {
             "name": "symfony/dependency-injection",
-            "version": "v3.3.18",
+            "version": "v4.4.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dependency-injection.git",
-                "reference": "54243abc4e1a1a15e274e391bd6f7090b44711f1"
+                "reference": "755b18859be26b90f4bf63753432d3387458bf31"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/54243abc4e1a1a15e274e391bd6f7090b44711f1",
-                "reference": "54243abc4e1a1a15e274e391bd6f7090b44711f1",
+                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/755b18859be26b90f4bf63753432d3387458bf31",
+                "reference": "755b18859be26b90f4bf63753432d3387458bf31",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.5.9|>=7.0.8",
-                "psr/container": "^1.0"
+                "php": "^7.1.3",
+                "psr/container": "^1.0",
+                "symfony/service-contracts": "^1.1.6|^2"
             },
             "conflict": {
-                "symfony/config": "<3.3.7",
-                "symfony/finder": "<3.3",
-                "symfony/yaml": "<3.3"
+                "symfony/config": "<4.3|>=5.0",
+                "symfony/finder": "<3.4",
+                "symfony/proxy-manager-bridge": "<3.4",
+                "symfony/yaml": "<3.4"
             },
             "provide": {
-                "psr/container-implementation": "1.0"
+                "psr/container-implementation": "1.0",
+                "symfony/service-implementation": "1.0"
             },
             "require-dev": {
-                "symfony/config": "~3.3",
-                "symfony/expression-language": "~2.8|~3.0",
-                "symfony/yaml": "~3.3"
+                "symfony/config": "^4.3",
+                "symfony/expression-language": "^3.4|^4.0|^5.0",
+                "symfony/yaml": "^3.4|^4.0|^5.0"
             },
             "suggest": {
                 "symfony/config": "",
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.3-dev"
+                    "dev-master": "4.4-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Symfony DependencyInjection Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-29T09:02:23+00:00"
+            "time": "2020-03-30T10:09:30+00:00"
         },
         {
             "name": "symfony/dom-crawler",
         },
         {
             "name": "symfony/event-dispatcher",
-            "version": "v3.4.39",
+            "version": "v5.0.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher.git",
-                "reference": "9d4e22943b73acc1ba50595b7de1a01fe9dbad48"
+                "reference": "24f40d95385774ed5c71dbf014edd047e2f2f3dc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9d4e22943b73acc1ba50595b7de1a01fe9dbad48",
-                "reference": "9d4e22943b73acc1ba50595b7de1a01fe9dbad48",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/24f40d95385774ed5c71dbf014edd047e2f2f3dc",
+                "reference": "24f40d95385774ed5c71dbf014edd047e2f2f3dc",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.5.9|>=7.0.8"
+                "php": "^7.2.5",
+                "symfony/event-dispatcher-contracts": "^2"
             },
             "conflict": {
-                "symfony/dependency-injection": "<3.3"
+                "symfony/dependency-injection": "<4.4"
+            },
+            "provide": {
+                "psr/event-dispatcher-implementation": "1.0",
+                "symfony/event-dispatcher-implementation": "2.0"
             },
             "require-dev": {
                 "psr/log": "~1.0",
-                "symfony/config": "~2.8|~3.0|~4.0",
-                "symfony/dependency-injection": "~3.3|~4.0",
-                "symfony/expression-language": "~2.8|~3.0|~4.0",
-                "symfony/stopwatch": "~2.8|~3.0|~4.0"
+                "symfony/config": "^4.4|^5.0",
+                "symfony/dependency-injection": "^4.4|^5.0",
+                "symfony/expression-language": "^4.4|^5.0",
+                "symfony/http-foundation": "^4.4|^5.0",
+                "symfony/service-contracts": "^1.1|^2",
+                "symfony/stopwatch": "^4.4|^5.0"
             },
             "suggest": {
                 "symfony/dependency-injection": "",
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.4-dev"
+                    "dev-master": "5.0-dev"
                 }
             },
             "autoload": {
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-03-15T09:38:08+00:00"
+            "time": "2020-03-27T16:56:45+00:00"
+        },
+        {
+            "name": "symfony/event-dispatcher-contracts",
+            "version": "v2.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/event-dispatcher-contracts.git",
+                "reference": "af23c2584d4577d54661c434446fb8fbed6025dd"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/af23c2584d4577d54661c434446fb8fbed6025dd",
+                "reference": "af23c2584d4577d54661c434446fb8fbed6025dd",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5",
+                "psr/event-dispatcher": "^1"
+            },
+            "suggest": {
+                "symfony/event-dispatcher-implementation": ""
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\EventDispatcher\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Generic abstractions related to dispatching event",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "time": "2019-11-18T17:27:11+00:00"
         },
         {
             "name": "symfony/filesystem",
             ],
             "description": "Symfony Filesystem Component",
             "homepage": "https://symfony.com",
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
             "time": "2020-03-27T16:56:45+00:00"
         },
         {
             "time": "2020-02-27T09:26:54+00:00"
         },
         {
-            "name": "symfony/polyfill-mbstring",
+            "name": "symfony/polyfill-intl-idn",
             "version": "v1.15.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/symfony/polyfill-mbstring.git",
-                "reference": "81ffd3a9c6d707be22e3012b827de1c9775fc5ac"
+                "url": "https://github.com/symfony/polyfill-intl-idn.git",
+                "reference": "47bd6aa45beb1cd7c6a16b7d1810133b728bdfcf"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/81ffd3a9c6d707be22e3012b827de1c9775fc5ac",
-                "reference": "81ffd3a9c6d707be22e3012b827de1c9775fc5ac",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/47bd6aa45beb1cd7c6a16b7d1810133b728bdfcf",
+                "reference": "47bd6aa45beb1cd7c6a16b7d1810133b728bdfcf",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.3"
+                "php": ">=5.3.3",
+                "symfony/polyfill-mbstring": "^1.3",
+                "symfony/polyfill-php72": "^1.10"
             },
             "suggest": {
-                "ext-mbstring": "For best performance"
+                "ext-intl": "For best performance"
             },
             "type": "library",
             "extra": {
             },
             "autoload": {
                 "psr-4": {
-                    "Symfony\\Polyfill\\Mbstring\\": ""
+                    "Symfony\\Polyfill\\Intl\\Idn\\": ""
                 },
                 "files": [
                     "bootstrap.php"
             ],
             "authors": [
                 {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
+                    "name": "Laurent Bassin",
+                    "email": "laurent@bassin.info"
                 },
                 {
                     "name": "Symfony Community",
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony polyfill for the Mbstring extension",
+            "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
             "homepage": "https://symfony.com",
             "keywords": [
                 "compatibility",
-                "mbstring",
+                "idn",
+                "intl",
                 "polyfill",
                 "portable",
                 "shim"
             "time": "2020-03-09T19:04:49+00:00"
         },
         {
-            "name": "symfony/process",
-            "version": "v2.8.52",
+            "name": "symfony/polyfill-mbstring",
+            "version": "v1.15.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/symfony/process.git",
-                "reference": "c3591a09c78639822b0b290d44edb69bf9f05dc8"
+                "url": "https://github.com/symfony/polyfill-mbstring.git",
+                "reference": "81ffd3a9c6d707be22e3012b827de1c9775fc5ac"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/81ffd3a9c6d707be22e3012b827de1c9775fc5ac",
+                "reference": "81ffd3a9c6d707be22e3012b827de1c9775fc5ac",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.3"
+            },
+            "suggest": {
+                "ext-mbstring": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.15-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Polyfill\\Mbstring\\": ""
+                },
+                "files": [
+                    "bootstrap.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for the Mbstring extension",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "mbstring",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-03-09T19:04:49+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php72",
+            "version": "v1.15.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php72.git",
+                "reference": "37b0976c78b94856543260ce09b460a7bc852747"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/37b0976c78b94856543260ce09b460a7bc852747",
+                "reference": "37b0976c78b94856543260ce09b460a7bc852747",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.15-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php72\\": ""
+                },
+                "files": [
+                    "bootstrap.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 7.2+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-02-27T09:26:54+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php73",
+            "version": "v1.15.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php73.git",
+                "reference": "0f27e9f464ea3da33cbe7ca3bdf4eb66def9d0f7"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/process/zipball/c3591a09c78639822b0b290d44edb69bf9f05dc8",
-                "reference": "c3591a09c78639822b0b290d44edb69bf9f05dc8",
+                "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/0f27e9f464ea3da33cbe7ca3bdf4eb66def9d0f7",
+                "reference": "0f27e9f464ea3da33cbe7ca3bdf4eb66def9d0f7",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.9"
+                "php": ">=5.3.3"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.8-dev"
+                    "dev-master": "1.15-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php73\\": ""
+                },
+                "files": [
+                    "bootstrap.php"
+                ],
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-02-27T09:26:54+00:00"
+        },
+        {
+            "name": "symfony/process",
+            "version": "v5.0.7",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/process.git",
+                "reference": "c5ca4a0fc16a0c888067d43fbcfe1f8a53d8e70e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/process/zipball/c5ca4a0fc16a0c888067d43fbcfe1f8a53d8e70e",
+                "reference": "c5ca4a0fc16a0c888067d43fbcfe1f8a53d8e70e",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.0-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Symfony Process Component",
             "homepage": "https://symfony.com",
-            "time": "2018-11-11T11:18:13+00:00"
+            "time": "2020-03-27T16:56:45+00:00"
+        },
+        {
+            "name": "symfony/service-contracts",
+            "version": "v2.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/service-contracts.git",
+                "reference": "144c5e51266b281231e947b51223ba14acf1a749"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/144c5e51266b281231e947b51223ba14acf1a749",
+                "reference": "144c5e51266b281231e947b51223ba14acf1a749",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5",
+                "psr/container": "^1.0"
+            },
+            "suggest": {
+                "symfony/service-implementation": ""
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\Service\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Generic abstractions related to writing services",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "time": "2019-11-18T17:27:11+00:00"
         },
         {
             "name": "symfony/translation",
-            "version": "v3.3.18",
+            "version": "v4.4.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation.git",
-                "reference": "90cb5ca3eb84b3053fef876e11e405fd819487fc"
+                "reference": "4e54d336f2eca5facad449d0b0118bb449375b76"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation/zipball/90cb5ca3eb84b3053fef876e11e405fd819487fc",
-                "reference": "90cb5ca3eb84b3053fef876e11e405fd819487fc",
+                "url": "https://api.github.com/repos/symfony/translation/zipball/4e54d336f2eca5facad449d0b0118bb449375b76",
+                "reference": "4e54d336f2eca5facad449d0b0118bb449375b76",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.5.9|>=7.0.8",
-                "symfony/polyfill-mbstring": "~1.0"
+                "php": "^7.1.3",
+                "symfony/polyfill-mbstring": "~1.0",
+                "symfony/translation-contracts": "^1.1.6|^2"
             },
             "conflict": {
-                "symfony/config": "<2.8",
-                "symfony/yaml": "<3.3"
+                "symfony/config": "<3.4",
+                "symfony/dependency-injection": "<3.4",
+                "symfony/http-kernel": "<4.4",
+                "symfony/yaml": "<3.4"
+            },
+            "provide": {
+                "symfony/translation-implementation": "1.0"
             },
             "require-dev": {
                 "psr/log": "~1.0",
-                "symfony/config": "~2.8|~3.0",
-                "symfony/intl": "^2.8.18|^3.2.5",
-                "symfony/yaml": "~3.3"
+                "symfony/config": "^3.4|^4.0|^5.0",
+                "symfony/console": "^3.4|^4.0|^5.0",
+                "symfony/dependency-injection": "^3.4|^4.0|^5.0",
+                "symfony/finder": "~2.8|~3.0|~4.0|^5.0",
+                "symfony/http-kernel": "^4.4",
+                "symfony/intl": "^3.4|^4.0|^5.0",
+                "symfony/service-contracts": "^1.1.2|^2",
+                "symfony/yaml": "^3.4|^4.0|^5.0"
             },
             "suggest": {
-                "psr/log": "To use logging capability in translator",
+                "psr/log-implementation": "To use logging capability in translator",
                 "symfony/config": "",
                 "symfony/yaml": ""
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.3-dev"
+                    "dev-master": "4.4-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Symfony Translation Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-18T14:19:00+00:00"
+            "time": "2020-03-27T16:54:36+00:00"
+        },
+        {
+            "name": "symfony/translation-contracts",
+            "version": "v2.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/translation-contracts.git",
+                "reference": "8cc682ac458d75557203b2f2f14b0b92e1c744ed"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/8cc682ac458d75557203b2f2f14b0b92e1c744ed",
+                "reference": "8cc682ac458d75557203b2f2f14b0b92e1c744ed",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5"
+            },
+            "suggest": {
+                "symfony/translation-implementation": ""
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\Translation\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Generic abstractions related to translation",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "time": "2019-11-18T17:27:11+00:00"
         },
         {
             "name": "symfony/yaml",
-            "version": "v3.3.18",
+            "version": "v4.4.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/yaml.git",
-                "reference": "af615970e265543a26ee712c958404eb9b7ac93d"
+                "reference": "ef166890d821518106da3560086bfcbeb4fadfec"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/yaml/zipball/af615970e265543a26ee712c958404eb9b7ac93d",
-                "reference": "af615970e265543a26ee712c958404eb9b7ac93d",
+                "url": "https://api.github.com/repos/symfony/yaml/zipball/ef166890d821518106da3560086bfcbeb4fadfec",
+                "reference": "ef166890d821518106da3560086bfcbeb4fadfec",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.5.9|>=7.0.8"
+                "php": "^7.1.3",
+                "symfony/polyfill-ctype": "~1.8"
+            },
+            "conflict": {
+                "symfony/console": "<3.4"
             },
             "require-dev": {
-                "symfony/console": "~2.8|~3.0"
+                "symfony/console": "^3.4|^4.0|^5.0"
             },
             "suggest": {
                 "symfony/console": "For validating YAML files using the lint command"
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.3-dev"
+                    "dev-master": "4.4-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Symfony Yaml Component",
             "homepage": "https://symfony.com",
-            "time": "2018-01-20T15:04:53+00:00"
+            "time": "2020-03-30T11:41:10+00:00"
         },
         {
             "name": "theseer/tokenizer",
         },
         {
             "name": "webmozart/assert",
-            "version": "1.7.0",
+            "version": "1.8.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/webmozart/assert.git",
-                "reference": "aed98a490f9a8f78468232db345ab9cf606cf598"
+                "reference": "ab2cb0b3b559010b75981b1bdce728da3ee90ad6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/webmozart/assert/zipball/aed98a490f9a8f78468232db345ab9cf606cf598",
-                "reference": "aed98a490f9a8f78468232db345ab9cf606cf598",
+                "url": "https://api.github.com/repos/webmozart/assert/zipball/ab2cb0b3b559010b75981b1bdce728da3ee90ad6",
+                "reference": "ab2cb0b3b559010b75981b1bdce728da3ee90ad6",
                 "shasum": ""
             },
             "require": {
                 "symfony/polyfill-ctype": "^1.8"
             },
             "conflict": {
-                "vimeo/psalm": "<3.6.0"
+                "vimeo/psalm": "<3.9.1"
             },
             "require-dev": {
                 "phpunit/phpunit": "^4.8.36 || ^7.5.13"
                 "check",
                 "validate"
             ],
-            "time": "2020-02-14T12:15:55+00:00"
+            "time": "2020-04-18T12:12:48+00:00"
         }
     ],
     "aliases": [
index fb45533..90989bb 100644 (file)
@@ -742,6 +742,9 @@ $CFG->admin = 'admin';
 // Force developer level debug and add debug info to the output of cron
 // $CFG->showcrondebugging = true;
 //
+// Force result of checks used to determine whether a site is considered "public" or not (such as for site registration).
+// $CFG->site_is_public = false;
+//
 //=========================================================================
 // 8. FORCED SETTINGS
 //=========================================================================
index 10ea721..179e80f 100644 (file)
@@ -25,5 +25,5 @@
 $string['pluginname'] = 'H5P';
 $string['pluginname_help'] = 'Content bank to upload and share H5P content';
 $string['privacy:metadata'] = 'The H5P content bank plugin does not store any personal data.';
-$string['h5p:access'] = 'Access to H5P content in the content bank';
-$string['h5p:upload'] = 'Upload new H5P content';
+$string['h5p:access'] = 'Access H5P content in the content bank';
+$string['h5p:upload'] = 'Upload new H5P content';
index 8d03118..61f28ea 100644 (file)
@@ -48,7 +48,7 @@ Feature: H5P file upload to content bank for admins
     And I click on "Save changes" "button"
     And I wait until the page is ready
     And I should see "filltheblanks.h5p"
-    And I navigate to "Plugins > Content bank > Manage content bank content types" in site administration
+    And I navigate to "Plugins > Content bank > Manage content types" in site administration
     And I click on "Disable" "icon" in the "H5P" "table_row"
     And I wait until the page is ready
     When I click on "Content bank" "link"
index 7280b1b..b49a79c 100644 (file)
@@ -31,7 +31,7 @@ Feature: Delete H5P file from the content bank
     When I open the action menu in "region-main-settings-menu" "region"
     Then I should see "Delete"
     And I choose "Delete" in the open action menu
-    And I should see "Are you sure you want to delete content 'filltheblanks.h5p'?"
+    And I should see "Are you sure you want to delete the content 'filltheblanks.h5p'"
     And I click on "Cancel" "button" in the "Delete content" "dialogue"
     And I should see "filltheblanks.h5p"
     And I open the action menu in "region-main-settings-menu" "region"
index 14f20e1..98ef06a 100644 (file)
@@ -18,13 +18,13 @@ Feature: Display and choose from the available activities in course
     And I am on "Course" course homepage with editing mode on
 
   Scenario: The available activities are displayed to the teacher in the activity chooser
-    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
-    Then I should see "Add an activity or resource" in the ".modal-title" "css_element"
+    Given I click on "Add an activity" "button" in the "Topic 1" "section"
+    Then I should see "Add an activity" in the ".modal-title" "css_element"
     And I should see "Assignment" in the ".modal-body" "css_element"
 
   Scenario: The teacher can choose to add an activity from the activity items in the activity chooser
-    Given I click on "Add an activity or resource" "button" in the "Topic 3" "section"
-    When I click on "Add a new Assignment" "link" in the "Add an activity or resource" "dialogue"
+    Given I click on "Add an activity" "button" in the "Topic 3" "section"
+    When I click on "Add a new Assignment" "link" in the "Add an activity" "dialogue"
     Then I should see "Adding a new Assignment"
     And I set the following fields to these values:
       | Assignment name | Test Assignment Topic 3 |
@@ -32,19 +32,19 @@ Feature: Display and choose from the available activities in course
     Then I should see "Test Assignment Topic 3" in the "Topic 3" "section"
 
   Scenario: The teacher can choose to add an activity from the activity summary in the activity chooser
-    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
-    When I click on "Information about the Assignment activity" "button" in the "Add an activity or resource" "dialogue"
+    Given I click on "Add an activity" "button" in the "Topic 1" "section"
+    When I click on "Information about the Assignment activity" "button" in the "Add an activity" "dialogue"
     When I click on "Add a new Assignment" "link" in the "help" "core_course > Activity chooser screen"
     Then I should see "Adding a new Assignment"
 
   Scenario: Show summary
-    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
-    When I click on "Information about the Assignment activity" "button" in the "Add an activity or resource" "dialogue"
+    Given I click on "Add an activity" "button" in the "Topic 1" "section"
+    When I click on "Information about the Assignment activity" "button" in the "Add an activity" "dialogue"
     Then I should see "Assignment" in the "help" "core_course > Activity chooser screen"
     And I should see "The assignment activity module enables a teacher to communicate tasks, collect work and provide grades and feedback."
 
   Scenario: Hide summary
-    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
+    Given I click on "Add an activity" "button" in the "Topic 1" "section"
     When I click on "Information about the Assignment activity" "button" in the "modules" "core_course > Activity chooser screen"
     And I should see "The assignment activity module enables a teacher to communicate tasks, collect work and provide grades and feedback." in the "help" "core_course > Activity chooser screen"
     And I should see "Back" in the "help" "core_course > Activity chooser screen"
@@ -52,7 +52,7 @@ Feature: Display and choose from the available activities in course
     Then "modules" "core_course > Activity chooser screen" should exist
     And "help" "core_course > Activity chooser screen" should not exist
     And "Back" "button" should not exist in the "modules" "core_course > Activity chooser screen"
-    And I should not see "The assignment activity module enables a teacher to communicate tasks, collect work and provide grades and feedback." in the "Add an activity or resource" "dialogue"
+    And I should not see "The assignment activity module enables a teacher to communicate tasks, collect work and provide grades and feedback." in the "Add an activity" "dialogue"
 
   Scenario: View recommended activities
     When I log out
@@ -65,85 +65,85 @@ Feature: Display and choose from the available activities in course
     And I log in as "teacher"
     And I am on "Course" course homepage with editing mode on
     And I open the activity chooser
-    Then I should see "Recommended" in the "Add an activity or resource" "dialogue"
-    And I click on "Recommended" "link" in the "Add an activity or resource" "dialogue"
+    Then I should see "Recommended" in the "Add an activity" "dialogue"
+    And I click on "Recommended" "link" in the "Add an activity" "dialogue"
     And I should see "Book" in the "recommended" "core_course > Activity chooser tab"
 
   Scenario: Favourite a module in the activity chooser
     Given I open the activity chooser
-    And I should not see "Starred" in the "Add an activity or resource" "dialogue"
-    And I click on "Star Assignment module" "button" in the "Add an activity or resource" "dialogue"
-    And I should see "Starred" in the "Add an activity or resource" "dialogue"
-    When I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
+    And I should not see "Starred" in the "Add an activity" "dialogue"
+    And I click on "Star Assignment activity" "button" in the "Add an activity" "dialogue"
+    And I should see "Starred" in the "Add an activity" "dialogue"
+    When I click on "Starred" "link" in the "Add an activity" "dialogue"
     Then I should see "Assignment" in the "favourites" "core_course > Activity chooser tab"
     And I click on "Information about the Assignment activity" "button" in the "favourites" "core_course > Activity chooser tab"
     And I should see "The assignment activity module enables a teacher to communicate tasks, collect work and provide grades and feedback."
 
   Scenario: Add a favourite module and check it exists when reopening the chooser
     Given I open the activity chooser
-    And I click on "Star Assignment module" "button" in the "Add an activity or resource" "dialogue"
-    And I click on "Star Forum module" "button" in the "Add an activity or resource" "dialogue"
-    And I should see "Starred" in the "Add an activity or resource" "dialogue"
-    And I click on "Close" "button" in the "Add an activity or resource" "dialogue"
-    When I click on "Add an activity or resource" "button" in the "Topic 3" "section"
-    And I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
+    And I click on "Star Assignment activity" "button" in the "Add an activity" "dialogue"
+    And I click on "Star Forum activity" "button" in the "Add an activity" "dialogue"
+    And I should see "Starred" in the "Add an activity" "dialogue"
+    And I click on "Close" "button" in the "Add an activity" "dialogue"
+    When I click on "Add an activity" "button" in the "Topic 3" "section"
+    And I click on "Starred" "link" in the "Add an activity" "dialogue"
     Then I should see "Forum" in the "favourites" "core_course > Activity chooser tab"
 
   Scenario: Add a favourite and then remove it whilst checking the tabs work as expected
     Given I open the activity chooser
-    And I click on "Star Assignment module" "button" in the "Add an activity or resource" "dialogue"
-    And I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
-    And I click on "Star Assignment module" "button" in the "Add an activity or resource" "dialogue"
-    Then I should not see "Starred" in the "Add an activity or resource" "dialogue"
+    And I click on "Star Assignment activity" "button" in the "Add an activity" "dialogue"
+    And I click on "Starred" "link" in the "Add an activity" "dialogue"
+    And I click on "Star Assignment activity" "button" in the "Add an activity" "dialogue"
+    Then I should not see "Starred" in the "Add an activity" "dialogue"
 
   Scenario: The teacher can search for an activity by it's name
-    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
+    Given I click on "Add an activity" "button" in the "Topic 1" "section"
     When I set the field "search" to "Lesson"
-    Then I should see "1 results found" in the "Add an activity or resource" "dialogue"
-    And I should see "Lesson" in the "Add an activity or resource" "dialogue"
+    Then I should see "1 results found" in the "Add an activity" "dialogue"
+    And I should see "Lesson" in the "Add an activity" "dialogue"
 
   Scenario: The teacher can search for an activity by it's description
     Given I open the activity chooser
     When I set the field "search" to "The lesson activity module enables a teacher to deliver content"
-    Then I should see "1 results found" in the "Add an activity or resource" "dialogue"
-    And I should see "Lesson" in the "Add an activity or resource" "dialogue"
+    Then I should see "1 results found" in the "Add an activity" "dialogue"
+    And I should see "Lesson" in the "Add an activity" "dialogue"
 
   Scenario: Search results are not returned if the search query does not match any activity name or description
-    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
+    Given I click on "Add an activity" "button" in the "Topic 1" "section"
     When I set the field "search" to "Random search query"
-    Then I should see "0 results found" in the "Add an activity or resource" "dialogue"
+    Then I should see "0 results found" in the "Add an activity" "dialogue"
     And ".option" "css_element" should not exist in the ".searchresultitemscontainer" "css_element"
 
   Scenario: Teacher can return to the default activity chooser state by manually removing the search query
-    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
+    Given I click on "Add an activity" "button" in the "Topic 1" "section"
     And I set the field "search" to "Lesson"
-    And I should see "1 results found" in the "Add an activity or resource" "dialogue"
-    And I should see "Lesson" in the "Add an activity or resource" "dialogue"
+    And I should see "1 results found" in the "Add an activity" "dialogue"
+    And I should see "Lesson" in the "Add an activity" "dialogue"
     When I set the field "search" to ""
-    And I should not see "1 results found" in the "Add an activity or resource" "dialogue"
+    And I should not see "1 results found" in the "Add an activity" "dialogue"
     Then ".searchresultscontainer" "css_element" should not exist
     And ".optionscontainer" "css_element" should exist
 
   Scenario: Teacher can not see a "clear" button if a search query is not entered in the activity chooser search bar
-    When I click on "Add an activity or resource" "button" in the "Topic 1" "section"
+    When I click on "Add an activity" "button" in the "Topic 1" "section"
     Then "Clear search input" "button" should not exist
 
   Scenario: Teacher can see a "clear" button after entering a search query in the activity chooser search bar
-    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
+    Given I click on "Add an activity" "button" in the "Topic 1" "section"
     When I set the field "search" to "Search query"
     Then "Clear search input" "button" should not exist
 
   Scenario: Teacher can not see a "clear" button if the search query is removed in the activity chooser search bar
-    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
+    Given I click on "Add an activity" "button" in the "Topic 1" "section"
     And I set the field "search" to "Search query"
     And "Clear search input" "button" should exist
     When I set the field "search" to ""
     Then "Clear search input" "button" should not exist
 
   Scenario: Teacher can instantly remove the search query from the activity search bar by clicking on the "clear" button
-    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
+    Given I click on "Add an activity" "button" in the "Topic 1" "section"
     And I set the field "search" to "Search query"
-    And I should see "results found" in the "Add an activity or resource" "dialogue"
+    And I should see "results found" in the "Add an activity" "dialogue"
     When I click on "Clear search input" "button"
     Then I should not see "Search query"
     And ".searchresultscontainer" "css_element" should not exist
index 8ec6ac6..bd19792 100644 (file)
@@ -122,7 +122,7 @@ abstract class data_controller {
      *
      * @return string
      */
-    protected function get_form_element_name() : string {
+    public function get_form_element_name() : string {
         return 'customfield_' . $this->get_field()->get('shortname');
     }
 
index 07e22b7..5dea8aa 100644 (file)
@@ -123,6 +123,18 @@ abstract class field_controller {
         return $fieldcontroller;
     }
 
+    /**
+     * Perform pre-processing of field values, for example those that originate from an external source (e.g. upload course tool)
+     *
+     * Override in plugin classes as necessary
+     *
+     * @param string $value
+     * @return mixed
+     */
+    public function parse_value(string $value) {
+        return $value;
+    }
+
     /**
      * Validate the data on the field configuration form
      *
index af25c2c..023f0d2 100644 (file)
@@ -638,8 +638,11 @@ abstract class handler {
      *
      * @param \MoodleQuickForm $mform
      * @param int $instanceid id of the instance, can be null when instance is being created
+     * @param string $headerlangidentifier If specified, a lang string will be used for field category headings
+     * @param string $headerlangcomponent
      */
-    public function instance_form_definition(\MoodleQuickForm $mform, int $instanceid = 0) {
+    public function instance_form_definition(\MoodleQuickForm $mform, int $instanceid = 0,
+            ?string $headerlangidentifier = null, ?string $headerlangcomponent = null) {
 
         $editablefields = $this->get_editable_fields($instanceid);
         $fieldswithdata = api::get_instance_fields_data($editablefields, $instanceid);
@@ -647,8 +650,14 @@ abstract class handler {
         foreach ($fieldswithdata as $data) {
             $categoryid = $data->get_field()->get_category()->get('id');
             if ($categoryid != $lastcategoryid) {
-                $mform->addElement('header', 'category_' . $categoryid,
-                    format_string($data->get_field()->get_category()->get('name')));
+                $categoryname = format_string($data->get_field()->get_category()->get('name'));
+
+                // Load category header lang string if specified.
+                if (!empty($headerlangidentifier)) {
+                    $categoryname = get_string($headerlangidentifier, $headerlangcomponent, $categoryname);
+                }
+
+                $mform->addElement('header', 'category_' . $categoryid, $categoryname);
                 $lastcategoryid = $categoryid;
             }
             $data->instance_form_definition($mform);
index bcdf196..f8fb5ad 100644 (file)
@@ -114,4 +114,17 @@ class field_controller extends \core_customfield\field_controller {
             $this->get_formatted_name());
         return $ret;
     }
-}
+
+    /**
+     * Convert given value into appropriate timestamp
+     *
+     * @param string $value
+     * @return int
+     */
+    public function parse_value(string $value) {
+        $timestamp = strtotime($value);
+
+        // If we have a valid, positive timestamp then return it.
+        return $timestamp > 0 ? $timestamp : 0;
+    }
+}
\ No newline at end of file
index 8445c34..7498722 100644 (file)
@@ -171,10 +171,39 @@ class customfield_date_plugin_testcase extends advanced_testcase {
         $this->assertEquals(null, $d->export_value());
     }
 
+    /**
+     * Data provider for {@see test_parse_value}
+     *
+     * @return array
+     */
+    public function parse_value_provider() : array {
+        return [
+            // Valid times.
+            ['2019-10-01', strtotime('2019-10-01')],
+            ['2019-10-01 14:00', strtotime('2019-10-01 14:00')],
+            // Invalid times.
+            ['ZZZZZ', 0],
+            ['202-04-01', 0],
+            ['2019-15-15', 0],
+        ];
+    }
+    /**
+     * Test field parse_value method
+     *
+     * @param string $value
+     * @param int $expected
+     * @return void
+     *
+     * @dataProvider parse_value_provider
+     */
+    public function test_parse_value(string $value, int $expected) {
+        $this->assertSame($expected, $this->cfields[1]->parse_value($value));
+    }
+
     /**
      * Deleting fields and data
      */
     public function test_delete() {
         $this->cfcat->get_handler()->delete_all();
     }
-}
+}
\ No newline at end of file
index 79fa408..c47f05c 100644 (file)
@@ -119,4 +119,14 @@ class field_controller extends \core_customfield\field_controller {
             $this->get_formatted_name());
         return $ret;
     }
-}
+
+    /**
+     * Locate the value parameter in the field options array, and return it's index
+     *
+     * @param string $value
+     * @return int
+     */
+    public function parse_value(string $value) {
+        return (int) array_search($value, self::get_options_array($this));
+    }
+}
\ No newline at end of file
index 0ada2bb..6def88c 100644 (file)
@@ -157,10 +157,46 @@ class customfield_select_plugin_testcase extends advanced_testcase {
         $this->assertEquals('b', $d->export_value());
     }
 
+    /**
+     * Data provider for {@see test_parse_value}
+     *
+     * @return array
+     */
+    public function parse_value_provider() : array {
+        return [
+            ['Red', 1],
+            ['Blue', 2],
+            ['Green', 3],
+            ['Mauve', 0],
+        ];
+    }
+
+    /**
+     * Test field parse_value method
+     *
+     * @param string $value
+     * @param int $expected
+     * @return void
+     *
+     * @dataProvider parse_value_provider
+     */
+    public function test_parse_value(string $value, int $expected) {
+        $field = $this->get_generator()->create_field([
+            'categoryid' => $this->cfcat->get('id'),
+            'type' => 'select',
+            'shortname' => 'myselect',
+            'configdata' => [
+                'options' => "Red\nBlue\nGreen",
+            ],
+        ]);
+
+        $this->assertSame($expected, $field->parse_value($value));
+    }
+
     /**
      * Deleting fields and data
      */
     public function test_delete() {
         $this->cfcat->get_handler()->delete_all();
     }
-}
+}
\ No newline at end of file
index 57b0d58..384918c 100644 (file)
@@ -59,7 +59,7 @@ class data_controller extends \core_customfield\data_controller {
      *
      * @return string
      */
-    protected function get_form_element_name() : string {
+    public function get_form_element_name() : string {
         return parent::get_form_element_name() . '_editor';
     }
 
index 6c9aebd..319ef65 100644 (file)
@@ -74,7 +74,7 @@ $string['insertcomment'] = 'Insert frequently used comment';
 $string['maxscore'] = 'Maximum score';
 $string['name'] = 'Name';
 $string['needregrademessage'] = 'The marking guide definition was changed after this student had been graded. The student can not see this marking guide until you check the marking guide and update the grade.';
-$string['outof'] = 'Out of {$a}';
+$string['outof'] = 'Score out of {$a}';
 $string['pluginname'] = 'Marking guide';
 $string['previewmarkingguide'] = 'Preview marking guide';
 $string['privacy:metadata:criterionid'] = 'An identifier to a criterion for advanced marking.';
index 745a244..9982217 100644 (file)
@@ -138,7 +138,7 @@ class gradingform_rubric_renderer extends plugin_renderer_base {
         if ($mode == gradingform_rubric_controller::DISPLAY_EDIT_FULL) {
             $value = get_string('criterionaddlevel', 'gradingform_rubric');
             $button = html_writer::empty_tag('input', array('type' => 'submit', 'name' => '{NAME}[criteria][{CRITERION-id}][levels][addlevel]',
-                'id' => '{NAME}-criteria-{CRITERION-id}-levels-addlevel', 'value' => $value));
+                'id' => '{NAME}-criteria-{CRITERION-id}-levels-addlevel', 'value' => $value, 'class' => 'btn btn-secondary'));
             $criteriontemplate .= html_writer::tag('td', $button, array('class' => 'addlevel'));
         }
         $displayremark = ($options['enableremarks'] && ($mode != gradingform_rubric_controller::DISPLAY_VIEW || $options['showremarksstudent']));
index 3b9a2d2..460fbb9 100644 (file)
 
 .gradingform_rubric.editor .addcriterion input,
 .gradingform_rubric.editor .addlevel input {
-    background: transparent url([[pix:t/add]]) no-repeat top left;
+    background: #fff url([[pix:t/add]]) no-repeat 7px 8px;
     display: block;
     color: #555;
     font-weight: bold;
 }
 
 .gradingform_rubric.editor .addcriterion input {
-    background-position: 5px 8px;
     height: 30px;
     line-height: 29px;
     margin-bottom: 14px;
 }
 
 .gradingform_rubric.editor .addlevel input {
-    background-position: 5px 5px;
-    height: 25px;
-    line-height: 24px;
-    margin-bottom: 45px;
-    padding-left: 18px;
+    padding-left: 24px;
     padding-right: 8px;
 }
 
index ca5ea1a..222f128 100644 (file)
@@ -25,5 +25,5 @@
 defined('MOODLE_INTERNAL') || die();
 
 $string['pluginname'] = 'H5P framework v1.24';
-$string['pluginname_help'] = 'H5P framework. Version 1.24';
-$string['privacy:metadata'] = 'H5P framework v1.24 do not store any personal data.';
+$string['pluginname_help'] = 'H5P framework version 1.24.';
+$string['privacy:metadata'] = 'The H5P framework v1.24 does not store any personal data.';
index b736c9b..9370d23 100644 (file)
@@ -236,7 +236,6 @@ $string['configenablemobilewebservice'] = 'Enable mobile service for the officia
 $string['configenablerssfeeds'] = 'If enabled, RSS feeds are generated by various features across the site, such as blogs, forums, database activities and glossaries. Note that RSS feeds also need to be enabled for the particular activity modules.';
 $string['configenablerssfeedsdisabled'] = 'It is not available because RSS feeds are disabled in all the Site. To enable them, go to the Variables settings under Admin Configuration.';
 $string['configenablerssfeedsdisabled2'] = 'RSS feeds are currently disabled at site level. They may be enabled in Advanced features in the Site administration.';
-$string['configenablesafebrowserintegration'] = 'This adds the choice \'Require Safe Exam Browser\' to the \'Browser security\' field on the quiz settings form. See https://www.safeexambrowser.org/ for more information.';
 $string['configenablestats'] = 'If you choose \'yes\' here, Moodle\'s cronjob will process the logs and gather some statistics.  Depending on the amount of traffic on your site, this can take awhile. If you enable this, you will be able to see some interesting graphs and statistics about each of your courses, or on a sitewide basis.';
 $string['configenabletrusttext'] = 'By default Moodle will always thoroughly clean text that comes from users to remove any possible bad scripts, media etc that could be a security risk.  The Trusted Content system is a way of giving particular users that you trust the ability to include these advanced features in their content without interference.  To enable this system, you need to first enable this setting, and then grant the Trusted Content permission to a specific Moodle role.  Texts created or uploaded by such users will be marked as trusted and will not be cleaned before display.';
 $string['configenablewebservices'] = 'Web services enable other systems, such as the Moodle app, to log in to the site and perform operations. For extra security, the setting should be disabled if you are not using the app, or an external tool/service that requires integration via web services.';
@@ -330,7 +329,7 @@ $string['configrequestedstudentname'] = 'Word for student used in requested cour
 $string['configrequestedstudentsname'] = 'Word for students used in requested courses';
 $string['configrequestedteachername'] = 'Word for teacher used in requested courses';
 $string['configrequestedteachersname'] = 'Word for teachers used in requested courses';
-$string['configreverseproxyignore'] = 'If your server is behind multiple reverse proxies that append to the X-Forwarded-For header then you will need to specify a comma separated list of ip addresses or subnets of the reverse proxies to be ignored in order to find the users correct IP address.';
+$string['configreverseproxyignore'] = 'If your server is behind multiple reverse proxies that append to the X-Forwarded-For header, then specify a comma-separated list of IP addresses or subnets of the reverse proxies to be ignored in order to find the user\'s correct IP address.';
 $string['configsectioninterface'] = 'Interface';
 $string['configsectionmail'] = 'Mail';
 $string['configsectionmaintenance'] = 'Maintenance';
@@ -545,7 +544,6 @@ $string['enablemoodlenet'] = 'Enable integration with MoodleNet instances';
 $string['enablemoodlenet_desc'] = 'If enabled, and provided the MoodleNet plugin is installed, users can import content from MoodleNet into this site.';
 $string['enablerecordcache'] = 'Enable record cache';
 $string['enablerssfeeds'] = 'Enable RSS feeds';
-$string['enablesafebrowserintegration'] = 'Enable Safe Exam Browser integration';
 $string['enablesearchareas'] = 'Enable search areas';
 $string['enablestats'] = 'Enable statistics';
 $string['enabletrusttext'] = 'Enable trusted content';
@@ -1220,10 +1218,10 @@ $string['task_scheduled_concurrency_limit'] = 'Scheduled task concurrency limit'
 $string['task_scheduled_concurrency_limit_desc'] = 'The number of scheduled task runners allowed to run concurrently. If the limit is high then the server may experience high load which affects performance. A setting of 0 will disable processing of scheduled tasks completely.';
 $string['task_scheduled_max_runtime'] = 'Scheduled task runner lifetime';
 $string['task_scheduled_max_runtime_desc'] = 'The age of a scheduled task runner before it is freed.';
-$string['task_adhoc_concurrency_limit'] = 'Adhoc task concurrency limit';
-$string['task_adhoc_concurrency_limit_desc'] = 'The number of adhoc task runners allowed to run concurrently. If the limit is high then scheduled tasks may not run regularly when there are lots of adhoc tasks. A setting of 0 will disable processing of adhoc tasks completely.';
-$string['task_adhoc_max_runtime'] = 'Adhoc task runner lifetime';
-$string['task_adhoc_max_runtime_desc'] = 'The age of an adhoc task runner before it is freed. A low duration is recommended as there is no limit to the number of adhoc tasks queued. If this number is too high and you have a large adhoc task queue then scheduled tasks may not be run regularly.';
+$string['task_adhoc_concurrency_limit'] = 'Ad hoc task concurrency limit';
+$string['task_adhoc_concurrency_limit_desc'] = 'The number of ad hoc task runners allowed to run concurrently. If the limit is high then scheduled tasks may not run regularly when there are lots of ad hoc tasks. A setting of 0 will disable processing of ad hoc tasks completely.';
+$string['task_adhoc_max_runtime'] = 'Ad hoc task runner lifetime';
+$string['task_adhoc_max_runtime_desc'] = 'The age of an ad hoc task runner before it is freed. A low duration is recommended as there is no limit to the number of ad hoc tasks queued. If this number is too high and you have a large ad hoc task queue then scheduled tasks may not be run regularly.';
 $string['task_logmode'] = 'When to log';
 $string['task_logmode_desc'] = 'You can choose when you wish task logging to take place. By default logs are always captured. You can disable logging entirely, or change to only log tasks which fail.';
 $string['task_logmode_none'] = 'Do not log anything';
index 862ed68..eb3dc7b 100644 (file)
@@ -81,7 +81,7 @@ $string['cachedef_string'] = 'Language string cache';
 $string['cachedef_tags'] = 'Tags collections and areas';
 $string['cachedef_temp_tables'] = 'Temporary tables cache';
 $string['cachedef_userselections'] = 'Data used to persist user selections throughout Moodle';
-$string['cachedef_user_favourite_course_content_items'] = 'User\'s favourite content items (activities, resources and their subtypes)';
+$string['cachedef_user_favourite_course_content_items'] = 'User\'s starred items';
 $string['cachedef_user_group_groupings'] = 'User\'s groupings and groups per course';
 $string['cachedef_user_course_content_items'] = 'User\'s content items (activities, resources and their subtypes) per course';
 $string['cachedef_yuimodules'] = 'YUI Module definitions';
index 80b39b0..4916a4d 100644 (file)
@@ -26,14 +26,14 @@ $string['author'] = 'Author';
 $string['contentdeleted'] = 'The content has been deleted.';
 $string['contentnotdeleted'] = 'An error was encountered while trying to delete the content.';
 $string['deletecontent'] = 'Delete content';
-$string['deletecontentconfirm'] = '<p>Are you sure you want to delete content <em>\'{$a->name}\'</em>? It will remove the content and all its files.</p><p>This operation can not be undone.</p>';
+$string['deletecontentconfirm'] = 'Are you sure you want to delete the content <em>\'{$a->name}\'</em> and all associated files? This action cannot be undone.';
 $string['file'] = 'Upload content';
 $string['file_help'] = 'Files may be stored in the content bank for use in courses. Only files used by content types enabled on the site may be uploaded.';
 $string['name'] = 'Content';
-$string['nopermissiontodelete'] = 'You have no permissions to delete the content.';
+$string['nopermissiontodelete'] = 'You do not have permission to delete content.';
 $string['privacy:metadata:userid'] = 'The ID of the user creating or modifying content bank content.';
-$string['privacy:metadata:content:usercreated'] = 'The user has created the content.';
-$string['privacy:metadata:content:usermodified'] = 'Last user has modified the content.';
+$string['privacy:metadata:content:usercreated'] = 'The user who created the content.';
+$string['privacy:metadata:content:usermodified'] = 'The last user who modified the content.';
 $string['timecreated'] = 'Time created';
-$string['unsupported'] = 'This content type is not supported';
+$string['unsupported'] = 'This content type is not supported.';
 $string['upload'] = 'Upload';
index c373515..c6a8d9a 100644 (file)
@@ -27,11 +27,11 @@ $string['aria:coursecategory'] = 'Course category';
 $string['aria:courseimage'] = 'Course image';
 $string['aria:courseshortname'] = 'Course short name';
 $string['aria:coursename'] = 'Course name';
-$string['aria:defaulttab'] = 'The default modules';
+$string['aria:defaulttab'] = 'Default activities';
 $string['aria:favourite'] = 'Course is starred';
-$string['aria:favouritestab'] = 'Your starred modules';
-$string['aria:recommendedtab'] = 'The recommended modules';
-$string['aria:modulefavourite'] = 'Star {$a} module';
+$string['aria:favouritestab'] = 'Starred activities';
+$string['aria:recommendedtab'] = 'Recommended activities';
+$string['aria:modulefavourite'] = 'Star {$a} activity';
 $string['coursealreadyfinished'] = 'Course already finished';
 $string['coursenotyetstarted'] = 'The course has not yet started';
 $string['coursenotyetfinished'] = 'The course has not yet finished';
@@ -48,7 +48,7 @@ $string['errorendbeforestart'] = 'The end date ({$a}) is before the course start
 $string['favourite'] = 'Starred course';
 $string['gradetopassnotset'] = 'This course does not have a grade to pass set. It may be set in the grade item of the course (Gradebook setup).';
 $string['informationformodule'] = 'Information about the {$a} activity';
-$string['module'] = 'Module';
+$string['module'] = 'Activity';
 $string['nocourseactivity'] = 'Not enough course activity between the start and the end of the course';
 $string['nocourseendtime'] = 'The course does not have an end time';
 $string['nocoursesections'] = 'No course sections';
index 9e70441..6fcb077 100644 (file)
@@ -64,6 +64,7 @@ $string['enrolmentnewuser'] = '{$a->user} has enrolled in course "{$a->course}"'
 $string['enrolmentmethod'] = 'Enrolment method';
 $string['enrolments'] = 'Enrolments';
 $string['enrolmentoptions'] = 'Enrolment options';
+$string['enrolmentupdatedforuser'] = 'The enrolment for user "{$a->fullname}" has been updated';
 $string['enrolnotpermitted'] = 'You do not have permission or are not allowed to enrol someone in this course';
 $string['enrolperiod'] = 'Enrolment duration';
 $string['enrolusage'] = 'Instances / enrolments';
@@ -136,6 +137,7 @@ $string['totalunenrolledusers'] = '{$a} unenrolled users';
 $string['totalotherusers'] = '{$a} other users';
 $string['unassignnotpermitted'] = 'You do not have permission to unassign roles in this course';
 $string['unenrol'] = 'Unenrol';
+$string['unenrolleduser'] = 'The user "{$a->fullname}" was unenrolled from the course';
 $string['unenrolconfirm'] = 'Do you really want to unenrol "{$a->user}" (previously enrolled via "{$a->enrolinstancename}") from "{$a->course}"?';
 $string['unenrolme'] = 'Unenrol me from {$a}';
 $string['unenrolnotpermitted'] = 'You do not have permission or can not unenrol this user from this course.';
index a1f5006..cf78c4c 100644 (file)
@@ -181,15 +181,15 @@ $string['commentmisconf'] = 'Comment ID is misconfigured';
 $string['componentisuptodate'] = 'Component is up-to-date';
 $string['confirmationnotenabled'] = 'User confirmation is not enabled on this site';
 $string['confirmsesskeybad'] = 'Sorry, but your session key could not be confirmed to carry out this action.  This security feature prevents against accidental or malicious execution of important functions in your name.  Please make sure you really wanted to execute this function.';
-$string['contenttypenotfound'] = 'The \'{$a}\' content bank type doesn\'t exist or is not recognized';
+$string['contenttypenotfound'] = 'The \'{$a}\' content bank type doesn\'t exist or is not recognised.';
 $string['couldnotassignrole'] = 'A serious but unspecified error occurred while trying to assign a role to you';
 $string['couldnotupdatenoexistinguser'] = 'Cannot update the user - user doesn\'t exist';
 $string['couldnotverifyagedigitalconsent'] = 'An error occurred while trying to verify the age of digital consent.<br />Please contact administrator.';
 $string['countriesphpempty'] = 'Error: The file countries.php in language pack {$a} is empty or missing.';
 $string['coursedoesnotbelongtocategory'] = 'The course doesn\'t belong to this category';
-$string['courseformatnotfound'] = 'The course format \'{$a}\' doesn\'t exist or is not recognized';
+$string['courseformatnotfound'] = 'The course format \'{$a}\' doesn\'t exist or is not recognised.';
 $string['coursegroupunknown'] = 'Course corresponding to group {$a} not specified';
-$string['courseidnotfound'] = 'Course id doesn\'t exist';
+$string['courseidnotfound'] = 'The course ID doesn\'t exist.';
 $string['courseidnumbertaken'] = 'ID number is already used for another course ({$a})';
 $string['coursemisconf'] = 'Course is misconfigured';
 $string['courserequestdisabled'] = 'Sorry, but course requests have been disabled by the administrator.';
index b9858b5..d640425 100644 (file)
@@ -152,7 +152,7 @@ $string['noextension'] = 'The file you uploaded is not a valid HTML5 Package. (I
 $string['noh5plibhandlerdefined'] = 'There isn\'t any H5P framework handler installed, so H5P content can\'t be displayed.';
 $string['nojson'] = 'The main h5p.json file is not valid';
 $string['nopermissiontodeploy'] = 'This file can\'t be displayed because it has been uploaded by a user without the required capability to deploy H5P content.';
-$string['nopermissiontoedit'] = 'You do not have permission to edit H5P content';
+$string['nopermissiontoedit'] = 'You do not have permission to edit H5P content.';
 $string['notrustablefile'] = 'This file can\'t be displayed because it has been uploaded by a user without the capability to update H5P content types.  Please contact your administrator to ask for the content type to be installed.';
 $string['nounzip'] = 'The file you uploaded is not a valid HTML5 Package. (It is not possible to unzip it.)';
 $string['offlineDialogBody'] = 'We were unable to send information about your completion of this task. Please check your internet connection.';
index eae72de..f0a53e9 100644 (file)
@@ -70,7 +70,7 @@ $string['addnewuser'] = 'Add a new user';
 $string['addnousersrecip'] = 'Add users who haven\'t accessed this {$a} to recipient list';
 $string['addpagehere'] = 'Add text here';
 $string['addresource'] = 'Add a resource...';
-$string['addresourceoractivity'] = 'Add an activity or resource';
+$string['addresourceoractivity'] = 'Add an activity';
 $string['addresourcetosection'] = 'Add a resource to section \'{$a}\'';
 $string['address'] = 'Address';
 $string['addsections'] = 'Add sections';
@@ -176,7 +176,7 @@ $string['backtohome'] = 'Back to the site home';
 $string['backtopageyouwereon'] = 'Back to the page you were on';
 $string['backup'] = 'Backup';
 $string['backupactivehelp'] = 'Choose whether or not to do automated backups.';
-$string['backupadhocpending'] = 'Course backup adhoc task pending';
+$string['backupadhocpending'] = 'Course backup ad hoc task pending';
 $string['backupcancelled'] = 'Backup cancelled';
 $string['backupcoursefileshelp'] = 'If enabled then course files will be included in automated backups';
 $string['backupdate'] = 'Backup date';
@@ -1002,7 +1002,7 @@ $string['changepassword'] = 'Change password';
 $string['changessaved'] = 'Changes saved';
 $string['check'] = 'Check';
 $string['checks'] = 'Checks';
-$string['checksok'] = 'All \'{$a}\' checks ok';
+$string['checksok'] = 'All \'{$a}\' checks OK';
 $string['checkall'] = 'Check all';
 $string['checkingbackup'] = 'Checking backup';
 $string['checkingcourse'] = 'Checking course';
@@ -1183,7 +1183,7 @@ $string['makethismyhome'] = 'Make this my home page';
 $string['makeunavailable'] = 'Make unavailable';
 $string['manageblocks'] = 'Blocks';
 $string['managecategorythis'] = 'Manage this category';
-$string['managecontentbanktypes'] = 'Manage content bank content types';
+$string['managecontentbanktypes'] = 'Manage content types';
 $string['managecourses'] = 'Manage courses';
 $string['managedataformats'] = 'Manage data formats';
 $string['managedatabase'] = 'Database';
@@ -1297,7 +1297,7 @@ $string['moodleversion'] = 'Moodle version';
 $string['moodlerelease'] = 'Moodle release';
 $string['more'] = 'more';
 $string['morehelp'] = 'More help';
-$string['morehelpaboutmodule'] = 'More help about the {$a} module';
+$string['morehelpaboutmodule'] = 'More help about the {$a} activity';
 $string['moreinfo'] = 'More info';
 $string['moreinformation'] = 'More information about this error';
 $string['moreprofileinfoneeded'] = 'Please tell us more about yourself';
@@ -1619,7 +1619,7 @@ $string['privacy:metadata:log:module'] = 'module';
 $string['privacy:metadata:log:time'] = 'The time when the action took place';
 $string['privacy:metadata:log:url'] = 'The URL related to the event';
 $string['privacy:metadata:log:userid'] = 'The ID of the user who performed the action';
-$string['privacy:metadata:task_adhoc'] = 'The status of adhoc tasks.';
+$string['privacy:metadata:task_adhoc'] = 'The status of ad hoc tasks.';
 $string['privacy:metadata:task_adhoc:component'] = 'The component owning the task.';
 $string['privacy:metadata:task_adhoc:nextruntime'] = 'The earliest time to run this task.';
 $string['privacy:metadata:task_adhoc:userid'] = 'The user to run the task as.';
index bc6d094..7ed41e6 100644 (file)
@@ -188,8 +188,8 @@ $string['norepositoriesexternalavailable'] = 'Sorry, none of your current reposi
 $string['notyourinstances'] = 'You can not view/edit repository instances of another user';
 $string['off'] = 'Enabled but hidden';
 $string['original'] = 'Original';
-$string['originalextensionchange'] = 'The original file extension has been modified as a part of the file name change. Changing the extension from ".{$a->originalextension}" to ".{$a->newextension}" could potentially cause some side effects.';
-$string['originalextensionremove'] = 'The original file extension has been removed as a part of the file name change. Removing the extension ".{$a}" could potentially cause some side effects.';
+$string['originalextensionchange'] = 'The original file extension has been modified as a part of the file name change. Changing the extension from ".{$a->originalextension}" to ".{$a->newextension}" may result in a file which cannot be opened.';
+$string['originalextensionremove'] = 'The original file extension has been removed as a part of the file name change. Removing the extension ".{$a}" is likely to result in a file which cannot be opened.';
 $string['openpicker'] = 'Choose a file...';
 $string['operation'] = 'Operation';
 $string['on'] = 'Enabled and visible';
index a37ebed..12a250e 100644 (file)
@@ -151,9 +151,9 @@ $string['confirmunassigntitle'] = 'Confirm role change';
 $string['confirmunassignyes'] = 'Remove';
 $string['confirmunassignno'] = 'Cancel';
 $string['contentbank:access'] = 'Access the content bank';
-$string['contentbank:deleteanycontent'] = 'Delete any content from the content bank on the site';
-$string['contentbank:deleteowncontent'] = 'Delete content from the content bank created by the user';
-$string['contentbank:upload'] = 'Upload new content in the content bank';
+$string['contentbank:deleteanycontent'] = 'Delete any content from the content bank';
+$string['contentbank:deleteowncontent'] = 'Delete content from own content bank';
+$string['contentbank:upload'] = 'Upload content to the content bank';
 $string['context'] = 'Context';
 $string['course:activityvisibility'] = 'Hide/show activities';
 $string['course:bulkmessaging'] = 'Send a message to many people';
@@ -183,7 +183,7 @@ $string['course:markcomplete'] = 'Mark users as complete in course completion';
 $string['course:movesections'] = 'Move sections';
 $string['course:overridecompletion'] = 'Override activity completion status';
 $string['course:renameroles'] = 'Rename roles';
-$string['course:recommendactivity'] = 'Recommend activities to the activity chooser';
+$string['course:recommendactivity'] = 'Recommend activities in the activity chooser';
 $string['course:request'] = 'Request new courses';
 $string['course:reset'] = 'Reset course';
 $string['course:reviewotherusers'] = 'Review other users';
index 969d782..f2ddae7 100644 (file)
Binary files a/lib/amd/build/custom_interaction_events.min.js and b/lib/amd/build/custom_interaction_events.min.js differ
index 5db8445..7e05919 100644 (file)
Binary files a/lib/amd/build/custom_interaction_events.min.js.map and b/lib/amd/build/custom_interaction_events.min.js.map differ
index 98725b8..674fffa 100644 (file)
@@ -43,6 +43,7 @@ define(['jquery', 'core/key_codes'], function($, keyCodes) {
         ctrlPageUp: 'cie:ctrlPageUp',
         ctrlPageDown: 'cie:ctrlPageDown',
         enter: 'cie:enter',
+        accessibleChange: 'cie:accessibleChange',
     };
     // Static cache of jQuery events that have been handled. This should
     // only be populated by JavaScript generated events (which will keep it
@@ -415,6 +416,48 @@ define(['jquery', 'core/key_codes'], function($, keyCodes) {
         addKeyboardEvent(element, events.enter, keyCodes.enter);
     };
 
+    /**
+     * Trigger the AccessibleChange event on the given element if the value of the element is changed.
+     *
+     * @method addAccessibleChangeListener
+     * @private
+     * @param {object} element jQuery object to add event listeners to
+     */
+    var addAccessibleChangeListener = function(element) {
+        var onMac = navigator.userAgent.indexOf('Macintosh') !== -1;
+        var touchEnabled = ('ontouchstart' in window) || (('msMaxTouchPoints' in navigator) && (navigator.msMaxTouchPoints > 0));
+        if (onMac || touchEnabled) {
+            element.on('change', function(e) {
+                triggerEvent(events.accessibleChange, e);
+            });
+        } else {
+            element.on('focus', function() {
+                $(this).data('initValue', this.value);
+            });
+            element.on('blur', function(e) {
+                var initValue = $(this).data('initValue');
+                $(this).removeData('initValue');
+                if (this.value !== initValue) {
+                    triggerEvent(events.accessibleChange, e);
+                }
+            });
+            element.on('keydown', function(e) {
+                if ((e.which === keyCodes.enter) && this.value !== $(this).data('initValue')) {
+                    triggerEvent(events.accessibleChange, e);
+                } else if (e.which === keyCodes.escape) {
+                    this.value = $(this).data('initValue');
+                }
+            });
+            element.on('click', function(e) {
+                var initValue = $(this).data('initValue');
+                // Some browsers trigger onclick before onblur, therefore it is possible that initValue is undefined.
+                if (typeof initValue !== 'undefined' && initValue != this.value) {
+                    triggerEvent(events.accessibleChange, e);
+                }
+            });
+        }
+    };
+
     /**
      * Get the list of events and their handlers.
      *
@@ -441,6 +484,7 @@ define(['jquery', 'core/key_codes'], function($, keyCodes) {
         handlers[events.ctrlPageUp] = addCtrlPageUpListener;
         handlers[events.ctrlPageDown] = addCtrlPageDownListener;
         handlers[events.enter] = addEnterListener;
+        handlers[events.accessibleChange] = addAccessibleChangeListener;
 
         return handlers;
     };
index c9eeb6c..31d6679 100644 (file)
@@ -51,6 +51,6 @@ $string['tcpsocketport'] = 'TCP socket port';
 $string['tcpsocketportdesc'] = 'The port to use when connecting to ClamAV';
 $string['unknownerror'] = 'There was an unknown error with ClamAV.';
 $string['tries'] = 'Scanning attempts';
-$string['tries_desc'] = 'Number of attempts clamav will try when there is an error during scanning process';
+$string['tries_desc'] = 'Number of attempts made by ClamAV if there is an error during the scanning process.';
 $string['tries_notice'] = 'Clamav scanning has tried {$a->tries} time(s).
 {$a->notice}';
index 313cd08..03880b0 100644 (file)
@@ -517,7 +517,14 @@ function cli_execute_parallel($cmds, $cwd = null, $delay = 0) {
 
     // Create child process.
     foreach ($cmds as $name => $cmd) {
-        $process = new Symfony\Component\Process\Process($cmd);
+        if (method_exists('\\Symfony\\Component\\Process\\Process', 'fromShellCommandline')) {
+            // Process 4.2 and up.
+            $process = Symfony\Component\Process\Process::fromShellCommandline($cmd);
+        } else {
+            // Process 4.1 and older.
+            $process = new Symfony\Component\Process\Process(null);
+            $process->setCommandLine($cmd);
+        }
 
         $process->setWorkingDirectory($cwd);
         $process->setTimeout(null);
index f898c36..2a6eae8 100644 (file)
@@ -86,6 +86,7 @@ class core_component {
     protected static $psr0namespaces = array(
         'Horde' => 'lib/horde/framework/Horde',
         'Mustache' => 'lib/mustache/src/Mustache',
+        'CFPropertyList' => 'lib/plist/classes/CFPropertyList',
     );
     /** @var array associative array of PRS-4 namespaces and corresponding paths. */
     protected static $psr4namespaces = array(
index d2e9a3d..387df8f 100644 (file)
@@ -245,4 +245,22 @@ final class ip_utils {
         return false;
     }
 
+    /**
+     * Return IP address for given hostname, or null on failure
+     *
+     * @param string $hostname
+     * @return string|null
+     */
+    public static function get_ip_address(string $hostname): ?string {
+        if (self::is_domain_name($hostname)) {
+            $address = gethostbyname($hostname);
+
+            // If address is different from hostname, we have success.
+            if (strcasecmp($address, $hostname) !== 0) {
+                return $address;
+            }
+        }
+
+        return null;
+    }
 }
index 61536ec..962bc3e 100644 (file)
@@ -1960,7 +1960,7 @@ class core_plugin_manager {
 
             'quizaccess' => array(
                 'delaybetweenattempts', 'ipaddress', 'numattempts', 'offlineattempts', 'openclosedate',
-                'password', 'safebrowser', 'securewindow', 'timelimit'
+                'password', 'seb', 'securewindow', 'timelimit'
             ),
 
             'report' => array(
index b54639a..0367914 100644 (file)
@@ -339,7 +339,7 @@ class redis extends handler {
     }
 
     /**
-     * Obtain a session lock so we are the only one using it at the moent.
+     * Obtain a session lock so we are the only one using it at the moment.
      *
      * @param string $id The session id to lock.
      * @return bool true when session was locked, exception otherwise.
@@ -355,23 +355,43 @@ class redis extends handler {
          * on the session for the entire time it is open.  If another AJAX call, or page is using
          * the session then we just wait until it finishes before we can open the session.
          */
+
+        // Store the current host, process id and the request URI so it's easy to track who has the lock.
+        $hostname = gethostname();
+        if ($hostname === false) {
+            $hostname = 'UNKNOWN HOST';
+        }
+        $pid = getmypid();
+        if ($pid === false) {
+            $pid = 'UNKNOWN';
+        }
+        $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : 'unknown uri';
+
+        $whoami = "[pid {$pid}] {$hostname}:$uri";
+
         while (!$haslock) {
-            $haslock = $this->connection->setnx($lockkey, '1');
-            if (!$haslock) {
-                usleep(rand(100000, 1000000));
-                if ($this->time() > $startlocktime + $this->acquiretimeout) {
-                    // This is a fatal error, better inform users.
-                    // It should not happen very often - all pages that need long time to execute
-                    // should close session immediately after access control checks.
-                    error_log('Cannot obtain session lock for sid: '.$id.' within '.$this->acquiretimeout.
-                            '. It is likely another page has a long session lock, or the session lock was never released.');
-                    throw new exception("Unable to obtain session lock");
-                }
-            } else {
+
+            $haslock = $this->connection->setnx($lockkey, $whoami);
+
+            if ($haslock) {
                 $this->locks[$id] = $this->time() + $this->lockexpire;
                 $this->connection->expire($lockkey, $this->lockexpire);
                 return true;
             }
+
+            if ($this->time() > $startlocktime + $this->acquiretimeout) {
+                // This is a fatal error, better inform users.
+                // It should not happen very often - all pages that need long time to execute
+                // should close session immediately after access control checks.
+                $whohaslock = $this->connection->get($lockkey);
+                // @codingStandardsIgnoreStart
+                error_log("Cannot obtain session lock for sid: $id within $this->acquiretimeout seconds. " .
+                    "It is likely another page ($whohaslock) has a long session lock, or the session lock was never released.");
+                // @codingStandardsIgnoreEnd
+                throw new exception("Unable to obtain session lock");
+            }
+
+            usleep(rand(100000, 1000000));
         }
     }
 
index b7fd89c..fdf4e3e 100644 (file)
@@ -568,8 +568,7 @@ class manager {
 
         $where = '(nextruntime IS NULL OR nextruntime < :timestart1)';
         $params = array('timestart1' => $timestart);
-        $records = $DB->get_records_select('task_adhoc', $where, $params, 'nextruntime ASC, id ASC');
-
+        $records = $DB->get_records_select('task_adhoc', $where, $params, 'nextruntime ASC, id ASC', '*', 0, 2000);
         $records = self::ensure_adhoc_task_qos($records);
 
         $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
index 15b781f..eecc092 100644 (file)
@@ -38,7 +38,7 @@ class messaging_cleanup_task extends scheduled_task {
     }
 
     /**
-     * Do the job.
+     * Do the job. Each message processor also gets the chance to perform it's own cleanup.
      * Throw exceptions on errors (the job will be retried).
      */
     public function execute() {
@@ -46,9 +46,17 @@ class messaging_cleanup_task extends scheduled_task {
 
         $timenow = time();
 
+        $processors = get_message_processors(true);
+
         // Cleanup read and unread notifications.
         if (!empty($CFG->messagingdeleteallnotificationsdelay)) {
             $notificationdeletetime = $timenow - $CFG->messagingdeleteallnotificationsdelay;
+
+            /** @var \message_output $processor */
+            foreach (array_column($processors, 'object') as $processor) {
+                $processor->cleanup_all_notifications($notificationdeletetime);
+            }
+
             $params = array('notificationdeletetime' => $notificationdeletetime);
             $DB->delete_records_select('notifications', 'timecreated < :notificationdeletetime', $params);
         }
@@ -56,6 +64,12 @@ class messaging_cleanup_task extends scheduled_task {
         // Cleanup read notifications.
         if (!empty($CFG->messagingdeletereadnotificationsdelay)) {
             $notificationdeletetime = $timenow - $CFG->messagingdeletereadnotificationsdelay;
+
+            /** @var \message_output $processor */
+            foreach (array_column($processors, 'object') as $processor) {
+                $processor->cleanup_read_notifications($notificationdeletetime);
+            }
+
             $params = array('notificationdeletetime' => $notificationdeletetime);
             $DB->delete_records_select('notifications', 'timeread < :notificationdeletetime', $params);
         }
index f71d4d4..0b5f9e4 100644 (file)
@@ -2306,5 +2306,13 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2020041700.01);
     }
 
+    if ($oldversion < 2020042800.01) {
+        // Delete obsolete config value.
+        unset_config('enablesafebrowserintegration');
+        // Clean up config of the old plugin.
+        unset_all_config_for_plugin('quizaccess_safebrowser');
+
+        upgrade_main_savepoint(true, 2020042800.01);
+    }
     return true;
 }
index 6d4a2be..8ac684b 100644 (file)
@@ -9438,7 +9438,7 @@ function get_performance_info() {
         // Attempt to avoid devs debugging peformance issues, when its caused by css building and so on.
         $info['html'] .= '<p><strong>Warning: Theme designer mode is enabled.</strong></p>';
     }
-    $info['html'] .= '<ul class="list-unstyled ml-1 row">';         // Holds userfriendly HTML representation.
+    $info['html'] .= '<ul class="list-unstyled row mx-md-0">';         // Holds userfriendly HTML representation.
 
     $info['realtime'] = microtime_diff($PERF->starttime, microtime());
 
@@ -9462,7 +9462,7 @@ function get_performance_info() {
         $info['txt']  .= 'memory_peak: '.$info['memory_peak'].'B (' . display_size($info['memory_peak']).') ';
     }
 
-    $info['html'] .= '</ul><ul class="list-unstyled ml-1 row">';
+    $info['html'] .= '</ul><ul class="list-unstyled row mx-md-0">';
     $inc = get_included_files();
     $info['includecount'] = count($inc);
     $info['html'] .= '<li class="included col-sm-4">Included '.$info['includecount'].' files</li> ';
@@ -9546,57 +9546,185 @@ function get_performance_info() {
     }
 
     $info['html'] .= '</ul>';
+    $html = '';
     if ($stats = cache_helper::get_stats()) {
-        $html = '<ul class="cachesused list-unstyled ml-1 row">';
-        $html .= '<li class="cache-stats-heading font-weight-bold">Caches used (hits/misses/sets)</li>';
-        $html .= '</ul><ul class="cachesused list-unstyled ml-1">';
+
+        $table = new html_table();
+        $table->attributes['class'] = 'cachesused table table-dark table-sm w-auto table-bordered';
+        $table->head = ['Mode', 'Cache item', 'Static', 'H', 'M', get_string('mappingprimary', 'cache'), 'H', 'M', 'S'];
+        $table->data = [];
+        $table->align = ['left', 'left', 'left', 'right', 'right', 'left', 'right', 'right', 'right'];
+
         $text = 'Caches used (hits/misses/sets): ';
         $hits = 0;
         $misses = 0;
         $sets = 0;
+        $maxstores = 0;
+
+        // We want to align static caches into their own column.
+        $hasstatic = false;
+        foreach ($stats as $definition => $details) {
+            $numstores = count($details['stores']);
+            $first = key($details['stores']);
+            if ($first !== cache_store::STATIC_ACCEL) {
+                $numstores++; // Add a blank space for the missing static store.
+            }
+            $maxstores = max($maxstores, $numstores);
+        }
+
+        $storec = 0;
+
+        while ($storec++ < ($maxstores - 2)) {
+            if ($storec == ($maxstores - 2)) {
+                $table->head[] = get_string('mappingfinal', 'cache');
+            } else {
+                $table->head[] = "Store $storec";
+            }
+            $table->align[] = 'left';
+            $table->align[] = 'right';
+            $table->align[] = 'right';
+            $table->align[] = 'right';
+            $table->head[] = 'H';
+            $table->head[] = 'M';
+            $table->head[] = 'S';
+        }
+
+        ksort($stats);
+
         foreach ($stats as $definition => $details) {
             switch ($details['mode']) {
                 case cache_store::MODE_APPLICATION:
                     $modeclass = 'application';
-                    $mode = ' <span title="application cache">[a]</span>';
+                    $mode = ' <span title="application cache">App</span>';
                     break;
                 case cache_store::MODE_SESSION:
                     $modeclass = 'session';
-                    $mode = ' <span title="session cache">[s]</span>';
+                    $mode = ' <span title="session cache">Ses</span>';
                     break;
                 case cache_store::MODE_REQUEST:
                     $modeclass = 'request';
-                    $mode = ' <span title="request cache">[r]</span>';
+                    $mode = ' <span title="request cache">Req</span>';
                     break;
             }
-            $html .= '<li class="d-inline-flex"><ul class="cache-definition-stats list-unstyled ml-1 mb-1 cache-mode-'.$modeclass.' card d-inline-block">';
-            $html .= '<li class="cache-definition-stats-heading p-t-1 card-header bg-dark bg-inverse font-weight-bold">' .
-                $definition . $mode.'</li>';
+            $row = [$mode, $definition];
+
             $text .= "$definition {";
+
+            $storec = 0;
             foreach ($details['stores'] as $store => $data) {
-                $hits += $data['hits'];
+
+                if ($storec == 0 && $store !== cache_store::STATIC_ACCEL) {
+                    $row[] = '';
+                    $row[] = '';
+                    $row[] = '';
+                    $storec++;
+                }
+
+                $hits   += $data['hits'];
                 $misses += $data['misses'];
-                $sets += $data['sets'];
+                $sets   += $data['sets'];
                 if ($data['hits'] == 0 and $data['misses'] > 0) {
-                    $cachestoreclass = 'nohits text-danger';
+                    $cachestoreclass = 'nohits bg-danger';
                 } else if ($data['hits'] < $data['misses']) {
-                    $cachestoreclass = 'lowhits text-warning';
+                    $cachestoreclass = 'lowhits bg-warning text-dark';
                 } else {
-                    $cachestoreclass = 'hihits text-success';
+                    $cachestoreclass = 'hihits';
                 }
                 $text .= "$store($data[hits]/$data[misses]/$data[sets]) ";
-                $html .= "<li class=\"cache-store-stats $cachestoreclass p-x-1\">" .
-                    "$store: $data[hits] / $data[misses] / $data[sets]</li>";
-                // This makes boxes of same sizes.
-                if (count($details['stores']) == 1) {
-                    $html .= "<li class=\"cache-store-stats $cachestoreclass p-x-1\">&nbsp;</li>";
+                $cell = new html_table_cell($store);
+                $cell->attributes = ['class' => $cachestoreclass];
+                $row[] = $cell;
+                $cell = new html_table_cell($data['hits']);
+                $cell->attributes = ['class' => $cachestoreclass];
+                $row[] = $cell;
+                $cell = new html_table_cell($data['misses']);
+                $cell->attributes = ['class' => $cachestoreclass];
+                $row[] = $cell;
+
+                if ($store !== cache_store::STATIC_ACCEL) {
+                    // The static cache is never set.
+                    $cell = new html_table_cell($data['sets']);
+                    $cell->attributes = ['class' => $cachestoreclass];
+                    $row[] = $cell;
                 }
+                $storec++;
+            }
+            while ($storec++ < $maxstores) {
+                $row[] = '';
+                $row[] = '';
+                $row[] = '';
+                $row[] = '';
             }
-            $html .= '</ul></li>';
             $text .= '} ';
+
+            $table->data[] = $row;
         }
-        $html .= '</ul> ';
-        $html .= "<div class='cache-total-stats row'>Total: $hits / $misses / $sets</div>";
+
+        $html .= html_writer::table($table);
+
+        // Now lets also show sub totals for each cache store.
+        $storetotals = [];
+        $storetotal = ['hits' => 0, 'misses' => 0, 'sets' => 0];
+        foreach ($stats as $definition => $details) {
+            foreach ($details['stores'] as $store => $data) {
+                if (!array_key_exists($store, $storetotals)) {
+                    $storetotals[$store] = ['hits' => 0, 'misses' => 0, 'sets' => 0];
+                }
+                $storetotals[$store]['class']   = $data['class'];
+                $storetotals[$store]['hits']   += $data['hits'];
+                $storetotals[$store]['misses'] += $data['misses'];
+                $storetotals[$store]['sets']   += $data['sets'];
+                $storetotal['hits']   += $data['hits'];
+                $storetotal['misses'] += $data['misses'];
+                $storetotal['sets']   += $data['sets'];
+            }
+        }
+
+        $table = new html_table();
+        $table->attributes['class'] = 'cachesused table table-dark table-sm w-auto table-bordered';
+        $table->head = [get_string('storename', 'cache'), get_string('type_cachestore', 'plugin'), 'H', 'M', 'S'];
+        $table->data = [];
+        $table->align = ['left', 'left', 'right', 'right', 'right'];
+
+        ksort($storetotals);
+
+        foreach ($storetotals as $store => $data) {
+            $row = [];
+            if ($data['hits'] == 0 and $data['misses'] > 0) {
+                $cachestoreclass = 'nohits bg-danger';
+            } else if ($data['hits'] < $data['misses']) {
+                $cachestoreclass = 'lowhits bg-warning text-dark';
+            } else {
+                $cachestoreclass = 'hihits';
+            }
+            $cell = new html_table_cell($store);
+            $cell->attributes = ['class' => $cachestoreclass];
+            $row[] = $cell;
+            $cell = new html_table_cell($data['class']);
+            $cell->attributes = ['class' => $cachestoreclass];
+            $row[] = $cell;
+            $cell = new html_table_cell($data['hits']);
+            $cell->attributes = ['class' => $cachestoreclass];
+            $row[] = $cell;
+            $cell = new html_table_cell($data['misses']);
+            $cell->attributes = ['class' => $cachestoreclass];
+            $row[] = $cell;
+            $cell = new html_table_cell($data['sets']);
+            $cell->attributes = ['class' => $cachestoreclass];
+            $row[] = $cell;
+            $table->data[] = $row;
+        }
+        $row = [
+            get_string('total'),
+            '',
+            $storetotal['hits'],
+            $storetotal['misses'],
+            $storetotal['sets'],
+        ];
+        $table->data[] = $row;
+
+        $html .= html_writer::table($table);
+
         $info['cachesused'] = "$hits / $misses / $sets";
         $info['html'] .= $html;
         $info['txt'] .= $text.'. ';
@@ -9606,7 +9734,7 @@ function get_performance_info() {
         $info['txt'] .= 'Caches used (hits/misses/sets): 0/0/0 ';
     }
 
-    $info['html'] = '<div class="performanceinfo siteinfo container-fluid">'.$info['html'].'</div>';
+    $info['html'] = '<div class="performanceinfo siteinfo container-fluid px-md-0 overflow-auto mt-3">'.$info['html'].'</div>';
     return $info;
 }
 
@@ -10443,18 +10571,26 @@ function get_callable_name($callable) {
  * It just performs some simple checks, and mainly is used for places where we want to hide some options
  * such as site registration when $CFG->wwwroot is not publicly accessible.
  * Good thing is there is no false negative.
+ * Note that it's possible to force the result of this check by specifying $CFG->site_is_public in config.php
  *
  * @return bool
  */
 function site_is_public() {
     global $CFG;
 
+    // Return early if site admin has forced this setting.
+    if (isset($CFG->site_is_public)) {
+        return (bool)$CFG->site_is_public;
+    }
+
     $host = parse_url($CFG->wwwroot, PHP_URL_HOST);
 
     if ($host === 'localhost' || preg_match('|^127\.\d+\.\d+\.\d+$|', $host)) {
         $ispublic = false;
     } else if (\core\ip_utils::is_ip_address($host) && !ip_is_public($host)) {
         $ispublic = false;
+    } else if (($address = \core\ip_utils::get_ip_address($host)) && !ip_is_public($address)) {
+        $ispublic = false;
     } else {
         $ispublic = true;
     }
index f6d432d..75980f0 100644 (file)
@@ -3093,7 +3093,7 @@ class paging_bar implements renderable, templatable {
 
         if ($this->page > 0) {
             $data->previous = [
-                'page' => $this->page - 1,
+                'page' => $this->page,
                 'url' => (new moodle_url($this->baseurl, [$this->pagevar => $this->page - 1]))->out(false)
             ];
         }
@@ -3139,7 +3139,7 @@ class paging_bar implements renderable, templatable {
 
         if ($this->page + 1 != $lastpage) {
             $data->next = [
-                'page' => $this->page + 1,
+                'page' => $this->page + 2,
                 'url' => (new moodle_url($this->baseurl, [$this->pagevar => $this->page + 1]))->out(false)
             ];
         }
diff --git a/lib/plist/LICENSE b/lib/plist/LICENSE
new file mode 100644 (file)
index 0000000..5e63f61
--- /dev/null
@@ -0,0 +1,22 @@
+The MIT License
+
+Copyright (c) 2009 Christian Kruse, Rodney Rehm
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
diff --git a/lib/plist/README.md b/lib/plist/README.md
new file mode 100644 (file)
index 0000000..7eaf4f8
--- /dev/null
@@ -0,0 +1,39 @@
+# CFPropertyList
+
+The PHP implementation of Apple's PropertyList can handle XML PropertyLists as well as binary PropertyLists. It offers functionality to easily convert data between worlds, e.g. recalculating timestamps from unix epoch to apple epoch and vice versa. A feature to automagically create (guess) the plist structure from a normal PHP data structure will help you dump your data to plist in no time.
+
+Note: CFPropertylist was originally hosted on [Google Code](http://code.google.com/p/cfpropertylist/)
+
+## Choose Your Favorite Operating System
+
+CFPropertyList does not rely on any "Apple proprietary" components, like plutil. CFPropertyList runs on any Operating System with PHP and some standard extensions installed.
+
+Although you might want to deliver data to your iPhone application, you might want to run those server side services on your standard Linux (or even Windows) environment, rather than buying an expensive Apple Server. With CFPropertyList you now have the power to provide data from your favorite Operating System.
+
+## Requirements And Limitations
+
+* requires PHP5.3 (as of CFPropertyList 2.0)
+* requires either [MBString](http://php.net/mbstring) or [Iconv](http://php.net/iconv)
+* requires either [BC](http://php.net/bc) or [GMP](http://php.net/gmp) or [phpseclib](http://phpseclib.sourceforge.net/) (see BigIntegerBug for an explanation) - as of CFPropertyList 1.0.1
+
+## Authors
+
+- Rodney Rehm <rodney.rehm@medialize.de>
+- Christian Kruse <cjk@wwwtech.de>
+- PSR-0 changes by Jarvis Badgley <https://github.com/ChiperSoft/CFPropertyList>
+
+## License
+
+CFPropertyList is published under the [MIT License](http://www.opensource.org/licenses/mit-license.php).
+
+## Installation
+
+see [Composer / Packagist](http://packagist.org/packages/rodneyrehm/plist).
+
+## Related
+
+* [man(5) plist](http://developer.apple.com/documentation/Darwin/Reference/ManPages/man5/plist.5.html)
+* [CFBinaryPList.c](http://www.opensource.apple.com/source/CF/CF-476.15/CFBinaryPList.c)
+* [CFPropertyList in Ruby](http://rubyforge.org/projects/cfpropertylist/)
+* [CFPropertyList in Python](https://github.com/bencochran/CFPropertyList)
+* [plist on Wikipedia](http://en.wikipedia.org/wiki/Plist)
diff --git a/lib/plist/classes/CFPropertyList/CFBinaryPropertyList.php b/lib/plist/classes/CFPropertyList/CFBinaryPropertyList.php
new file mode 100644 (file)
index 0000000..34cb708
--- /dev/null
@@ -0,0 +1,1051 @@
+<?php
+/**
+ * CFPropertyList
+ * {@link http://developer.apple.com/documentation/Darwin/Reference/ManPages/man5/plist.5.html Property Lists}
+ * @author Rodney Rehm <rodney.rehm@medialize.de>
+ * @author Christian Kruse <cjk@wwwtech.de>
+ * @package plist
+ * @version $Id$
+ */
+namespace CFPropertyList;
+
+/**
+ * Facility for reading and writing binary PropertyLists. Ported from {@link http://www.opensource.apple.com/source/CF/CF-476.15/CFBinaryPList.c CFBinaryPList.c}.
+ * @author Rodney Rehm <rodney.rehm@medialize.de>
+ * @author Christian Kruse <cjk@wwwtech.de>
+ * @package plist
+ * @example example-read-02.php Read a Binary PropertyList
+ * @example example-read-03.php Read a PropertyList without knowing the type
+ */
+abstract class CFBinaryPropertyList {
+  /**
+   * Content of the plist (unparsed string)
+   * @var string
+   */
+  protected $content = NULL;
+
+  /**
+   * position in the (unparsed) string
+   * @var integer
+   */
+  protected $pos = 0;
+
+  /**
+   * Table containing uniqued objects
+   * @var array
+   */
+  protected $uniqueTable = Array();
+
+  /**
+   * Number of objects in file
+   * @var integer
+   */
+  protected $countObjects = 0;
+
+  /**
+   * The length of all strings in the file (byte length, not character length)
+   * @var integer
+   */
+  protected $stringSize = 0;
+
+  /**
+   * The length of all ints in file (byte length)
+   * @var integer
+   */
+  protected $intSize = 0;
+
+  /**
+   * The length of misc objects (i.e. not integer and not string) in file
+   * @var integer
+   */
+  protected $miscSize = 0;
+
+  /**
+   * Number of object references in file (needed to calculate reference byte length)
+   * @var integer
+   */
+  protected $objectRefs = 0;
+
+  /**
+   * Number of objects written during save phase; needed to calculate the size of the object table
+   * @var integer
+   */
+  protected $writtenObjectCount = 0;
+
+  /**
+   * Table containing all objects in the file
+   */
+  protected $objectTable = Array();
+
+  /**
+   * The size of object references
+   */
+  protected $objectRefSize = 0;
+
+  /**
+   * The „offsets” (i.e. the different entries) in the file
+   */
+  protected $offsets = Array();
+
+  /**
+   * Read a „null type” (filler byte, true, false, 0 byte)
+   * @param $length The byte itself
+   * @return the byte value (e.g. CFBoolean(true), CFBoolean(false), 0 or 15)
+   * @throws PListException on encountering an unknown null type
+   */
+  protected function readBinaryNullType($length) {
+    switch($length) {
+      case 0: return 0; // null type
+      case 8: return new CFBoolean(false);
+      case 9: return new CFBoolean(true);
+      case 15: return 15; // fill type
+    }
+
+    throw new PListException("unknown null type: $length");
+  }
+
+  /**
+   * Create an 64 bit integer using bcmath or gmp
+   * @param int $hi The higher word
+   * @param int $lo The lower word
+   * @return mixed The integer (as int if possible, as string if not possible)
+   * @throws PListException if neither gmp nor bc available
+   */
+  protected static function make64Int($hi,$lo) {
+    // on x64, we can just use int
+    if(PHP_INT_SIZE > 4) return (((int)$hi)<<32) | ((int)$lo);
+
+    // lower word has to be unsigned since we don't use bitwise or, we use bcadd/gmp_add
+    $lo = sprintf("%u", $lo);
+
+    // use GMP or bcmath if possible
+    if(function_exists("gmp_mul")) return gmp_strval(gmp_add(gmp_mul($hi, "4294967296"), $lo));
+
+    if(function_exists("bcmul")) return bcadd(bcmul($hi,"4294967296"), $lo);
+
+    if(class_exists('Math_BigInteger')) {
+      $bi = new \Math_BigInteger($hi);
+      return $bi->multiply(new \Math_BigInteger("4294967296"))->add(new \Math_BigInteger($lo))->toString();
+    }
+
+    throw new PListException("either gmp or bc has to be installed, or the Math_BigInteger has to be available!");
+  }
+
+  /**
+   * Read an integer value
+   * @param integer $length The length (in bytes) of the integer value, coded as „set bit $length to 1”
+   * @return CFNumber The integer value
+   * @throws PListException if integer val is invalid
+   * @throws IOException if read error occurs
+   * @uses make64Int() to overcome PHP's big integer problems
+   */
+  protected function readBinaryInt($length) {
+    if($length > 3) throw new PListException("Integer greater than 8 bytes: $length");
+
+    $nbytes = 1 << $length;
+
+    $val = null;
+    if(strlen($buff = substr($this->content, $this->pos, $nbytes)) != $nbytes) throw IOException::readError("");
+    $this->pos += $nbytes;
+
+    switch($length) {
+      case 0:
+        $val = unpack("C", $buff);
+        $val = $val[1];
+        break;
+      case 1:
+        $val = unpack("n", $buff);
+        $val = $val[1];
+        break;
+      case 2:
+        $val = unpack("N", $buff);
+        $val = $val[1];
+        break;
+      case 3:
+        $words = unpack("Nhighword/Nlowword",$buff);
+        //$val = $words['highword'] << 32 | $words['lowword'];
+        $val = self::make64Int($words['highword'],$words['lowword']);
+        break;
+    }
+
+    return new CFNumber($val);
+  }
+
+  /**
+   * Read a real value
+   * @param integer $length The length (in bytes) of the integer value, coded as „set bit $length to 1”
+   * @return CFNumber The real value
+   * @throws PListException if real val is invalid
+   * @throws IOException if read error occurs
+   */
+  protected function readBinaryReal($length) {
+    if($length > 3) throw new PListException("Real greater than 8 bytes: $length");
+
+    $nbytes = 1 << $length;
+    $val = null;
+    if(strlen($buff = substr($this->content,$this->pos, $nbytes)) != $nbytes) throw IOException::readError("");
+    $this->pos += $nbytes;
+
+    switch($length) {
+      case 0: // 1 byte float? must be an error
+      case 1: // 2 byte float? must be an error
+        $x = $length + 1;
+        throw new PListException("got {$x} byte float, must be an error!");
+      case 2:
+        $val = unpack("f", strrev($buff));
+        $val = $val[1];
+        break;
+      case 3:
+        $val = unpack("d", strrev($buff));
+        $val = $val[1];
+        break;
+    }
+
+    return new CFNumber($val);
+  }
+
+  /**
+   * Read a date value
+   * @param integer $length The length (in bytes) of the integer value, coded as „set bit $length to 1”
+   * @return CFDate The date value
+   * @throws PListException if date val is invalid
+   * @throws IOException if read error occurs
+   */
+  protected function readBinaryDate($length) {
+    if($length > 3) throw new PListException("Date greater than 8 bytes: $length");
+
+    $nbytes = 1 << $length;
+    $val = null;
+    if(strlen($buff = substr($this->content, $this->pos, $nbytes)) != $nbytes) throw IOException::readError("");
+    $this->pos += $nbytes;
+
+    switch($length) {
+      case 0: // 1 byte CFDate is an error
+      case 1: // 2 byte CFDate is an error
+        $x = $length + 1;
+        throw new PListException("{$x} byte CFdate, error");
+
+      case 2:
+        $val = unpack("f", strrev($buff));
+        $val = $val[1];
+        break;
+      case 3:
+        $val = unpack("d", strrev($buff));
+        $val = $val[1];
+        break;
+    }
+
+    return new CFDate($val,CFDate::TIMESTAMP_APPLE);
+  }
+
+  /**
+   * Read a data value
+   * @param integer $length The length (in bytes) of the integer value, coded as „set bit $length to 1”
+   * @return CFData The data value
+   * @throws IOException if read error occurs
+   */
+  protected function readBinaryData($length) {
+    if($length == 0) $buff = "";
+    else {
+      $buff = substr($this->content, $this->pos, $length);
+      if(strlen($buff) != $length) throw IOException::readError("");
+      $this->pos += $length;
+    }
+
+    return new CFData($buff,false);
+  }
+
+  /**
+   * Read a string value, usually coded as utf8
+   * @param integer $length The length (in bytes) of the string value
+   * @return CFString The string value, utf8 encoded
+   * @throws IOException if read error occurs
+   */
+  protected function readBinaryString($length) {
+    if($length == 0) $buff = "";
+    else {
+      if(strlen($buff = substr($this->content, $this->pos, $length)) != $length) throw IOException::readError("");
+      $this->pos += $length;
+    }
+
+    if(!isset($this->uniqueTable[$buff])) $this->uniqueTable[$buff] = true;
+    return new CFString($buff);
+  }
+
+  /**
+   * Convert the given string from one charset to another.
+   * Trying to use MBString, Iconv, Recode - in that particular order.
+   * @param string $string the string to convert
+   * @param string $fromCharset the charset the given string is currently encoded in
+   * @param string $toCharset the charset to convert to, defaults to UTF-8
+   * @return string the converted string
+   * @throws PListException on neither MBString, Iconv, Recode being available
+   */
+  public static function convertCharset($string, $fromCharset, $toCharset='UTF-8') {
+    if(function_exists('mb_convert_encoding')) return mb_convert_encoding($string, $toCharset, $fromCharset);
+    if(function_exists('iconv')) return iconv($fromCharset, $toCharset, $string);
+    if(function_exists('recode_string')) return recode_string($fromCharset .'..'. $toCharset, $string);
+
+    throw new PListException('neither iconv nor mbstring supported. how are we supposed to work on strings here?');
+  }
+
+  /**
+   * Count characters considering character set
+   * Trying to use MBString, Iconv - in that particular order.
+   * @param string $string the string to convert
+   * @param string $charset the charset the given string is currently encoded in
+   * @return integer The number of characters in that string
+   * @throws PListException on neither MBString, Iconv being available
+   */
+  public static function charsetStrlen($string,$charset="UTF-8") {
+    if(function_exists('mb_strlen')) return mb_strlen($string, $charset);
+    if(function_exists('iconv_strlen')) return iconv_strlen($string,$charset);
+
+    throw new PListException('neither iconv nor mbstring supported. how are we supposed to work on strings here?');
+  }
+
+  /**
+   * Read a unicode string value, coded as UTF-16BE
+   * @param integer $length The length (in bytes) of the string value
+   * @return CFString The string value, utf8 encoded
+   * @throws IOException if read error occurs
+   */
+  protected function readBinaryUnicodeString($length) {
+    /* The problem is: we get the length of the string IN CHARACTERS;
+       since a char in UTF-16 can be 16 or 32 bit long, we don't really know
+       how long the string is in bytes */
+    if(strlen($buff = substr($this->content, $this->pos, 2*$length)) != 2*$length) throw IOException::readError("");
+    $this->pos += 2 * $length;
+
+    if(!isset($this->uniqueTable[$buff])) $this->uniqueTable[$buff] = true;
+    return new CFString(self::convertCharset($buff, "UTF-16BE", "UTF-8"));
+  }
+
+  /**
+   * Read an array value, including contained objects
+   * @param integer $length The number of contained objects
+   * @return CFArray The array value, including the objects
+   * @throws IOException if read error occurs
+   */
+  protected function readBinaryArray($length) {
+    $ary = new CFArray();
+
+    // first: read object refs
+    if($length != 0) {
+      if(strlen($buff = substr($this->content, $this->pos, $length * $this->objectRefSize)) != $length * $this->objectRefSize) throw IOException::readError("");
+      $this->pos += $length * $this->objectRefSize;
+
+      $objects = self::unpackWithSize($this->objectRefSize, $buff);
+
+      // now: read objects
+      for($i=0;$i<$length;++$i) {
+        $object = $this->readBinaryObjectAt($objects[$i+1]+1);
+        $ary->add($object);
+      }
+    }
+
+    return $ary;
+  }
+
+  /**
+   * Read a dictionary value, including contained objects
+   * @param integer $length The number of contained objects
+   * @return CFDictionary The dictionary value, including the objects
+   * @throws IOException if read error occurs
+   */
+  protected function readBinaryDict($length) {
+    $dict = new CFDictionary();
+
+    // first: read keys
+    if($length != 0) {
+      if(strlen($buff = substr($this->content, $this->pos, $length * $this->objectRefSize)) != $length * $this->objectRefSize) throw IOException::readError("");
+      $this->pos += $length * $this->objectRefSize;
+      $keys = self::unpackWithSize($this->objectRefSize, $buff);
+
+      // second: read object refs
+      if(strlen($buff = substr($this->content, $this->pos, $length * $this->objectRefSize)) != $length * $this->objectRefSize) throw IOException::readError("");
+      $this->pos += $length * $this->objectRefSize;
+      $objects = self::unpackWithSize($this->objectRefSize, $buff);
+
+      // read real keys and objects
+      for($i=0;$i<$length;++$i) {
+        $key = $this->readBinaryObjectAt($keys[$i+1]+1);
+        $object = $this->readBinaryObjectAt($objects[$i+1]+1);
+        $dict->add($key->getValue(),$object);
+      }
+    }
+
+    return $dict;
+  }
+
+  /**
+   * Read an object type byte, decode it and delegate to the correct reader function
+   * @return mixed The value of the delegate reader, so any of the CFType subclasses
+   * @throws IOException if read error occurs
+   */
+  function readBinaryObject() {
+    // first: read the marker byte
+    if(strlen($buff = substr($this->content,$this->pos,1)) != 1) throw IOException::readError("");
+    $this->pos++;
+
+    $object_length = unpack("C*", $buff);
+    $object_length = $object_length[1]  & 0xF;
+    $buff = unpack("H*", $buff);
+    $buff = $buff[1];
+
+    $object_type = substr($buff, 0, 1);
+    if($object_type != "0" && $object_length == 15) {
+      $object_length = $this->readBinaryObject($this->objectRefSize);
+      $object_length = $object_length->getValue();
+    }
+
+    $retval = null;
+    switch($object_type) {
+      case '0': // null, false, true, fillbyte
+        $retval = $this->readBinaryNullType($object_length);
+        break;
+      case '1': // integer
+        $retval = $this->readBinaryInt($object_length);
+        break;
+      case '2': // real
+        $retval = $this->readBinaryReal($object_length);
+        break;
+      case '3': // date
+        $retval = $this->readBinaryDate($object_length);
+        break;
+      case '4': // data
+        $retval = $this->readBinaryData($object_length);
+        break;
+      case '5': // byte string, usually utf8 encoded
+        $retval = $this->readBinaryString($object_length);
+        break;
+      case '6': // unicode string (utf16be)
+        $retval = $this->readBinaryUnicodeString($object_length);
+        break;
+      case '8':
+        $num = $this->readBinaryInt($object_length);
+        $retval = new CFUid($num->getValue());
+        break;
+      case 'a': // array
+        $retval = $this->readBinaryArray($object_length);
+        break;
+      case 'd': // dictionary
+        $retval = $this->readBinaryDict($object_length);
+        break;
+    }
+
+    return $retval;
+  }
+
+  /**
+   * Read an object type byte at position $pos, decode it and delegate to the correct reader function
+   * @param integer $pos The table position in the offsets table
+   * @return mixed The value of the delegate reader, so any of the CFType subclasses
+   */
+  function readBinaryObjectAt($pos) {
+    $this->pos = $this->offsets[$pos];
+    return $this->readBinaryObject();
+  }
+
+  /**
+   * Parse a binary plist string
+   * @return void
+   * @throws IOException if read error occurs
+   */
+  public function parseBinaryString() {
+    $this->uniqueTable = Array();
+    $this->countObjects = 0;
+    $this->stringSize = 0;
+    $this->intSize = 0;
+    $this->miscSize = 0;
+    $this->objectRefs = 0;
+
+    $this->writtenObjectCount = 0;
+    $this->objectTable = Array();
+    $this->objectRefSize = 0;
+
+    $this->offsets = Array();
+
+    // first, we read the trailer: 32 byte from the end
+    $buff = substr($this->content,-32);
+
+    if(strlen($buff) < 32) {
+      throw new PListException('Error in PList format: content is less than at least necessary 32 bytes!');
+    }
+
+    $infos = unpack("x6/Coffset_size/Cobject_ref_size/x4/Nnumber_of_objects/x4/Ntop_object/x4/Ntable_offset",$buff);
+
+    // after that, get the offset table
+    $coded_offset_table = substr($this->content,$infos['table_offset'],$infos['number_of_objects'] * $infos['offset_size']);
+    if(strlen($coded_offset_table) != $infos['number_of_objects'] * $infos['offset_size']) throw IOException::readError("");
+    $this->countObjects = $infos['number_of_objects'];
+
+    // decode offset table
+    $formats = Array("","C*","n*",NULL,"N*");
+    if($infos['offset_size'] == 3) { # since PHP does not support parenthesis in pack/unpack expressions,
+                                     # "(H6)*" does not work and we have to work round this by repeating the
+                                     # expression as often as it fits in the string
+      $this->offsets = array(NULL);
+      while($coded_offset_table) {
+        $str = unpack("H6",$coded_offset_table);
+        $this->offsets[] = hexdec($str[1]);
+        $coded_offset_table = substr($coded_offset_table,3);
+      }
+    }
+    else $this->offsets = unpack($formats[$infos['offset_size']],$coded_offset_table);
+
+    $this->uniqueTable = Array();
+    $this->objectRefSize = $infos['object_ref_size'];
+
+    $top = $this->readBinaryObjectAt($infos['top_object']+1);
+    $this->add($top);
+  }
+
+  /**
+   * Read a binary plist stream
+   * @param resource $stream The stream to read
+   * @return void
+   * @throws IOException if read error occurs
+   */
+  function readBinaryStream($stream) {
+    if(($str = stream_get_contents($stream)) === false || empty($str)) {
+      throw new PListException("Error reading stream!");
+    }
+
+    $this->parseBinary($str);
+  }
+
+  /**
+   * parse a binary plist string
+   * @param string $content The stream to read, defaults to {@link $this->content}
+   * @return void
+   * @throws IOException if read error occurs
+   */
+  function parseBinary($content=NULL) {
+    if($content !== NULL) {
+      $this->content = $content;
+    }
+
+    if(empty($this->content)) {
+      throw new PListException("Content may not be empty!");
+    }
+
+    if(substr($this->content,0,8) != 'bplist00') {
+      throw new PListException("Invalid binary string!");
+    }
+
+    $this->pos = 0;
+
+    $this->parseBinaryString();
+  }
+
+  /**
+   * Read a binary plist file
+   * @param string $file The file to read
+   * @return void
+   * @throws IOException if read error occurs
+   */
+  function readBinary($file) {
+    if(!($fd = fopen($file,"rb"))) {
+      throw new IOException("Could not open file {$file}!");
+    }
+
+    $this->readBinaryStream($fd);
+    fclose($fd);
+  }
+
+  /**
+   * calculate the bytes needed for a size integer value
+   * @param integer $int The integer value to calculate
+   * @return integer The number of bytes needed
+   */
+  public static function bytesSizeInt($int) {
+    $nbytes = 0;
+
+    if($int > 0xE) $nbytes += 2; // 2 size-bytes
+    if($int > 0xFF) $nbytes += 1; // 3 size-bytes
+    if($int > 0xFFFF) $nbytes += 2; // 5 size-bytes
+
+    return $nbytes;
+  }
+
+  /**
+   * Calculate the byte needed for a „normal” integer value
+   * @param integer $int The integer value
+   * @return integer The number of bytes needed + 1 (because of the „marker byte”)
+   */
+  public static function bytesInt($int) {
+    $nbytes = 1;
+
+    if($int > 0xFF) $nbytes += 1; // 2 byte integer
+    if($int > 0xFFFF) $nbytes += 2; // 4 byte integer
+    if($int > 0xFFFFFFFF) $nbytes += 4; // 8 byte integer
+    if($int < 0) $nbytes += 7; // 8 byte integer (since it is signed)
+
+    return $nbytes + 1; // one „marker” byte
+  }
+
+  /**
+   * „pack” a value (i.e. write the binary representation as big endian to a string) with the specified size
+   * @param integer $nbytes The number of bytes to pack
+   * @param integer $int The integer value to pack
+   * @return string The packed value as string
+   */
+  public static function packItWithSize($nbytes,$int) {
+    $formats = Array("C", "n", "N", "N");
+    $format = $formats[$nbytes-1];
+
+    if($nbytes == 3) return substr(pack($format, $int), -3);
+    return pack($format, $int);
+  }
+
+  /**
+   * „unpack” multiple values of the specified size (i.e. get the integers from their binary representation) from a string
+   * @param integer $nbytes The number of bytes of each value to unpack
+   * @param integer $buff The string packed with integer values
+   * @return array The unpacked integers
+   */
+  public static function unpackWithSize($nbytes,$buff) {
+    $formats = Array("C*", "n*", "N*", "N*");
+    $format = $formats[$nbytes-1];
+
+    if($nbytes == 3) $buff = "\0" . implode("\0", str_split($buff, 3));
+    return unpack($format, $buff);
+  }
+
+  /**
+   * Calculate the bytes needed to save the number of objects
+   * @param integer $count_objects The number of objects
+   * @return integer The number of bytes
+   */
+  public static function bytesNeeded($count_objects) {
+    $nbytes = 0;
+
+    while($count_objects >= 1) {
+      $nbytes++;
+      $count_objects /= 256;
+    }
+
+    return $nbytes;
+  }
+
+  /**
+   * Code an integer to byte representation
+   * @param integer $int The integer value
+   * @return string The packed byte value
+   */
+  public static function intBytes($int) {
+    $intbytes = "";
+
+    if($int > 0xFFFF) $intbytes = "\x12".pack("N", $int); // 4 byte integer
+    elseif($int > 0xFF) $intbytes = "\x11".pack("n", $int); // 2 byte integer
+    else $intbytes = "\x10".pack("C", $int); // 8 byte integer
+
+    return $intbytes;
+  }
+
+  /**
+   * Code an type byte, consisting of the type marker and the length of the type
+   * @param string $type The type byte value (i.e. "d" for dictionaries)
+   * @param integer $type_len The length of the type
+   * @return string The packed type byte value
+   */
+  public static function typeBytes($type,$type_len) {
+    $optional_int = "";
+
+    if($type_len < 15) $type .= sprintf("%x", $type_len);
+    else {
+      $type .= "f";
+      $optional_int = self::intBytes($type_len);
+    }
+
+    return pack("H*", $type).$optional_int;
+  }
+
+  /**
+   * Count number of objects and create a unique table for strings
+   * @param $value The value to count and unique
+   * @return void
+   */
+  protected function uniqueAndCountValues($value) {
+    // no uniquing for other types than CFString and CFData
+    if($value instanceof CFNumber) {
+      $val = $value->getValue();
+      if(intval($val) == $val && !is_float($val) && strpos($val,'.') === false) $this->intSize += self::bytesInt($val);
+      else $this->miscSize += 9; // 9 bytes (8 + marker byte) for real
+      $this->countObjects++;
+      return;
+    }
+    elseif($value instanceof CFDate) {
+      $this->miscSize += 9; // since date in plist is real, we need 9 byte (8 + marker byte)
+      $this->countObjects++;
+      return;
+    }
+    elseif($value instanceof CFBoolean) {
+      $this->countObjects++;
+      $this->miscSize += 1;
+      return;
+    }
+    elseif($value instanceof CFArray) {
+      $cnt = 0;
+      foreach($value as $v) {
+        ++$cnt;
+        $this->uniqueAndCountValues($v);
+        $this->objectRefs++; // each array member is a ref
+      }
+
+      $this->countObjects++;
+      $this->intSize += self::bytesSizeInt($cnt);
+      $this->miscSize++; // marker byte for array
+      return;
+    }
+    elseif($value instanceof CFDictionary) {
+      $cnt = 0;
+      foreach($value as $k => $v) {
+        ++$cnt;
+        if(!isset($this->uniqueTable[$k])) {
+          $this->uniqueTable[$k] = 0;
+          $len = self::binaryStrlen($k);
+          $this->stringSize += $len + 1;
+          $this->intSize += self::bytesSizeInt(self::charsetStrlen($k,'UTF-8'));
+        }
+
+        $this->objectRefs += 2; // both, key and value, are refs
+        $this->uniqueTable[$k]++;
+        $this->uniqueAndCountValues($v);
+      }
+
+      $this->countObjects++;
+      $this->miscSize++; // marker byte for dict
+      $this->intSize += self::bytesSizeInt($cnt);
+      return;
+    }
+    elseif($value instanceOf CFData) {
+      $val = $value->getValue();
+      $len = strlen($val);
+      $this->intSize += self::bytesSizeInt($len);
+      $this->miscSize += $len + 1;
+      $this->countObjects++;
+      return;
+    }
+    else $val = $value->getValue();
+
+    if(!isset($this->uniqueTable[$val])) {
+      $this->uniqueTable[$val] = 0;
+      $len = self::binaryStrlen($val);
+      $this->stringSize += $len + 1;
+      $this->intSize += self::bytesSizeInt(self::charsetStrlen($val,'UTF-8'));
+    }
+    $this->uniqueTable[$val]++;
+  }
+
+  /**
+   * Convert CFPropertyList to binary format; since we have to count our objects we simply unique CFDictionary and CFArray
+   * @return string The binary plist content
+   */
+  public function toBinary() {
+    $this->uniqueTable = Array();
+    $this->countObjects = 0;
+    $this->stringSize = 0;
+    $this->intSize = 0;
+    $this->miscSize = 0;
+    $this->objectRefs = 0;
+
+    $this->writtenObjectCount = 0;
+    $this->objectTable = Array();
+    $this->objectRefSize = 0;
+
+    $this->offsets = Array();
+
+    $binary_str = "bplist00";
+    $value = $this->getValue(true);
+    $this->uniqueAndCountValues($value);
+
+    $this->countObjects += count($this->uniqueTable);
+    $this->objectRefSize = self::bytesNeeded($this->countObjects);
+    $file_size = $this->stringSize + $this->intSize + $this->miscSize + $this->objectRefs * $this->objectRefSize + 40;
+    $offset_size = self::bytesNeeded($file_size);
+    $table_offset = $file_size - 32;
+
+    $this->objectTable = Array();
+    $this->writtenObjectCount = 0;
+    $this->uniqueTable = Array(); // we needed it to calculate several values
+    $value->toBinary($this);
+
+    $object_offset = 8;
+    $offsets = Array();
+
+    for($i=0;$i<count($this->objectTable);++$i) {
+      $binary_str .= $this->objectTable[$i];
+      $offsets[$i] = $object_offset;
+      $object_offset += strlen($this->objectTable[$i]);
+    }
+
+    for($i=0;$i<count($offsets);++$i) {
+      $binary_str .= self::packItWithSize($offset_size, $offsets[$i]);
+    }
+
+
+    $binary_str .= pack("x6CC", $offset_size, $this->objectRefSize);
+    $binary_str .= pack("x4N", $this->countObjects);
+    $binary_str .= pack("x4N", 0);
+    $binary_str .= pack("x4N", $table_offset);
+
+    return $binary_str;
+  }
+
+  /**
+   * Counts the number of bytes the string will have when coded; utf-16be if non-ascii characters are present.
+   * @param string $val The string value
+   * @return integer The length of the coded string in bytes
+   */
+  protected static function binaryStrlen($val) {
+    for($i=0;$i<strlen($val);++$i) {
+      if(ord($val{$i}) >= 128) {
+        $val = self::convertCharset($val, 'UTF-8', 'UTF-16BE');
+        return strlen($val);
+      }
+    }
+
+    return strlen($val);
+  }
+
+  /**
+   * Uniques and transforms a string value to binary format and adds it to the object table
+   * @param string $val The string value
+   * @return integer The position in the object table
+   */
+  public function stringToBinary($val) {
+    $saved_object_count = -1;
+
+    if(!isset($this->uniqueTable[$val])) {
+      $saved_object_count = $this->writtenObjectCount++;
+      $this->uniqueTable[$val] = $saved_object_count;
+      $utf16 = false;
+
+      for($i=0;$i<strlen($val);++$i) {
+        if(ord($val{$i}) >= 128) {
+          $utf16 = true;
+          break;
+        }
+      }
+
+      if($utf16) {
+        $bdata = self::typeBytes("6", mb_strlen($val,'UTF-8')); // 6 is 0110, unicode string (utf16be)
+        $val = self::convertCharset($val, 'UTF-8', 'UTF-16BE');
+        $this->objectTable[$saved_object_count] = $bdata.$val;
+      }
+      else {
+        $bdata = self::typeBytes("5", strlen($val)); // 5 is 0101 which is an ASCII string (seems to be ASCII encoded)
+        $this->objectTable[$saved_object_count] = $bdata.$val;
+      }
+    }
+    else $saved_object_count = $this->uniqueTable[$val];
+
+    return $saved_object_count;
+  }
+
+  /**
+   * Codes an integer to binary format
+   * @param integer $value The integer value
+   * @return string the coded integer
+   */
+  protected function intToBinary($value) {
+    $nbytes = 0;
+    if($value > 0xFF) $nbytes = 1; // 1 byte integer
+    if($value > 0xFFFF) $nbytes += 1; // 4 byte integer
+    if($value > 0xFFFFFFFF) $nbytes += 1; // 8 byte integer
+    if($value < 0) $nbytes = 3; // 8 byte integer, since signed
+
+    $bdata = self::typeBytes("1", $nbytes); // 1 is 0001, type indicator for integer
+    $buff = "";
+
+    if($nbytes < 3) {
+      if($nbytes == 0) $fmt = "C";
+      elseif($nbytes == 1) $fmt = "n";
+      else $fmt = "N";
+
+      $buff = pack($fmt, $value);
+    }
+    else {
+      if(PHP_INT_SIZE > 4) {
+        // 64 bit signed integer; we need the higher and the lower 32 bit of the value
+        $high_word = $value >> 32;
+        $low_word = $value & 0xFFFFFFFF;
+      }
+      else {
+        // since PHP can only handle 32bit signed, we can only get 32bit signed values at this point - values above 0x7FFFFFFF are
+        // floats. So we ignore the existance of 64bit on non-64bit-machines
+        if($value < 0) $high_word = 0xFFFFFFFF;
+        else $high_word = 0;
+        $low_word = $value;
+      }
+      $buff = pack("N", $high_word).pack("N", $low_word);
+    }
+
+    return $bdata.$buff;
+  }
+
+  /**
+   * Codes a real value to binary format
+   * @param float $val The real value
+   * @return string The coded real
+   */
+  protected function realToBinary($val) {
+    $bdata = self::typeBytes("2", 3); // 2 is 0010, type indicator for reals
+    return $bdata.strrev(pack("d", (float)$val));
+  }
+
+  public function uidToBinary($value) {
+    $saved_object_count = $this->writtenObjectCount++;
+
+    $val = "";
+
+    $nbytes = 0;
+    if($value > 0xFF) $nbytes = 1; // 1 byte integer
+    if($value > 0xFFFF) $nbytes += 1; // 4 byte integer
+    if($value > 0xFFFFFFFF) $nbytes += 1; // 8 byte integer
+    if($value < 0) $nbytes = 3; // 8 byte integer, since signed
+
+    $bdata = self::typeBytes("1000", $nbytes); // 1 is 0001, type indicator for integer
+    $buff = "";
+
+    if($nbytes < 3) {
+      if($nbytes == 0) $fmt = "C";
+      elseif($nbytes == 1) $fmt = "n";
+      else $fmt = "N";
+
+      $buff = pack($fmt, $value);
+    }
+    else {
+      if(PHP_INT_SIZE > 4) {
+        // 64 bit signed integer; we need the higher and the lower 32 bit of the value
+        $high_word = $value >> 32;
+        $low_word = $value & 0xFFFFFFFF;
+      }
+      else {
+        // since PHP can only handle 32bit signed, we can only get 32bit signed values at this point - values above 0x7FFFFFFF are
+        // floats. So we ignore the existance of 64bit on non-64bit-machines
+        if($value < 0) $high_word = 0xFFFFFFFF;
+        else $high_word = 0;
+        $low_word = $value;
+      }
+      $buff = pack("N", $high_word).pack("N", $low_word);
+    }
+
+    $val = $bdata.$buff;
+
+    $this->objectTable[$saved_object_count] = $val;
+    return $saved_object_count;
+  }
+
+  /**
+   * Converts a numeric value to binary and adds it to the object table
+   * @param numeric $value The numeric value
+   * @return integer The position in the object table
+   */
+  public function numToBinary($value) {
+    $saved_object_count = $this->writtenObjectCount++;
+
+    $val = "";
+    if(intval($value) == $value && !is_float($value) && strpos($value,'.') === false) $val = $this->intToBinary($value);
+    else $val = $this->realToBinary($value);
+
+    $this->objectTable[$saved_object_count] = $val;
+    return $saved_object_count;
+  }
+
+  /**
+   * Convert date value (apple format) to binary and adds it to the object table
+   * @param integer $value The date value
+   * @return integer The position of the coded value in the object table
+   */
+  public function dateToBinary($val) {
+    $saved_object_count = $this->writtenObjectCount++;
+
+    $hour = gmdate("H",$val);
+    $min = gmdate("i",$val);
+    $sec = gmdate("s",$val);
+    $mday = gmdate("j",$val);
+    $mon = gmdate("n",$val);
+    $year = gmdate("Y",$val);
+
+    $val = gmmktime($hour,$min,$sec,$mon,$mday,$year) - CFDate::DATE_DIFF_APPLE_UNIX; // CFDate is a real, number of seconds since 01/01/2001 00:00:00 GMT
+
+    $bdata = self::typeBytes("3", 3); // 3 is 0011, type indicator for date
+    $this->objectTable[$saved_object_count] = $bdata.strrev(pack("d", $val));
+
+    return $saved_object_count;
+  }
+
+  /**
+   * Convert a bool value to binary and add it to the object table
+   * @param bool $val The boolean value
+   * @return integer The position in the object table
+   */
+  public function boolToBinary($val) {
+    $saved_object_count = $this->writtenObjectCount++;
+    $this->objectTable[$saved_object_count] = $val ? "\x9" : "\x8"; // 0x9 is 1001, type indicator for true; 0x8 is 1000, type indicator for false
+    return $saved_object_count;
+  }
+
+  /**
+   * Convert data value to binary format and add it to the object table
+   * @param string $val The data value
+   * @return integer The position in the object table
+   */
+  public function dataToBinary($val) {
+    $saved_object_count = $this->writtenObjectCount++;
+
+    $bdata = self::typeBytes("4", strlen($val)); // a is 1000, type indicator for data
+    $this->objectTable[$saved_object_count] = $bdata.$val;
+
+    return $saved_object_count;
+  }
+
+  /**
+   * Convert array to binary format and add it to the object table
+   * @param CFArray $val The array to convert
+   * @return integer The position in the object table
+   */
+  public function arrayToBinary($val) {
+    $saved_object_count = $this->writtenObjectCount++;
+
+    $bdata = self::typeBytes("a", count($val->getValue())); // a is 1010, type indicator for arrays
+
+    foreach($val as $v) {
+      $bval = $v->toBinary($this);
+      $bdata .= self::packItWithSize($this->objectRefSize, $bval);
+    }
+
+    $this->objectTable[$saved_object_count] = $bdata;
+    return $saved_object_count;
+  }
+
+  /**
+   * Convert dictionary to binary format and add it to the object table
+   * @param CFDictionary $val The dict to convert
+   * @return integer The position in the object table
+   */
+  public function dictToBinary($val) {
+    $saved_object_count = $this->writtenObjectCount++;
+    $bdata = self::typeBytes("d", count($val->getValue())); // d=1101, type indicator for dictionary
+
+    foreach($val as $k => $v) {
+      $str = new CFString($k);
+      $key = $str->toBinary($this);
+      $bdata .= self::packItWithSize($this->objectRefSize, $key);
+    }
+
+    foreach($val as $k => $v) {
+      $bval = $v->toBinary($this);
+      $bdata .= self::packItWithSize($this->objectRefSize, $bval);
+    }
+
+    $this->objectTable[$saved_object_count] = $bdata;
+    return $saved_object_count;
+  }
+
+}
+
+# eof
diff --git a/lib/plist/classes/CFPropertyList/CFPropertyList.php b/lib/plist/classes/CFPropertyList/CFPropertyList.php
new file mode 100644 (file)
index 0000000..0e07b7c
--- /dev/null
@@ -0,0 +1,608 @@
+<?php
+/**
+ * CFPropertyList
+ * {@link http://developer.apple.com/documentation/Darwin/Reference/ManPages/man5/plist.5.html Property Lists}
+ * @author Rodney Rehm <rodney.rehm@medialize.de>
+ * @author Christian Kruse <cjk@wwwtech.de>
+ * @package plist
+ * @version $Id$
+ * @example example-read-01.php Read an XML PropertyList
+ * @example example-read-02.php Read a Binary PropertyList
+ * @example example-read-03.php Read a PropertyList without knowing the type
+ * @example example-create-01.php Using the CFPropertyList API
+ * @example example-create-02.php Using {@link CFTypeDetector}
+ * @example example-create-03.php Using {@link CFTypeDetector} with {@link CFDate} and {@link CFData}
+ * @example example-modify-01.php Read, modify and save a PropertyList
+ */
+
+namespace CFPropertyList;
+use \Iterator, \DOMDocument, \DOMException, DOMImplementation, DOMNode;
+
+/**
+ * Require IOException, PListException, CFType and CFBinaryPropertyList
+ */
+require_once(__DIR__.'/IOException.php');
+require_once(__DIR__.'/PListException.php');
+require_once(__DIR__.'/CFType.php');
+require_once(__DIR__.'/CFBinaryPropertyList.php');
+require_once(__DIR__.'/CFTypeDetector.php');
+
+/**
+ * Property List
+ * Interface for handling reading, editing and saving Property Lists as defined by Apple.
+ * @author Rodney Rehm <rodney.rehm@medialize.de>
+ * @author Christian Kruse <cjk@wwwtech.de>
+ * @package plist
+ * @example example-read-01.php Read an XML PropertyList
+ * @example example-read-02.php Read a Binary PropertyList
+ * @example example-read-03.php Read a PropertyList without knowing the type
+ * @example example-create-01.php Using the CFPropertyList API
+ * @example example-create-02.php Using {@link CFTypeDetector}
+ * @example example-create-03.php Using {@link CFTypeDetector} with {@link CFDate} and {@link CFData}
+ * @example example-create-04.php Using and extended {@link CFTypeDetector}
+ */
+class CFPropertyList extends CFBinaryPropertyList implements Iterator {
+  /**
+   * Format constant for binary format
+   * @var integer
+   */
+  const FORMAT_BINARY = 1;
+
+  /**
+   * Format constant for xml format
+   * @var integer
+   */
+  const FORMAT_XML = 2;
+
+  /**
+   * Format constant for automatic format recognizing
+   * @var integer
+   */
+  const FORMAT_AUTO = 0;
+
+  /**
+   * Path of PropertyList
+   * @var string
+   */
+  protected $file = null;
+  
+  /**
+   * Detected format of PropertyList
+   * @var integer
+   */
+  protected $detectedFormat = null;
+
+  /**
+   * Path of PropertyList
+   * @var integer
+   */
+  protected $format = null;
+
+  /**
+   * CFType nodes
+   * @var array
+   */
+  protected $value = array();
+
+  /**
+   * Position of iterator {@link http://php.net/manual/en/class.iterator.php}
+   * @var integer
+   */
+  protected $iteratorPosition = 0;
+
+  /**
+   * List of Keys for numerical iterator access {@link http://php.net/manual/en/class.iterator.php}
+   * @var array
+   */
+  protected $iteratorKeys = null;
+
+  /**
+   * List of NodeNames to ClassNames for resolving plist-files
+   * @var array
+   */
+  protected static $types = array(
+    'string'  => 'CFString',
+    'real'    => 'CFNumber',
+    'integer' => 'CFNumber',
+    'date'    => 'CFDate',
+    'true'    => 'CFBoolean',
+    'false'   => 'CFBoolean',
+    'data'    => 'CFData',
+    'array'   => 'CFArray',
+    'dict'    => 'CFDictionary'
+ );
+
+
+  /**
+   * Create new CFPropertyList.
+   * If a path to a PropertyList is specified, it is loaded automatically.
+   * @param string $file Path of PropertyList
+   * @param integer $format he format of the property list, see {@link FORMAT_XML}, {@link FORMAT_BINARY} and {@link FORMAT_AUTO}, defaults to {@link FORMAT_AUTO}
+   * @throws IOException if file could not be read by {@link load()}
+   * @uses $file for storing the current file, if specified
+   * @uses load() for loading the plist-file
+   */
+  public function __construct($file=null,$format=self::FORMAT_AUTO) {
+    $this->file = $file;
+    $this->format = $format;
+    $this->detectedFormat = $format;
+    if($this->file) $this->load();
+  }
+
+  /**
+   * Load an XML PropertyList.
+   * @param string $file Path of PropertyList, defaults to {@link $file}
+   * @return void
+   * @throws IOException if file could not be read
+   * @throws DOMException if XML-file could not be read properly
+   * @uses load() to actually load the file
+   */
+  public function loadXML($file=null) {
+    $this->load($file,CFPropertyList::FORMAT_XML);
+  }
+
+  /**
+   * Load an XML PropertyList.
+   * @param resource $stream A stream containing the xml document.
+   * @return void
+   * @throws IOException if stream could not be read
+   * @throws DOMException if XML-stream could not be read properly
+   */
+  public function loadXMLStream($stream) {
+    if(($contents = stream_get_contents($stream)) === FALSE) throw IOException::notReadable('<stream>');
+    $this->parse($contents,CFPropertyList::FORMAT_XML);
+  }
+
+  /**
+   * Load an binary PropertyList.
+   * @param string $file Path of PropertyList, defaults to {@link $file}
+   * @return void
+   * @throws IOException if file could not be read
+   * @throws PListException if binary plist-file could not be read properly
+   * @uses load() to actually load the file
+   */
+  public function loadBinary($file=null) {
+    $this->load($file,CFPropertyList::FORMAT_BINARY);
+  }
+
+  /**
+   * Load an binary PropertyList.
+   * @param stream $stream Stream containing the PropertyList
+   * @return void
+   * @throws IOException if file could not be read
+   * @throws PListException if binary plist-file could not be read properly
+   * @uses parse() to actually load the file
+   */
+  public function loadBinaryStream($stream) {
+    if(($contents = stream_get_contents($stream)) === FALSE) throw IOException::notReadable('<stream>');
+    $this->parse($contents,CFPropertyList::FORMAT_BINARY);
+  }
+
+  /**
+   * Load a plist file.
+   * Load and import a plist file.
+   * @param string $file Path of PropertyList, defaults to {@link $file}
+   * @param integer $format The format of the property list, see {@link FORMAT_XML}, {@link FORMAT_BINARY} and {@link FORMAT_AUTO}, defaults to {@link $format}
+   * @return void
+   * @throws PListException if file format version is not 00
+   * @throws IOException if file could not be read
+   * @throws DOMException if plist file could not be parsed properly
+   * @uses $file if argument $file was not specified
+   * @uses $value reset to empty array
+   * @uses import() for importing the values
+   */
+  public function load($file=null,$format=null) {
+    $file = $file ? $file : $this->file;
+    $format = $format !== null ? $format : $this->format;
+    $this->value = array();
+
+    if(!is_readable($file)) throw IOException::notReadable($file);
+
+    switch($format) {
+      case CFPropertyList::FORMAT_BINARY:
+        $this->readBinary($file);
+        break;
+      case CFPropertyList::FORMAT_AUTO: // what we now do is ugly, but neccessary to recognize the file format
+        $fd = fopen($file,"rb");
+        if(($magic_number = fread($fd,8)) === false) throw IOException::notReadable($file);
+        fclose($fd);
+
+        $filetype = substr($magic_number,0,6);
+        $version  = substr($magic_number,-2);
+
+        if($filetype == "bplist") {
+          if($version != "00") throw new PListException("Wrong file format version! Expected 00, got $version!");
+          $this->detectedFormat = CFPropertyList::FORMAT_BINARY;
+          $this->readBinary($file);
+          break;
+        }
+        $this->detectedFormat = CFPropertyList::FORMAT_XML;
+        // else: xml format, break not neccessary
+      case CFPropertyList::FORMAT_XML:
+        $doc = new DOMDocument();
+        if(!$doc->load($file)) throw new DOMException();
+        $this->import($doc->documentElement, $this);
+        break;
+    }
+  }
+
+  /**
+   * Parse a plist string.
+   * Parse and import a plist string.
+   * @param string $str String containing the PropertyList, defaults to {@link $content}
+   * @param integer $format The format of the property list, see {@link FORMAT_XML}, {@link FORMAT_BINARY} and {@link FORMAT_AUTO}, defaults to {@link $format}
+   * @return void
+   * @throws PListException if file format version is not 00
+   * @throws IOException if file could not be read
+   * @throws DOMException if plist file could not be parsed properly
+   * @uses $content if argument $str was not specified
+   * @uses $value reset to empty array
+   * @uses import() for importing the values
+   */
+  public function parse($str=NULL,$format=NULL) {
+    $format = $format !== null ? $format : $this->format;
+    $str = $str !== null ? $str : $this->content;
+    $this->value = array();
+
+    switch($format) {
+      case CFPropertyList::FORMAT_BINARY:
+        $this->parseBinary($str);
+        break;
+      case CFPropertyList::FORMAT_AUTO: // what we now do is ugly, but neccessary to recognize the file format
+        if(($magic_number = substr($str,0,8)) === false) throw IOException::notReadable("<string>");
+
+        $filetype = substr($magic_number,0,6);
+        $version  = substr($magic_number,-2);
+
+        if($filetype == "bplist") {
+          if($version != "00") throw new PListException("Wrong file format version! Expected 00, got $version!");
+          $this->detectedFormat = CFPropertyList::FORMAT_BINARY;
+          $this->parseBinary($str);
+          break;
+        }
+        $this->detectedFormat = CFPropertyList::FORMAT_XML;
+        // else: xml format, break not neccessary
+      case CFPropertyList::FORMAT_XML:
+        $doc = new DOMDocument();
+        if(!$doc->loadXML($str)) throw new DOMException();
+        $this->import($doc->documentElement, $this);
+        break;
+    }
+  }
+
+  /**
+   * Convert a DOMNode into a CFType.
+   * @param DOMNode $node Node to import children of
+   * @param CFDictionary|CFArray|CFPropertyList $parent
+   * @return void
+   */
+  protected function import(DOMNode $node, $parent) {
+    // abort if there are no children
+    if(!$node->childNodes->length) return;
+
+    foreach($node->childNodes as $n) {
+      // skip if we can't handle the element
+      if(!isset(self::$types[$n->nodeName])) continue;
+
+      $class = 'CFPropertyList\\'.self::$types[$n->nodeName];
+      $key = null;
+
+      // find previous <key> if possible
+      $ps = $n->previousSibling;
+      while($ps && $ps->nodeName == '#text' && $ps->previousSibling) $ps = $ps->previousSibling;
+
+      // read <key> if possible
+      if($ps && $ps->nodeName == 'key') $key = $ps->firstChild->nodeValue;
+
+      switch($n->nodeName) {
+        case 'date':
+          $value = new $class(CFDate::dateValue($n->nodeValue));
+          break;
+        case 'data':
+          $value = new $class($n->nodeValue,true);
+          break;
+        case 'string':
+          $value = new $class($n->nodeValue);
+          break;
+
+        case 'real':
+        case 'integer':
+          $value = new $class($n->nodeName == 'real' ? floatval($n->nodeValue) : intval($n->nodeValue));
+          break;
+
+        case 'true':
+        case 'false':
+          $value = new $class($n->nodeName == 'true');
+          break;
+
+        case 'array':
+        case 'dict':
+          $value = new $class();
+          $this->import($n, $value);
+
+          if($value instanceof CFDictionary) {
+            $hsh = $value->getValue();
+            if(isset($hsh['CF$UID']) && count($hsh) == 1) {
+              $value = new CFUid($hsh['CF$UID']->getValue());
+            }
+          }
+
+          break;
+      }
+
+      // Dictionaries need a key
+      if($parent instanceof CFDictionary) $parent->add($key, $value);
+      // others don't
+      else $parent->add($value);
+    }
+  }
+
+  /**
+   * Convert CFPropertyList to XML and save to file.
+   * @param string $file Path of PropertyList, defaults to {@link $file}
+   * @return void
+   * @throws IOException if file could not be read
+   * @uses $file if $file was not specified
+   */
+  public function saveXML($file) {
+    $this->save($file,CFPropertyList::FORMAT_XML);
+  }
+
+  /**
+   * Convert CFPropertyList to binary format (bplist00) and save to file.
+   * @param string $file Path of PropertyList, defaults to {@link $file}
+   * @return void
+   * @throws IOException if file could not be read
+   * @uses $file if $file was not specified
+   */
+  public function saveBinary($file) {
+    $this->save($file,CFPropertyList::FORMAT_BINARY);
+  }
+
+  /**
+   * Convert CFPropertyList to XML or binary and save to file.
+   * @param string $file Path of PropertyList, defaults to {@link $file}
+   * @param string $format Format of PropertyList, defaults to {@link $format}
+   * @return void
+   * @throws IOException if file could not be read
+   * @throws PListException if evaluated $format is neither {@link FORMAT_XML} nor {@link FORMAL_BINARY}
+   * @uses $file if $file was not specified
+   * @uses $format if $format was not specified
+   */
+  public function save($file=null,$format=null) {
+    $file = $file ? $file : $this->file;
+    $format = $format ? $format : $this->format;
+    if($format == self::FORMAT_AUTO)$format = $this->detectedFormat;
+
+    if( !in_array( $format, array( self::FORMAT_BINARY, self::FORMAT_XML ) ) )
+      throw new PListException( "format {$format} is not supported, use CFPropertyList::FORMAT_BINARY or CFPropertyList::FORMAT_XML" );
+
+    if(!file_exists($file)) {
+      // dirname("file.xml") == "" and is treated as the current working directory
+      if(!is_writable(dirname($file))) throw IOException::notWritable($file);
+    }
+    else if(!is_writable($file)) throw IOException::notWritable($file);
+
+    $content = $format == self::FORMAT_BINARY ? $this->toBinary() : $this->toXML();
+
+    $fh = fopen($file, 'wb');
+    fwrite($fh,$content);
+    fclose($fh);
+  }
+
+  /**
+   * Convert CFPropertyList to XML
+   * @param bool $formatted Print plist formatted (i.e. with newlines and whitespace indention) if true; defaults to false
+   * @return string The XML content
+   */
+  public function toXML($formatted=false) {
+    $domimpl = new DOMImplementation();
+    // <!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+    $dtd = $domimpl->createDocumentType('plist', '-//Apple//DTD PLIST 1.0//EN', 'http://www.apple.com/DTDs/PropertyList-1.0.dtd');
+    $doc = $domimpl->createDocument(null, "plist", $dtd);
+    $doc->encoding = "UTF-8";
+
+    // format output
+    if($formatted) {
+      $doc->formatOutput = true;
+      $doc->preserveWhiteSpace = true;
+    }
+
+    // get documentElement and set attribs
+    $plist = $doc->documentElement;
+    $plist->setAttribute('version', '1.0');
+
+    // add PropertyList's children
+    $plist->appendChild($this->getValue(true)->toXML($doc));
+
+    return $doc->saveXML();
+  }
+
+
+  /************************************************************************************************
+   *    M A N I P U L A T I O N
+   ************************************************************************************************/
+
+  /**
+   * Add CFType to collection.
+   * @param CFType $value CFType to add to collection
+   * @return void
+   * @uses $value for adding $value
+   */
+  public function add(CFType $value=null) {
+    // anything but CFType is null, null is an empty string - sad but true
+    if( !$value )
+      $value = new CFString();
+
+    $this->value[] = $value;
+  }
+
+  /**
+   * Get CFType from collection.
+   * @param integer $key Key of CFType to retrieve from collection
+   * @return CFType CFType found at $key, null else
+   * @uses $value for retrieving CFType of $key
+   */
+  public function get($key) {
+    if(isset($this->value[$key])) return $this->value[$key];
+    return null;
+  }
+
+  /**
+   * Generic getter (magic)
+   *
+   * @param integer $key Key of CFType to retrieve from collection
+   * @return CFType CFType found at $key, null else
+   * @author Sean Coates <sean@php.net>