Merge branch 'MDL-57643-master' of git://github.com/jleyva/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Wed, 15 Mar 2017 23:58:08 +0000 (07:58 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 15 Mar 2017 23:58:08 +0000 (07:58 +0800)
107 files changed:
admin/settings/plugins.php
admin/settings/server.php
admin/templates/setting_manage_plugins.mustache [new file with mode: 0644]
admin/tool/behat/cli/run.php
admin/updatesetting.php [new file with mode: 0644]
auth/db/classes/task/sync_users.php [new file with mode: 0644]
auth/db/cli/sync_users.php
auth/db/config.html
auth/db/db/tasks.php [new file with mode: 0644]
auth/db/lang/en/auth_db.php
auth/db/version.php
cache/tests/fixtures/lib.php
calendar/classes/rrule_manager.php
calendar/event.php
calendar/lib.php
calendar/tests/behat/behat_calendar.php
calendar/tests/behat/calendar_import.feature [new file with mode: 0644]
calendar/tests/fixtures/import.ics [new file with mode: 0644]
calendar/tests/rrule_manager_test.php [new file with mode: 0644]
calendar/tests/rrule_manager_tests.php [deleted file]
completion/criteria/completion_criteria_activity.php
enrol/ldap/classes/task/sync_enrolments.php [new file with mode: 0644]
enrol/ldap/cli/sync.php
enrol/ldap/db/tasks.php [new file with mode: 0644]
enrol/ldap/lang/en/enrol_ldap.php
enrol/ldap/upgrade.txt [new file with mode: 0644]
enrol/ldap/version.php
files/classes/conversion.php [new file with mode: 0644]
files/classes/converter.php [new file with mode: 0644]
files/classes/converter_interface.php [new file with mode: 0644]
files/classes/task/conversion_cleanup_task.php [new file with mode: 0644]
files/converter/unoconv/classes/converter.php [new file with mode: 0644]
files/converter/unoconv/db/install.php [new file with mode: 0644]
files/converter/unoconv/lang/en/fileconverter_unoconv.php [new file with mode: 0644]
files/converter/unoconv/settings.php [new file with mode: 0644]
files/converter/unoconv/tests/converter_test.php [new file with mode: 0644]
files/converter/unoconv/tests/fixtures/unoconv-source.docx [moved from lib/tests/fixtures/unoconv-source.docx with 100% similarity]
files/converter/unoconv/tests/fixtures/unoconv-source.html [moved from lib/tests/fixtures/unoconv-source.html with 100% similarity]
files/converter/unoconv/testunoconv.php [moved from mod/assign/feedback/editpdf/testunoconv.php with 72% similarity]
files/converter/unoconv/version.php [new file with mode: 0644]
files/tests/conversion_test.php [new file with mode: 0644]
files/tests/converter_test.php [new file with mode: 0644]
grade/report/singleview/classes/local/screen/screen.php
grade/report/singleview/index.php
grade/report/singleview/lang/en/gradereport_singleview.php
lang/en/admin.php
lang/en/calendar.php
lang/en/plugin.php
lib/adminlib.php
lib/behat/behat_base.php
lib/classes/component.php
lib/classes/filetypes.php
lib/classes/plugin_manager.php
lib/classes/plugininfo/fileconverter.php [new file with mode: 0644]
lib/db/install.xml
lib/db/tasks.php
lib/db/upgrade.php
lib/filelib.php
lib/filestorage/file_storage.php
lib/tests/behat/behat_navigation.php
lib/tests/component_test.php
lib/tests/unoconv_test.php [deleted file]
lib/upgrade.txt
mod/assign/feedback/editpdf/ajax.php
mod/assign/feedback/editpdf/classes/combined_document.php [new file with mode: 0644]
mod/assign/feedback/editpdf/classes/document_services.php
mod/assign/feedback/editpdf/classes/pdf.php
mod/assign/feedback/editpdf/classes/renderer.php
mod/assign/feedback/editpdf/classes/task/convert_submissions.php
mod/assign/feedback/editpdf/classes/widget.php
mod/assign/feedback/editpdf/lang/en/assignfeedback_editpdf.php
mod/assign/feedback/editpdf/locallib.php
mod/assign/feedback/editpdf/settings.php
mod/assign/feedback/editpdf/tests/behat/annotate_pdf.feature
mod/assign/feedback/editpdf/tests/behat/behat_assignfeedback_editpdf.php
mod/assign/feedback/editpdf/tests/behat/group_annotations.feature
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-debug.js
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-min.js
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor.js
mod/assign/feedback/editpdf/yui/src/editor/js/editor.js
mod/assign/feedback/editpdf/yui/src/editor/meta/editor.json
mod/data/classes/external.php
mod/data/db/services.php
mod/data/lib.php
mod/data/tests/externallib_test.php
mod/data/tests/lib_test.php
mod/data/version.php
mod/data/view.php
mod/feedback/classes/external.php [new file with mode: 0644]
mod/feedback/classes/external/feedback_summary_exporter.php [new file with mode: 0644]
mod/feedback/db/services.php [new file with mode: 0644]
mod/feedback/item/feedback_item_class.php
mod/feedback/item/info/lib.php
mod/feedback/item/label/lib.php
mod/feedback/item/multichoice/lib.php
mod/feedback/item/multichoicerated/lib.php
mod/feedback/item/numeric/lib.php
mod/feedback/item/textarea/lib.php
mod/feedback/item/textfield/lib.php
mod/feedback/tests/external_test.php [new file with mode: 0644]
mod/feedback/tests/generator/lib.php
mod/feedback/tests/generator_test.php
mod/feedback/version.php
report/security/lang/en/report_security.php
report/security/locallib.php
theme/boost/scss/moodle/course.scss
version.php

index bd759a7..1dfdd6a 100644 (file)
@@ -230,6 +230,19 @@ if ($hassiteconfig) {
         300, PARAM_INT, 10));
     $ADMIN->add('mediaplayers', $temp);
 
+    // Convert plugins.
+    $ADMIN->add('modules', new admin_category('fileconverterplugins', new lang_string('type_fileconverter_plural', 'plugin')));
+    $temp = new admin_settingpage('managefileconverterplugins', new lang_string('type_fileconverter', 'plugin'));
+    $temp->add(new admin_setting_manage_fileconverter_plugins());
+    $ADMIN->add('fileconverterplugins', $temp);
+
+    $plugins = core_plugin_manager::instance()->get_plugins_of_type('fileconverter');
+    core_collator::asort_objects_by_property($plugins, 'displayname');
+    foreach ($plugins as $plugin) {
+        /** @var \core\plugininfo\media $plugin */
+        $plugin->load_settings($ADMIN, 'fileconverterplugins', $hassiteconfig);
+    }
+
     $plugins = core_plugin_manager::instance()->get_plugins_of_type('media');
     core_collator::asort_objects_by_property($plugins, 'displayname');
     foreach ($plugins as $plugin) {
index 2ad2777..32af104 100644 (file)
@@ -12,7 +12,6 @@ $temp->add(new admin_setting_configexecutable('pathtodu', new lang_string('patht
 $temp->add(new admin_setting_configexecutable('aspellpath', new lang_string('aspellpath', 'admin'), new lang_string('edhelpaspellpath'), ''));
 $temp->add(new admin_setting_configexecutable('pathtodot', new lang_string('pathtodot', 'admin'), new lang_string('pathtodot_help', 'admin'), ''));
 $temp->add(new admin_setting_configexecutable('pathtogs', new lang_string('pathtogs', 'admin'), new lang_string('pathtogs_help', 'admin'), '/usr/bin/gs'));
-$temp->add(new admin_setting_configexecutable('pathtounoconv', new lang_string('pathtounoconv', 'admin'), new lang_string('pathtounoconv_help', 'admin'), '/usr/bin/unoconv'));
 $ADMIN->add('server', $temp);
 
 
diff --git a/admin/templates/setting_manage_plugins.mustache b/admin/templates/setting_manage_plugins.mustache
new file mode 100644 (file)
index 0000000..5c38e51
--- /dev/null
@@ -0,0 +1,65 @@
+<table class="admintable generaltable">
+    <thead>
+        <tr>
+            <th class="header">{{#str}}name, moodle{{/str}}</th>
+            {{#infocolumnname}}
+            <th class="header">{{infocolumnname}}</th>
+            {{/infocolumnname}}
+            <th class="header">{{#str}}order, moodle{{/str}}</th>
+            <th class="header">{{#str}}isenabled, plugin{{/str}}</th>
+            <th class="header">{{#str}}settings, moodle{{/str}}</th>
+            <th class="header">{{#str}}uninstall, plugin{{/str}}</th>
+        </tr>
+    </thead>
+    <tbody>
+        {{#plugins}}
+        <tr>
+            <td>{{plugin}}</td>
+            {{#infocolumnname}}
+            <td>
+                {{info}}
+            </td>
+            {{/infocolumnname}}
+            <td class="text-nowrap">
+                {{#moveuplink}}
+                    <a href="{{{moveuplink}}}">
+                        {{#pix}}t/up, moodle, {{#str}} up, moodle {{/str}}{{/pix}}
+                    </a>
+                {{/moveuplink}}
+                {{^moveuplink}}
+                    {{#pix}}spacer, moodle{{/pix}}
+                {{/moveuplink}}
+
+                {{#movedownlink}}
+                    <a href="{{{movedownlink}}}">
+                        {{#pix}}t/down, moodle, {{#str}} down, moodle {{/str}}{{/pix}}
+                    </a>
+                {{/movedownlink}}
+                {{^movedownlink}}
+                    {{#pix}}spacer, moodle{{/pix}}
+                {{/movedownlink}}
+            </td>
+            <td>
+                <a href="{{{togglelink}}}">
+                    {{#toggletarget}}
+                    {{#pix}}i/show, moodle, {{#str}} enable, moodle {{/str}}{{/pix}}
+                    {{/toggletarget}}
+                    {{^toggletarget}}
+                    {{#pix}}i/hide, moodle, {{#str}} disable, moodle {{/str}}{{/pix}}
+                    {{/toggletarget}}
+                </a>
+            </td>
+            <td>
+                {{#settingslink}}
+                    <a href="{{{settingslink}}}">{{#str}}settings,plugin{{/str}}</a>
+                {{/settingslink}}
+            </td>
+            <td>
+                {{#uninstalllink}}
+                    <a href="{{{uninstalllink}}}">{{#str}}uninstall,plugin{{/str}}</a>
+                {{/uninstalllink}}
+            </td>
+        </tr>
+        {{/plugins}}
+    </tbody>
+</table>
index b52337b..98375c4 100644 (file)
@@ -325,6 +325,7 @@ if ($verbose || $status) {
         }
 
         // Update status after auto-rerun finished.
+        $status = 0;
         foreach ($exitcodes as $name => $exitcode) {
             if ($exitcode) {
                 if (!empty($parallelrun)) {
diff --git a/admin/updatesetting.php b/admin/updatesetting.php
new file mode 100644 (file)
index 0000000..a6b3b1d
--- /dev/null
@@ -0,0 +1,80 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Generic plugin config manipulation script.
+ *
+ * @package    admin
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define('NO_OUTPUT_BUFFERING', true);
+
+require_once('../config.php');
+require_once($CFG->libdir.'/adminlib.php');
+
+$action  = required_param('action', PARAM_ALPHANUMEXT);
+$plugin  = required_param('plugin', PARAM_PLUGIN);
+$type    = required_param('type', PARAM_PLUGIN);
+
+$PAGE->set_url('/admin/updatesetting.php');
+$PAGE->set_context(context_system::instance());
+
+require_login();
+require_capability('moodle/site:config', context_system::instance());
+require_sesskey();
+
+$plugintypeclass = "\\core\\plugininfo\\{$type}";
+
+$plugins = \core_plugin_manager::instance()->get_plugins_of_type($type);
+$sortorder = array_values($plugintypeclass::get_enabled_plugins());
+
+$return = $plugintypeclass::get_manage_url();
+
+if (!array_key_exists($plugin, $plugins)) {
+    redirect($return);
+}
+
+switch ($action) {
+    case 'disable':
+        $plugins[$plugin]->set_enabled(false);
+        break;
+
+    case 'enable':
+        $plugins[$plugin]->set_enabled(true);
+        break;
+
+    case 'up':
+        if (($pos = array_search($plugin, $sortorder)) > 0) {
+            $tmp = $sortorder[$pos - 1];
+            $sortorder[$pos - 1] = $sortorder[$pos];
+            $sortorder[$pos] = $tmp;
+            $plugintypeclass::set_enabled_plugins($sortorder);
+        }
+        break;
+
+    case 'down':
+        if ((($pos = array_search($plugin, $sortorder)) !== false) && ($pos < count($sortorder) - 1)) {
+            $tmp = $sortorder[$pos + 1];
+            $sortorder[$pos + 1] = $sortorder[$pos];
+            $sortorder[$pos] = $tmp;
+            $plugintypeclass::set_enabled_plugins($sortorder);
+        }
+        break;
+}
+
+redirect($return);
diff --git a/auth/db/classes/task/sync_users.php b/auth/db/classes/task/sync_users.php
new file mode 100644 (file)
index 0000000..59b7bc1
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Sync users task
+ * @package   auth_db
+ * @author    Guy Thomas <gthomas@moodlerooms.com>
+ * @copyright Copyright (c) 2017 Blackboard Inc.
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace auth_db\task;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Sync users task class
+ * @package   auth_db
+ * @author    Guy Thomas <gthomas@moodlerooms.com>
+ * @copyright Copyright (c) 2017 Blackboard Inc.
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class sync_users extends \core\task\scheduled_task {
+
+    /**
+     * Name for this task.
+     *
+     * @return string
+     */
+    public function get_name() {
+        return get_string('auth_dbsyncuserstask', 'auth_db');
+    }
+
+    /**
+     * Run task for synchronising users.
+     */
+    public function execute() {
+        if (!is_enabled_auth('db')) {
+            mtrace('auth_db plugin is disabled, synchronisation stopped', 2);
+            return;
+        }
+
+        $dbauth = get_auth_plugin('db');
+        $config = get_config('auth/db');
+        $trace = new \text_progress_trace();
+        $update = !empty($config->updateusers);
+        $dbauth->sync_users($trace, $update);
+    }
+
+}
index f84bf2f..3d0b12b 100644 (file)
@@ -81,6 +81,14 @@ if (!is_enabled_auth('db')) {
     cli_error('auth_db plugin is disabled, synchronisation stopped', 2);
 }
 
+cli_problem('[AUTH DB] The sync users cron has been deprecated. Please use the scheduled task instead.');
+
+// Abort execution of the CLI script if the \auth_db\task\sync_users is enabled.
+$task = \core\task\manager::get_scheduled_task('auth_db\task\sync_users');
+if (!$task->get_disabled()) {
+    cli_error('[AUTH DB] The scheduled task sync_users is enabled, the cron execution has been aborted.');
+}
+
 if (empty($options['verbose'])) {
     $trace = new null_progress_trace();
 } else {
index 426e101..fb751d0 100644 (file)
@@ -46,6 +46,9 @@
     if (!isset($config->removeuser)) {
         $config->removeuser = AUTH_REMOVEUSER_KEEP;
     }
+    if (!isset($config->updateusers)) {
+        $config->updateusers = 0;
+    }
 
     $yesno = array( get_string('no'), get_string('yes') );
 
     </td>
 </tr>
 
+<tr valign="top">
+    <td align="right"><label for="menuupdateusers"><?php print_string('auth_dbupdateusers', 'auth_db') ?></label></td>
+    <td>
+        <?php echo html_writer::select($yesno, 'updateusers', $config->updateusers, false); ?>
+    </td>
+    <td>
+        <?php print_string('auth_dbupdateusers_description','auth_db') ?>
+    </td>
+</tr>
+
 <?php
 
 print_auth_lock_options($this->authtype, $user_fields, get_string('auth_dbextrafields', 'auth_db'), true, true, $this->get_custom_user_profile_fields());
diff --git a/auth/db/db/tasks.php b/auth/db/db/tasks.php
new file mode 100644 (file)
index 0000000..226c24a
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Task definition for auth_db.
+ * @author    Guy Thomas <gthomas@moodlerooms.com>
+ * @copyright Copyright (c) 2017 Blackboard Inc.
+ * @package   auth_db
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$tasks = array(
+    array(
+        'classname' => '\auth_db\task\sync_users',
+        'blocking' => 0,
+        'minute' => 'R',
+        'hour' => 'R',
+        'day' => '*',
+        'month' => '*',
+        'dayofweek' => '*',
+        'disabled' => 1
+    )
+);
index 7a9ae73..857ae2c 100644 (file)
@@ -56,10 +56,13 @@ $string['auth_dbsuspenduser'] = 'Suspended user {$a->name} id {$a->id}';
 $string['auth_dbsuspendusererror'] = 'Error suspending user {$a}';
 $string['auth_dbsybasequoting'] = 'Use sybase quotes';
 $string['auth_dbsybasequotinghelp'] = 'Sybase style single quote escaping - needed for Oracle, MS SQL and some other databases. Do not use for MySQL!';
+$string['auth_dbsyncuserstask'] = 'Synchronise users task';
 $string['auth_dbtable'] = 'Name of the table in the database';
 $string['auth_dbtable_key'] = 'Table';
 $string['auth_dbtype'] = 'The database type (See the <a href="http://phplens.com/adodb/supported.databases.html" target="_blank">ADOdb documentation</a> for details)';
 $string['auth_dbtype_key'] = 'Database';
+$string['auth_dbupdateusers'] = 'Update users';
+$string['auth_dbupdateusers_description'] = 'As well as inserting new users, update existing users.';
 $string['auth_dbupdatinguser'] = 'Updating user {$a->name} id {$a->id}';
 $string['auth_dbuser'] = 'Username with read access to the database';
 $string['auth_dbuser_key'] = 'DB user';
index d2cbeba..ccb61d0 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2016120500;        // The current plugin version (Date: YYYYMMDDXX)
+$plugin->version   = 2017022200;        // The current plugin version (Date: YYYYMMDDXX)
 $plugin->requires  = 2016112900;        // Requires this Moodle version
 $plugin->component = 'auth_db';         // Full name of the plugin (used for diagnostics)
index c6b405b..0f1d389 100644 (file)
@@ -288,6 +288,20 @@ class cache_config_testing extends cache_config_writer {
         global $CFG;
         return $CFG->wwwroot.'phpunit';
     }
+
+    /**
+     * Checks if the configuration file exists.
+     *
+     * @return bool True if it exists
+     */
+    public static function config_file_exists() {
+        // Allow for late static binding by using static.
+        $configfilepath = static::get_config_file_path();
+
+        // Invalidate opcode php cache, so we get correct status of file.
+        core_component::invalidate_opcode_php_cache($configfilepath);
+        return file_exists($configfilepath);
+    }
 }
 
 /**
index 81c3117..f11d89a 100644 (file)
  */
 
 namespace core_calendar;
+
+use calendar_event;
+use DateInterval;
+use DateTime;
+use moodle_exception;
+use stdClass;
+
 defined('MOODLE_INTERNAL') || die();
 require_once($CFG->dirroot . '/calendar/lib.php');
 
@@ -134,6 +141,17 @@ class rrule_manager {
     /** const int For forever repeating events, repeat for this many years */
     const TIME_UNLIMITED_YEARS = 10;
 
+    /** const array Array of days in a week. */
+    const DAYS_OF_WEEK = [
+        'MO' => self::DAY_MONDAY,
+        'TU' => self::DAY_TUESDAY,
+        'WE' => self::DAY_WEDNESDAY,
+        'TH' => self::DAY_THURSDAY,
+        'FR' => self::DAY_FRIDAY,
+        'SA' => self::DAY_SATURDAY,
+        'SU' => self::DAY_SUNDAY,
+    ];
+
     /** @var string string representing the recurrence rule */
     protected $rrule;
 
@@ -176,8 +194,8 @@ class rrule_manager {
     /** @var array List of setpos rules */
     protected $bysetpos = array();
 
-    /** @var array week start rules */
-    protected $wkst;
+    /** @var string Week start rule. Default is Monday. */
+    protected $wkst = self::DAY_MONDAY;
 
     /**
      * Constructor for the class
@@ -199,13 +217,50 @@ class rrule_manager {
         foreach ($rules as $rule) {
             $this->parse_rrule_property($rule);
         }
+        // Validate the rules as a whole.
+        $this->validate_rules();
+    }
+
+    /**
+     * Create events for specified rrule.
+     *
+     * @param calendar_event $passedevent Properties of event to create.
+     * @throws moodle_exception
+     */
+    public function create_events($passedevent) {
+        global $DB;
+
+        $event = clone($passedevent);
+        // If Frequency is not set, there is nothing to do.
+        if (empty($this->freq)) {
+            return;
+        }
+
+        // Delete all child events in case of an update. This should be faster than verifying if the event exists and updating it.
+        $where = "repeatid = ? AND id != ?";
+        $DB->delete_records_select('event', $where, array($event->id, $event->id));
+        $eventrec = $event->properties();
+
+        // Generate timestamps that obey the rrule.
+        $eventtimes = $this->generate_recurring_event_times($eventrec);
+
+        // Adjust the parent event's timestart, if necessary.
+        if (count($eventtimes) > 0 && !in_array($eventrec->timestart, $eventtimes)) {
+            $calevent = new calendar_event($eventrec);
+            $updatedata = (object)['timestart' => $eventtimes[0], 'repeatid' => $eventrec->id];
+            $calevent->update($updatedata, false);
+            $eventrec->timestart = $calevent->timestart;
+        }
+
+        // Create the recurring calendar events.
+        $this->create_recurring_events($eventrec, $eventtimes);
     }
 
     /**
      * Parse a property of the recurrence rule.
      *
      * @param string $prop property string with type-value pair
-     * @throws \moodle_exception
+     * @throws moodle_exception
      */
     protected function parse_rrule_property($prop) {
         list($property, $value) = explode('=', $prop);
@@ -214,47 +269,47 @@ class rrule_manager {
                 $this->set_frequency($value);
                 break;
             case 'UNTIL' :
-                $this->until = strtotime($value);
+                $this->set_until($value);
                 break;
             CASE 'COUNT' :
-                $this->count = intval($value);
+                $this->set_count($value);
                 break;
             CASE 'INTERVAL' :
-                $this->interval = intval($value);
+                $this->set_interval($value);
                 break;
             CASE 'BYSECOND' :
-                $this->bysecond = explode(',', $value);
+                $this->set_bysecond($value);
                 break;
             CASE 'BYMINUTE' :
-                $this->byminute = explode(',', $value);
+                $this->set_byminute($value);
                 break;
             CASE 'BYHOUR' :
-                $this->byhour = explode(',', $value);
+                $this->set_byhour($value);
                 break;
             CASE 'BYDAY' :
-                $this->byday = explode(',', $value);
+                $this->set_byday($value);
                 break;
             CASE 'BYMONTHDAY' :
-                $this->bymonthday = explode(',', $value);
+                $this->set_bymonthday($value);
                 break;
             CASE 'BYYEARDAY' :
-                $this->byyearday = explode(',', $value);
+                $this->set_byyearday($value);
                 break;
             CASE 'BYWEEKNO' :
-                $this->byweekno = explode(',', $value);
+                $this->set_byweekno($value);
                 break;
             CASE 'BYMONTH' :
-                $this->bymonth = explode(',', $value);
+                $this->set_bymonth($value);
                 break;
             CASE 'BYSETPOS' :
-                $this->bysetpos = explode(',', $value);
+                $this->set_bysetpos($value);
                 break;
             CASE 'WKST' :
                 $this->wkst = $this->get_day($value);
                 break;
             default:
                 // We should never get here, something is very wrong.
-                throw new \moodle_exception('errorrrule', 'calendar');
+                throw new moodle_exception('errorrrule', 'calendar');
         }
     }
 
@@ -262,7 +317,7 @@ class rrule_manager {
      * Sets Frequency property.
      *
      * @param string $freq Frequency of event
-     * @throws \moodle_exception
+     * @throws moodle_exception
      */
     protected function set_frequency($freq) {
         switch ($freq) {
@@ -289,7 +344,7 @@ class rrule_manager {
                 break;
             default:
                 // We should never get here, something is very wrong.
-                throw new \moodle_exception('errorrrulefreq', 'calendar');
+                throw new moodle_exception('errorrrulefreq', 'calendar');
         }
     }
 
@@ -297,7 +352,7 @@ class rrule_manager {
      * Gets the day from day string.
      *
      * @param string $daystring Day string (MO, TU, etc)
-     * @throws \moodle_exception
+     * @throws moodle_exception
      *
      * @return string Day represented by the parameter.
      */
@@ -326,341 +381,958 @@ class rrule_manager {
                 break;
             default:
                 // We should never get here, something is very wrong.
-                throw new \moodle_exception('errorrruleday', 'calendar');
+                throw new moodle_exception('errorrruleday', 'calendar');
         }
     }
 
     /**
-     * Create events for specified rrule.
+     * Sets the UNTIL rule.
      *
-     * @param \calendar_event $passedevent Properties of event to create.
-     * @throws \moodle_exception
+     * @param string $until The date string representation of the UNTIL rule.
+     * @throws moodle_exception
      */
-    public function create_events($passedevent) {
-        global $DB;
+    protected function set_until($until) {
+        $this->until = strtotime($until);
+    }
 
-        $event = clone($passedevent);
-        // If Frequency is not set, there is nothing to do.
-        if (empty($this->freq)) {
-            return;
-        }
+    /**
+     * Sets the COUNT rule.
+     *
+     * @param string $count The count value.
+     * @throws moodle_exception
+     */
+    protected function set_count($count) {
+        $this->count = intval($count);
+    }
 
-        // Delete all child events in case of an update. This should be faster than verifying if the event exists and updating it.
-        $where = "repeatid = ? AND id != ?";
-        $DB->delete_records_select('event', $where, array($event->id, $event->id));
-        $eventrec = $event->properties();
+    /**
+     * Sets the INTERVAL rule.
+     *
+     * The INTERVAL rule part contains a positive integer representing how often the recurrence rule repeats.
+     * The default value is "1", meaning:
+     *  - every second for a SECONDLY rule, or
+     *  - every minute for a MINUTELY rule,
+     *  - every hour for an HOURLY rule,
+     *  - every day for a DAILY rule,
+     *  - every week for a WEEKLY rule,
+     *  - every month for a MONTHLY rule and
+     *  - every year for a YEARLY rule.
+     *
+     * @param string $intervalstr The value for the interval rule.
+     * @throws moodle_exception
+     */
+    protected function set_interval($intervalstr) {
+        $interval = intval($intervalstr);
+        if ($interval < 1) {
+            throw new moodle_exception('errorinvalidinterval', 'calendar');
+        }
+        $this->interval = $interval;
+    }
 
-        switch ($this->freq) {
-            case self::FREQ_DAILY :
-                $this->create_repeated_events($eventrec, DAYSECS);
-                break;
-            case self::FREQ_WEEKLY :
-                $this->create_weekly_events($eventrec);
-                break;
-            case self::FREQ_MONTHLY :
-                $this->create_monthly_events($eventrec);
-                break;
-            case self::FREQ_YEARLY :
-                $this->create_yearly_events($eventrec);
-                break;
-            default :
-                // We should never get here, something is very wrong.
-                throw new \moodle_exception('errorrulefreq', 'calendar');
+    /**
+     * Sets the BYSECOND rule.
+     *
+     * The BYSECOND rule part specifies a comma-separated list of seconds within a minute.
+     * Valid values are 0 to 59.
+     *
+     * @param string $bysecond Comma-separated list of seconds within a minute.
+     * @throws moodle_exception
+     */
+    protected function set_bysecond($bysecond) {
+        $seconds = explode(',', $bysecond);
+        $bysecondrules = [];
+        foreach ($seconds as $second) {
+            if ($second < 0 || $second > 59) {
+                throw new moodle_exception('errorinvalidbysecond', 'calendar');
+            }
+            $bysecondrules[] = (int)$second;
+        }
+        $this->bysecond = $bysecondrules;
+    }
 
+    /**
+     * Sets the BYMINUTE rule.
+     *
+     * The BYMINUTE rule part specifies a comma-separated list of seconds within an hour.
+     * Valid values are 0 to 59.
+     *
+     * @param string $byminute Comma-separated list of minutes within an hour.
+     * @throws moodle_exception
+     */
+    protected function set_byminute($byminute) {
+        $minutes = explode(',', $byminute);
+        $byminuterules = [];
+        foreach ($minutes as $minute) {
+            if ($minute < 0 || $minute > 59) {
+                throw new moodle_exception('errorinvalidbyminute', 'calendar');
+            }
+            $byminuterules[] = (int)$minute;
         }
+        $this->byminute = $byminuterules;
+    }
 
+    /**
+     * Sets the BYHOUR rule.
+     *
+     * The BYHOUR rule part specifies a comma-separated list of hours of the day.
+     * Valid values are 0 to 23.
+     *
+     * @param string $byhour Comma-separated list of hours of the day.
+     * @throws moodle_exception
+     */
+    protected function set_byhour($byhour) {
+        $hours = explode(',', $byhour);
+        $byhourrules = [];
+        foreach ($hours as $hour) {
+            if ($hour < 0 || $hour > 23) {
+                throw new moodle_exception('errorinvalidbyhour', 'calendar');
+            }
+            $byhourrules[] = (int)$hour;
+        }
+        $this->byhour = $byhourrules;
     }
 
     /**
-     * Create repeated events.
+     * Sets the BYDAY rule.
+     *
+     * The BYDAY rule part specifies a comma-separated list of days of the week;
+     *  - MO indicates Monday;
+     *  - TU indicates Tuesday;
+     *  - WE indicates Wednesday;
+     *  - TH indicates Thursday;
+     *  - FR indicates Friday;
+     *  - SA indicates Saturday;
+     *  - SU indicates Sunday.
      *
-     * @param \stdClass $event Event properties to create event
-     * @param int $timediff Time difference between events in seconds
-     * @param bool $currenttime If set, the event timestart is used as the timestart for the first event,
-     *                          else timestart + timediff used as the timestart for the first event. Set to true if
-     *                          parent event is not a part of this chain.
+     * Each BYDAY value can also be preceded by a positive (+n) or negative (-n) integer.
+     * If present, this indicates the nth occurrence of the specific day within the MONTHLY or YEARLY RRULE.
+     * For example, within a MONTHLY rule, +1MO (or simply 1MO) represents the first Monday within the month,
+     * whereas -1MO represents the last Monday of the month.
+     * If an integer modifier is not present, it means all days of this type within the specified frequency.
+     * For example, within a MONTHLY rule, MO represents all Mondays within the month.
+     *
+     * @param string $byday Comma-separated list of days of the week.
+     * @throws moodle_exception
      */
-    protected function create_repeated_events($event, $timediff, $currenttime = false) {
+    protected function set_byday($byday) {
+        $weekdays = array_keys(self::DAYS_OF_WEEK);
+        $days = explode(',', $byday);
+        $bydayrules = [];
+        foreach ($days as $day) {
+            $suffix = substr($day, -2);
+            if (!in_array($suffix, $weekdays)) {
+                throw new moodle_exception('errorinvalidbydaysuffix', 'calendar');
+            }
 
-        $event = clone($event); // We don't want to edit the master record.
-        $event->repeatid = $event->id; // Set parent id for all events.
-        unset($event->id); // We want new events created, not update the existing one.
-        unset($event->uuid); // uuid should be unique.
-        $count = $this->count;
+            $bydayrule = new stdClass();
+            $bydayrule->day = substr($suffix, -2);
+            $bydayrule->value = (int)str_replace($suffix, '', $day);
 
-        // Multiply by interval if used.
-        if ($this->interval) {
-            $timediff *= $this->interval;
-        }
-        if (!$currenttime) {
-            $event->timestart += $timediff;
+            $bydayrules[] = $bydayrule;
         }
 
-        // Create events.
-        if ($count > 0) {
-            // Count specified, use it.
-            if (!$currenttime) {
-                $count--; // Already a parent event has been created.
+        $this->byday = $bydayrules;
+    }
+
+    /**
+     * Sets the BYMONTHDAY rule.
+     *
+     * The BYMONTHDAY rule part specifies a comma-separated list of days of the month.
+     * Valid values are 1 to 31 or -31 to -1. For example, -10 represents the tenth to the last day of the month.
+     *
+     * @param string $bymonthday Comma-separated list of days of the month.
+     * @throws moodle_exception
+     */
+    protected function set_bymonthday($bymonthday) {
+        $monthdays = explode(',', $bymonthday);
+        $bymonthdayrules = [];
+        foreach ($monthdays as $day) {
+            // Valid values are 1 to 31 or -31 to -1.
+            if ($day < -31 || $day > 31 || $day == 0) {
+                throw new moodle_exception('errorinvalidbymonthday', 'calendar');
             }
-            for ($i = 0; $i < $count; $i++, $event->timestart += $timediff) {
-                unset($event->id); // It is set during creation.
-                \calendar_event::create($event, false);
+            $bymonthdayrules[] = (int)$day;
+        }
+
+        // Sort these MONTHDAY rules in ascending order.
+        sort($bymonthdayrules);
+
+        $this->bymonthday = $bymonthdayrules;
+    }
+
+    /**
+     * Sets the BYYEARDAY rule.
+     *
+     * The BYYEARDAY rule part specifies a comma-separated list of days of the year.
+     * Valid values are 1 to 366 or -366 to -1. For example, -1 represents the last day of the year (December 31st)
+     * and -306 represents the 306th to the last day of the year (March 1st).
+     *
+     * @param string $byyearday Comma-separated list of days of the year.
+     * @throws moodle_exception
+     */
+    protected function set_byyearday($byyearday) {
+        $yeardays = explode(',', $byyearday);
+        $byyeardayrules = [];
+        foreach ($yeardays as $day) {
+            // Valid values are 1 to 366 or -366 to -1.
+            if ($day < -366 || $day > 366 || $day == 0) {
+                throw new moodle_exception('errorinvalidbyyearday', 'calendar');
             }
-        } else {
-            // No count specified, use datetime constraints.
-            $until = $this->until;
-            if (empty($until)) {
-                // Forever event. We don't have any such concept in Moodle, hence we repeat it for a constant time.
-                $until = time() + (YEARSECS * self::TIME_UNLIMITED_YEARS);
+            $byyeardayrules[] = (int)$day;
+        }
+        $this->byyearday = $byyeardayrules;
+    }
+
+    /**
+     * Sets the BYWEEKNO rule.
+     *
+     * The BYWEEKNO rule part specifies a comma-separated list of ordinals specifying weeks of the year.
+     * Valid values are 1 to 53 or -53 to -1. This corresponds to weeks according to week numbering as defined in [ISO 8601].
+     * A week is defined as a seven day period, starting on the day of the week defined to be the week start (see WKST).
+     * Week number one of the calendar year is the first week which contains at least four (4) days in that calendar year.
+     * This rule part is only valid for YEARLY rules. For example, 3 represents the third week of the year.
+     *
+     * Note: Assuming a Monday week start, week 53 can only occur when Thursday is January 1 or if it is a leap year and Wednesday
+     * is January 1.
+     *
+     * @param string $byweekno Comma-separated list of number of weeks.
+     * @throws moodle_exception
+     */
+    protected function set_byweekno($byweekno) {
+        $weeknumbers = explode(',', $byweekno);
+        $byweeknorules = [];
+        foreach ($weeknumbers as $week) {
+            // Valid values are 1 to 53 or -53 to -1.
+            if ($week < -53 || $week > 53 || $week == 0) {
+                throw new moodle_exception('errorinvalidbyweekno', 'calendar');
             }
-            for (; $event->timestart < $until; $event->timestart += $timediff) {
-                unset($event->id); // It is set during creation.
-                \calendar_event::create($event, false);
+            $byweeknorules[] = (int)$week;
+        }
+        $this->byweekno = $byweeknorules;
+    }
+
+    /**
+     * Sets the BYMONTH rule.
+     *
+     * The BYMONTH rule part specifies a comma-separated list of months of the year.
+     * Valid values are 1 to 12.
+     *
+     * @param string $bymonth Comma-separated list of months of the year.
+     * @throws moodle_exception
+     */
+    protected function set_bymonth($bymonth) {
+        $months = explode(',', $bymonth);
+        $bymonthrules = [];
+        foreach ($months as $month) {
+            // Valid values are 1 to 12.
+            if ($month < 1 || $month > 12) {
+                throw new moodle_exception('errorinvalidbymonth', 'calendar');
             }
+            $bymonthrules[] = (int)$month;
         }
+        $this->bymonth = $bymonthrules;
     }
 
     /**
-     * Create repeated events based on offsets.
+     * Sets the BYSETPOS rule.
+     *
+     * The BYSETPOS rule part specifies a comma-separated list of values which corresponds to the nth occurrence within the set of
+     * events specified by the rule. Valid values are 1 to 366 or -366 to -1.
+     * It MUST only be used in conjunction with another BYxxx rule part.
+     *
+     * For example "the last work day of the month" could be represented as: RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1
      *
-     * @param \stdClass $event
-     * @param int $secsoffset Seconds since the start of the day that this event occurs
-     * @param int $dayoffset Day offset.
-     * @param int $monthoffset Months offset.
-     * @param int $yearoffset Years offset.
-     * @param int $start timestamp to apply offsets onto.
-     * @param bool $currenttime If set, the event timestart is used as the timestart for the first event,
-     *                          else timestart + timediff(monthly offset + yearly offset) used as the timestart for the first
-     *                          event.Set to true if parent event is not a part of this chain.
+     * @param string $bysetpos Comma-separated list of values.
+     * @throws moodle_exception
      */
-    protected function create_repeated_events_by_offsets($event, $secsoffset, $dayoffset, $monthoffset, $yearoffset, $start,
-                                                         $currenttime = false) {
+    protected function set_bysetpos($bysetpos) {
+        $setposes = explode(',', $bysetpos);
+        $bysetposrules = [];
+        foreach ($setposes as $pos) {
+            // Valid values are 1 to 366 or -366 to -1.
+            if ($pos < -366 || $pos > 366 || $pos == 0) {
+                throw new moodle_exception('errorinvalidbysetpos', 'calendar');
+            }
+            $bysetposrules[] = (int)$pos;
+        }
+        $this->bysetpos = $bysetposrules;
+    }
 
-        $event = clone($event); // We don't want to edit the master record.
-        $event->repeatid = $event->id; // Set parent id for all events.
-        unset($event->id); // We want new events created, not update the existing one.
-        unset($event->uuid); // uuid should be unique.
-        $count = $this->count;
-        // First event time in this chain.
-        $event->timestart = strtotime("+$dayoffset days", $start) + $secsoffset;
+    /**
+     * Validate the rules as a whole.
+     *
+     * @throws moodle_exception
+     */
+    protected function validate_rules() {
+        // UNTIL and COUNT cannot be in the same recurrence rule.
+        if (!empty($this->until) && !empty($this->count)) {
+            throw new moodle_exception('errorhasuntilandcount', 'calendar');
+        }
 
-        if (!$currenttime) {
-            // Skip one event, since parent event is a part of this chain.
-            $event->timestart = strtotime("+$monthoffset months +$yearoffset years", $event->timestart);
+        // BYSETPOS only be used in conjunction with another BYxxx rule part.
+        if (!empty($this->bysetpos) && empty($this->bymonth) && empty($this->bymonthday) && empty($this->bysecond)
+            && empty($this->byday) && empty($this->byweekno) && empty($this->byhour) && empty($this->byminute)
+            && empty($this->byyearday)) {
+            throw new moodle_exception('errormustbeusedwithotherbyrule', 'calendar');
         }
 
-        // Create events.
-        if ($count > 0) {
-            // Count specified, use it.
-            if (!$currenttime) {
-                $count--; // Already a parent event has been created.
-            }
-            for ($i = 0; $i < $count; $i++) {
-                unset($event->id); // It is set during creation.
-                \calendar_event::create($event, false);
-                $event->timestart = strtotime("+$monthoffset months +$yearoffset years", $event->timestart);
+        // Integer values preceding BYDAY rules can only be present for MONTHLY or YEARLY RRULE.
+        foreach ($this->byday as $bydayrule) {
+            if (!empty($bydayrule->value) && $this->freq != self::FREQ_MONTHLY && $this->freq != self::FREQ_YEARLY) {
+                throw new moodle_exception('errorinvalidbydayprefix', 'calendar');
             }
-        } else {
-            // No count specified, use datetime constraints.
-            $until = $this->until;
-            if (empty($until)) {
-                // Forever event. We don't have any such concept in Moodle, hence we repeat it for a constant time.
-                $until = time() + (YEARSECS * self::TIME_UNLIMITED_YEARS );
+        }
+
+        // The BYWEEKNO rule is only valid for YEARLY rules.
+        if (!empty($this->byweekno) && $this->freq != self::FREQ_YEARLY) {
+            throw new moodle_exception('errornonyearlyfreqwithbyweekno', 'calendar');
+        }
+    }
+
+    /**
+     * Creates calendar events for the recurring events.
+     *
+     * @param stdClass $event The parent event.
+     * @param int[] $eventtimes The timestamps of the recurring events.
+     */
+    protected function create_recurring_events($event, $eventtimes) {
+        $count = false;
+        if ($this->count) {
+            $count = $this->count;
+        }
+
+        foreach ($eventtimes as $time) {
+            // Skip if time is the same time with the parent event's timestamp.
+            if ($time == $event->timestart) {
+                continue;
             }
-            for (; $event->timestart < $until;) {
-                unset($event->id); // It is set during creation.
-                \calendar_event::create($event, false);
-                $event->timestart = strtotime("+$monthoffset months +$yearoffset years", $event->timestart);
 
+            // Decrement count, if set.
+            if ($count !== false) {
+                $count--;
+                if ($count == 0) {
+                    break;
+                }
             }
+
+            // Create the recurring event.
+            $cloneevent = clone($event);
+            $cloneevent->repeatid = $event->id;
+            $cloneevent->timestart = $time;
+            unset($cloneevent->id);
+            calendar_event::create($cloneevent, false);
         }
     }
 
     /**
-     * Create repeated events based on offsets from a fixed start date.
+     * Generates recurring events based on the parent event and the RRULE set.
+     *
+     * If multiple BYxxx rule parts are specified, then after evaluating the specified FREQ and INTERVAL rule parts,
+     * the BYxxx rule parts are applied to the current set of evaluated occurrences in the following order:
+     * BYMONTH, BYWEEKNO, BYYEARDAY, BYMONTHDAY, BYDAY, BYHOUR, BYMINUTE, BYSECOND and BYSETPOS;
+     * then COUNT and UNTIL are evaluated.
      *
-     * @param \stdClass $event
-     * @param int $secsoffset Seconds since the start of the day that this event occurs
-     * @param string $prefix Prefix string to add to strtotime while calculating next date for the event.
-     * @param int $monthoffset Months offset.
-     * @param int $yearoffset Years offset.
-     * @param int $start timestamp to apply offsets onto.
-     * @param bool $currenttime If set, the event timestart is used as the timestart + offset for the first event,
-     *                          else timestart + timediff(monthly offset + yearly offset) + offset used as the timestart for the
-     *                          first event, from the given fixed start time. Set to true if parent event is not a part of this
-     *                          chain.
+     * @param stdClass $event The event object.
+     * @return array The list of timestamps that obey the given RRULE.
      */
-    protected function create_repeated_events_by_offsets_from_fixedstart($event, $secsoffset, $prefix, $monthoffset,
-                                                                         $yearoffset, $start, $currenttime = false) {
+    protected function generate_recurring_event_times($event) {
+        $interval = $this->get_interval();
 
-        $event = clone($event); // We don't want to edit the master record.
-        $event->repeatid = $event->id; // Set parent id for all events.
-        unset($event->id); // We want new events created, not update the existing one.
-        unset($event->uuid); // uuid should be unique.
-        $count = $this->count;
+        // Candidate event times.
+        $eventtimes = [];
 
-        // First event time in this chain.
-        if (!$currenttime) {
-            // Skip one event, since parent event is a part of this chain.
-            $moffset = $monthoffset;
-            $yoffset = $yearoffset;
-            $event->timestart = strtotime("+$monthoffset months +$yearoffset years", $start);
-            $event->timestart = strtotime($prefix, $event->timestart) + $secsoffset;
+        $eventdatetime = new DateTime(date('Y-m-d H:i:s', $event->timestart));
+
+        $until = null;
+        if (empty($this->count)) {
+            if ($this->until) {
+                $until = $this->until;
+            } else {
+                // Forever event. However, since there's no such thing as 'forever' (at least not in Moodle),
+                // we only repeat the events until 10 years from the current time.
+                $untildate = new DateTime();
+                $foreverinterval = new DateInterval('P' . self::TIME_UNLIMITED_YEARS . 'Y');
+                $untildate->add($foreverinterval);
+                $until = $untildate->getTimestamp();
+            }
         } else {
-            $moffset = 0;
-            $yoffset = 0;
-            $event->timestart = strtotime($prefix, $event->timestart) + $secsoffset;
-        }
-        // Create events.
-        if ($count > 0) {
-            // Count specified, use it.
-            if (!$currenttime) {
-                $count--; // Already a parent event has been created.
+            // If count is defined, let's define a tentative until date. We'll just trim the number of events later.
+            $untildate = clone($eventdatetime);
+            $count = $this->count;
+            while ($count >= 0) {
+                $untildate->add($interval);
+                $count--;
             }
-            for ($i = 0; $i < $count; $i++) {
-                unset($event->id); // It is set during creation.
-                \calendar_event::create($event, false);
-                $moffset += $monthoffset;
-                $yoffset += $yearoffset;
-                $event->timestart = strtotime("+$moffset months +$yoffset years", $start);
-                $event->timestart = strtotime($prefix, $event->timestart) + $secsoffset;
+            $until = $untildate->getTimestamp();
+        }
+
+        // No filters applied. Generate recurring events right away.
+        if (!$this->has_by_rules()) {
+            // Get initial list of prospective events.
+            $tmpstart = clone($eventdatetime);
+            while ($tmpstart->getTimestamp() <= $until) {
+                $eventtimes[] = $tmpstart->getTimestamp();
+                $tmpstart->add($interval);
             }
-        } else {
-            // No count specified, use datetime constraints.
-            $until = $this->until;
-            if (empty($until)) {
-                // Forever event. We don't have any such concept in Moodle, hence we repeat it for a constant time.
-                $until = time() + (YEARSECS * self::TIME_UNLIMITED_YEARS );
+            return $eventtimes;
+        }
+
+        // Get all of potential dates covered by the periods from the event's start date until the last.
+        $dailyinterval = new DateInterval('P1D');
+        $boundslist = $this->get_period_bounds_list($eventdatetime->getTimestamp(), $until);
+        foreach ($boundslist as $bounds) {
+            $tmpdate = new DateTime(date('Y-m-d H:i:s', $bounds->start));
+            while ($tmpdate->getTimestamp() >= $bounds->start && $tmpdate->getTimestamp() < $bounds->next) {
+                $eventtimes[] = $tmpdate->getTimestamp();
+                $tmpdate->add($dailyinterval);
+            }
+        }
+
+        // Evaluate BYMONTH rules.
+        $eventtimes = $this->filter_by_month($eventtimes);
+
+        // Evaluate BYWEEKNO rules.
+        $eventtimes = $this->filter_by_weekno($eventtimes);
+
+        // Evaluate BYYEARDAY rules.
+        $eventtimes = $this->filter_by_yearday($eventtimes);
+
+        // If BYYEARDAY, BYMONTHDAY and BYDAY are not set, default to BYMONTHDAY based on the DTSTART's day.
+        if ($this->freq != self::FREQ_DAILY && empty($this->byyearday) && empty($this->bymonthday) && empty($this->byday)) {
+            $this->bymonthday = [$eventdatetime->format('j')];
+        }
+
+        // Evaluate BYMONTHDAY rules.
+        $eventtimes = $this->filter_by_monthday($eventtimes);
+
+        // Evaluate BYDAY rules.
+        $eventtimes = $this->filter_by_day($event, $eventtimes, $until);
+
+        // Evaluate BYHOUR rules.
+        $eventtimes = $this->apply_hour_minute_second_rules($eventdatetime, $eventtimes);
+
+        // Evaluate BYSETPOS rules.
+        $eventtimes = $this->filter_by_setpos($event, $eventtimes, $until);
+
+        // Sort event times in ascending order.
+        sort($eventtimes);
+
+        // Finally, filter candidate event times to make sure they are within the DTSTART and UNTIL/tentative until boundaries.
+        $results = [];
+        foreach ($eventtimes as $time) {
+            // Skip out-of-range events.
+            if ($time < $eventdatetime->getTimestamp()) {
+                continue;
             }
-            for (; $event->timestart < $until;) {
-                unset($event->id); // It is set during creation.
-                \calendar_event::create($event, false);
-                $moffset += $monthoffset;
-                $yoffset += $yearoffset;
-                $event->timestart = strtotime("+$moffset months +$yoffset years", $start);
-                $event->timestart = strtotime($prefix, $event->timestart) + $secsoffset;
+            // End if event time is beyond the until limit.
+            if ($time > $until) {
+                break;
             }
+            $results[] = $time;
         }
+
+        return $results;
     }
 
     /**
-     * Create events for weekly frequency.
+     * Generates a DateInterval object based on the FREQ and INTERVAL rules.
      *
-     * @param \stdClass $event Event properties to create event
+     * @return DateInterval
+     * @throws moodle_exception
      */
-    protected function create_weekly_events($event) {
-        // If by day is not present, it means all days of the week.
-        if (empty($this->byday)) {
-            $this->byday = array('MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU');
-        }
-        // This much seconds after the start of the day.
-        $offset = $event->timestart - mktime(0, 0, 0, date("n", $event->timestart), date("j", $event->timestart), date("Y",
-                $event->timestart));
-        foreach ($this->byday as $daystring) {
-            $day = $this->get_day($daystring);
-            if (date('l', $event->timestart) == $day) {
-                // Parent event is a part of this day chain.
-                $this->create_repeated_events($event, WEEKSECS, false);
-            } else {
-                // Parent event is not a part of this day chain.
-                $cpyevent = clone($event); // We don't want to change timestart of master record.
-                $cpyevent->timestart = strtotime("+$offset seconds next $day", $cpyevent->timestart);
-                $this->create_repeated_events($cpyevent, WEEKSECS, true);
+    protected function get_interval() {
+        $intervalspec = null;
+        switch ($this->freq) {
+            case self::FREQ_YEARLY:
+                $intervalspec = 'P' . $this->interval . 'Y';
+                break;
+            case self::FREQ_MONTHLY:
+                $intervalspec = 'P' . $this->interval . 'M';
+                break;
+            case self::FREQ_WEEKLY:
+                $intervalspec = 'P' . $this->interval . 'W';
+                break;
+            case self::FREQ_DAILY:
+                $intervalspec = 'P' . $this->interval . 'D';
+                break;
+            case self::FREQ_HOURLY:
+                $intervalspec = 'PT' . $this->interval . 'H';
+                break;
+            case self::FREQ_MINUTELY:
+                $intervalspec = 'PT' . $this->interval . 'M';
+                break;
+            case self::FREQ_SECONDLY:
+                $intervalspec = 'PT' . $this->interval . 'S';
+                break;
+            default:
+                // We should never get here, something is very wrong.
+                throw new moodle_exception('errorrrulefreq', 'calendar');
+        }
+
+        return new DateInterval($intervalspec);
+    }
+
+    /**
+     * Determines whether the RRULE has BYxxx rules or not.
+     *
+     * @return bool True if there is one or more BYxxx rules to process. False, otherwise.
+     */
+    protected function has_by_rules() {
+        return !empty($this->bymonth) || !empty($this->bymonthday) || !empty($this->bysecond) || !empty($this->byday)
+            || !empty($this->byweekno) || !empty($this->byhour) || !empty($this->byminute) || !empty($this->byyearday);
+    }
+
+    /**
+     * Filter event times based on the BYMONTH rule.
+     *
+     * @param int[] $eventdates Timestamps of event times to be filtered.
+     * @return int[] Array of filtered timestamps.
+     */
+    protected function filter_by_month($eventdates) {
+        if (empty($this->bymonth)) {
+            return $eventdates;
+        }
+
+        $filteredbymonth = [];
+        foreach ($eventdates as $time) {
+            foreach ($this->bymonth as $month) {
+                $prospectmonth = date('n', $time);
+                if ($month == $prospectmonth) {
+                    $filteredbymonth[] = $time;
+                    break;
+                }
             }
         }
+        return $filteredbymonth;
     }
 
     /**
-     * Create events for monthly frequency.
+     * Filter event times based on the BYWEEKNO rule.
      *
-     * @param \stdClass $event Event properties to create event
+     * @param int[] $eventdates Timestamps of event times to be filtered.
+     * @return int[] Array of filtered timestamps.
      */
-    protected function create_monthly_events($event) {
-        // Either bymonthday or byday should be set.
-        if (empty($this->bymonthday) && empty($this->byday)
-                || !empty($this->bymonthday) && !empty($this->byday)) {
-            return;
+    protected function filter_by_weekno($eventdates) {
+        if (empty($this->byweekno)) {
+            return $eventdates;
+        }
+
+        $filteredbyweekno = [];
+        $weeklyinterval = null;
+        foreach ($eventdates as $time) {
+            $tmpdate = new DateTime(date('Y-m-d H:i:s', $time));
+            foreach ($this->byweekno as $weekno) {
+                if ($weekno > 0) {
+                    if ($tmpdate->format('W') == $weekno) {
+                        $filteredbyweekno[] = $time;
+                        break;
+                    }
+                } else if ($weekno < 0) {
+                    if ($weeklyinterval === null) {
+                        $weeklyinterval = new DateInterval('P1W');
+                    }
+                    $weekstart = new DateTime();
+                    $weekstart->setISODate($tmpdate->format('Y'), $weekno);
+                    $weeknext = clone($weekstart);
+                    $weeknext->add($weeklyinterval);
+
+                    $tmptimestamp = $tmpdate->getTimestamp();
+
+                    if ($tmptimestamp >= $weekstart->getTimestamp() && $tmptimestamp < $weeknext->getTimestamp()) {
+                        $filteredbyweekno[] = $time;
+                        break;
+                    }
+                }
+            }
+        }
+        return $filteredbyweekno;
+    }
+
+    /**
+     * Filter event times based on the BYYEARDAY rule.
+     *
+     * @param int[] $eventdates Timestamps of event times to be filtered.
+     * @return int[] Array of filtered timestamps.
+     */
+    protected function filter_by_yearday($eventdates) {
+        if (empty($this->byyearday)) {
+            return $eventdates;
+        }
+
+        $filteredbyyearday = [];
+        foreach ($eventdates as $time) {
+            $tmpdate = new DateTime(date('Y-m-d', $time));
+
+            foreach ($this->byyearday as $yearday) {
+                $dayoffset = abs($yearday) - 1;
+                $dayoffsetinterval = new DateInterval("P{$dayoffset}D");
+
+                if ($yearday > 0) {
+                    $tmpyearday = (int)$tmpdate->format('z') + 1;
+                    if ($tmpyearday == $yearday) {
+                        $filteredbyyearday[] = $time;
+                        break;
+                    }
+                } else if ($yearday < 0) {
+                    $yeardaydate = new DateTime('last day of ' . $tmpdate->format('Y'));
+                    $yeardaydate->sub($dayoffsetinterval);
+
+                    $tmpdate->getTimestamp();
+
+                    if ($yeardaydate->format('z') == $tmpdate->format('z')) {
+                        $filteredbyyearday[] = $time;
+                        break;
+                    }
+                }
+            }
+        }
+        return $filteredbyyearday;
+    }
+
+    /**
+     * Filter event times based on the BYMONTHDAY rule.
+     *
+     * @param int[] $eventdates The event times to be filtered.
+     * @return int[] Array of filtered timestamps.
+     */
+    protected function filter_by_monthday($eventdates) {
+        if (empty($this->bymonthday)) {
+            return $eventdates;
         }
-        // This much seconds after the start of the day.
-        $offset = $event->timestart - mktime(0, 0, 0, date("n", $event->timestart), date("j", $event->timestart), date("Y",
-                $event->timestart));
-        $monthstart = mktime(0, 0, 0, date("n", $event->timestart), 1, date("Y", $event->timestart));
-        if (!empty($this->bymonthday)) {
+
+        $filteredbymonthday = [];
+        foreach ($eventdates as $time) {
+            $eventdatetime = new DateTime(date('Y-m-d', $time));
             foreach ($this->bymonthday as $monthday) {
-                $dayoffset = $monthday - 1; // Number of days we want to add to the first day.
-                if ($monthday == date("j", $event->timestart)) {
-                    // Parent event is a part of this day chain.
-                    $this->create_repeated_events_by_offsets($event, $offset, $dayoffset, $this->interval, 0, $monthstart,
-                        false);
-                } else {
-                    // Parent event is not a part of this day chain.
-                    $this->create_repeated_events_by_offsets($event, $offset, $dayoffset, $this->interval, 0, $monthstart, true);
+                // Days to add/subtract.
+                $daysoffset = abs($monthday) - 1;
+                $dayinterval = new DateInterval("P{$daysoffset}D");
+
+                if ($monthday > 0) {
+                    if ($eventdatetime->format('j') == $monthday) {
+                        $filteredbymonthday[] = $time;
+                        break;
+                    }
+                } else if ($monthday < 0) {
+                    $tmpdate = clone($eventdatetime);
+                    // Reset to the first day of the month.
+                    $tmpdate->modify('first day of this month');
+                    // Then go to last day of the month.
+                    $tmpdate->modify('last day of this month');
+                    if ($daysoffset > 0) {
+                        // Then subtract the monthday value.
+                        $tmpdate->sub($dayinterval);
+                    }
+                    if ($eventdatetime->format('j') == $tmpdate->format('j')) {
+                        $filteredbymonthday[] = $time;
+                        break;
+                    }
                 }
             }
-        } else {
-            foreach ($this->byday as $dayrule) {
-                $day = substr($dayrule, strlen($dayrule) - 2); // Last two chars.
-                $prefix = str_replace($day, '', $dayrule);
-                if (empty($prefix) || !is_numeric($prefix)) {
-                    return;
+        }
+        return $filteredbymonthday;
+    }
+
+    /**
+     * Filter event times based on the BYDAY rule.
+     *
+     * @param stdClass $event The parent event.
+     * @param int[] $eventdates The event times to be filtered.
+     * @param int $until Event times generation limit date.
+     * @return int[] Array of filtered timestamps.
+     */
+    protected function filter_by_day($event, $eventdates, $until) {
+        if (empty($this->byday)) {
+            return $eventdates;
+        }
+
+        $filteredbyday = [];
+
+        $bounds = $this->get_period_bounds_list($event->timestart, $until);
+
+        $nextmonthinterval = new DateInterval('P1M');
+        foreach ($eventdates as $time) {
+            $tmpdatetime = new DateTime(date('Y-m-d', $time));
+
+            foreach ($this->byday as $day) {
+                $dayname = self::DAYS_OF_WEEK[$day->day];
+
+                // Skip if they day name of the event time does not match the day part of the BYDAY rule.
+                if ($tmpdatetime->format('l') !== $dayname) {
+                    continue;
                 }
-                $day = $this->get_day($day);
-                if ($day == date('l', $event->timestart)) {
-                    // Parent event is a part of this day chain.
-                    $this->create_repeated_events_by_offsets_from_fixedstart($event, $offset, "$prefix $day", $this->interval, 0,
-                        $monthstart, false);
+
+                if (empty($day->value)) {
+                    // No modifier value. Applies to all weekdays of the given period.
+                    $filteredbyday[] = $time;
+                    break;
+                } else if ($day->value > 0) {
+                    // Positive value.
+                    if ($this->freq === self::FREQ_YEARLY && empty($this->bymonth)) {
+                        // Get the first day of the year.
+                        $firstdaydate = $tmpdatetime->format('Y') . '-01-01';
+                    } else {
+                        // Get the first day of the month.
+                        $firstdaydate = $tmpdatetime->format('Y-m') . '-01';
+                    }
+                    $expecteddate = new DateTime($firstdaydate);
+                    $count = $day->value;
+                    // Get the nth week day of the year/month.
+                    $expecteddate->modify("+$count $dayname");
+                    if ($expecteddate->format('Y-m-d') === $tmpdatetime->format('Y-m-d')) {
+                        $filteredbyday[] = $time;
+                        break;
+                    }
+
                 } else {
-                    // Parent event is not a part of this day chain.
-                    $this->create_repeated_events_by_offsets_from_fixedstart($event, $offset, "$prefix $day", $this->interval, 0,
-                        $monthstart, true);
-                }
+                    // Negative value.
+                    $count = $day->value;
+                    if ($this->freq === self::FREQ_YEARLY && empty($this->bymonth)) {
+                        // The -Nth week day of the year.
+                        $eventyear = (int)$tmpdatetime->format('Y');
+                        // Get temporary DateTime object starting from the first day of the next year.
+                        $expecteddate = new DateTime((++$eventyear) . '-01-01');
+                        while ($count < 0) {
+                            // Get the start of the previous week.
+                            $expecteddate->modify('last ' . $this->wkst);
+                            $tmpexpecteddate = clone($expecteddate);
+                            if ($tmpexpecteddate->format('l') !== $dayname) {
+                                $tmpexpecteddate->modify('next ' . $dayname);
+                            }
+                            if ($this->in_bounds($tmpexpecteddate->getTimestamp(), $bounds)) {
+                                $expecteddate = $tmpexpecteddate;
+                                $count++;
+                            }
+                        }
+                        if ($expecteddate->format('l') !== $dayname) {
+                            $expecteddate->modify('next ' . $dayname);
+                        }
+                        if ($expecteddate->getTimestamp() == $time) {
+                            $filteredbyday[] = $time;
+                            break;
+                        }
+
+                    } else {
+                        // The -Nth week day of the month.
+                        $expectedmonthyear = $tmpdatetime->format('F Y');
+                        $expecteddate = new DateTime("first day of $expectedmonthyear");
+                        $expecteddate->add($nextmonthinterval);
+                        while ($count < 0) {
+                            // Get the start of the previous week.
+                            $expecteddate->modify('last ' . $this->wkst);
+                            $tmpexpecteddate = clone($expecteddate);
+                            if ($tmpexpecteddate->format('l') !== $dayname) {
+                                $tmpexpecteddate->modify('next ' . $dayname);
+                            }
+                            if ($this->in_bounds($tmpexpecteddate->getTimestamp(), $bounds)) {
+                                $expecteddate = $tmpexpecteddate;
+                                $count++;
+                            }
+                        }
 
+                        // Compare the expected date with the event's timestamp.
+                        if ($expecteddate->getTimestamp() == $time) {
+                            $filteredbyday[] = $time;
+                            break;
+                        }
+                    }
+                }
             }
         }
+        return $filteredbyday;
     }
 
     /**
-     * Create events for yearly frequency.
+     * Applies BYHOUR, BYMINUTE and BYSECOND rules to the calculated event dates.
+     * Defaults to the DTSTART's hour/minute/second component when not defined.
      *
-     * @param \stdClass $event Event properties to create event
+     * @param DateTime $eventdatetime The parent event DateTime object pertaining to the DTSTART.
+     * @param int[] $eventdates Array of candidate event date timestamps.
+     * @return array List of updated event timestamps that contain the time component of the event times.
      */
-    protected function create_yearly_events($event) {
+    protected function apply_hour_minute_second_rules(DateTime $eventdatetime, $eventdates) {
+        // If BYHOUR rules are not set, set the hour of the events from the DTSTART's hour component.
+        if (empty($this->byhour)) {
+            $this->byhour = [$eventdatetime->format('G')];
+        }
+        // If BYMINUTE rules are not set, set the hour of the events from the DTSTART's minute component.
+        if (empty($this->byminute)) {
+            $this->byminute = [(int)$eventdatetime->format('i')];
+        }
+        // If BYSECOND rules are not set, set the hour of the events from the DTSTART's second component.
+        if (empty($this->bysecond)) {
+            $this->bysecond = [(int)$eventdatetime->format('s')];
+        }
 
-        // This much seconds after the start of the month.
-        $offset = $event->timestart - mktime(0, 0, 0, date("n", $event->timestart), date("j", $event->timestart), date("Y",
-                $event->timestart));
+        $results = [];
+        foreach ($eventdates as $time) {
+            $datetime = new DateTime(date('Y-m-d', $time));
+            foreach ($this->byhour as $hour) {
+                foreach ($this->byminute as $minute) {
+                    foreach ($this->bysecond as $second) {
+                        $datetime->setTime($hour, $minute, $second);
+                        $results[] = $datetime->getTimestamp();
+                    }
+                }
+            }
+        }
+        return $results;
+    }
 
-        if (empty($this->bymonth)) {
-            // Event's month is taken if not specified.
-            $this->bymonth = array(date("n", $event->timestart));
-        }
-        foreach ($this->bymonth as $month) {
-            if (empty($this->byday)) {
-                // If byday is not present, the rule must represent the same month as the event start date. Basically we only
-                // have to add + $this->interval number of years to get the next event date.
-                if ($month == date("n", $event->timestart)) {
-                    // Parent event is a part of this month chain.
-                    $this->create_repeated_events_by_offsets($event, 0, 0, 0, $this->interval, $event->timestart, false);
+    /**
+     * Filter event times based on the BYSETPOS rule.
+     *
+     * @param stdClass $event The parent event.
+     * @param int[] $eventtimes The event times to be filtered.
+     * @param int $until Event times generation limit date.
+     * @return int[] Array of filtered timestamps.
+     */
+    protected function filter_by_setpos($event, $eventtimes, $until) {
+        if (empty($this->bysetpos)) {
+            return $eventtimes;
+        }
+
+        $filteredbysetpos = [];
+        $boundslist = $this->get_period_bounds_list($event->timestart, $until);
+        sort($eventtimes);
+        foreach ($boundslist as $bounds) {
+            // Generate a list of candidate event times based that are covered in a period's bounds.
+            $prospecttimes = [];
+            foreach ($eventtimes as $time) {
+                if ($time >= $bounds->start && $time < $bounds->next) {
+                    $prospecttimes[] = $time;
                 }
-            } else {
-                $dayrule = reset($this->byday);
-                $day = substr($dayrule, strlen($dayrule) - 2); // Last two chars.
-                $prefix = str_replace($day, '', $dayrule);
-                if (empty($prefix) || !is_numeric($prefix)) {
-                    return;
+            }
+            if (empty($prospecttimes)) {
+                continue;
+            }
+            // Add the event times that correspond to the set position rule into the filtered results.
+            foreach ($this->bysetpos as $pos) {
+                $tmptimes = $prospecttimes;
+                if ($pos < 0) {
+                    rsort($tmptimes);
                 }
-                $day = $this->get_day($day);
-                $monthstart = mktime(0, 0, 0, $month, 1, date("Y", $event->timestart));
-                if ($day == date('l', $event->timestart)) {
-                    // Parent event is a part of this day chain.
-                    $this->create_repeated_events_by_offsets_from_fixedstart($event, $offset, "$prefix $day", 0,
-                            $this->interval, $monthstart, false);
-                } else {
-                    // Parent event is not a part of this day chain.
-                    $this->create_repeated_events_by_offsets_from_fixedstart($event, $offset, "$prefix $day", 0,
-                        $this->interval, $monthstart, true);
+                $index = abs($pos) - 1;
+                if (isset($tmptimes[$index])) {
+                    $filteredbysetpos[] = $tmptimes[$index];
                 }
             }
         }
+        return $filteredbysetpos;
+    }
+
+    /**
+     * Gets the list of period boundaries covered by the recurring events.
+     *
+     * @param int $eventtime The event timestamp.
+     * @param int $until The end timestamp.
+     * @return array List of period bounds, with start and next properties.
+     */
+    protected function get_period_bounds_list($eventtime, $until) {
+        $interval = $this->get_interval();
+        $periodbounds = $this->get_period_boundaries($eventtime);
+        $periodstart = $periodbounds['start'];
+        $periodafter = $periodbounds['next'];
+        $bounds = [];
+        if ($until !== null) {
+            while ($periodstart->getTimestamp() < $until) {
+                $bounds[] = (object)[
+                    'start' => $periodstart->getTimestamp(),
+                    'next' => $periodafter->getTimestamp()
+                ];
+                $periodstart->add($interval);
+                $periodafter->add($interval);
+            }
+        } else {
+            $count = $this->count;
+            while ($count > 0) {
+                $bounds[] = (object)[
+                    'start' => $periodstart->getTimestamp(),
+                    'next' => $periodafter->getTimestamp()
+                ];
+                $periodstart->add($interval);
+                $periodafter->add($interval);
+                $count--;
+            }
+        }
+
+        return $bounds;
+    }
+
+    /**
+     * Determine whether the date-time in question is within the bounds of the periods that are covered by the RRULE.
+     *
+     * @param int $time The timestamp to be evaluated.
+     * @param array $bounds Array of period boundaries covered by the RRULE.
+     * @return bool
+     */
+    protected function in_bounds($time, $bounds) {
+        foreach ($bounds as $bound) {
+            if ($time >= $bound->start && $time < $bound->next) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Determines the start and end DateTime objects that serve as references to determine whether a calculated event timestamp
+     * falls on the period defined by these DateTimes objects.
+     *
+     * @param int $eventtime Unix timestamp of the event time.
+     * @return DateTime[]
+     * @throws moodle_exception
+     */
+    protected function get_period_boundaries($eventtime) {
+        $nextintervalspec = null;
+
+        switch ($this->freq) {
+            case self::FREQ_YEARLY:
+                $nextintervalspec = 'P1Y';
+                $timestart = date('Y-01-01', $eventtime);
+                break;
+            case self::FREQ_MONTHLY:
+                $nextintervalspec = 'P1M';
+                $timestart = date('Y-m-01', $eventtime);
+                break;
+            case self::FREQ_WEEKLY:
+                $nextintervalspec = 'P1W';
+                if (date('l', $eventtime) === $this->wkst) {
+                    $weekstarttime = $eventtime;
+                } else {
+                    $weekstarttime = strtotime('last ' . $this->wkst, $eventtime);
+                }
+                $timestart = date('Y-m-d', $weekstarttime);
+                break;
+            case self::FREQ_DAILY:
+                $nextintervalspec = 'P1D';
+                $timestart = date('Y-m-d', $eventtime);
+                break;
+            case self::FREQ_HOURLY:
+                $nextintervalspec = 'PT1H';
+                $timestart = date('Y-m-d H:00:00', $eventtime);
+                break;
+            case self::FREQ_MINUTELY:
+                $nextintervalspec = 'PT1M';
+                $timestart = date('Y-m-d H:i:00', $eventtime);
+                break;
+            case self::FREQ_SECONDLY:
+                $nextintervalspec = 'PT1S';
+                $timestart = date('Y-m-d H:i:s', $eventtime);
+                break;
+            default:
+                // We should never get here, something is very wrong.
+                throw new moodle_exception('errorrrulefreq', 'calendar');
+        }
+
+        $eventstart = new DateTime($timestart);
+        $eventnext = clone($eventstart);
+        $nextinterval = new DateInterval($nextintervalspec);
+        $eventnext->add($nextinterval);
+
+        return [
+            'start' => $eventstart,
+            'next' => $eventnext,
+        ];
     }
-}
\ No newline at end of file
+}
index bd8c098..66c6a6f 100644 (file)
@@ -125,6 +125,13 @@ if ($eventid !== 0) {
     if (!calendar_add_event_allowed($event)) {
         print_error('nopermissions');
     }
+
+    // Check to see if this event is part of a subscription or import.
+    // If so display a warning on edit.
+    if (isset($event->subscriptionid) && ($event->subscriptionid != null)) {
+        \core\notification::add(get_string('eventsubscriptioneditwarning', 'calendar'), \core\output\notification::NOTIFY_INFO);
+    }
+
 } else {
     $title = get_string('newevent', 'calendar');
     calendar_get_allowed_types($formoptions->eventtypes, $course);
index 0ef3d9e..018ffd7 100644 (file)
@@ -1667,9 +1667,12 @@ function calendar_edit_event_allowed($event) {
         return false;
     }
 
-    // You cannot edit calendar subscription events presently.
-    if (!empty($event->subscriptionid)) {
-        return false;
+    // You cannot edit URL based calendar subscription events presently.
+    if (isset($event->subscriptionid)) {
+        if (!empty($event->subscription->url)) {
+            // This event can be updated externally, so it cannot be edited.
+            return false;
+        }
     }
 
     $sitecontext = context_system::instance();
index d485235..2d4ecab 100644 (file)
@@ -110,4 +110,17 @@ class behat_calendar extends behat_base {
         $todaysday = ltrim($todaysday, '0');
         return $this->i_hover_over_day_of_this_month_in_calendar($todaysday);
     }
+
+    /**
+     * Navigate to a specific date in the calendar.
+     *
+     * @Given /^I view the calendar for "(?P<month>\d+)" "(?P<year>\d+)"$/
+     * @param int $month the month selected as a number
+     * @param int $year the four digit year
+     */
+    public function i_view_the_calendar_for($month, $year) {
+        $time = make_timestamp($year, $month, 1);
+        $this->getSession()->visit($this->locate_path('/calendar/view.php?view=month&course=1&time='.$time));
+
+    }
 }
diff --git a/calendar/tests/behat/calendar_import.feature b/calendar/tests/behat/calendar_import.feature
new file mode 100644 (file)
index 0000000..43ac339
--- /dev/null
@@ -0,0 +1,49 @@
+@core @core_calendar @_file_upload @javascript
+Feature: Import and edit calendar events
+  In order to manipulate imported calendar events
+  As an user
+  I need to import calendar events then edit them.
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | teacher | 1 | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | format |
+      | Course 1 | C1 | topics |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+
+  Scenario: Import then edit a calendar event.
+    Given I log in as "teacher1"
+    And I view the calendar for "1" "2016"
+    And I press "Manage subscriptions"
+    And I set the following fields to these values:
+      | Calendar name  | Test Import |
+      | Import from    | Calendar file (.ics) |
+      | Type of event  | User events |
+    And I upload "calendar/tests/fixtures/import.ics" file to "Calendar file (.ics)" filemanager
+    And I press "Add"
+    And I should see "Events imported: 2"
+    And I view the calendar for "2" "2017"
+    And I should see "February 2017"
+    And I should see "Event on 2-15-2017"
+    And I should see "Event on 2-25-2017"
+    And I follow "Event on 2-15-2017"
+    And I should see "Event source: Test Import"
+    And I follow "Edit event"
+    And I set the following fields to these values:
+      | Event title    | Event on 2-20-2017 |
+      | Description    | Event on 2-20-2017 |
+      | timestart[day] | 20 |
+    And I press "Save changes"
+    When I view the calendar for "2" "2017"
+    Then I should see "Event on 2-20-2017"
+    And I should see "Event on 2-25-2017"
+    And I should not see "Event on 2-15-2017"
+    And I press "Manage subscriptions"
+    And I press "Remove"
+    And I view the calendar for "2" "2017"
+    And I should not see "Event on 2-25-2017"
+    And I should not see "Event on 2-20-2017"
diff --git a/calendar/tests/fixtures/import.ics b/calendar/tests/fixtures/import.ics
new file mode 100644 (file)
index 0000000..d414e63
--- /dev/null
@@ -0,0 +1,25 @@
+BEGIN:VCALENDAR
+METHOD:PUBLISH
+PRODID:-//John Papaioannou/NONSGML Bennu 0.1//EN
+VERSION:2.0
+BEGIN:VEVENT
+UID:198001@127.0.0.1:8888/moodle32
+SUMMARY:Event on 2-15-2017
+DESCRIPTION:Event on 2-15-2017
+CLASS:PUBLIC
+LAST-MODIFIED:20170226T014326Z
+DTSTAMP:20170226T014355Z
+DTSTART;VALUE=DATE:20170214
+DTEND;VALUE=DATE:20170215
+END:VEVENT
+BEGIN:VEVENT
+UID:198000@127.0.0.1:8888/moodle32
+SUMMARY:Event on 2-25-2017
+DESCRIPTION:Event on 2-25-2017
+CLASS:PUBLIC
+LAST-MODIFIED:20170226T014258Z
+DTSTAMP:20170226T014355Z
+DTSTART;VALUE=DATE:20170224
+DTEND;VALUE=DATE:20170225
+END:VEVENT
+END:VCALENDAR
diff --git a/calendar/tests/rrule_manager_test.php b/calendar/tests/rrule_manager_test.php
new file mode 100644 (file)
index 0000000..2b0f315
--- /dev/null
@@ -0,0 +1,2716 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Defines test class to test manage rrule during ical imports.
+ *
+ * @package core_calendar
+ * @category test
+ * @copyright 2014 onwards Ankit Agarwal <ankit.agrr@gmail.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/calendar/lib.php');
+
+use core_calendar\rrule_manager;
+
+/**
+ * Defines test class to test manage rrule during ical imports.
+ *
+ * @package core_calendar
+ * @category test
+ * @copyright 2014 onwards Ankit Agarwal <ankit.agrr@gmail.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_calendar_rrule_manager_testcase extends advanced_testcase {
+
+    /** @var calendar_event a dummy event */
+    protected $event;
+
+    /**
+     * Set up method.
+     */
+    protected function setUp() {
+        global $DB;
+        $this->resetAfterTest();
+
+        // Set our timezone based on the timezone in the RFC's samples (US/Eastern).
+        $tz = 'US/Eastern';
+        $this->setTimezone($tz);
+        $timezone = new DateTimeZone($tz);
+        // Create our event's DTSTART date based on RFC's samples (most commonly used in RFC is 1997-09-02 09:00:00 EDT).
+        $time = DateTime::createFromFormat('Ymd\THis', '19970902T090000', $timezone);
+        $timestart = $time->getTimestamp();
+
+        $user = $this->getDataGenerator()->create_user();
+        $sub = new stdClass();
+        $sub->url = '';
+        $sub->courseid = 0;
+        $sub->groupid = 0;
+        $sub->userid = $user->id;
+        $sub->pollinterval = 0;
+        $subid = $DB->insert_record('event_subscriptions', $sub, true);
+
+        $event = new stdClass();
+        $event->name = 'Event name';
+        $event->description = '';
+        $event->timestart = $timestart;
+        $event->timeduration = 3600;
+        $event->uuid = 'uuid';
+        $event->subscriptionid = $subid;
+        $event->userid = $user->id;
+        $event->groupid = 0;
+        $event->courseid = 0;
+        $event->eventtype = 'user';
+        $eventobj = calendar_event::create($event, false);
+        $DB->set_field('event', 'repeatid', $eventobj->id, array('id' => $eventobj->id));
+        $eventobj->repeatid = $eventobj->id;
+        $this->event = $eventobj;
+    }
+
+    /**
+     * Test parse_rrule() method.
+     */
+    public function test_parse_rrule() {
+        $rules = [
+            'FREQ=YEARLY',
+            'COUNT=3',
+            'INTERVAL=4',
+            'BYSECOND=20,40',
+            'BYMINUTE=2,30',
+            'BYHOUR=3,4',
+            'BYDAY=MO,TH',
+            'BYMONTHDAY=20,30',
+            'BYYEARDAY=300,-20',
+            'BYWEEKNO=22,33',
+            'BYMONTH=3,4'
+        ];
+        $rrule = implode(';', $rules);
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+
+        $bydayrules = [
+            (object)[
+                'day' => 'MO',
+                'value' => 0
+            ],
+            (object)[
+                'day' => 'TH',
+                'value' => 0
+            ],
+        ];
+
+        $props = [
+            'freq' => rrule_manager::FREQ_YEARLY,
+            'count' => 3,
+            'interval' => 4,
+            'bysecond' => [20, 40],
+            'byminute' => [2, 30],
+            'byhour' => [3, 4],
+            'byday' => $bydayrules,
+            'bymonthday' => [20, 30],
+            'byyearday' => [300, -20],
+            'byweekno' => [22, 33],
+            'bymonth' => [3, 4],
+        ];
+
+        $reflectionclass = new ReflectionClass($mang);
+        foreach ($props as $prop => $expectedval) {
+            $rcprop = $reflectionclass->getProperty($prop);
+            $rcprop->setAccessible(true);
+            $this->assertEquals($expectedval, $rcprop->getValue($mang));
+        }
+    }
+
+    /**
+     * Test exception is thrown for invalid property.
+     */
+    public function test_parse_rrule_validation() {
+        $rrule = "RANDOM=PROPERTY;";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test exception is thrown for invalid frequency.
+     */
+    public function test_freq_validation() {
+        $rrule = "FREQ=RANDOMLY;";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of rules with both COUNT and UNTIL parameters.
+     */
+    public function test_until_count_validation() {
+        $until = $this->event->timestart + DAYSECS * 4;
+        $until = date('Y-m-d', $until);
+        $rrule = "FREQ=DAILY;COUNT=2;UNTIL=$until";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of INTERVAL rule.
+     */
+    public function test_interval_validation() {
+        $rrule = "INTERVAL=0";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYSECOND rule.
+     */
+    public function test_bysecond_validation() {
+        $rrule = "BYSECOND=30,45,60";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYMINUTE rule.
+     */
+    public function test_byminute_validation() {
+        $rrule = "BYMINUTE=30,45,60";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYMINUTE rule.
+     */
+    public function test_byhour_validation() {
+        $rrule = "BYHOUR=23,45";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYDAY rule.
+     */
+    public function test_byday_validation() {
+        $rrule = "BYDAY=MO,2SE";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYDAY rule with prefixes.
+     */
+    public function test_byday_with_prefix_validation() {
+        // This is acceptable.
+        $rrule = "FREQ=MONTHLY;BYDAY=-1MO,2SA";
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+
+        // This is also acceptable.
+        $rrule = "FREQ=YEARLY;BYDAY=MO,2SA";
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+
+        // This is invalid.
+        $rrule = "FREQ=WEEKLY;BYDAY=MO,2SA";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYMONTHDAY rule.
+     */
+    public function test_bymonthday_upper_bound_validation() {
+        $rrule = "BYMONTHDAY=1,32";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYMONTHDAY rule.
+     */
+    public function test_bymonthday_0_validation() {
+        $rrule = "BYMONTHDAY=1,0";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYMONTHDAY rule.
+     */
+    public function test_bymonthday_lower_bound_validation() {
+        $rrule = "BYMONTHDAY=1,-31,-32";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYYEARDAY rule.
+     */
+    public function test_byyearday_upper_bound_validation() {
+        $rrule = "BYYEARDAY=1,366,367";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYYEARDAY rule.
+     */
+    public function test_byyearday_0_validation() {
+        $rrule = "BYYEARDAY=0";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYYEARDAY rule.
+     */
+    public function test_byyearday_lower_bound_validation() {
+        $rrule = "BYYEARDAY=-1,-366,-367";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYWEEKNO rule.
+     */
+    public function test_non_yearly_freq_with_byweekno() {
+        $rrule = "BYWEEKNO=1,53";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYWEEKNO rule.
+     */
+    public function test_byweekno_upper_bound_validation() {
+        $rrule = "FREQ=YEARLY;BYWEEKNO=1,53,54";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYWEEKNO rule.
+     */
+    public function test_byweekno_0_validation() {
+        $rrule = "FREQ=YEARLY;BYWEEKNO=0";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYWEEKNO rule.
+     */
+    public function test_byweekno_lower_bound_validation() {
+        $rrule = "FREQ=YEARLY;BYWEEKNO=-1,-53,-54";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYMONTH rule.
+     */
+    public function test_bymonth_upper_bound_validation() {
+        $rrule = "BYMONTH=1,12,13";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYMONTH rule.
+     */
+    public function test_bymonth_lower_bound_validation() {
+        $rrule = "BYMONTH=0";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYSETPOS rule.
+     */
+    public function test_bysetpos_without_other_byrules() {
+        $rrule = "BYSETPOS=1,366";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYSETPOS rule.
+     */
+    public function test_bysetpos_upper_bound_validation() {
+        $rrule = "BYSETPOS=1,366,367";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYSETPOS rule.
+     */
+    public function test_bysetpos_0_validation() {
+        $rrule = "BYSETPOS=0";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test parsing of BYSETPOS rule.
+     */
+    public function test_bysetpos_lower_bound_validation() {
+        $rrule = "BYSETPOS=-1,-366,-367";
+        $mang = new rrule_manager($rrule);
+        $this->expectException('moodle_exception');
+        $mang->parse_rrule();
+    }
+
+    /**
+     * Test recurrence rules for daily frequency.
+     */
+    public function test_daily_events() {
+        global $DB;
+
+        $rrule = 'FREQ=DAILY;COUNT=3'; // This should generate 2 child events + 1 parent.
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
+        $this->assertEquals(3, $count);
+        $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
+                'timestart' => ($this->event->timestart + DAYSECS)));
+        $this->assertTrue($result);
+        $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
+                'timestart' => ($this->event->timestart + 2 * DAYSECS)));
+        $this->assertTrue($result);
+
+        $until = $this->event->timestart + DAYSECS * 2;
+        $until = date('Y-m-d', $until);
+        $rrule = "FREQ=DAILY;UNTIL=$until"; // This should generate 1 child event + 1 parent,since by then until bound would be hit.
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
+        $this->assertEquals(2, $count);
+        $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
+                'timestart' => ($this->event->timestart + DAYSECS)));
+        $this->assertTrue($result);
+
+        $rrule = 'FREQ=DAILY;COUNT=3;INTERVAL=3'; // This should generate 2 child events + 1 parent, every 3rd day.
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
+        $this->assertEquals(3, $count);
+        $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
+                'timestart' => ($this->event->timestart + 3 * DAYSECS)));
+        $this->assertTrue($result);
+        $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
+                'timestart' => ($this->event->timestart + 6 * DAYSECS)));
+        $this->assertTrue($result);
+    }
+
+    /**
+     * Every 300 days, forever.
+     */
+    public function test_every_300_days_forever() {
+        global $DB;
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+
+        $interval = new DateInterval('P300D');
+        $untildate = new DateTime();
+        $untildate->add(new DateInterval('P10Y'));
+        $until = $untildate->getTimestamp();
+
+        // Forever event. This should generate events for time() + 10 year period, every 300 days.
+        $rrule = 'FREQ=DAILY;INTERVAL=300';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+        $records = $DB->get_records('event', array('repeatid' => $this->event->id), 'timestart ASC');
+
+        $expecteddate = clone($startdatetime);
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($until, $record->timestart);
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+            // Go to next iteration.
+            $expecteddate->add($interval);
+        }
+    }
+
+    /**
+     * Test recurrence rules for weekly frequency.
+     */
+    public function test_weekly_events() {
+        global $DB;
+
+        $rrule = 'FREQ=WEEKLY;COUNT=1';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
+        $this->assertEquals(1, $count);
+        for ($i = 0; $i < $count; $i++) {
+            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
+                    'timestart' => ($this->event->timestart + $i * DAYSECS)));
+            $this->assertTrue($result);
+        }
+        // This much seconds after the start of the day.
+        $offset = $this->event->timestart - mktime(0, 0, 0, date("n", $this->event->timestart), date("j", $this->event->timestart),
+                date("Y", $this->event->timestart));
+
+        // This should generate 4 weekly Monday events.
+        $until = $this->event->timestart + WEEKSECS * 4;
+        $until = date('Ymd\This\Z', $until);
+        $rrule = "FREQ=WEEKLY;BYDAY=MO;UNTIL=$until";
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
+        $this->assertEquals(4, $count);
+        $timestart = $this->event->timestart;
+        for ($i = 0; $i < $count; $i++) {
+            $timestart = strtotime("+$offset seconds next Monday", $timestart);
+            $result = $DB->record_exists('event', array('repeatid' => $this->event->id, 'timestart' => $timestart));
+            $this->assertTrue($result);
+        }
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $startdate = new DateTime(date('Y-m-d', $this->event->timestart));
+
+        $offsetinterval = $startdatetime->diff($startdate, true);
+        $interval = new DateInterval('P3W');
+
+        // Every 3 weeks on Monday, Wednesday for 2 times.
+        $rrule = 'FREQ=WEEKLY;INTERVAL=3;BYDAY=MO,WE;COUNT=2';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(2, $records);
+
+        $expecteddate = clone($startdate);
+        $expecteddate->modify('1997-09-03');
+        foreach ($records as $record) {
+            $expecteddate->add($offsetinterval);
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            if (date('D', $record->timestart) === 'Mon') {
+                // Go to the fifth day of this month.
+                $expecteddate->modify('next Wednesday');
+            } else {
+                // Reset to Monday.
+                $expecteddate->modify('last Monday');
+                // Go to next period.
+                $expecteddate->add($interval);
+            }
+        }
+
+        // Forever event. This should generate events over time() + 10 year period, every 50th Monday.
+        $rrule = 'FREQ=WEEKLY;BYDAY=MO;INTERVAL=50';
+
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $untildate = new DateTime();
+        $untildate->add(new DateInterval('P10Y'));
+        $until = $untildate->getTimestamp();
+
+        $interval = new DateInterval('P50W');
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        // First instance of this set of recurring events: Monday, 17-08-1998.
+        $expecteddate = clone($startdate);
+        $expecteddate->modify('1998-08-17');
+        $expecteddate->add($offsetinterval);
+        foreach ($records as $record) {
+            $eventdateexpected = $expecteddate->format('Y-m-d H:i:s');
+            $eventdateactual = date('Y-m-d H:i:s', $record->timestart);
+            $this->assertEquals($eventdateexpected, $eventdateactual);
+
+            $expecteddate->add($interval);
+            $this->assertLessThanOrEqual($until, $record->timestart);
+        }
+    }
+
+    /**
+     * Test recurrence rules for monthly frequency for RRULE with COUNT and BYMONTHDAY rules set.
+     */
+    public function test_monthly_events_with_count_bymonthday() {
+        global $DB;
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $interval = new DateInterval('P1M');
+
+        $rrule = "FREQ=MONTHLY;COUNT=3;BYMONTHDAY=2"; // This should generate 3 events in total.
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+        $records = $DB->get_records('event', array('repeatid' => $this->event->id), 'timestart ASC');
+        $this->assertCount(3, $records);
+
+        $expecteddate = clone($startdatetime);
+        foreach ($records as $record) {
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+            // Go to next month.
+            $expecteddate->add($interval);
+        }
+    }
+
+    /**
+     * Test recurrence rules for monthly frequency for RRULE with BYMONTHDAY and UNTIL rules set.
+     */
+    public function test_monthly_events_with_until_bymonthday() {
+        global $DB;
+
+        // This should generate 10 child event + 1 parent, since by then until bound would be hit.
+        $until = strtotime('+1 day +10 months', $this->event->timestart);
+        $until = date('Ymd\This\Z', $until);
+        $rrule = "FREQ=MONTHLY;BYMONTHDAY=2;UNTIL=$until";
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+        $count = $DB->count_records('event', ['repeatid' => $this->event->id]);
+        $this->assertEquals(11, $count);
+        for ($i = 0; $i < 11; $i++) {
+            $time = strtotime("+$i month", $this->event->timestart);
+            $result = $DB->record_exists('event', ['repeatid' => $this->event->id, 'timestart' => $time]);
+            $this->assertTrue($result);
+        }
+    }
+
+    /**
+     * Test recurrence rules for monthly frequency for RRULE with BYMONTHDAY and UNTIL rules set.
+     */
+    public function test_monthly_events_with_until_bymonthday_multi() {
+        global $DB;
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $startdate = new DateTime(date('Y-m-d', $this->event->timestart));
+        $offsetinterval = $startdatetime->diff($startdate, true);
+        $interval = new DateInterval('P2M');
+        $untildate = clone($startdatetime);
+        $untildate->add(new DateInterval('P10M10D'));
+        $until = $untildate->format('Ymd\This\Z');
+
+        // This should generate 11 child event + 1 parent, since by then until bound would be hit.
+        $rrule = "FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=2,5;UNTIL=$until";
+
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(12, $records);
+
+        $expecteddate = clone($startdate);
+        $expecteddate->add($offsetinterval);
+        foreach ($records as $record) {
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            if (date('j', $record->timestart) == 2) {
+                // Go to the fifth day of this month.
+                $expecteddate->add(new DateInterval('P3D'));
+            } else {
+                // Reset date to the first day of the month.
+                $expecteddate->modify('first day of this month');
+                // Go to next month period.
+                $expecteddate->add($interval);
+                // Go to the second day of the next month period.
+                $expecteddate->modify('+1 day');
+            }
+        }
+    }
+
+    /**
+     * Test recurrence rules for monthly frequency for RRULE with BYMONTHDAY forever.
+     */
+    public function test_monthly_events_with_bymonthday_forever() {
+        global $DB;
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $startdate = new DateTime(date('Y-m-d', $this->event->timestart));
+
+        $offsetinterval = $startdatetime->diff($startdate, true);
+        $interval = new DateInterval('P12M');
+
+        // Forever event. This should generate events over 10 year period, on 2nd day of every 12th month.
+        $rrule = "FREQ=MONTHLY;INTERVAL=12;BYMONTHDAY=2";
+
+        $mang = new rrule_manager($rrule);
+        $until = time() + (YEARSECS * $mang::TIME_UNLIMITED_YEARS);
+
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        $expecteddate = clone($startdate);
+        $expecteddate->add($offsetinterval);
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($until, $record->timestart);
+
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Reset date to the first day of the month.
+            $expecteddate->modify('first day of this month');
+            // Go to next month period.
+            $expecteddate->add($interval);
+            // Go to the second day of the next month period.
+            $expecteddate->modify('+1 day');
+        }
+    }
+
+    /**
+     * Test recurrence rules for monthly frequency for RRULE with COUNT and BYDAY rules set.
+     */
+    public function test_monthly_events_with_count_byday() {
+        global $DB;
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $startdate = new DateTime(date('Y-m-d', $this->event->timestart));
+
+        $offsetinterval = $startdatetime->diff($startdate, true);
+        $interval = new DateInterval('P1M');
+
+        $rrule = 'FREQ=MONTHLY;COUNT=3;BYDAY=1MO'; // This should generate 3 events in total, first monday of the month.
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        // First occurrence of this set of recurring events: 06-10-1997.
+        $expecteddate = clone($startdate);
+        $expecteddate->modify('1997-10-06');
+        $expecteddate->add($offsetinterval);
+        foreach ($records as $record) {
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Go to next month period.
+            $expecteddate->add($interval);
+            $expecteddate->modify('first Monday of this month');
+            $expecteddate->add($offsetinterval);
+        }
+    }
+
+    /**
+     * Test recurrence rules for monthly frequency for RRULE with BYDAY and UNTIL rules set.
+     */
+    public function test_monthly_events_with_until_byday() {
+        global $DB;
+
+        // This much seconds after the start of the day.
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $startdate = new DateTime(date('Y-m-d', $this->event->timestart));
+        $offsetinterval = $startdatetime->diff($startdate, true);
+
+        $untildate = clone($startdatetime);
+        $untildate->add(new DateInterval('P10M1D'));
+        $until = $untildate->format('Ymd\This\Z');
+
+        // This rule should generate 9 events in total from first Monday of October 1997 to first Monday of June 1998.
+        $rrule = "FREQ=MONTHLY;BYDAY=1MO;UNTIL=$until";
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(9, $records);
+
+        $expecteddate = clone($startdate);
+        $expecteddate->modify('first Monday of October 1997');
+        foreach ($records as $record) {
+            $expecteddate->add($offsetinterval);
+
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Go to next month.
+            $expecteddate->modify('first day of next month');
+            // Go to the first Monday of the next month.
+            $expecteddate->modify('first Monday of this month');
+        }
+    }
+
+    /**
+     * Test recurrence rules for monthly frequency for RRULE with BYMONTHDAY and UNTIL rules set.
+     */
+    public function test_monthly_events_with_until_byday_multi() {
+        global $DB;
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $startdate = new DateTime(date('Y-m-d', $this->event->timestart));
+
+        $offsetinterval = $startdatetime->diff($startdate, true);
+        $interval = new DateInterval('P2M');
+
+        $untildate = clone($startdatetime);
+        $untildate->add(new DateInterval('P10M20D'));
+        $until = $untildate->format('Ymd\This\Z');
+
+        // This should generate 11 events from 17 Sep 1997 to 15 Jul 1998.
+        $rrule = "FREQ=MONTHLY;INTERVAL=2;BYDAY=1MO,3WE;UNTIL=$until";
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(11, $records);
+
+        $expecteddate = clone($startdate);
+        $expecteddate->modify('1997-09-17');
+        foreach ($records as $record) {
+            $expecteddate->add($offsetinterval);
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            if (date('D', $record->timestart) === 'Mon') {
+                // Go to the fifth day of this month.
+                $expecteddate->modify('third Wednesday of this month');
+            } else {
+                // Go to next month period.
+                $expecteddate->add($interval);
+                $expecteddate->modify('first Monday of this month');
+            }
+        }
+    }
+
+    /**
+     * Test recurrence rules for monthly frequency for RRULE with BYDAY forever.
+     */
+    public function test_monthly_events_with_byday_forever() {
+        global $DB;
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $startdate = new DateTime(date('Y-m-d', $this->event->timestart));
+
+        $offsetinterval = $startdatetime->diff($startdate, true);
+        $interval = new DateInterval('P12M');
+
+        // Forever event. This should generate events over 10 year period, on 2nd day of every 12th month.
+        $rrule = "FREQ=MONTHLY;INTERVAL=12;BYDAY=1MO";
+
+        $mang = new rrule_manager($rrule);
+        $until = time() + (YEARSECS * $mang::TIME_UNLIMITED_YEARS);
+
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        $expecteddate = new DateTime('first Monday of September 1998');
+        foreach ($records as $record) {
+            $expecteddate->add($offsetinterval);
+            $this->assertLessThanOrEqual($until, $record->timestart);
+
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Go to next month period.
+            $expecteddate->add($interval);
+            // Reset date to the first Monday of the month.
+            $expecteddate->modify('first Monday of this month');
+        }
+    }
+
+    /**
+     * Test recurrence rules for yearly frequency.
+     */
+    public function test_yearly_events() {
+        global $DB;
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $startdate = new DateTime(date('Y-m-d', $this->event->timestart));
+
+        $offsetinterval = $startdatetime->diff($startdate, true);
+        $interval = new DateInterval('P1Y');
+
+        $rrule = "FREQ=YEARLY;COUNT=3;BYMONTH=9"; // This should generate 3 events in total.
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(3, $records);
+
+        $expecteddate = clone($startdatetime);
+        foreach ($records as $record) {
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Go to next period.
+            $expecteddate->add($interval);
+        }
+
+        // Create a yearly event, until the time limit is hit.
+        $until = strtotime('+20 day +10 years', $this->event->timestart);
+        $until = date('Ymd\THis\Z', $until);
+        $rrule = "FREQ=YEARLY;BYMONTH=9;UNTIL=$until"; // Forever event.
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
+        $this->assertEquals(11, $count);
+        for ($i = 0, $time = $this->event->timestart; $time < $until; $i++, $yoffset = $i * 2,
+            $time = strtotime("+$yoffset years", $this->event->timestart)) {
+            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
+                    'timestart' => ($time)));
+            $this->assertTrue($result);
+        }
+
+        // This should generate 5 events in total, every second year in the given month of the event.
+        $rrule = "FREQ=YEARLY;BYMONTH=9;INTERVAL=2;COUNT=5";
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
+        $this->assertEquals(5, $count);
+        for ($i = 0, $time = $this->event->timestart; $i < 5; $i++, $yoffset = $i * 2,
+            $time = strtotime("+$yoffset years", $this->event->timestart)) {
+            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
+                    'timestart' => ($time)));
+            $this->assertTrue($result);
+        }
+
+        $rrule = "FREQ=YEARLY;BYMONTH=9;INTERVAL=2"; // Forever event.
+        $mang = new rrule_manager($rrule);
+        $until = time() + (YEARSECS * $mang::TIME_UNLIMITED_YEARS);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+        for ($i = 0, $time = $this->event->timestart; $time < $until; $i++, $yoffset = $i * 2,
+            $time = strtotime("+$yoffset years", $this->event->timestart)) {
+            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
+                    'timestart' => ($time)));
+            $this->assertTrue($result);
+        }
+
+        $rrule = "FREQ=YEARLY;COUNT=3;BYMONTH=9;BYDAY=1MO"; // This should generate 3 events in total.
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(3, $records);
+
+        $expecteddate = clone($startdatetime);
+        $expecteddate->modify('first Monday of September 1998');
+        $expecteddate->add($offsetinterval);
+        foreach ($records as $record) {
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Go to next period.
+            $expecteddate->add($interval);
+            $monthyear = $expecteddate->format('F Y');
+            $expecteddate->modify('first Monday of ' . $monthyear);
+            $expecteddate->add($offsetinterval);
+        }
+
+        // Create a yearly event on the specified month, until the time limit is hit.
+        $untildate = clone($startdatetime);
+        $untildate->add(new DateInterval('P10Y20D'));
+        $until = $untildate->format('Ymd\THis\Z');
+
+        $rrule = "FREQ=YEARLY;BYMONTH=9;UNTIL=$until;BYDAY=1MO";
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        // 10 yearly records every first Monday of September 1998 to first Monday of September 2007.
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(10, $records);
+
+        $expecteddate = clone($startdatetime);
+        $expecteddate->modify('first Monday of September 1998');
+        $expecteddate->add($offsetinterval);
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untildate->getTimestamp(), $record->timestart);
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Go to next period.
+            $expecteddate->add($interval);
+            $monthyear = $expecteddate->format('F Y');
+            $expecteddate->modify('first Monday of ' . $monthyear);
+            $expecteddate->add($offsetinterval);
+        }
+
+        // This should generate 5 events in total, every second year in the month of September.
+        $rrule = "FREQ=YEARLY;BYMONTH=9;INTERVAL=2;COUNT=5;BYDAY=1MO";
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        // 5 bi-yearly records every first Monday of September 1998 to first Monday of September 2007.
+        $interval = new DateInterval('P2Y');
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(5, $records);
+
+        $expecteddate = clone($startdatetime);
+        $expecteddate->modify('first Monday of September 1999');
+        $expecteddate->add($offsetinterval);
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untildate->getTimestamp(), $record->timestart);
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Go to next period.
+            $expecteddate->add($interval);
+            $monthyear = $expecteddate->format('F Y');
+            $expecteddate->modify('first Monday of ' . $monthyear);
+            $expecteddate->add($offsetinterval);
+        }
+    }
+
+    /**
+     * Test for rrule with FREQ=YEARLY with BYMONTH and BYDAY rules set, recurring forever.
+     */
+    public function test_yearly_bymonth_byday_forever() {
+        global $DB;
+
+        // Every 2 years on the first Monday of September.
+        $rrule = "FREQ=YEARLY;BYMONTH=9;INTERVAL=2;BYDAY=1MO";
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        $untildate = new DateTime();
+        $untildate->add(new DateInterval('P' . $mang::TIME_UNLIMITED_YEARS . 'Y'));
+        $untiltimestamp = $untildate->getTimestamp();
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $startdate = new DateTime(date('Y-m-d', $this->event->timestart));
+
+        $offsetinterval = $startdatetime->diff($startdate, true);
+        $interval = new DateInterval('P2Y');
+
+        // First occurrence of this set of events is on the first Monday of September 1999.
+        $expecteddate = clone($startdatetime);
+        $expecteddate->modify('first Monday of September 1999');
+        $expecteddate->add($offsetinterval);
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Go to next period.
+            $expecteddate->add($interval);
+            $monthyear = $expecteddate->format('F Y');
+            $expecteddate->modify('first Monday of ' . $monthyear);
+            $expecteddate->add($offsetinterval);
+        }
+    }
+
+    /**
+     * Test for rrule with FREQ=YEARLY recurring forever.
+     */
+    public function test_yearly_forever() {
+        global $DB;
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+
+        $interval = new DateInterval('P2Y');
+
+        $rrule = 'FREQ=YEARLY;INTERVAL=2'; // Forever event.
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        $untildate = new DateTime();
+        $untildate->add(new DateInterval('P' . $mang::TIME_UNLIMITED_YEARS . 'Y'));
+        $untiltimestamp = $untildate->getTimestamp();
+
+        $expecteddate = clone($startdatetime);
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Go to next period.
+            $expecteddate->add($interval);
+        }
+    }
+
+    /******************************************************************************************************************************/
+    /* Tests based on the examples from the RFC.                                                                                  */
+    /******************************************************************************************************************************/
+
+    /**
+     * Daily for 10 occurrences:
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=DAILY;COUNT=10
+     *   ==> (1997 9:00 AM EDT)September 2-11
+     */
+    public function test_daily_count() {
+        global $DB;
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $interval = new DateInterval('P1D');
+
+        $rrule = 'FREQ=DAILY;COUNT=10';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(10, $records);
+
+        $expecteddate = new DateTime(date('Y-m-d H:i:s', $startdatetime->getTimestamp()));
+        foreach ($records as $record) {
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Go to next period.
+            $expecteddate->add($interval);
+        }
+    }
+
+    /**
+     * Daily until December 24, 1997:
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=DAILY;UNTIL=19971224T000000Z
+     *   ==> (1997 9:00 AM EDT)September 2-30;October 1-25
+     *       (1997 9:00 AM EST)October 26-31;November 1-30;December 1-23
+     */
+    public function test_daily_until() {
+        global $DB;
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $interval = new DateInterval('P1D');
+
+        $untildate = new DateTime('19971224T000000Z');
+        $untiltimestamp = $untildate->getTimestamp();
+
+        $rrule = 'FREQ=DAILY;UNTIL=19971224T000000Z';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // 113 daily events from 02-09-1997 to 23-12-1997.
+        $this->assertCount(113, $records);
+
+        $expecteddate = new DateTime(date('Y-m-d H:i:s', $startdatetime->getTimestamp()));
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+            // Go to next period.
+            $expecteddate->add($interval);
+        }
+    }
+
+    /**
+     * Every other day - forever:
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=DAILY;INTERVAL=2
+     *   ==> (1997 9:00 AM EDT)September2,4,6,8...24,26,28,30;October 2,4,6...20,22,24
+     *       (1997 9:00 AM EST)October 26,28,30;November 1,3,5,7...25,27,29;Dec 1,3,...
+     */
+    public function test_every_other_day_forever() {
+        global $DB;
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $interval = new DateInterval('P2D');
+
+        $rrule = 'FREQ=DAILY;INTERVAL=2';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        $untildate = new DateTime();
+        $untildate->add(new DateInterval('P' . $mang::TIME_UNLIMITED_YEARS . 'Y'));
+        $untiltimestamp = $untildate->getTimestamp();
+
+        $expecteddate = new DateTime(date('Y-m-d H:i:s', $startdatetime->getTimestamp()));
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+            // Go to next period.
+            $expecteddate->add($interval);
+        }
+    }
+
+    /**
+     * Every 10 days, 5 occurrences:
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=DAILY;INTERVAL=10;COUNT=5
+     *   ==> (1997 9:00 AM EDT)September 2,12,22;October 2,12
+     */
+    public function test_every_10_days_5_count() {
+        global $DB;
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $interval = new DateInterval('P10D');
+
+        $rrule = 'FREQ=DAILY;INTERVAL=10;COUNT=5';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(5, $records);
+
+        $expecteddate = new DateTime(date('Y-m-d H:i:s', $startdatetime->getTimestamp()));
+        foreach ($records as $record) {
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+            // Go to next period.
+            $expecteddate->add($interval);
+        }
+    }
+
+    /**
+     * Everyday in January, for 3 years:
+     *
+     * DTSTART;TZID=US-Eastern:19980101T090000
+     * RRULE:FREQ=YEARLY;UNTIL=20000131T090000Z;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA
+     *   ==> (1998 9:00 AM EDT)January 1-31
+     *       (1999 9:00 AM EDT)January 1-31
+     *       (2000 9:00 AM EDT)January 1-31
+     */
+    public function test_everyday_in_jan_for_3_years_yearly() {
+        global $DB;
+
+        // Change our event's date to 01-01-1998, based on the example from the RFC.
+        $this->change_event_startdate('19980101T090000', 'US/Eastern');
+
+        $rrule = 'FREQ=YEARLY;UNTIL=20000131T090000Z;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // 92 events from 01-01-1998 to 03-01-2000.
+        $this->assertCount(92, $records);
+
+        $untildate = new DateTime('20000131T090000Z');
+        $untiltimestamp = $untildate->getTimestamp();
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+
+            // Assert that the event's date is in January.
+            $this->assertEquals('January', date('F', $record->timestart));
+        }
+    }
+
+    /**
+     * Everyday in January, for 3 years:
+     *
+     * DTSTART;TZID=US-Eastern:19980101T090000
+     * RRULE:FREQ=DAILY;UNTIL=20000131T090000Z;BYMONTH=1
+     *   ==> (1998 9:00 AM EDT)January 1-31
+     *       (1999 9:00 AM EDT)January 1-31
+     *       (2000 9:00 AM EDT)January 1-31
+     */
+    public function test_everyday_in_jan_for_3_years_daily() {
+        global $DB;
+
+        // Change our event's date to 01-01-1998, based on the example from the RFC.
+        $this->change_event_startdate('19980101T090000', 'US/Eastern');
+
+        $rrule = 'FREQ=DAILY;UNTIL=20000131T090000Z;BYMONTH=1';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // 92 events from 01-01-1998 to 03-01-2000.
+        $this->assertCount(92, $records);
+
+        $untildate = new DateTime('20000131T090000Z');
+        $untiltimestamp = $untildate->getTimestamp();
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+
+            // Assert that the event's date is in January.
+            $this->assertEquals('January', date('F', $record->timestart));
+        }
+    }
+
+    /**
+     * Weekly for 10 occurrences
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=WEEKLY;COUNT=10
+     *   ==> (1997 9:00 AM EDT)September 2,9,16,23,30;October 7,14,21
+     *       (1997 9:00 AM EST)October 28;November 4
+     */
+    public function test_weekly_10_count() {
+        global $DB;
+
+        $interval = new DateInterval('P1W');
+
+        $rrule = 'FREQ=WEEKLY;COUNT=10';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(10, $records);
+
+        $expecteddate = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        foreach ($records as $record) {
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+            // Go to next period.
+            $expecteddate->add($interval);
+        }
+    }
+
+    /**
+     * Weekly until December 24, 1997.
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=WEEKLY;UNTIL=19971224T000000Z
+     *   ==> (1997 9:00 AM EDT)September 2,9,16,23,30;October 7,14,21,28
+     *       (1997 9:00 AM EST)November 4,11,18,25;December 2,9,16,23
+     */
+    public function test_weekly_until_24_dec_1997() {
+        global $DB;
+
+        $interval = new DateInterval('P1W');
+
+        $rrule = 'FREQ=WEEKLY;UNTIL=19971224T000000Z';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // 17 iterations from 02-09-1997 13:00 UTC to 23-12-1997 13:00 UTC.
+        $this->assertCount(17, $records);
+
+        $untildate = new DateTime('19971224T000000Z');
+        $untiltimestamp = $untildate->getTimestamp();
+        $expecteddate = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+            // Go to next period.
+            $expecteddate->add($interval);
+        }
+    }
+
+    /**
+     * Every other week - forever:
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU
+     *   ==> (1997 9:00 AM EDT)September 2,16,30;October 14
+     *       (1997 9:00 AM EST)October 28;November 11,25;December 9,23
+     *       (1998 9:00 AM EST)January 6,20;February
+     *        ...
+     */
+    public function test_every_other_week_forever() {
+        global $DB;
+
+        $interval = new DateInterval('P2W');
+
+        $rrule = 'FREQ=WEEKLY;INTERVAL=2;WKST=SU';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        $untildate = new DateTime();
+        $untildate->add(new DateInterval('P' . $mang::TIME_UNLIMITED_YEARS . 'Y'));
+        $untiltimestamp = $untildate->getTimestamp();
+
+        $expecteddate = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+            // Go to next period.
+            $expecteddate->add($interval);
+        }
+    }
+
+    /**
+     * Weekly on Tuesday and Thursday for 5 weeks:
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=WEEKLY;UNTIL=19971007T000000Z;WKST=SU;BYDAY=TU,TH
+     *   ==> (1997 9:00 AM EDT)September 2,4,9,11,16,18,23,25,30;October 2
+     */
+    public function test_weekly_on_tue_thu_for_5_weeks_by_until() {
+        global $DB;
+
+        $rrule = 'FREQ=WEEKLY;UNTIL=19971007T000000Z;WKST=SU;BYDAY=TU,TH';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // 17 iterations from 02-09-1997 13:00 UTC to 23-12-1997 13:00 UTC.
+        $this->assertCount(10, $records);
+
+        $untildate = new DateTime('19971007T000000Z');
+        $untiltimestamp = $untildate->getTimestamp();
+        $expecteddate = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $startdate = new DateTime($expecteddate->format('Y-m-d'));
+        $offset = $expecteddate->diff($startdate, true);
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+            // Go to next period.
+            if ($expecteddate->format('l') === rrule_manager::DAY_TUESDAY) {
+                $expecteddate->modify('next Thursday');
+            } else {
+                $expecteddate->modify('next Tuesday');
+            }
+            $expecteddate->add($offset);
+        }
+    }
+
+    /**
+     * Weekly on Tuesday and Thursday for 5 weeks:
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=WEEKLY;COUNT=10;WKST=SU;BYDAY=TU,TH
+     *   ==> (1997 9:00 AM EDT)September 2,4,9,11,16,18,23,25,30;October 2
+     */
+    public function test_weekly_on_tue_thu_for_5_weeks_by_count() {
+        global $DB;
+
+        $rrule = 'FREQ=WEEKLY;COUNT=10;WKST=SU;BYDAY=TU,TH';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // 17 iterations from 02-09-1997 13:00 UTC to 23-12-1997 13:00 UTC.
+        $this->assertCount(10, $records);
+
+        $expecteddate = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $startdate = new DateTime($expecteddate->format('Y-m-d'));
+        $offset = $expecteddate->diff($startdate, true);
+        foreach ($records as $record) {
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+            // Go to next period.
+            if ($expecteddate->format('l') === rrule_manager::DAY_TUESDAY) {
+                $expecteddate->modify('next Thursday');
+            } else {
+                $expecteddate->modify('next Tuesday');
+            }
+            $expecteddate->add($offset);
+        }
+    }
+
+    /**
+     * Every other week on Monday, Wednesday and Friday until December 24, 1997, but starting on Tuesday, September 2, 1997:
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=19971224T000000Z;WKST=SU;BYDAY=MO,WE,FR
+     *   ==> (1997 9:00 AM EDT)September 3,5,15,17,19,29;October 1,3,13,15,17
+     *       (1997 9:00 AM EST)October 27,29,31;November 10,12,14,24,26,28;December 8,10,12,22
+     */
+    public function test_every_other_week_until_24_dec_1997_byday() {
+        global $DB;
+
+        $rrule = 'FREQ=WEEKLY;INTERVAL=2;UNTIL=19971224T000000Z;WKST=SU;BYDAY=MO,WE,FR';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // 24 iterations every M-W-F from 03-09-1997 13:00 UTC to 22-12-1997 13:00 UTC.
+        $this->assertCount(24, $records);
+
+        $untildate = new DateTime('19971224T000000Z');
+        $untiltimestamp = $untildate->getTimestamp();
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $startdate = new DateTime(date('Y-m-d', $this->event->timestart));
+
+        $offsetinterval = $startdatetime->diff($startdate, true);
+
+        // First occurrence of this set of events is on 3 September 1999.
+        $expecteddate = clone($startdatetime);
+        $expecteddate->modify('next Wednesday');
+        $expecteddate->add($offsetinterval);
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Go to next period.
+            switch ($expecteddate->format('l')) {
+                case rrule_manager::DAY_MONDAY:
+                    $expecteddate->modify('next Wednesday');
+                    break;
+                case rrule_manager::DAY_WEDNESDAY:
+                    $expecteddate->modify('next Friday');
+                    break;
+                default:
+                    $expecteddate->modify('next Monday');
+                    // Increment expected date by 1 week if the next day is Monday.
+                    $expecteddate->add(new DateInterval('P1W'));
+                    break;
+            }
+            $expecteddate->add($offsetinterval);
+        }
+    }
+
+    /**
+     * Every other week on Tuesday and Thursday, for 8 occurrences:
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=8;WKST=SU;BYDAY=TU,TH
+     *   ==> (1997 9:00 AM EDT)September 2,4,16,18,30;October 2,14,16
+     */
+    public function test_every_other_week_byday_8_count() {
+        global $DB;
+
+        $rrule = 'FREQ=WEEKLY;INTERVAL=2;COUNT=8;WKST=SU;BYDAY=TU,TH';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // Should correspond to COUNT rule.
+        $this->assertCount(8, $records);
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $startdate = new DateTime(date('Y-m-d', $this->event->timestart));
+
+        $offsetinterval = $startdatetime->diff($startdate, true);
+
+        // First occurrence of this set of events is on 2 September 1999.
+        $expecteddate = clone($startdatetime);
+        foreach ($records as $record) {
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Go to next period.
+            switch ($expecteddate->format('l')) {
+                case rrule_manager::DAY_TUESDAY:
+                    $expecteddate->modify('next Thursday');
+                    break;
+                default:
+                    $expecteddate->modify('next Tuesday');
+                    // Increment expected date by 1 week if the next day is Tuesday.
+                    $expecteddate->add(new DateInterval('P1W'));
+                    break;
+            }
+            $expecteddate->add($offsetinterval);
+        }
+    }
+
+    /**
+     * Monthly on the 1st Friday for ten occurrences:
+     *
+     * DTSTART;TZID=US-Eastern:19970905T090000
+     * RRULE:FREQ=MONTHLY;COUNT=10;BYDAY=1FR
+     *   ==> (1997 9:00 AM EDT)September 5;October 3
+     *       (1997 9:00 AM EST)November 7;Dec 5
+     *       (1998 9:00 AM EST)January 2;February 6;March 6;April 3
+     *       (1998 9:00 AM EDT)May 1;June 5
+     */
+    public function test_monthly_every_first_friday_10_count() {
+        global $DB;
+
+        // Change our event's date to 05-09-1997, based on the example from the RFC.
+        $startdatetime = $this->change_event_startdate('19970905T090000', 'US/Eastern');
+        $startdate = new DateTime(date('Y-m-d', $this->event->timestart));
+        $offsetinterval = $startdatetime->diff($startdate, true);
+
+        $rrule = 'FREQ=MONTHLY;COUNT=10;BYDAY=1FR';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // Should correspond to COUNT rule.
+        $this->assertCount(10, $records);
+
+        foreach ($records as $record) {
+            // Get the first Friday of the record's month.
+            $recordmonthyear = date('F Y', $record->timestart);
+            $expecteddate = new DateTime('first Friday of ' . $recordmonthyear);
+            // Add the time of the event.
+            $expecteddate->add($offsetinterval);
+
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+        }
+    }
+
+    /**
+     * Monthly on the 1st Friday until December 24, 1997:
+     *
+     * DTSTART;TZID=US-Eastern:19970905T090000
+     * RRULE:FREQ=MONTHLY;UNTIL=19971224T000000Z;BYDAY=1FR
+     *   ==> (1997 9:00 AM EDT)September 5;October 3
+     *       (1997 9:00 AM EST)November 7;December 5
+     */
+    public function test_monthly_every_first_friday_until() {
+        global $DB;
+
+        // Change our event's date to 05-09-1997, based on the example from the RFC.
+        $startdatetime = $this->change_event_startdate('19970905T090000', 'US/Eastern');
+        $startdate = new DateTime(date('Y-m-d', $this->event->timestart));
+        $offsetinterval = $startdatetime->diff($startdate, true);
+
+        $rrule = 'FREQ=MONTHLY;UNTIL=19971224T000000Z;BYDAY=1FR';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // Should have 4 events, every first friday of September 1997 to December 1997.
+        $this->assertCount(4, $records);
+
+        foreach ($records as $record) {
+            // Get the first Friday of the record's month.
+            $recordmonthyear = date('F Y', $record->timestart);
+            $expecteddate = new DateTime('first Friday of ' . $recordmonthyear);
+            // Add the time of the event.
+            $expecteddate->add($offsetinterval);
+
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+        }
+    }
+
+    /**
+     * Every other month on the 1st and last Sunday of the month for 10 occurrences:
+     *
+     * DTSTART;TZID=US-Eastern:19970907T090000
+     * RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=10;BYDAY=1SU,-1SU
+     *   ==> (1997 9:00 AM EDT)September 7,28
+     *       (1997 9:00 AM EST)November 2,30
+     *       (1998 9:00 AM EST)January 4,25;March 1,29
+     *       (1998 9:00 AM EDT)May 3,31
+     */
+    public function test_every_other_month_1st_and_last_sunday_10_count() {
+        global $DB;
+
+        // Change our event's date to 05-09-1997, based on the example from the RFC.
+        $startdatetime = $this->change_event_startdate('19970907T090000', 'US/Eastern');
+        $startdate = new DateTime(date('Y-m-d', $this->event->timestart));
+        $offsetinterval = $startdatetime->diff($startdate, true);
+
+        $rrule = 'FREQ=MONTHLY;INTERVAL=2;COUNT=10;BYDAY=1SU,-1SU';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // Should have 10 records based on COUNT rule.
+        $this->assertCount(10, $records);
+
+        // First occurrence is 07-09-1997 which is the first Sunday.
+        $ordinal = 'first';
+        foreach ($records as $record) {
+            // Get date of the month's first/last Sunday.
+            $recordmonthyear = date('F Y', $record->timestart);
+            $expecteddate = new DateTime($ordinal . ' Sunday of ' . $recordmonthyear);
+            $expecteddate->add($offsetinterval);
+
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+            if ($ordinal === 'first') {
+                $ordinal = 'last';
+            } else {
+                $ordinal = 'first';
+            }
+        }
+    }
+
+    /**
+     * Monthly on the second to last Monday of the month for 6 months:
+     *
+     * DTSTART;TZID=US-Eastern:19970922T090000
+     * RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=-2MO
+     *   ==> (1997 9:00 AM EDT)September 22;October 20
+     *       (1997 9:00 AM EST)November 17;December 22
+     *       (1998 9:00 AM EST)January 19;February 16
+     */
+    public function test_monthly_last_monday_for_6_months() {
+        global $DB;
+
+        // Change our event's date to 05-09-1997, based on the example from the RFC.
+        $startdatetime = $this->change_event_startdate('19970922T090000', 'US/Eastern');
+        $startdate = new DateTime($startdatetime->format('Y-m-d'));
+        $offsetinterval = $startdatetime->diff($startdate, true);
+
+        $rrule = 'FREQ=MONTHLY;COUNT=6;BYDAY=-2MO';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // Should have 6 records based on COUNT rule.
+        $this->assertCount(6, $records);
+
+        foreach ($records as $record) {
+            // Get date of the month's last Monday.
+            $recordmonthyear = date('F Y', $record->timestart);
+            $expecteddate = new DateTime('last Monday of ' . $recordmonthyear);
+            // Modify to get the second to the last Monday.
+            $expecteddate->modify('last Monday');
+            // Add offset.
+            $expecteddate->add($offsetinterval);
+
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+        }
+    }
+
+    /**
+     * Monthly on the third to the last day of the month, forever:
+     *
+     * DTSTART;TZID=US-Eastern:19970928T090000
+     * RRULE:FREQ=MONTHLY;BYMONTHDAY=-3
+     *   ==> (1997 9:00 AM EDT)September 28
+     *       (1997 9:00 AM EST)October 29;November 28;December 29
+     *       (1998 9:00 AM EST)January 29;February 26
+     *       ...
+     */
+    public function test_third_to_the_last_day_of_the_month_forever() {
+        global $DB;
+
+        // Change our event's date to 05-09-1997, based on the example from the RFC.
+        $this->change_event_startdate('19970928T090000', 'US/Eastern');
+
+        $rrule = 'FREQ=MONTHLY;BYMONTHDAY=-3';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        $untildate = new DateTime();
+        $untildate->add(new DateInterval('P' . $mang::TIME_UNLIMITED_YEARS . 'Y'));
+        $untiltimestamp = $untildate->getTimestamp();
+
+        $subinterval = new DateInterval('P2D');
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+
+            // Get date of the third to the last day of the month.
+            $recordmonthyear = date('F Y', $record->timestart);
+            $expecteddate = new DateTime('last day of ' . $recordmonthyear);
+            // Set time to 9am.
+            $expecteddate->setTime(9, 0);
+            // Modify to get the third to the last day of the month.
+            $expecteddate->sub($subinterval);
+
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+        }
+    }
+
+    /**
+     * Monthly on the 2nd and 15th of the month for 10 occurrences:
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=2,15
+     *   ==> (1997 9:00 AM EDT)September 2,15;October 2,15
+     *       (1997 9:00 AM EST)November 2,15;December 2,15
+     *       (1998 9:00 AM EST)January 2,15
+     */
+    public function test_every_2nd_and_15th_of_the_month_10_count() {
+        global $DB;
+
+        $startdatetime = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $startdate = new DateTime($startdatetime->format('Y-m-d'));
+        $offsetinterval = $startdatetime->diff($startdate, true);
+
+        $rrule = 'FREQ=MONTHLY;COUNT=10;BYMONTHDAY=2,15';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // Should have 10 records based on COUNT rule.
+        $this->assertCount(10, $records);
+
+        $day = '02';
+        foreach ($records as $record) {
+            // Get the first Friday of the record's month.
+            $recordmonthyear = date('Y-m', $record->timestart);
+
+            // Get date of the month's last Monday.
+            $expecteddate = new DateTime("$recordmonthyear-$day");
+            // Add offset.
+            $expecteddate->add($offsetinterval);
+            if ($day === '02') {
+                $day = '15';
+            } else {
+                $day = '02';
+            }
+
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+        }
+    }
+
+    /**
+     * Monthly on the first and last day of the month for 10 occurrences:
+     *
+     * DTSTART;TZID=US-Eastern:19970930T090000
+     * RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=1,-1
+     *   ==> (1997 9:00 AM EDT)September 30;October 1
+     *       (1997 9:00 AM EST)October 31;November 1,30;December 1,31
+     *       (1998 9:00 AM EST)January 1,31;February 1
+     */
+    public function test_every_first_and_last_day_of_the_month_10_count() {
+        global $DB;
+
+        $startdatetime = $this->change_event_startdate('19970930T090000', 'US/Eastern');
+        $startdate = new DateTime($startdatetime->format('Y-m-d'));
+        $offsetinterval = $startdatetime->diff($startdate, true);
+
+        $rrule = 'FREQ=MONTHLY;COUNT=10;BYMONTHDAY=1,-1';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // Should have 10 records based on COUNT rule.
+        $this->assertCount(10, $records);
+
+        // First occurrence is 30-Sep-1997.
+        $day = 'last';
+        foreach ($records as $record) {
+            // Get the first Friday of the record's month.
+            $recordmonthyear = date('F Y', $record->timestart);
+
+            // Get date of the month's last Monday.
+            $expecteddate = new DateTime("$day day of $recordmonthyear");
+            // Add offset.
+            $expecteddate->add($offsetinterval);
+
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            if ($day === 'first') {
+                $day = 'last';
+            } else {
+                $day = 'first';
+            }
+        }
+    }
+
+    /**
+     * Every 18 months on the 10th thru 15th of the month for 10 occurrences:
+     *
+     * DTSTART;TZID=US-Eastern:19970910T090000
+     * RRULE:FREQ=MONTHLY;INTERVAL=18;COUNT=10;BYMONTHDAY=10,11,12,13,14,15
+     *   ==> (1997 9:00 AM EDT)September 10,11,12,13,14,15
+     *       (1999 9:00 AM EST)March 10,11,12,13
+     */
+    public function test_every_18_months_days_10_to_15_10_count() {
+        global $DB;
+
+        $startdatetime = $this->change_event_startdate('19970910T090000', 'US/Eastern');
+
+        $rrule = 'FREQ=MONTHLY;INTERVAL=18;COUNT=10;BYMONTHDAY=10,11,12,13,14,15';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // Should have 10 records based on COUNT rule.
+        $this->assertCount(10, $records);
+
+        // First occurrence is 10-Sep-1997.
+        $expecteddate = clone($startdatetime);
+        $expecteddate->setTimezone(new DateTimeZone(get_user_timezone()));
+        foreach ($records as $record) {
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Get next expected date.
+            if ($expecteddate->format('d') == 15) {
+                // If 15th, increment by 18 months.
+                $expecteddate->add(new DateInterval('P18M'));
+                // Then go back to the 10th.
+                $expecteddate->sub(new DateInterval('P5D'));
+            } else {
+                // Otherwise, increment by 1 day.
+                $expecteddate->add(new DateInterval('P1D'));
+            }
+        }
+    }
+
+    /**
+     * Every Tuesday, every other month:
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=TU
+     *   ==> (1997 9:00 AM EDT)September 2,9,16,23,30
+     *       (1997 9:00 AM EST)November 4,11,18,25
+     *       (1998 9:00 AM EST)January 6,13,20,27;March 3,10,17,24,31
+     *       ...
+     */
+    public function test_every_tuesday_every_other_month_forever() {
+        global $DB;
+
+        $rrule = 'FREQ=MONTHLY;INTERVAL=2;BYDAY=TU';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        $untildate = new DateTime();
+        $untildate->add(new DateInterval('P' . $mang::TIME_UNLIMITED_YEARS . 'Y'));
+        $untiltimestamp = $untildate->getTimestamp();
+
+        $expecteddate = new DateTime(date('Y-m-d H:i:s', $this->event->timestart));
+        $nextmonth = new DateTime($expecteddate->format('Y-m-d'));
+        $offset = $expecteddate->diff($nextmonth, true);
+        $nextmonth->modify('first day of next month');
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Get next expected date.
+            $expecteddate->modify('next Tuesday');
+            if ($expecteddate->getTimestamp() >= $nextmonth->getTimestamp()) {
+                // Go to the end of the month.
+                $expecteddate->modify('last day of this month');
+                // Find the next Tuesday.
+                $expecteddate->modify('next Tuesday');
+
+                // Increment next month by 2 months.
+                $nextmonth->add(new DateInterval('P2M'));
+            }
+            $expecteddate->add($offset);
+        }
+    }
+
+    /**
+     * Yearly in June and July for 10 occurrences:
+     *
+     * DTSTART;TZID=US-Eastern:19970610T090000
+     * RRULE:FREQ=YEARLY;COUNT=10;BYMONTH=6,7
+     *   ==> (1997 9:00 AM EDT)June 10;July 10
+     *       (1998 9:00 AM EDT)June 10;July 10
+     *       (1999 9:00 AM EDT)June 10;July 10
+     *       (2000 9:00 AM EDT)June 10;July 10
+     *       (2001 9:00 AM EDT)June 10;July 10
+     * Note: Since none of the BYDAY, BYMONTHDAY or BYYEARDAY components are specified, the day is gotten from DTSTART.
+     */
+    public function test_yearly_in_june_july_10_count() {
+        global $DB;
+
+        $startdatetime = $this->change_event_startdate('19970610T090000', 'US/Eastern');
+
+        $rrule = 'FREQ=YEARLY;COUNT=10;BYMONTH=6,7';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // Should have 10 records based on COUNT rule.
+        $this->assertCount(10, $records);
+
+        $expecteddate = $startdatetime;
+        $expecteddate->setTimezone(new DateTimeZone(get_user_timezone()));
+        $monthinterval = new DateInterval('P1M');
+        $yearinterval = new DateInterval('P1Y');
+        foreach ($records as $record) {
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Get next expected date.
+            if ($expecteddate->format('m') == 6) {
+                // Go to the month of July.
+                $expecteddate->add($monthinterval);
+            } else {
+                // Go to the month of June next year.
+                $expecteddate->sub($monthinterval);
+                $expecteddate->add($yearinterval);
+            }
+        }
+    }
+
+    /**
+     * Every other year on January, February, and March for 10 occurrences:
+     *
+     * DTSTART;TZID=US-Eastern:19970310T090000
+     * RRULE:FREQ=YEARLY;INTERVAL=2;COUNT=10;BYMONTH=1,2,3
+     *   ==> (1997 9:00 AM EST)March 10
+     *       (1999 9:00 AM EST)January 10;February 10;March 10
+     *       (2001 9:00 AM EST)January 10;February 10;March 10
+     *       (2003 9:00 AM EST)January 10;February 10;March 10
+     */
+    public function test_every_other_year_in_june_july_10_count() {
+        global $DB;
+
+        $startdatetime = $this->change_event_startdate('19970310T090000', 'US/Eastern');
+
+        $rrule = 'FREQ=YEARLY;INTERVAL=2;COUNT=10;BYMONTH=1,2,3';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // Should have 10 records based on COUNT rule.
+        $this->assertCount(10, $records);
+
+        $expecteddate = $startdatetime;
+        $expecteddate->setTimezone(new DateTimeZone(get_user_timezone()));
+        $monthinterval = new DateInterval('P1M');
+        $yearinterval = new DateInterval('P2Y');
+        foreach ($records as $record) {
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Get next expected date.
+            if ($expecteddate->format('m') != 3) {
+                // Go to the next month.
+                $expecteddate->add($monthinterval);
+            } else {
+                // Go to the month of January next year.
+                $expecteddate->sub($monthinterval);
+                $expecteddate->sub($monthinterval);
+                $expecteddate->add($yearinterval);
+            }
+        }
+    }
+
+    /**
+     * Every 3rd year on the 1st, 100th and 200th day for 10 occurrences:
+     *
+     * DTSTART;TZID=US-Eastern:19970101T090000
+     * RRULE:FREQ=YEARLY;INTERVAL=3;COUNT=10;BYYEARDAY=1,100,200
+     *   ==> (1997 9:00 AM EST)January 1
+     *       (1997 9:00 AM EDT)April 10;July 19
+     *       (2000 9:00 AM EST)January 1
+     *       (2000 9:00 AM EDT)April 9;July 18
+     *       (2003 9:00 AM EST)January 1
+     *       (2003 9:00 AM EDT)April 10;July 19
+     *       (2006 9:00 AM EST)January 1
+     */
+    public function test_every_3_years_1st_100th_200th_days_10_count() {
+        global $DB;
+
+        $startdatetime = $this->change_event_startdate('19970101T090000', 'US/Eastern');
+
+        $rrule = 'FREQ=YEARLY;INTERVAL=3;COUNT=10;BYYEARDAY=1,100,200';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        // Should have 10 records based on COUNT rule.
+        $this->assertCount(10, $records);
+
+        $expecteddate = $startdatetime;
+        $expecteddate->setTimezone(new DateTimeZone(get_user_timezone()));
+        $hundredthdayinterval = new DateInterval('P99D');
+        $twohundredthdayinterval = new DateInterval('P100D');
+        $yearinterval = new DateInterval('P3Y');
+
+        foreach ($records as $record) {
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Get next expected date.
+            if ($expecteddate->format('z') == 0) { // January 1.
+                $expecteddate->add($hundredthdayinterval);
+            } else if ($expecteddate->format('z') == 99) { // 100th day of the year.
+                $expecteddate->add($twohundredthdayinterval);
+            } else { // 200th day of the year.
+                $expecteddate->add($yearinterval);
+                $expecteddate->modify('January 1');
+            }
+        }
+    }
+
+    /**
+     * Every 20th Monday of the year, forever:
+     *
+     * DTSTART;TZID=US-Eastern:19970519T090000
+     * RRULE:FREQ=YEARLY;BYDAY=20MO
+     *   ==> (1997 9:00 AM EDT)May 19
+     *       (1998 9:00 AM EDT)May 18
+     *       (1999 9:00 AM EDT)May 17
+     *       ...
+     */
+    public function test_yearly_every_20th_monday_forever() {
+        global $DB;
+
+        // Change our event's date to 19-05-1997, based on the example from the RFC.
+        $startdatetime = $this->change_event_startdate('19970519T090000', 'US/Eastern');
+
+        $startdate = new DateTime($startdatetime->format('Y-m-d'));
+
+        $offset = $startdatetime->diff($startdate, true);
+
+        $interval = new DateInterval('P1Y');
+
+        $rrule = 'FREQ=YEARLY;BYDAY=20MO';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        $untildate = new DateTime();
+        $untildate->add(new DateInterval('P' . $mang::TIME_UNLIMITED_YEARS . 'Y'));
+        $untiltimestamp = $untildate->getTimestamp();
+
+        $expecteddate = $startdatetime;
+        $expecteddate->setTimezone(new DateTimeZone(get_user_timezone()));
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Go to next period.
+            $expecteddate->modify('January 1');
+            $expecteddate->add($interval);
+            $expecteddate->modify("+20 Monday");
+            $expecteddate->add($offset);
+        }
+    }
+
+    /**
+     * Monday of week number 20 (where the default start of the week is Monday), forever:
+     *
+     * DTSTART;TZID=US-Eastern:19970512T090000
+     * RRULE:FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO
+     * ==> (1997 9:00 AM EDT)May 12
+     *     (1998 9:00 AM EDT)May 11
+     *     (1999 9:00 AM EDT)May 17
+     *     ...
+     */
+    public function test_yearly_byweekno_forever() {
+        global $DB;
+
+        // Change our event's date to 12-05-1997, based on the example from the RFC.
+        $startdatetime = $this->change_event_startdate('19970512T090000', 'US/Eastern');
+
+        $startdate = clone($startdatetime);
+        $startdate->modify($startdate->format('Y-m-d'));
+
+        $offset = $startdatetime->diff($startdate, true);
+
+        $interval = new DateInterval('P1Y');
+
+        $rrule = 'FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        $untildate = new DateTime();
+        $untildate->add(new DateInterval('P' . $mang::TIME_UNLIMITED_YEARS . 'Y'));
+        $untiltimestamp = $untildate->getTimestamp();
+
+        $expecteddate = new DateTime(date('Y-m-d H:i:s', $startdatetime->getTimestamp()));
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Go to next period.
+            $expecteddate->add($interval);
+            $expecteddate->setISODate($expecteddate->format('Y'), 20);
+            $expecteddate->add($offset);
+        }
+    }
+
+    /**
+     * Every Thursday in March, forever:
+     *
+     * DTSTART;TZID=US-Eastern:19970313T090000
+     * RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=TH
+     *   ==> (1997 9:00 AM EST)March 13,20,27
+     *       (1998 9:00 AM EST)March 5,12,19,26
+     *       (1999 9:00 AM EST)March 4,11,18,25
+     *       ...
+     */
+    public function test_every_thursday_in_march_forever() {
+        global $DB;
+
+        // Change our event's date to 12-05-1997, based on the example from the RFC.
+        $startdatetime = $this->change_event_startdate('19970313T090000', 'US/Eastern');
+
+        $interval = new DateInterval('P1Y');
+
+        $rrule = 'FREQ=YEARLY;BYMONTH=3;BYDAY=TH';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        $untildate = new DateTime();
+        $untildate->add(new DateInterval('P' . $mang::TIME_UNLIMITED_YEARS . 'Y'));
+        $untiltimestamp = $untildate->getTimestamp();
+
+        $expecteddate = $startdatetime;
+        $startdate = new DateTime($startdatetime->format('Y-m-d'));
+        $offsetinterval = $startdatetime->diff($startdate, true);
+        $expecteddate->setTimezone(new DateTimeZone(get_user_timezone()));
+        $april1st = new DateTime('1997-04-01');
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Go to next period.
+            $expecteddate->modify('next Thursday');
+            if ($expecteddate->getTimestamp() >= $april1st->getTimestamp()) {
+                // Reset to 1st of March.
+                $expecteddate->modify('first day of March');
+                // Go to next year.
+                $expecteddate->add($interval);
+                if ($expecteddate->format('l') !== rrule_manager::DAY_THURSDAY) {
+                    $expecteddate->modify('next Thursday');
+                }
+                // Increment to next year's April 1st.
+                $april1st->add($interval);
+            }
+            $expecteddate->add($offsetinterval);
+        }
+    }
+
+    /**
+     * Every Thursday, but only during June, July, and August, forever:
+     *
+     * DTSTART;TZID=US-Eastern:19970605T090000
+     * RRULE:FREQ=YEARLY;BYDAY=TH;BYMONTH=6,7,8
+     *   ==> (1997 9:00 AM EDT)June 5,12,19,26;July 3,10,17,24,31;August 7,14,21,28
+     *       (1998 9:00 AM EDT)June 4,11,18,25;July 2,9,16,23,30;August 6,13,20,27
+     *       (1999 9:00 AM EDT)June 3,10,17,24;July 1,8,15,22,29;August 5,12,19,26
+     *       ...
+     */
+    public function test_every_thursday_june_july_august_forever() {
+        global $DB;
+
+        // Change our event's date to 05-06-1997, based on the example from the RFC.
+        $startdatetime = $this->change_event_startdate('19970605T090000', 'US/Eastern');
+
+        $startdate = new DateTime($startdatetime->format('Y-m-d'));
+
+        $offset = $startdatetime->diff($startdate, true);
+
+        $interval = new DateInterval('P1Y');
+
+        $rrule = 'FREQ=YEARLY;BYDAY=TH;BYMONTH=6,7,8';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        $untildate = new DateTime();
+        $untildate->add(new DateInterval('P' . $mang::TIME_UNLIMITED_YEARS . 'Y'));
+        $untiltimestamp = $untildate->getTimestamp();
+
+        $expecteddate = new DateTime(date('Y-m-d H:i:s', $startdatetime->getTimestamp()));
+        $september1st = new DateTime('1997-09-01');
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Go to next period.
+            $expecteddate->modify('next Thursday');
+            if ($expecteddate->getTimestamp() >= $september1st->getTimestamp()) {
+                $expecteddate->add($interval);
+                $expecteddate->modify('June 1');
+                if ($expecteddate->format('l') !== rrule_manager::DAY_THURSDAY) {
+                    $expecteddate->modify('next Thursday');
+                }
+                $september1st->add($interval);
+            }
+            $expecteddate->add($offset);
+        }
+    }
+
+    /**
+     * Every Friday the 13th, forever:
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * EXDATE;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13
+     *   ==> (1998 9:00 AM EST)February 13;March 13;November 13
+     *       (1999 9:00 AM EDT)August 13
+     *       (2000 9:00 AM EDT)October 13
+     */
+    public function test_friday_the_thirteenth_forever() {
+        global $DB;
+
+        $rrule = 'FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        $untildate = new DateTime();
+        $untildate->add(new DateInterval('P' . $mang::TIME_UNLIMITED_YEARS . 'Y'));
+        $untiltimestamp = $untildate->getTimestamp();
+
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+            // Assert that the day of the month and the day correspond to Friday the 13th.
+            $this->assertEquals('Friday 13', date('l d', $record->timestart));
+        }
+    }
+
+    /**
+     * The first Saturday that follows the first Sunday of the month, forever:
+     *
+     * DTSTART;TZID=US-Eastern:19970913T090000
+     * RRULE:FREQ=MONTHLY;BYDAY=SA;BYMONTHDAY=7,8,9,10,11,12,13
+     *   ==> (1997 9:00 AM EDT)September 13;October 11
+     *       (1997 9:00 AM EST)November 8;December 13
+     *       (1998 9:00 AM EST)January 10;February 7;March 7
+     *       (1998 9:00 AM EDT)April 11;May 9;June 13...
+     */
+    public function test_first_saturday_following_first_sunday_forever() {
+        global $DB;
+
+        $startdatetime = $this->change_event_startdate('19970913T090000', 'US/Eastern');
+        $startdate = new DateTime($startdatetime->format('Y-m-d'));
+        $offset = $startdatetime->diff($startdate, true);
+
+        $rrule = 'FREQ=MONTHLY;BYDAY=SA;BYMONTHDAY=7,8,9,10,11,12,13';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        $untildate = new DateTime();
+        $untildate->add(new DateInterval('P' . $mang::TIME_UNLIMITED_YEARS . 'Y'));
+        $untiltimestamp = $untildate->getTimestamp();
+        $bymonthdays = [7, 8, 9, 10, 11, 12, 13];
+        foreach ($records as $record) {
+            $recordmonthyear = date('F Y', $record->timestart);
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+
+            // Get first Saturday after the first Sunday of the month.
+            $expecteddate = new DateTime('first Sunday of ' . $recordmonthyear);
+            $expecteddate->modify('next Saturday');
+            $expecteddate->add($offset);
+
+            // Assert the record's date corresponds to the first Saturday of the month.
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Assert that the record is either the 7th, 8th, 9th, ... 13th day of the month.
+            $this->assertContains(date('j', $record->timestart), $bymonthdays);
+        }
+    }
+
+    /**
+     * Every four years, the first Tuesday after a Monday in November, forever (U.S. Presidential Election day):
+     *
+     * DTSTART;TZID=US-Eastern:19961105T090000
+     * RRULE:FREQ=YEARLY;INTERVAL=4;BYMONTH=11;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8
+     *   ==> (1996 9:00 AM EST)November 5
+     *       (2000 9:00 AM EST)November 7
+     *       (2004 9:00 AM EST)November 2
+     *       ...
+     */
+    public function test_every_us_presidential_election_forever() {
+        global $DB;
+
+        $startdatetime = $this->change_event_startdate('19961105T090000', 'US/Eastern');
+        $startdate = new DateTime($startdatetime->format('Y-m-d'));
+        $offset = $startdatetime->diff($startdate, true);
+
+        $rrule = 'FREQ=YEARLY;INTERVAL=4;BYMONTH=11;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        $untildate = new DateTime();
+        $untildate->add(new DateInterval('P' . $mang::TIME_UNLIMITED_YEARS . 'Y'));
+        $untiltimestamp = $untildate->getTimestamp();
+        $bymonthdays = [2, 3, 4, 5, 6, 7, 8];
+        foreach ($records as $record) {
+            $recordmonthyear = date('F Y', $record->timestart);
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+
+            // Get first Saturday after the first Sunday of the month.
+            $expecteddate = new DateTime('first Monday of ' . $recordmonthyear);
+            $expecteddate->modify('next Tuesday');
+            $expecteddate->add($offset);
+
+            // Assert the record's date corresponds to the first Saturday of the month.
+            $this->assertEquals($expecteddate->format('Y-m-d H:i:s'), date('Y-m-d H:i:s', $record->timestart));
+
+            // Assert that the record is either the 2nd, 3rd, 4th ... 8th day of the month.
+            $this->assertContains(date('j', $record->timestart), $bymonthdays);
+        }
+    }
+
+    /**
+     * The 3rd instance into the month of one of Tuesday, Wednesday or Thursday, for the next 3 months:
+     *
+     * DTSTART;TZID=US-Eastern:19970904T090000
+     * RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=TU,WE,TH;BYSETPOS=3
+     *   ==> (1997 9:00 AM EDT)September 4;October 7
+     *       (1997 9:00 AM EST)November 6
+     */
+    public function test_monthly_bysetpos_3_count() {
+        global $DB;
+
+        $this->change_event_startdate('19970904T090000', 'US/Eastern');
+
+        $rrule = 'FREQ=MONTHLY;COUNT=3;BYDAY=TU,WE,TH;BYSETPOS=3';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(3, $records);
+
+        $expecteddates = [
+            (new DateTime('1997-09-04 09:00:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-10-07 09:00:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-11-06 09:00:00 EST'))->getTimestamp()
+        ];
+        foreach ($records as $record) {
+            $this->assertContains($record->timestart, $expecteddates, date('Y-m-d H:i:s', $record->timestart) . ' is not found.');
+        }
+    }
+
+    /**
+     * The 2nd to last weekday of the month:
+     *
+     * DTSTART;TZID=US-Eastern:19970929T090000
+     * RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2
+     *   ==> (1997 9:00 AM EDT)September 29
+     *       (1997 9:00 AM EST)October 30;November 27;December 30
+     *       (1998 9:00 AM EST)January 29;February 26;March 30
+     *       ...
+     */
+    public function test_second_to_the_last_weekday_of_the_month_forever() {
+        global $DB;
+
+        $this->change_event_startdate('19970929T090000', 'US/Eastern');
+
+        $rrule = 'FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+
+        $expecteddates = [
+            (new DateTime('1997-09-29 09:00:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-10-30 09:00:00 EST'))->getTimestamp(),
+            (new DateTime('1997-11-27 09:00:00 EST'))->getTimestamp(),
+            (new DateTime('1997-12-30 09:00:00 EST'))->getTimestamp(),
+            (new DateTime('1998-01-29 09:00:00 EST'))->getTimestamp(),
+            (new DateTime('1998-02-26 09:00:00 EST'))->getTimestamp(),
+            (new DateTime('1998-03-30 09:00:00 EST'))->getTimestamp(),
+        ];
+
+        $untildate = new DateTime();
+        $untildate->add(new DateInterval('P' . $mang::TIME_UNLIMITED_YEARS . 'Y'));
+        $untiltimestamp = $untildate->getTimestamp();
+
+        $i = 0;
+        foreach ($records as $record) {
+            $this->assertLessThanOrEqual($untiltimestamp, $record->timestart);
+
+            // Confirm that the first 7 records correspond to the expected dates listed above.
+            if ($i < 7) {
+                $this->assertEquals($expecteddates[$i], $record->timestart);
+                $i++;
+            }
+        }
+    }
+
+    /**
+     * Every 3 hours from 9:00 AM to 5:00 PM on a specific day:
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=HOURLY;INTERVAL=3;UNTIL=19970902T210000Z
+     *   ==> (September 2, 1997 EDT)09:00,12:00,15:00
+     */
+    public function test_every_3hours_9am_to_5pm() {
+        global $DB;
+
+        $rrule = 'FREQ=HOURLY;INTERVAL=3;UNTIL=19970902T210000Z';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(3, $records);
+
+        $expecteddates = [
+            (new DateTime('1997-09-02 09:00:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-09-02 12:00:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-09-02 15:00:00 EDT'))->getTimestamp(),
+        ];
+        foreach ($records as $record) {
+            $this->assertContains($record->timestart, $expecteddates, date('Y-m-d H:i:s', $record->timestart) . ' is not found.');
+        }
+    }
+
+    /**
+     * Every 15 minutes for 6 occurrences:
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=MINUTELY;INTERVAL=15;COUNT=6
+     *   ==> (September 2, 1997 EDT)09:00,09:15,09:30,09:45,10:00,10:15
+     */
+    public function test_every_15minutes_6_count() {
+        global $DB;
+
+        $rrule = 'FREQ=MINUTELY;INTERVAL=15;COUNT=6';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(6, $records);
+
+        $expecteddates = [
+            (new DateTime('1997-09-02 09:00:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-09-02 09:15:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-09-02 09:30:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-09-02 09:45:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-09-02 10:00:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-09-02 10:15:00 EDT'))->getTimestamp(),
+        ];
+        foreach ($records as $record) {
+            $this->assertContains($record->timestart, $expecteddates, date('Y-m-d H:i:s', $record->timestart) . ' is not found.');
+        }
+    }
+
+    /**
+     * Every hour and a half for 4 occurrences:
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=MINUTELY;INTERVAL=90;COUNT=4
+     *   ==> (September 2, 1997 EDT)09:00,10:30;12:00;13:30
+     */
+    public function test_every_90minutes_4_count() {
+        global $DB;
+
+        $rrule = 'FREQ=MINUTELY;INTERVAL=90;COUNT=4';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(4, $records);
+
+        $expecteddates = [
+            (new DateTime('1997-09-02 09:00:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-09-02 10:30:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-09-02 12:00:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-09-02 13:30:00 EDT'))->getTimestamp(),
+        ];
+        foreach ($records as $record) {
+            $this->assertContains($record->timestart, $expecteddates, date('Y-m-d H:i:s', $record->timestart) . ' is not found.');
+        }
+    }
+
+    /**
+     * Every 20 minutes from 9:00 AM to 4:40 PM every day for 100 times:
+     *
+     * (Original RFC example is set to everyday forever, but that will just take a lot of time for the test,
+     * so just limit the count to 50).
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=DAILY;BYHOUR=9,10,11,12,13,14,15,16;BYMINUTE=0,20,40;COUNT=50
+     *   ==> (September 2, 1997 EDT)9:00,9:20,9:40,10:00,10:20,...16:00,16:20,16:40
+     *       (September 3, 1997 EDT)9:00,9:20,9:40,10:00,10:20,...16:00,16:20,16:40
+     *       ...
+     */
+    public function test_every_20minutes_daily_byhour_byminute_50_count() {
+        global $DB;
+
+        $rrule = 'FREQ=DAILY;BYHOUR=9,10,11,12,13,14,15,16;BYMINUTE=0,20,40;COUNT=50';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $byminuteinterval = new DateInterval('PT20M');
+        $bydayinterval = new DateInterval('P1D');
+        $date = new DateTime('1997-09-02 09:00:00 EDT');
+        $expecteddates = [];
+        $count = 50;
+        for ($i = 0; $i < $count; $i++) {
+            $expecteddates[] = $date->getTimestamp();
+            $date->add($byminuteinterval);
+            if ($date->format('H') > 16) {
+                // Go to next day.
+                $date->add($bydayinterval);
+                // Reset time to 9am.
+                $date->setTime(9, 0);
+            }
+        }
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount($count, $records);
+
+        foreach ($records as $record) {
+            $this->assertContains($record->timestart, $expecteddates, date('Y-m-d H:i:s', $record->timestart) . ' is not found.');
+        }
+    }
+
+    /**
+     * Every 20 minutes from 9:00 AM to 4:40 PM every day for 100 times:
+     *
+     * (Original RFC example is set to everyday forever, but that will just take a lot of time for the test,
+     * so just limit the count to 50).
+     *
+     * DTSTART;TZID=US-Eastern:19970902T090000
+     * RRULE:FREQ=MINUTELY;INTERVAL=20;BYHOUR=9,10,11,12,13,14,15,16;COUNT=50
+     *   ==> (September 2, 1997 EDT)9:00,9:20,9:40,10:00,10:20,...16:00,16:20,16:40
+     *       (September 3, 1997 EDT)9:00,9:20,9:40,10:00,10:20,...16:00,16:20,16:40
+     *       ...
+     */
+    public function test_every_20minutes_minutely_byhour_50_count() {
+        global $DB;
+
+        $rrule = 'FREQ=MINUTELY;INTERVAL=20;BYHOUR=9,10,11,12,13,14,15,16;COUNT=50';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $byminuteinterval = new DateInterval('PT20M');
+        $bydayinterval = new DateInterval('P1D');
+        $date = new DateTime('1997-09-02 09:00:00');
+        $expecteddates = [];
+        $count = 50;
+        for ($i = 0; $i < $count; $i++) {
+            $expecteddates[] = $date->getTimestamp();
+            $date->add($byminuteinterval);
+            if ($date->format('H') > 16) {
+                // Go to next day.
+                $date->add($bydayinterval);
+                // Reset time to 9am.
+                $date->setTime(9, 0);
+            }
+        }
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount($count, $records);
+
+        foreach ($records as $record) {
+            $this->assertContains($record->timestart, $expecteddates, date('Y-m-d H:i:s', $record->timestart) . ' is not found.');
+        }
+    }
+
+    /**
+     * An example where the days generated makes a difference because of WKST:
+     *
+     * DTSTART;TZID=US-Eastern:19970805T090000
+     * RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=MO
+     *   ==> (1997 EDT)Aug 5,10,19,24
+     */
+    public function test_weekly_byday_with_wkst_mo() {
+        global $DB;
+
+        $this->change_event_startdate('19970805T090000', 'US/Eastern');
+
+        $rrule = 'FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=MO';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(4, $records);
+
+        $expecteddates = [
+            (new DateTime('1997-08-05 09:00:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-08-10 09:00:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-08-19 09:00:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-08-24 09:00:00 EDT'))->getTimestamp(),
+        ];
+        foreach ($records as $record) {
+            $this->assertContains($record->timestart, $expecteddates, date('Y-m-d H:i:s', $record->timestart) . ' is not found.');
+        }
+    }
+
+    /**
+     * An example where the days generated makes a difference because of WKST:
+     * Changing only WKST from MO to SU, yields different results...
+     *
+     * DTSTART;TZID=US-Eastern:19970805T090000
+     * RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=SU
+     *   ==> (1997 EDT)August 5,17,19,31
+     */
+    public function test_weekly_byday_with_wkst_su() {
+        global $DB;
+
+        $this->change_event_startdate('19970805T090000', 'US/Eastern');
+
+        $rrule = 'FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=SU';
+        $mang = new rrule_manager($rrule);
+        $mang->parse_rrule();
+        $mang->create_events($this->event);
+
+        $records = $DB->get_records('event', ['repeatid' => $this->event->id], 'timestart ASC', 'id, repeatid, timestart');
+        $this->assertCount(4, $records);
+
+        $expecteddates = [
+            (new DateTime('1997-08-05 09:00:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-08-17 09:00:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-08-19 09:00:00 EDT'))->getTimestamp(),
+            (new DateTime('1997-08-31 09:00:00 EDT'))->getTimestamp(),
+        ];
+
+        foreach ($records as $record) {
+            $this->assertContains($record->timestart, $expecteddates, date('Y-m-d H:i:s', $record->timestart) . ' is not found.');
+        }
+    }
+
+    /**
+     * Change the event's timestart (DTSTART) based on the test's needs.
+     *
+     * @param string $datestr The date string. In YYYYmmddThhiiss format. e.g. 19990902T090000.
+     * @param string $timezonestr A valid timezone string. e.g. 'US/Eastern'.
+     * @return bool|DateTime
+     */
+    protected function change_event_startdate($datestr, $timezonestr) {
+        $timezone = new DateTimeZone($timezonestr);
+        $newdatetime = DateTime::createFromFormat('Ymd\THis', $datestr, $timezone);
+
+        // Update the start date of the parent event.
+        $calevent = calendar_event::load($this->event->id);
+        $updatedata = (object)[
+            'timestart' => $newdatetime->getTimestamp(),
+            'repeatid' => $this->event->id
+        ];
+        $calevent->update($updatedata, false);
+        $this->event->timestart = $calevent->timestart;
+
+        return $newdatetime;
+    }
+}
diff --git a/calendar/tests/rrule_manager_tests.php b/calendar/tests/rrule_manager_tests.php
deleted file mode 100644 (file)
index a68d615..0000000
+++ /dev/null
@@ -1,537 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * Defines test class to test manage rrule during ical imports.
- *
- * @package core_calendar
- * @category test
- * @copyright 2014 onwards Ankit Agarwal <ankit.agrr@gmail.com>
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class core_calendar_rrule_manager_testcase extends advanced_testcase {
-
-    /** @var stdClass a dummy event */
-    protected $event;
-
-    /**
-     * Set up method.
-     */
-    protected function setUp() {
-        global $DB, $CFG;
-        $this->resetAfterTest();
-
-        $this->setTimezone('Australia/Perth');
-
-        $user = $this->getDataGenerator()->create_user();
-        $sub = new stdClass();
-        $sub->url = '';
-        $sub->courseid = 0;
-        $sub->groupid = 0;
-        $sub->userid = $user->id;
-        $sub->pollinterval = 0;
-        $subid = $DB->insert_record('event_subscriptions', $sub, true);
-
-        $event = new stdClass();
-        $event->name = 'Event name';
-        $event->description = '';
-        $event->timestart = 1385913700; // A 2013-12-2 Monday event.
-        $event->timeduration = 3600;
-        $event->uuid = 'uuid';
-        $event->subscriptionid = $subid;
-        $event->userid = $user->id;
-        $event->groupid = 0;
-        $event->courseid = 0;
-        $event->eventtype = 'user';
-        $eventobj = calendar_event::create($event, false);
-        $DB->set_field('event', 'repeatid', $eventobj->id, array('id' => $eventobj->id));
-        $eventobj->repeatid = $eventobj->id;
-        $this->event = $eventobj;
-    }
-
-    /**
-     * Test parse_rrule() method.
-     */
-    public function test_parse_rrule() {
-
-        $rrule = "FREQ=DAILY;COUNT=3;INTERVAL=4;BYSECOND=20,40;BYMINUTE=2,30;BYHOUR=3,4;BYDAY=MO,TH;BYMONTHDAY=20,
-                30;BYYEARDAY=300,-20;BYWEEKNO=22,33;BYMONTH=3,4";
-        $mang = new core_tests_calendar_rrule_manager($rrule);
-        $mang->parse_rrule();
-        $this->assertEquals(\core_calendar\rrule_manager::FREQ_DAILY, $mang->freq);
-        $this->assertEquals(3, $mang->count);
-        $this->assertEquals(4, $mang->interval);
-        $this->assertEquals(array(20, 40), $mang->bysecond);
-        $this->assertEquals(array(2, 30), $mang->byminute);
-        $this->assertEquals(array(3, 4), $mang->byhour);
-        $this->assertEquals(array('MO', 'TH'), $mang->byday);
-        $this->assertEquals(array(20, 30), $mang->bymonthday);
-        $this->assertEquals(array(300, -20), $mang->byyearday);
-        $this->assertEquals(array(22, 33), $mang->byweekno);
-        $this->assertEquals(array(3, 4), $mang->bymonth);
-    }
-
-    /**
-     * Test exception is thrown for invalid property.
-     *
-     * @expectedException moodle_exception
-     */
-    public function test_parse_rrule_validation() {
-
-        $rrule = "RANDOM=PROPERTY;";
-        $mang = new core_tests_calendar_rrule_manager($rrule);
-        $mang->parse_rrule();
-    }
-
-    /**
-     * Test exception is thrown for invalid frequency.
-     *
-     * @expectedException moodle_exception
-     */
-    public function test_freq_validation() {
-
-        $rrule = "FREQ=RANDOMLY;";
-        $mang = new core_tests_calendar_rrule_manager($rrule);
-        $mang->parse_rrule();
-    }
-
-    /**
-     * Test recurrence rules for daily frequency.
-     */
-    public function test_daily_events() {
-        global $DB;
-
-        $rrule = 'FREQ=DAILY;COUNT=3'; // This should generate 2 child events + 1 parent.
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(3, $count);
-        $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                'timestart' => ($this->event->timestart + DAYSECS)));
-        $this->assertTrue($result);
-        $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                'timestart' => ($this->event->timestart + 2 * DAYSECS)));
-        $this->assertTrue($result);
-
-        $until = $this->event->timestart + DAYSECS * 2;
-        $until = date('Y-m-d', $until);
-        $rrule = "FREQ=DAILY;UNTIL=$until"; // This should generate 1 child event + 1 parent,since by then until bound would be hit.
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(2, $count);
-        $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                'timestart' => ($this->event->timestart + DAYSECS)));
-        $this->assertTrue($result);
-
-        $rrule = 'FREQ=DAILY;COUNT=3;INTERVAL=3'; // This should generate 2 child events + 1 parent, every 3rd day.
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(3, $count);
-        $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                'timestart' => ($this->event->timestart + 3 * DAYSECS)));
-        $this->assertTrue($result);
-        $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                'timestart' => ($this->event->timestart + 6 * DAYSECS)));
-        $this->assertTrue($result);
-
-        // Forever event. This should generate events for time() + 10 year period, every 300th day.
-        $rrule = 'FREQ=DAILY;INTERVAL=300';
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $until = time() + (YEARSECS * $mang::TIME_UNLIMITED_YEARS);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        for ($i = 0, $time = $this->event->timestart; $time < $until; $i++, $time = $this->event->timestart + 300 * DAYSECS * $i) {
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => ($time)));
-            $this->assertTrue($result);
-        }
-    }
-
-    /**
-     * Test recurrence rules for weekly frequency.
-     */
-    public function test_weekly_events() {
-        global $DB;
-
-        $rrule = 'FREQ=WEEKLY;COUNT=1'; // This should generate 7 events in total, one for each day.
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(7, $count);
-        for ($i = 0; $i < 7; $i++) {
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => ($this->event->timestart + $i * DAYSECS)));
-            $this->assertTrue($result);
-        }
-
-        // This should generate 4 child event + 1 parent, since by then until bound would be hit.
-        $until = $this->event->timestart + WEEKSECS * 4;
-        $until = date('YmdThis', $until);
-        $rrule = "FREQ=WEEKLY;BYDAY=MO;UNTIL=$until";
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(5, $count);
-        for ($i = 0; $i < 5; $i++) {
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => ($this->event->timestart + $i * WEEKSECS)));
-            $this->assertTrue($result);
-        }
-
-        // This should generate 4 events in total every monday and Wednesday of every 3rd week.
-        $rrule = 'FREQ=WEEKLY;INTERVAL=3;BYDAY=MO,WE;COUNT=2';
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(4, $count);
-        $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                'timestart' => ($this->event->timestart + 3 * WEEKSECS))); // Monday event.
-        $this->assertTrue($result);
-        $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                'timestart' => ($this->event->timestart + 2 * DAYSECS))); // Wednesday event.
-        $this->assertTrue($result);
-        $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                'timestart' => ($this->event->timestart + 3 * WEEKSECS + 2 * DAYSECS))); // Wednesday event.
-        $this->assertTrue($result);
-
-        // Forever event. This should generate events over time() + 10 year period, every 50th monday.
-        $rrule = 'FREQ=WEEKLY;BYDAY=MO;INTERVAL=50';
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $until = time() + (YEARSECS * $mang::TIME_UNLIMITED_YEARS);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        for ($i = 0, $time = $this->event->timestart; $time < $until; $i++, $time = $this->event->timestart + 50 * WEEKSECS * $i) {
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => ($time)));
-            $this->assertTrue($result);
-        }
-    }
-
-    /**
-     * Test recurrence rules for monthly frequency.
-     */
-    public function test_monthly_events() {
-        global $DB;
-        $rrule = 'FREQ=MONTHLY;COUNT=3;BYMONTHDAY=2'; // This should generate 3 events in total.
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(3, $count);
-        for ($i = 0; $i < 3; $i++) {
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => (strtotime("+$i month", $this->event->timestart))));
-            $this->assertTrue($result);
-        }
-
-        // This much seconds after the start of the day.
-        $offset = $this->event->timestart - mktime(0, 0, 0, date("n", $this->event->timestart), date("j", $this->event->timestart),
-                date("Y", $this->event->timestart));
-        $monthstart = mktime(0, 0, 0, date("n", $this->event->timestart), 1, date("Y", $this->event->timestart));
-
-        $rrule = 'FREQ=MONTHLY;COUNT=3;BYDAY=1MO'; // This should generate 3 events in total, first monday of the month.
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(3, $count);
-        $time = strtotime('1 Monday', strtotime("+1 months", $monthstart)) + $offset;
-        $result = $DB->record_exists('event', array('repeatid' => $this->event->id, 'timestart' => $time));
-        $this->assertTrue($result);
-        $time = strtotime('1 Monday', strtotime("+2 months", $monthstart)) + $offset;
-        $result = $DB->record_exists('event', array('repeatid' => $this->event->id, 'timestart' => $time));
-        $this->assertTrue($result);
-
-        // This should generate 10 child event + 1 parent, since by then until bound would be hit.
-        $until = strtotime('+1 day +10 months', $this->event->timestart);
-        $until = date('YmdThis', $until);
-        $rrule = "FREQ=MONTHLY;BYMONTHDAY=2;UNTIL=$until";
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(11, $count);
-        for ($i = 0; $i < 11; $i++) {
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => (strtotime("+$i month", $this->event->timestart))));
-            $this->assertTrue($result);
-        }
-
-        // This should generate 10 child event + 1 parent, since by then until bound would be hit.
-        $until = strtotime('+1 day +10 months', $this->event->timestart);
-        $until = date('YmdThis', $until);
-        $rrule = "FREQ=MONTHLY;BYDAY=1MO;UNTIL=$until";
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(10, $count);
-        for ($i = 0; $i < 10; $i++) {
-            $time = strtotime('1 Monday', strtotime("+$i months", $monthstart)) + $offset;
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id, 'timestart' => $time));
-            $this->assertTrue($result);
-        }
-
-        // This should generate 11 child event + 1 parent, since by then until bound would be hit.
-        $until = strtotime('+10 day +10 months', $this->event->timestart); // 12 oct 2014.
-        $until = date('YmdThis', $until);
-        $rrule = "FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=2,5;UNTIL=$until";
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(12, $count);
-        for ($i = 0; $i < 6; $i++) {
-            $moffset = $i * 2;
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => (strtotime("+$moffset month", $this->event->timestart))));
-            $this->assertTrue($result);
-            // Event on the 5th of a month.
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => (strtotime("+3 days +$moffset month", $this->event->timestart))));
-            $this->assertTrue($result);
-        }
-
-        // This should generate 11 child event + 1 parent, since by then until bound would be hit.
-        $until = strtotime('+20 day +10 months', $this->event->timestart); // 22 oct 2014.
-        $until = date('YmdTHis', $until);
-        $rrule = "FREQ=MONTHLY;INTERVAL=2;BYDAY=1MO,3WE;UNTIL=$until";
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(12, $count);
-        for ($i = 0; $i < 6; $i++) {
-            $moffset = $i * 2;
-            $time = strtotime("+$moffset month", $monthstart);
-            $time2 = strtotime("+1 Monday", $time) + $offset;
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id, 'timestart' => $time2));
-            $this->assertTrue($result);
-            $time2 = strtotime("+3 Wednesday", $time) + $offset;
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => $time2)); // Event on the 5th of a month.
-            $this->assertTrue($result);
-        }
-
-        // Forever event. This should generate events over 10 year period, on 2nd of every 12th month.
-        $rrule = 'FREQ=MONTHLY;INTERVAL=12;BYMONTHDAY=2';
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $until = time() + (YEARSECS * $mang::TIME_UNLIMITED_YEARS);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        for ($i = 0, $time = $this->event->timestart; $time < $until; $i++, $moffset = $i * 12,
-                $time = strtotime("+$moffset month", $this->event->timestart)) {
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => ($time)));
-            $this->assertTrue($result);
-        }
-
-        // Forever event. This should generate 10 child events + 1 parent over 10 year period, every 50th Monday.
-        $rrule = 'FREQ=MONTHLY;BYDAY=1MO;INTERVAL=12';
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $until = time() + (YEARSECS * $mang::TIME_UNLIMITED_YEARS);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(11, $count);
-        for ($i = 0, $moffset = 0, $time = $this->event->timestart; $time < $until; $i++, $moffset = $i * 12) {
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id, 'timestart' => ($time)));
-            $this->assertTrue($result);
-            $time = strtotime("+$moffset month", $monthstart);
-            $time = strtotime("+1 Monday", $time) + $offset;
-        }
-    }
-
-    /**
-     * Test recurrence rules for yearly frequency.
-     */
-    public function test_yearly_events() {
-        global $DB;
-
-        $rrule = 'FREQ=YEARLY;COUNT=3;BYMONTH=12'; // This should generate 3 events in total.
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(3, $count);
-        for ($i = 0, $time = $this->event->timestart; $i < 3; $i++, $time = strtotime("+$i years", $this->event->timestart)) {
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id, 'timestart' => $time));
-            $this->assertTrue($result);
-        }
-
-        // Create an event every december, until the time limit is hit.
-        $until = strtotime('+20 day +10 years', $this->event->timestart);
-        $until = date('YmdTHis', $until);
-        $rrule = "FREQ=YEARLY;BYMONTH=12;UNTIL=$until"; // Forever event.
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $until = time() + (YEARSECS * $mang::TIME_UNLIMITED_YEARS);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(11, $count);
-        for ($i = 0, $time = $this->event->timestart; $time < $until; $i++, $yoffset = $i * 2,
-            $time = strtotime("+$yoffset years", $this->event->timestart)) {
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => ($time)));
-            $this->assertTrue($result);
-        }
-
-        // This should generate 5 events in total, every second year in the month of december.
-        $rrule = 'FREQ=YEARLY;BYMONTH=12;INTERVAL=2;COUNT=5';
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(5, $count);
-        for ($i = 0, $time = $this->event->timestart; $i < 5; $i++, $yoffset = $i * 2,
-            $time = strtotime("+$yoffset years", $this->event->timestart)) {
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => ($time)));
-            $this->assertTrue($result);
-        }
-
-        $rrule = 'FREQ=YEARLY;BYMONTH=12;INTERVAL=2'; // Forever event.
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $until = time() + (YEARSECS * $mang::TIME_UNLIMITED_YEARS);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(6, $count);
-        for ($i = 0, $time = $this->event->timestart; $time < $until; $i++, $yoffset = $i * 2,
-            $time = strtotime("+$yoffset years", $this->event->timestart)) {
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => ($time)));
-            $this->assertTrue($result);
-        }
-
-        // This much seconds after the start of the day.
-        $offset = $this->event->timestart - mktime(0, 0, 0, date("n", $this->event->timestart), date("j", $this->event->timestart),
-                date("Y", $this->event->timestart));
-        $yearstart = mktime(0, 0, 0, 1, 1, date("Y", $this->event->timestart));
-
-        $rrule = 'FREQ=YEARLY;COUNT=3;BYMONTH=12;BYDAY=1MO'; // This should generate 3 events in total.
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(3, $count);
-        for ($i = 0; $i < 3; $i++) {
-            $time = strtotime("+11 months +$i years", $yearstart);
-            $time = strtotime("+1 Monday", $time) + $offset;
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id, 'timestart' => $time));
-            $this->assertTrue($result);
-        }
-
-        // Create an event every december, until the time limit is hit.
-        $until = strtotime('+20 day +10 years', $this->event->timestart);
-        $until = date('YmdTHis', $until);
-        $rrule = "FREQ=YEARLY;BYMONTH=12;UNTIL=$until;BYDAY=1MO";
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $until = time() + (YEARSECS * $mang::TIME_UNLIMITED_YEARS);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(11, $count);
-        for ($i = 0, $time = $this->event->timestart; $time < $until; $i++) {
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => ($time)));
-            $this->assertTrue($result);
-            $time = strtotime("+11 months +$i years", $yearstart);
-            $time = strtotime("+1 Monday", $time) + $offset;
-        }
-
-        // This should generate 5 events in total, every second year in the month of december.
-        $rrule = 'FREQ=YEARLY;BYMONTH=12;INTERVAL=2;COUNT=5;BYDAY=1MO';
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(5, $count);
-        for ($i = $yoffset = 0, $time = $this->event->timestart; $i < 5; $i++, $yoffset = $i * 2) {
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => ($time)));
-            $this->assertTrue($result);
-            $time = strtotime("+11 months +$yoffset years", $yearstart);
-            $time = strtotime("+1 Monday", $time) + $offset;
-        }
-
-        $rrule = 'FREQ=YEARLY;BYMONTH=12;INTERVAL=2;BYDAY=1MO'; // Forever event.
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $until = time() + (YEARSECS * $mang::TIME_UNLIMITED_YEARS);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(6, $count);
-        for ($i = 0, $time = $this->event->timestart; $time < $until; $i++, $yoffset = $i * 2) {
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => ($time)));
-            $this->assertTrue($result);
-            $time = strtotime("+11 months +$yoffset years", $yearstart);
-            $time = strtotime("+1 Monday", $time) + $offset;
-        }
-
-        $rrule = 'FREQ=YEARLY;INTERVAL=2'; // Forever event.
-        $mang = new \core_calendar\rrule_manager($rrule);
-        $until = time() + (YEARSECS * $mang::TIME_UNLIMITED_YEARS);
-        $mang->parse_rrule();
-        $mang->create_events($this->event);
-        $count = $DB->count_records('event', array('repeatid' => $this->event->id));
-        $this->assertEquals(6, $count);
-        for ($i = 0, $time = $this->event->timestart; $time < $until; $i++, $yoffset = $i * 2) {
-            $result = $DB->record_exists('event', array('repeatid' => $this->event->id,
-                    'timestart' => ($time)));
-            $this->assertTrue($result);
-            $time = strtotime("+11 months +$yoffset years", $yearstart);
-            $time = strtotime("+1 Monday", $time) + $offset;
-        }
-    }
-}
-
-/**
- * Class core_calendar_test_rrule_manager
- *
- * Wrapper to access protected vars for testing.
- *
- * @package core_calendar
- * @category test
- * @copyright 2014 onwards Ankit Agarwal <ankit.agrr@gmail.com>
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class core_tests_calendar_rrule_manager extends \core_calendar\rrule_manager{
-
-    /**
-     * Magic method to get properties.
-     *
-     * @param $prop string property
-     *
-     * @return mixed
-     * @throws coding_exception
-     */
-    public function __get($prop) {
-        if (property_exists($this, $prop)) {
-            return $this->$prop;
-        }
-        throw new coding_exception('invalidproperty');
-    }
-}
index 28437de..29c570b 100644 (file)
@@ -84,13 +84,17 @@ class completion_criteria_activity extends completion_criteria {
 
             $this->course = $data->id;
 
-            foreach (array_keys($data->criteria_activity) as $activity) {
-
-                $module = $DB->get_record('course_modules', array('id' => $activity));
-                $this->module = self::get_mod_name($module->module);
-                $this->moduleinstance = $activity;
-                $this->id = NULL;
-                $this->insert();
+            // Data comes from advcheckbox, so contains keys for all activities.
+            // A value of 0 is 'not checked' whereas 1 is 'checked'.
+            foreach ($data->criteria_activity as $activity => $val) {
+                // Only update those which are checked.
+                if (!empty($val)) {
+                    $module = $DB->get_record('course_modules', array('id' => $activity));
+                    $this->module = self::get_mod_name($module->module);
+                    $this->moduleinstance = $activity;
+                    $this->id = null;
+                    $this->insert();
+                }
             }
         }
     }
diff --git a/enrol/ldap/classes/task/sync_enrolments.php b/enrol/ldap/classes/task/sync_enrolments.php
new file mode 100644 (file)
index 0000000..e34b72a
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Sync enrolments task
+ * @package enrol_ldap
+ * @author    Guy Thomas <gthomas@moodlerooms.com>
+ * @copyright Copyright (c) 2017 Blackboard Inc.
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace enrol_ldap\task;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class sync_enrolments
+ * @package enrol_ldap
+ * @author    Guy Thomas <gthomas@moodlerooms.com>
+ * @copyright Copyright (c) 2017 Blackboard Inc.
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class sync_enrolments extends \core\task\scheduled_task {
+
+    /**
+     * Name for this task.
+     *
+     * @return string
+     */
+    public function get_name() {
+        return get_string('syncenrolmentstask', 'enrol_ldap');
+    }
+
+    /**
+     * Run task for synchronising users.
+     */
+    public function execute() {
+
+        if (!enrol_is_enabled('ldap')) {
+            mtrace(get_string('pluginnotenabled', 'enrol_ldap'));
+            exit(0); // Note, exit with success code, this is not an error - it's just disabled.
+        }
+
+        /** @var enrol_ldap_plugin $enrol */
+        $enrol = enrol_get_plugin('ldap');
+
+        $trace = new \text_progress_trace();
+
+        // Update enrolments -- these handlers should autocreate courses if required.
+        $enrol->sync_enrolments($trace);
+    }
+
+}
index c1bff98..8aedc9f 100644 (file)
@@ -33,6 +33,8 @@
  *   - For debugging & better logging, you are encouraged to use in the command line:
  *     -d log_errors=1 -d error_reporting=E_ALL -d display_errors=0 -d html_errors=0
  *
+ * @deprecated since Moodle 3.3 MDL-57631 - please do not use this CLI script any more, use scheduled task instead.
+ * @todo       MDL-58268 This will be deleted in Moodle 3.7.
  * @package    enrol_ldap
  * @author     IƱaki Arenaza - based on code by Martin Dougiamas, Martin Langhoff and others
  * @copyright  1999 onwards Martin Dougiamas {@link http://moodle.com}
@@ -48,6 +50,14 @@ require_once("$CFG->libdir/clilib.php");
 // Ensure errors are well explained.
 set_debugging(DEBUG_DEVELOPER, true);
 
+cli_problem('[ENROL LDAP] The sync enrolments cron script has been deprecated. Please use the scheduled task instead.');
+
+// Abort execution of the CLI script if the enrol_ldap\task\sync_enrolments is enabled.
+$task = \core\task\manager::get_scheduled_task('enrol_ldap\task\sync_enrolments');
+if (!$task->get_disabled()) {
+    cli_error('[ENROL LDAP] The scheduled task sync_enrolments is enabled, the cron execution has been aborted.');
+}
+
 if (!enrol_is_enabled('ldap')) {
     cli_error(get_string('pluginnotenabled', 'enrol_ldap'), 2);
 }
diff --git a/enrol/ldap/db/tasks.php b/enrol/ldap/db/tasks.php
new file mode 100644 (file)
index 0000000..c1c4577
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Task definition for enrol_ldap.
+ * @author    Guy Thomas <gthomas@moodlerooms.com>
+ * @copyright Copyright (c) 2017 Blackboard Inc.
+ * @package   enrol_ldap
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$tasks = array(
+    array(
+        'classname' => '\enrol_ldap\task\sync_enrolments',
+        'blocking' => 0,
+        'minute' => 'R',
+        'hour' => 'R',
+        'day' => '*',
+        'month' => '*',
+        'dayofweek' => '*',
+        'disabled' => 1
+    )
+);
+
index 5d8b877..6a5b1c5 100644 (file)
@@ -109,6 +109,7 @@ $string['role_mapping_context'] = 'LDAP contexts for {$a}';
 $string['role_mapping_key'] = 'Map roles from LDAP ';
 $string['roles'] = 'Role mapping';
 $string['server_settings'] = 'LDAP server settings';
+$string['syncenrolmentstask'] = 'Synchronise enrolments task';
 $string['synccourserole'] = "== Synching course '{\$a->idnumber}' for role '{\$a->role_shortname}'\n";
 $string['template'] = 'Optional: auto-created courses can copy their settings from a template course';
 $string['template_key'] = 'Template';
diff --git a/enrol/ldap/upgrade.txt b/enrol/ldap/upgrade.txt
new file mode 100644 (file)
index 0000000..d00991d
--- /dev/null
@@ -0,0 +1,4 @@
+This files describes API changes in the enrol_ldap code.
+
+=== 3.3 ===
+* enrol/ldap/cli/sync.php script has been deprecated in favour of enrol_ldap\task\sync_enrolments task.
index 927c6f3..e063bb7 100644 (file)
@@ -25,6 +25,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2016120500;        // The current plugin version (Date: YYYYMMDDXX)
+$plugin->version   = 2016120501;        // The current plugin version (Date: YYYYMMDDXX)
 $plugin->requires  = 2016112900;        // Requires this Moodle version
 $plugin->component = 'enrol_ldap';      // Full name of the plugin (used for diagnostics)
diff --git a/files/classes/conversion.php b/files/classes/conversion.php
new file mode 100644 (file)
index 0000000..07872ce
--- /dev/null
@@ -0,0 +1,360 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Classes for converting files between different file formats.
+ *
+ * @package    core_files
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_files;
+
+defined('MOODLE_INTERNAL') || die();
+
+use stored_file;
+
+/**
+ * Class representing a conversion currently in progress.
+ *
+ * @package    core_files
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class conversion extends \core\persistent {
+
+    /**
+     * Status value representing a conversion waiting to start.
+     */
+    const STATUS_PENDING = 0;
+
+    /**
+     * Status value representing a conversion in progress.
+     */
+    const STATUS_IN_PROGRESS = 1;
+
+    /**
+     * Status value representing a successful conversion.
+     */
+    const STATUS_COMPLETE = 2;
+
+    /**
+     * Status value representing a failed conversion.
+     */
+    const STATUS_FAILED = -1;
+
+    /**
+     * Table name for this persistent.
+     */
+    const TABLE = 'file_conversion';
+
+    protected static function define_properties() {
+        return array(
+            'sourcefileid' => [
+                'type' => PARAM_INT,
+            ],
+            'targetformat' => [
+                'type' => PARAM_ALPHANUMEXT,
+            ],
+            'status' => [
+                'type' => PARAM_INT,
+                'choices' => [
+                    self::STATUS_PENDING,
+                    self::STATUS_IN_PROGRESS,
+                    self::STATUS_COMPLETE,
+                    self::STATUS_FAILED,
+                ],
+                'default' => self::STATUS_PENDING,
+            ],
+            'statusmessage' => [
+                'type' => PARAM_RAW,
+                'null' => NULL_ALLOWED,
+                'default' => null,
+            ],
+            'converter' => [
+                'type' => PARAM_RAW,
+                'null' => NULL_ALLOWED,
+                'default' => null,
+            ],
+            'destfileid' => [
+                'type' => PARAM_INT,
+                'null' => NULL_ALLOWED,
+                'default' => null,
+            ],
+            'data' => [
+                'type' => PARAM_RAW,
+                'null' => NULL_ALLOWED,
+                'default' => null,
+            ],
+        );
+    }
+
+    /**
+     * Fetch all conversions relating to the specified file.
+     *
+     * Only conversions which have a valid file are returned.
+     *
+     * @param   stored_file $file The source file being converted
+     * @param   string $format The targetforamt to filter to
+     * @return  conversion[]
+     */
+    public static function get_conversions_for_file(stored_file $file, $format) {
+        global $DB;
+        $instances = [];
+
+        // Conversion records are intended for tracking a conversion in progress or recently completed.
+        // The record is removed periodically, but the destination file is not.
+        // We need to fetch all conversion records which match the source file and target, and also all source and
+        // destination files which do not have a conversion record.
+        $sqlfields = self::get_sql_fields('c', 'conversion');
+
+        // Fetch actual conversions which relate to the specified source file, and have a matching conversion record,
+        // and either have a valid destination file which still exists, or do not have a destination file at all.
+        $sql = "SELECT {$sqlfields}
+                FROM {" . self::TABLE . "} c
+                INNER JOIN {files} conversionsourcefile ON conversionsourcefile.id = c.sourcefileid
+                LEFT JOIN {files} conversiondestfile ON conversiondestfile.id = c.destfileid
+                WHERE
+                    conversionsourcefile.contenthash = :ccontenthash
+                AND c.targetformat = :cformat
+                AND (
+                    c.destfileid IS NULL OR conversiondestfile.id IS NOT NULL
+                )";
+
+        // Fetch a empty conversion record for each source/destination combination that we find to match where the
+        // destination file is in the correct filearea/filepath/filename combination to meet the requirements.
+        // This ensures that existing conversions are used where possible, even if there is no 'conversion' record for
+        // them.
+        $sql .= "
+            UNION ALL
+                SELECT
+                    NULL AS conversionid,
+                    orphanedsourcefile.id AS conversionsourcefileid,
+                    :oformat AS conversiontargetformat,
+                    2 AS conversionstatus,
+                    NULL AS conversionstatusmessage,
+                    NULL AS conversionconverter,
+                    orphaneddestfile.id AS conversiondestfileid,
+                    NULL AS conversiondata,
+                    0 AS conversiontimecreated,
+                    0 AS conversiontimemodified,
+                    0 AS conversionusermodified
+                FROM {files} orphanedsourcefile
+                INNER JOIN {files} orphaneddestfile ON (
+                        orphaneddestfile.filename = orphanedsourcefile.contenthash
+                    AND orphaneddestfile.component = 'core'
+                    AND orphaneddestfile.filearea = 'documentconversion'
+                    AND orphaneddestfile.filepath = :ofilepath
+                )
+                LEFT JOIN {" . self::TABLE . "} orphanedconversion ON orphanedconversion.destfileid = orphaneddestfile.id
+                WHERE
+                    orphanedconversion.id IS NULL
+                AND
+                    orphanedsourcefile.id = :osourcefileid
+                ";
+        $records = $DB->get_records_sql($sql, [
+            'ccontenthash' => $file->get_contenthash(),
+            'osourcefileid' => $file->get_id(),
+            'cfilepath' => "/{$format}/",
+            'ofilepath' => "/{$format}/",
+            'cformat' => $format,
+            'oformat' => $format,
+        ]);
+
+        foreach ($records as $record) {
+            $data = self::extract_record($record, 'conversion');
+            $newrecord = new static(0, $data);
+            $instances[] = $newrecord;
+        }
+
+        return $instances;
+    }
+
+    /**
+     * Remove all old conversion records.
+     */
+    public static function remove_old_conversion_records() {
+        global $DB;
+
+        $DB->delete_records_select(self::TABLE, 'timemodified <= :weekagosecs', [
+            'weekagosecs' => time() - WEEKSECS,
+        ]);
+    }
+
+    /**
+     * Set the source file id for the conversion.
+     *
+     * @param   stored_file $file The file to convert
+     * @return  $this
+     */
+    public function set_sourcefile(stored_file $file) {
+        $this->raw_set('sourcefileid', $file->get_id());
+
+        return $this;
+    }
+
+    /**
+     * Fetch the source file.
+     *
+     * @return  stored_file|false
+     */
+    public function get_sourcefile() {
+        $fs = get_file_storage();
+
+        return $fs->get_file_by_id($this->get('sourcefileid'));
+    }
+
+    /**
+     * Set the destination file for this conversion.
+     *
+     * @param   string $filepath The path to the converted file
+     * @return  $this
+     */
+    public function store_destfile_from_path($filepath) {
+        if ($record = $this->get_file_record()) {
+            $fs = get_file_storage();
+            $existing = $fs->get_file(
+                $record['contextid'],
+                $record['component'],
+                $record['filearea'],
+                $record['itemid'],
+                $record['filepath'],
+                $record['filename']
+            );
+            if ($existing) {
+                $existing->delete();
+            }
+            $file = $fs->create_file_from_pathname($record, $filepath);
+
+            $this->raw_set('destfileid', $file->get_id());
+        }
+
+        return $this;
+    }
+
+    /**
+     * Set the destination file for this conversion.
+     *
+     * @param   string $content The content of the converted file
+     * @return  $this
+     */
+    public function store_destfile_from_string($content) {
+        if ($record = $this->get_file_record()) {
+            $fs = get_file_storage();
+            $existing = $fs->get_file(
+                $record['contextid'],
+                $record['component'],
+                $record['filearea'],
+                $record['itemid'],
+                $record['filepath'],
+                $record['filename']
+            );
+            if ($existing) {
+                $existing->delete();
+            }
+            $file = $fs->create_file_from_string($record, $content);
+
+            $this->raw_set('destfileid', $file->get_id());
+        }
+
+        return $this;
+    }
+
+    /**
+     * Get the destination file.
+     *
+     * @return  stored_file|this
+     */
+    public function get_destfile() {
+        $fs = get_file_storage();
+
+        return $fs->get_file_by_id($this->get('destfileid'));
+    }
+
+    /**
+     * Helper to ensure that the returned status is always an int.
+     *
+     * @return  int
+     */
+    protected function get_status() {
+        return (int) $this->raw_get('status');
+    }
+
+    /**
+     * Get an instance of the current converter.
+     *
+     * @return  converter_interface|false
+     */
+    public function get_converter_instance() {
+        $currentconverter = $this->get('converter');
+
+        if ($currentconverter && class_exists($currentconverter)) {
+            return new $currentconverter();
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Transform data into a storable format.
+     *
+     * @param   stdClass $data The data to be stored
+     * @return  $this
+     */
+    protected function set_data($data) {
+        $this->raw_set('data', json_encode($data));
+
+        return $this;
+    }
+
+    /**
+     * Transform data into a storable format.
+     *
+     * @return  stdClass The stored data
+     */
+    protected function get_data() {
+        $data = $this->raw_get('data');
+
+        if (!empty($data)) {
+            return json_decode($data);
+        }
+
+        return (object) [];
+    }
+
+    /**
+     * Return the file record base for use in the files table.
+     *
+     * @return  array
+     */
+    protected function get_file_record() {
+        $file = $this->get_sourcefile();
+
+        if (!$file) {
+            // If the source file was removed before we completed, we must return early.
+            return false;
+        }
+
+        return [
+            'contextid' => \context_system::instance()->id,
+            'component' => 'core',
+            'filearea'  => 'documentconversion',
+            'itemid'    => 0,
+            'filepath'  => "/" . $this->get('targetformat') . "/",
+            'filename'  => $file->get_contenthash(),
+        ];
+    }
+}
diff --git a/files/classes/converter.php b/files/classes/converter.php
new file mode 100644 (file)
index 0000000..d39d84f
--- /dev/null
@@ -0,0 +1,247 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Class for converting files between different file formats using unoconv.
+ *
+ * @package    core_files
+ * @copyright  2017 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_files;
+
+defined('MOODLE_INTERNAL') || die();
+
+use stored_file;
+
+/**
+ * Class for converting files between different formats using unoconv.
+ *
+ * @package    core_files
+ * @copyright  2017 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class converter {
+
+    /**
+     * Get a list of enabled plugins and classes.
+     *
+     * @return  array
+     */
+    protected function get_enabled_plugins() {
+        $plugins = \core\plugininfo\fileconverter::get_enabled_plugins();
+
+        $pluginclasses = [];
+        foreach ($plugins as $plugin) {
+            $pluginclasses[$plugin] = \core\plugininfo\fileconverter::get_classname($plugin);
+        }
+
+        return $pluginclasses;
+    }
+
+    /**
+     * Return the file_storage API.
+     *
+     * This allows for mocking of the file_storage API.
+     *
+     * @return file_storage
+     */
+    protected function get_file_storage() {
+        return get_file_storage();
+    }
+
+    /**
+     * Start the conversion for a stored_file into a new format.
+     *
+     * @param   stored_file $file The file to convert
+     * @param   string $format The desired target file format (file extension)
+     * @param   boolean $forcerefresh If true, the file will be converted every time (not cached).
+     * @return  conversion
+     */
+    public function start_conversion(stored_file $file, $format, $forcerefresh = false) {
+        $conversions = conversion::get_conversions_for_file($file, $format);
+
+        if ($forcerefresh || count($conversions) > 1) {
+            while ($conversion = array_shift($conversions)) {
+                if ($conversion->get('id')) {
+                    $conversion->delete();
+                }
+            }
+        }
+
+        if (empty($conversions)) {
+            $conversion = new conversion(0, (object) [
+                'sourcefileid' => $file->get_id(),
+                'targetformat' => $format,
+            ]);
+            $conversion->create();
+        } else {
+            $conversion = array_shift($conversions);
+        }
+
+        if ($conversion->get('status') !== conversion::STATUS_COMPLETE) {
+            $this->poll_conversion($conversion);
+        }
+
+        return $conversion;
+    }
+
+    /**
+     * Poll for updates to the supplied conversion.
+     *
+     * @param   conversion $conversion The conversion in progress
+     * @return  $this
+     */
+    public function poll_conversion(conversion $conversion) {
+        $format = $conversion->get('targetformat');
+        $file = $conversion->get_sourcefile();
+
+        if ($conversion->get('status') == conversion::STATUS_IN_PROGRESS) {
+            // The current conversion is in progress.
+            // Check for updates.
+            if ($instance = $conversion->get_converter_instance()) {
+                $instance->poll_conversion_status($conversion);
+            } else {
+                // Unable to fetch the converter instance.
+                // Reset the status back to PENDING so that it may be picked up again.
+                $conversion->set('status', conversion::STATUS_PENDING);
+                $conversion->update();
+            }
+        }
+
+        // Refresh the status.
+        $status = $conversion->get('status');
+        if ($status === conversion::STATUS_PENDING || $status === conversion::STATUS_FAILED) {
+            // The current status is either pending or failed.
+            // Attempt to pick up a new converter and convert the document.
+            $from = \core_filetypes::get_file_extension($file->get_mimetype());
+            $converters = $this->get_document_converter_classes($from, $format);
+            $currentconverter = $this->get_next_converter($converters, $conversion->get('converter'));
+
+            if (!$currentconverter) {
+                // No more converters available.
+                $conversion->set('status', conversion::STATUS_FAILED);
+                return $this;
+            }
+
+            do {
+                $conversion
+                    ->set('converter', $currentconverter)
+                    ->set('status', conversion::STATUS_IN_PROGRESS)
+                    ->update();
+
+                $instance = $conversion->get_converter_instance();
+                $instance->start_document_conversion($conversion);
+                $failed = $conversion->get('status') === conversion::STATUS_FAILED;
+                $currentconverter = $this->get_next_converter($converters, $currentconverter);
+            } while ($failed && $currentconverter);
+
+            $conversion->update();
+        }
+
+        return $this;
+    }
+
+    /**
+     * Fetch the next converter to try.
+     *
+     * @param   array $converters The list of converters to try
+     * @param   string|null $currentconverter The converter currently in use
+     * @return  string|false
+     */
+    protected function get_next_converter($converters, $currentconverter = null) {
+        if ($currentconverter) {
+            $keys = array_keys($converters, $currentconverter);
+            $key = $keys[0];
+            if (isset($converters[$key + 1])) {
+                return $converters[$key + 1];
+            } else {
+                return false;
+            }
+        } else if (!empty($converters)) {
+            return $converters[0];
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Fetch the class for the preferred document converter.
+     *
+     * @param   string $from The source target file (file extension)
+     * @param   string $to The desired target file format (file extension)
+     * @return  string The class for document conversion
+     */
+    protected function get_document_converter_classes($from, $to) {
+        $classes = [];
+
+        $converters = $this->get_enabled_plugins();
+        foreach ($converters as $plugin => $classname) {
+            if (!class_exists($classname)) {
+                continue;
+            }
+
+            if (!$classname::are_requirements_met()) {
+                continue;
+            }
+
+            if ($classname::supports($from, $to)) {
+                $classes[] = $classname;
+            }
+        }
+
+        return $classes;
+    }
+
+    /**
+     * Check whether document conversion is supported for this file and target format.
+     *
+     * @param   stored_file $file The file to convert
+     * @param   string $to The desired target file format (file extension)
+     * @return  bool Whether the target type can be converted
+     */
+    public function can_convert_storedfile_to(stored_file $file, $to) {
+        if ($file->is_directory()) {
+            // Directories cannot be converted.
+            return false;
+        }
+
+        if (!$file->get_filesize()) {
+            // Empty files cannot be converted.
+            return false;
+        }
+
+        $from = \core_filetypes::get_file_extension($file->get_mimetype());
+        if (!$from) {
+            // No mimetype could be found. Unable to determine converter.
+            return false;
+        }
+
+        return $this->can_convert_format_to($from, $to);
+    }
+
+    /**
+     * Check whether document conversion is supported for this file and target format.
+     *
+     * @param   string $from The source target file (file extension)
+     * @param   string $to The desired target file format (file extension)
+     * @return  bool Whether the target type can be converted
+     */
+    public function can_convert_format_to($from, $to) {
+        return !empty($this->get_document_converter_classes($from, $to));
+    }
+
+}
diff --git a/files/classes/converter_interface.php b/files/classes/converter_interface.php
new file mode 100644 (file)
index 0000000..52c1a94
--- /dev/null
@@ -0,0 +1,81 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Class for converting files between different file formats.
+ *
+ * @package    core_files
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_files;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class for converting files between different file formats.
+ *
+ * @package    docconvert_unoconv
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface converter_interface {
+
+    /**
+     * Whether the plugin is configured and requirements are met.
+     *
+     * Note: This function may be called frequently and caching is advisable.
+     *
+     * @return  bool
+     */
+    public static function are_requirements_met();
+
+    /**
+     * Convert a document to a new format and return a conversion object relating to the conversion in progress.
+     *
+     * @param   conversion $conversion The file to be converted
+     * @return  $this
+     */
+    public function start_document_conversion(conversion $conversion);
+
+    /**
+     * Poll an existing conversion for status update.
+     *
+     * @param   conversion $conversion The file to be converted
+     * @return  $this
+     */
+    public function poll_conversion_status(conversion $conversion);
+
+    /**
+     * Determine whether a conversion between the two supplied formats is achievable.
+     *
+     * Note: This function may be called frequently and caching is advisable.
+     *
+     * @param   string $from The source type
+     * @param   string $to The destination type
+     * @return  bool
+     */
+    public static function supports($from, $to);
+
+    /**
+     * A list of the supported conversions.
+     *
+     * Note: This information is only displayed to administrators.
+     *
+     * @return  string
+     */
+    public function get_supported_conversions();
+}
diff --git a/files/classes/task/conversion_cleanup_task.php b/files/classes/task/conversion_cleanup_task.php
new file mode 100644 (file)
index 0000000..1353b5b
--- /dev/null
@@ -0,0 +1,54 @@
+<?php
+
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A scheduled task to clear up old conversion records.
+ *
+ * @package    core_files
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_files\task;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * A scheduled task to clear up old conversion records.
+ *
+ * @package    core_files
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class conversion_cleanup_task extends \core\task\scheduled_task {
+
+    /**
+     * Get a descriptive name for this task (shown to admins).
+     *
+     * @return string
+     */
+    public function get_name() {
+        return get_string('fileconversioncleanuptask', 'admin');
+    }
+
+    /**
+     * Run task.
+     */
+    public function execute() {
+        \core_files\conversion::remove_old_conversion_records();
+    }
+
+}
diff --git a/files/converter/unoconv/classes/converter.php b/files/converter/unoconv/classes/converter.php
new file mode 100644 (file)
index 0000000..0aaf5bb
--- /dev/null
@@ -0,0 +1,362 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Class for converting files between different file formats using unoconv.
+ *
+ * @package    fileconverter_unoconv
+ * @copyright  2017 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace fileconverter_unoconv;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/filelib.php');
+use stored_file;
+
+use \core_files\conversion;
+
+/**
+ * Class for converting files between different formats using unoconv.
+ *
+ * @package    fileconverter_unoconv
+ * @copyright  2017 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class converter implements \core_files\converter_interface {
+
+    /** No errors */
+    const UNOCONVPATH_OK = 'ok';
+
+    /** Not set */
+    const UNOCONVPATH_EMPTY = 'empty';
+
+    /** Does not exist */
+    const UNOCONVPATH_DOESNOTEXIST = 'doesnotexist';
+
+    /** Is a dir */
+    const UNOCONVPATH_ISDIR = 'isdir';
+
+    /** Not executable */
+    const UNOCONVPATH_NOTEXECUTABLE = 'notexecutable';
+
+    /** Test file missing */
+    const UNOCONVPATH_NOTESTFILE = 'notestfile';
+
+    /** Version not supported */
+    const UNOCONVPATH_VERSIONNOTSUPPORTED = 'versionnotsupported';
+
+    /** Any other error */
+    const UNOCONVPATH_ERROR = 'error';
+
+    /**
+     * @var bool $requirementsmet Whether requirements have been met.
+     */
+    protected static $requirementsmet = null;
+
+    /**
+     * @var array $formats The list of formats supported by unoconv.
+     */
+    protected static $formats;
+
+    /**
+     * Convert a document to a new format and return a conversion object relating to the conversion in progress.
+     *
+     * @param   conversion $conversion The file to be converted
+     * @return  $this
+     */
+    public function start_document_conversion(\core_files\conversion $conversion) {
+        global $CFG;
+
+        if (!self::are_requirements_met()) {
+            $conversion->set('status', conversion::STATUS_FAILED);
+
+            return $this;
+        }
+
+        $file = $conversion->get_sourcefile();
+
+        // Sanity check that the conversion is supported.
+        $fromformat = pathinfo($file->get_filename(), PATHINFO_EXTENSION);
+        if (!self::is_format_supported($fromformat)) {
+            $conversion->set('status', conversion::STATUS_FAILED);
+
+            return $this;
+        }
+
+        $format = $conversion->get('targetformat');
+        if (!self::is_format_supported($format)) {
+            $conversion->set('status', conversion::STATUS_FAILED);
+
+            return $this;
+        }
+
+        // Update the status to IN_PROGRESS.
+        $conversion->set('status', \core_files\conversion::STATUS_IN_PROGRESS);
+        $conversion->update();
+
+        // Copy the file to the tmp dir.
+        $uniqdir = make_unique_writable_directory(make_temp_directory('core_file/conversions'));
+        \core_shutdown_manager::register_function('remove_dir', array($uniqdir));
+        $localfilename = $file->get_id() . '.' . $fromformat;
+
+        $filename = $uniqdir . '/' . $localfilename;
+        try {
+            // This function can either return false, or throw an exception so we need to handle both.
+            if ($file->copy_content_to($filename) === false) {
+                throw new file_exception('storedfileproblem', 'Could not copy file contents to temp file.');
+            }
+        } catch (file_exception $fe) {
+            throw $fe;
+        }
+
+        // The temporary file to copy into.
+        $newtmpfile = pathinfo($filename, PATHINFO_FILENAME) . '.' . $format;
+        $newtmpfile = $uniqdir . '/' . clean_param($newtmpfile, PARAM_FILE);
+
+        $cmd = escapeshellcmd(trim($CFG->pathtounoconv)) . ' ' .
+               escapeshellarg('-f') . ' ' .
+               escapeshellarg($format) . ' ' .
+               escapeshellarg('-o') . ' ' .
+               escapeshellarg($newtmpfile) . ' ' .
+               escapeshellarg($filename);
+
+        $output = null;
+        $currentdir = getcwd();
+        chdir($uniqdir);
+        $result = exec($cmd, $output);
+        chdir($currentdir);
+        touch($newtmpfile);
+        if (filesize($newtmpfile) === 0) {
+            $conversion->set('status', conversion::STATUS_FAILED);
+
+            return $this;
+        }
+
+        $conversion
+            ->store_destfile_from_path($newtmpfile)
+            ->set('status', conversion::STATUS_COMPLETE)
+            ->update();
+
+        return $this;
+    }
+
+    /**
+     * Poll an existing conversion for status update.
+     *
+     * @param   conversion $conversion The file to be converted
+     * @return  $this
+     */
+    public function poll_conversion_status(conversion $conversion) {
+        // Unoconv does not support asynchronous conversion.
+        return $this;
+    }
+
+    /**
+     * Generate and serve the test document.
+     *
+     * @return  stored_file
+     */
+    public function serve_test_document() {
+        global $CFG;
+        require_once($CFG->libdir . '/filelib.php');
+
+        $format = 'pdf';
+
+        $filerecord = [
+            'contextid' => \context_system::instance()->id,
+            'component' => 'test',
+            'filearea' => 'fileconverter_unoconv',
+            'itemid' => 0,
+            'filepath' => '/',
+            'filename' => 'unoconv_test.docx'
+        ];
+
+        // Get the fixture doc file content and generate and stored_file object.
+        $fs = get_file_storage();
+        $testdocx = $fs->get_file($filerecord['contextid'], $filerecord['component'], $filerecord['filearea'],
+                $filerecord['itemid'], $filerecord['filepath'], $filerecord['filename']);
+
+        if (!$testdocx) {
+            $fixturefile = dirname(__DIR__) . '/tests/fixtures/unoconv-source.docx';
+            $testdocx = $fs->create_file_from_pathname($filerecord, $fixturefile);
+        }
+
+        $conversions = conversion::get_conversions_for_file($testdocx, $format);
+
+        $conversion = new conversion(0, (object) [
+                'sourcefileid' => $testdocx->get_id(),
+                'targetformat' => $format,
+            ]);
+        $conversion->create();
+
+        // Convert the doc file to the target format and send it direct to the browser.
+        $conversion = $this->start_document_conversion($conversion);
+        do {
+            sleep(1);
+            $this->poll_conversion_status($conversion);
+            $status = $conversion->get('status');
+        } while ($status !== conversion::STATUS_COMPLETE && $status !== conversion::STATUS_FAILED);
+
+        readfile_accel($conversion->get_destfile(), 'application/pdf', true);
+    }
+
+    /**
+     * Whether the plugin is configured and requirements are met.
+     *
+     * @return  bool
+     */
+    public static function are_requirements_met() {
+        if (self::$requirementsmet === null) {
+            $requirementsmet = self::test_unoconv_path()->status === self::UNOCONVPATH_OK;
+            $requirementsmet = $requirementsmet && self::is_minimum_version_met();
+            self::$requirementsmet = $requirementsmet;
+        }
+
+        return self::$requirementsmet;
+    }
+
+    /**
+     * Whether the minimum version of unoconv has been met.
+     *
+     * @return  bool
+     */
+    protected static function is_minimum_version_met() {
+        global $CFG;
+
+        $currentversion = 0;
+        $supportedversion = 0.7;
+        $unoconvbin = \escapeshellarg($CFG->pathtounoconv);
+        $command = "$unoconvbin --version";
+        exec($command, $output);
+
+        // If the command execution returned some output, then get the unoconv version.
+        if ($output) {
+            foreach ($output as $response) {
+                if (preg_match('/unoconv (\\d+\\.\\d+)/', $response, $matches)) {
+                    $currentversion = (float) $matches[1];
+                }
+            }
+            if ($currentversion < $supportedversion) {
+                return false;
+            } else {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Whether the plugin is fully configured.
+     *
+     * @return  bool
+     */
+    public static function test_unoconv_path() {
+        global $CFG;
+
+        $unoconvpath = $CFG->pathtounoconv;
+
+        $ret = new \stdClass();
+        $ret->status = self::UNOCONVPATH_OK;
+        $ret->message = null;
+
+        if (empty($unoconvpath)) {
+            $ret->status = self::UNOCONVPATH_EMPTY;
+            return $ret;
+        }
+        if (!file_exists($unoconvpath)) {
+            $ret->status = self::UNOCONVPATH_DOESNOTEXIST;
+            return $ret;
+        }
+        if (is_dir($unoconvpath)) {
+            $ret->status = self::UNOCONVPATH_ISDIR;
+            return $ret;
+        }
+        if (!\file_is_executable($unoconvpath)) {
+            $ret->status = self::UNOCONVPATH_NOTEXECUTABLE;
+            return $ret;
+        }
+        if (!self::is_minimum_version_met()) {
+            $ret->status = self::UNOCONVPATH_VERSIONNOTSUPPORTED;
+            return $ret;
+        }
+
+        return $ret;
+
+    }
+
+    /**
+     * Whether a file conversion can be completed using this converter.
+     *
+     * @param   string $from The source type
+     * @param   string $to The destination type
+     * @return  bool
+     */
+    public static function supports($from, $to) {
+        return self::is_format_supported($from) && self::is_format_supported($to);
+    }
+
+    /**
+     * Whether the specified file format is supported.
+     *
+     * @param   string $format Whether conversions between this format and another are supported
+     * @return  bool
+     */
+    protected static function is_format_supported($format) {
+        $formats = self::fetch_supported_formats();
+
+        $format = trim(\core_text::strtolower($format));
+        return in_array($format, $formats);
+    }
+
+    /**
+     * Fetch the list of supported file formats.
+     *
+     * @return  array
+     */
+    protected static function fetch_supported_formats() {
+        global $CFG;
+
+        if (!isset(self::$formats)) {
+            // Ask unoconv for it's list of supported document formats.
+            $cmd = escapeshellcmd(trim($CFG->pathtounoconv)) . ' --show';
+            $pipes = array();
+            $pipesspec = array(2 => array('pipe', 'w'));
+            $proc = proc_open($cmd, $pipesspec, $pipes);
+            $programoutput = stream_get_contents($pipes[2]);
+            fclose($pipes[2]);
+            proc_close($proc);
+            $matches = array();
+            preg_match_all('/\[\.(.*)\]/', $programoutput, $matches);
+
+            $formats = $matches[1];
+            self::$formats = array_unique($formats);
+        }
+
+        return self::$formats;
+    }
+
+    /**
+     * A list of the supported conversions.
+     *
+     * @return  string
+     */
+    public function get_supported_conversions() {
+        return implode(', ', self::fetch_supported_formats());
+    }
+}
diff --git a/files/converter/unoconv/db/install.php b/files/converter/unoconv/db/install.php
new file mode 100644 (file)
index 0000000..9d1d03a
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Installation for unoconv.
+ *
+ * @package   fileconverter_unoconv
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+function xmldb_fileconverter_unoconv_install() {
+    global $CFG;
+
+    $unoconvpresent = !empty($CFG->pathtounoconv);
+    $unoconvpresent = $unoconvpresent && file_exists($CFG->pathtounoconv);
+    $unoconvpresent = $unoconvpresent && !is_dir($CFG->pathtounoconv);
+    $unoconvpresent = $unoconvpresent && file_is_executable($CFG->pathtounoconv);
+    if ($unoconvpresent) {
+        // Unoconv is currently configured correctly.
+        // Enable it.
+        $plugins = \core_plugin_manager::instance()->get_plugins_of_type('fileconverter');
+        $plugins['unoconv']->set_enabled(true);
+    }
+}
diff --git a/files/converter/unoconv/lang/en/fileconverter_unoconv.php b/files/converter/unoconv/lang/en/fileconverter_unoconv.php
new file mode 100644 (file)
index 0000000..b01fda3
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Strings for plugin 'fileconverter_unoconv'
+ *
+ * @package   fileconverter_unoconv
+ * @copyright 2017 Damyon Wiese
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['pathtounoconv'] = 'Path to unoconv document converter';
+$string['pathtounoconv_help'] = 'Path to unoconv document converter. This is an executable that is capable of converting between document formats supported by LibreOffice. This is optional, but if specified, Moodle will use it to automatically convert between document formats. This is used to support a wider range of input files for the assignment annotate PDF feature.';
+$string['pluginname'] = 'Unoconv';
+$string['test_unoconv'] = 'Test unoconv path';
+$string['test_unoconvdoesnotexist'] = 'The unoconv path does not point to the unoconv program. Please review your path settings.';
+$string['test_unoconvdownload'] = 'Download the converted pdf test file.';
+$string['test_unoconvempty'] = 'The unoconv path is not set. Please review your path settings.';
+$string['test_unoconvisdir'] = 'The unoconv path points to a folder, please include the unoconv program in the path you specify';
+$string['test_unoconvnotestfile'] = 'The test document to be coverted into a PDF is missing';
+$string['test_unoconvnotexecutable'] = 'The unoconv path points to a file that is not executable';
+$string['test_unoconvok'] = 'The unoconv path appears to be properly configured.';
+$string['test_unoconvversionnotsupported'] = 'The version of unoconv you have installed is not supported.';
diff --git a/files/converter/unoconv/settings.php b/files/converter/unoconv/settings.php
new file mode 100644 (file)
index 0000000..97a0359
--- /dev/null
@@ -0,0 +1,36 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Settings for unoconv.
+ *
+ * @package   fileconverter_unoconv
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+// Unoconv setting.
+$settings->add(new admin_setting_configexecutable('pathtounoconv',
+        new lang_string('pathtounoconv', 'fileconverter_unoconv'),
+        new lang_string('pathtounoconv_help', 'fileconverter_unoconv'),
+        '/usr/bin/unoconv')
+    );
+
+$url = new moodle_url('/files/converter/unoconv/testunoconv.php');
+$link = html_writer::link($url, get_string('test_unoconv', 'fileconverter_unoconv'));
+$settings->add(new admin_setting_heading('test_unoconv', '', $link));
diff --git a/files/converter/unoconv/tests/converter_test.php b/files/converter/unoconv/tests/converter_test.php
new file mode 100644 (file)
index 0000000..78cf8cb
--- /dev/null
@@ -0,0 +1,142 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Test unoconv functionality.
+ *
+ * @package    core
+ * @copyright  2016 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+
+/**
+ * A set of tests for some of the unoconv functionality within Moodle.
+ *
+ * @package    core
+ * @copyright  2016 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class fileconverter_unoconv_converter_testcase extends advanced_testcase {
+
+    /**
+     * Helper to skip tests which _require_ unoconv.
+     */
+    protected function require_unoconv() {
+        global $CFG;
+
+        if (empty($CFG->pathtounoconv) || !file_is_executable(trim($CFG->pathtounoconv))) {
+            // No conversions are possible, sorry.
+            $this->markTestSkipped();
+        }
+    }
+
+    /**
+     * Get a testable mock of the fileconverter_unoconv class.
+     *
+     * @param   array   $mockedmethods A list of methods you intend to override
+     *                  If no methods are specified, only abstract functions are mocked.
+     * @return  \fileconverter_unoconv\converter
+     */
+    protected function get_testable_mock($mockedmethods = null) {
+        $converter = $this->getMockBuilder(\fileconverter_unoconv\converter::class)
+            ->setMethods($mockedmethods)
+            ->getMock();
+
+        return $converter;
+    }
+
+    /**
+     * Tests for the start_document_conversion function.
+     */
+    public function test_start_document_conversion() {
+        $this->resetAfterTest();
+
+        $this->require_unoconv();
+
+        // Mock the file to be converted.
+        $filerecord = [
+            'contextid' => context_system::instance()->id,
+            'component' => 'test',
+            'filearea'  => 'unittest',
+            'itemid'    => 0,
+            'filepath'  => '/',
+            'filename'  => 'test.docx',
+        ];
+        $fs = get_file_storage();
+        $source = __DIR__ . DIRECTORY_SEPARATOR . 'fixtures' . DIRECTORY_SEPARATOR . 'unoconv-source.docx';
+        $testfile = $fs->create_file_from_pathname($filerecord, $source);
+
+        $converter = $this->get_testable_mock();
+        $conversion = new \core_files\conversion(0, (object) [
+            'targetformat' => 'pdf',
+        ]);
+        $conversion->set_sourcefile($testfile);
+        $conversion->create();
+
+        // Convert the document.
+        $converter->start_document_conversion($conversion);
+        $result = $conversion->get_destfile();
+        $this->assertNotFalse($result);
+        $this->assertSame('application/pdf', $result->get_mimetype());
+        $this->assertGreaterThan(0, $result->get_filesize());
+    }
+
+    /**
+     * Tests for the test_unoconv_path function.
+     *
+     * @dataProvider provider_test_unoconv_path
+     * @param   string $path The path to test
+     * @param   int $status The expected status
+     */
+    public function test_test_unoconv_path($path, $status) {
+        global $CFG;
+
+        $this->resetAfterTest();
+
+        // Set the current path.
+        $CFG->pathtounoconv = $path;
+
+        // Run the tests.
+        $result = \fileconverter_unoconv\converter::test_unoconv_path();
+
+        $this->assertEquals($status, $result->status);
+    }
+
+    /**
+     * Provider for test_unoconv_path.
+     *
+     * @return  array
+     */
+    public function provider_test_unoconv_path() {
+        return [
+            'Empty path' => [
+                'path' => null,
+                'status' => \fileconverter_unoconv\converter::UNOCONVPATH_EMPTY,
+            ],
+            'Invalid file' => [
+                'path' => '/path/to/nonexistent/file',
+                'status' => \fileconverter_unoconv\converter::UNOCONVPATH_DOESNOTEXIST,
+            ],
+            'Directory' => [
+                'path' => __DIR__,
+                'status' => \fileconverter_unoconv\converter::UNOCONVPATH_ISDIR,
+            ],
+        ];
+    }
+}
similarity index 72%
rename from mod/assign/feedback/editpdf/testunoconv.php
rename to files/converter/unoconv/testunoconv.php
index ba81b78..ab89c7a 100644 (file)
 //
 // You should have received a copy of the GNU General Public License
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
 /**
  * Test that unoconv is configured correctly
  *
- * @package   assignfeedback_editpdf
- * @copyright 2016 Simey Lameze
+ * @package   fileconverter_unoconv
+ * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-require(__DIR__ . '/../../../../config.php');
+require(__DIR__ . '/../../../config.php');
 require_once($CFG->libdir . '/filelib.php');
 
 $sendpdf = optional_param('sendpdf', 0, PARAM_BOOL);
 
-$PAGE->set_url(new moodle_url('/mod/assign/feedback/editpdf/testunoconv.php'));
+$PAGE->set_url(new moodle_url('/files/converter/unoconv/testunoconv.php'));
 $PAGE->set_context(context_system::instance());
 
 require_login();
 require_capability('moodle/site:config', context_system::instance());
 
-$strheading = get_string('test_unoconv', 'assignfeedback_editpdf');
+$strheading = get_string('test_unoconv', 'fileconverter_unoconv');
 $PAGE->navbar->add(get_string('administrationsite'));
 $PAGE->navbar->add(get_string('plugins', 'admin'));
 $PAGE->navbar->add(get_string('assignmentplugins', 'mod_assign'));
 $PAGE->navbar->add(get_string('feedbackplugins', 'mod_assign'));
-$PAGE->navbar->add(get_string('pluginname', 'assignfeedback_editpdf'),
-        new moodle_url('/admin/settings.php', array('section' => 'assignfeedback_editpdf')));
+$PAGE->navbar->add(get_string('pluginname', 'fileconverter_unoconv'),
+        new moodle_url('/admin/settings.php', array('section' => 'fileconverterunoconv')));
 $PAGE->navbar->add($strheading);
 $PAGE->set_heading($strheading);
 $PAGE->set_title($strheading);
+
+$converter = new \fileconverter_unoconv\converter();
+
 if ($sendpdf) {
     require_sesskey();
-    // Serve the generated test pdf.
-    file_storage::send_test_pdf();
+
+    $converter->serve_test_document();
     die();
 }
 
-$result = file_storage::test_unoconv_path();
+$result = \fileconverter_unoconv\converter::test_unoconv_path();
 switch ($result->status) {
-    case file_storage::UNOCONVPATH_OK:
-        $msg = $OUTPUT->notification(get_string('test_unoconvok', 'assignfeedback_editpdf'), 'success');
+    case \fileconverter_unoconv\converter::UNOCONVPATH_OK:
+        $msg = $OUTPUT->notification(get_string('test_unoconvok', 'fileconverter_unoconv'), 'success');
         $pdflink = new moodle_url($PAGE->url, array('sendpdf' => 1, 'sesskey' => sesskey()));
-        $msg .= html_writer::link($pdflink, get_string('test_unoconvdownload', 'assignfeedback_editpdf'));
+        $msg .= html_writer::link($pdflink, get_string('test_unoconvdownload', 'fileconverter_unoconv'));
         $msg .= html_writer::empty_tag('br');
         break;
 
-    case file_storage::UNOCONVPATH_ERROR:
-        $msg = $OUTPUT->notification($result->message, 'warning');
-        break;
-
     default:
-        $msg = $OUTPUT->notification(get_string("test_unoconv{$result->status}", 'assignfeedback_editpdf'), 'warning');
+        $msg = $OUTPUT->notification(get_string("test_unoconv{$result->status}", 'fileconverter_unoconv'), 'warning');
         break;
 }
-$returl = new moodle_url('/admin/settings.php', array('section' => 'assignfeedback_editpdf'));
+$returl = new moodle_url('/admin/settings.php', array('section' => 'fileconverter_unoconv'));
 $msg .= $OUTPUT->continue_button($returl);
 
 echo $OUTPUT->header();
diff --git a/files/converter/unoconv/version.php b/files/converter/unoconv/version.php
new file mode 100644 (file)
index 0000000..f6f5341
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Version details
+ *
+ * @package   fileconverter_unoconv
+ * @copyright 2017 Damyon Wiese
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->version   = 2017020600;         // The current plugin version (Date: YYYYMMDDXX)
+$plugin->requires  = 2016112900;         // Requires this Moodle version
+$plugin->component = 'fileconverter_unoconv'; // Full name of the plugin (used for diagnostics).
diff --git a/files/tests/conversion_test.php b/files/tests/conversion_test.php
new file mode 100644 (file)
index 0000000..662ccc3
--- /dev/null
@@ -0,0 +1,415 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+
+/**
+ * PHPUnit tests for conversion API.
+ *
+ * @package    core_files
+ * @copyright  2017 Andrew nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+use core_files\conversion;
+
+/**
+ * PHPUnit tests for conversion persistent.
+ *
+ * @package    core_files
+ * @copyright  2017 Andrew nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_files_conversion_testcase extends advanced_testcase {
+
+    /**
+     * Helper to create a stored file object with the given supplied content.
+     *
+     * @param   string $filecontent The content of the mocked file
+     * @param   string $filename The file name to use in the stored_file
+     * @param   string $filerecord Any overrides to the filerecord
+     * @return  stored_file
+     */
+    protected function create_stored_file($filecontent = 'content', $filename = 'testfile.txt', $filerecord = []) {
+        $filerecord = array_merge([
+                'contextid' => context_system::instance()->id,
+                'component' => 'core',
+                'filearea'  => 'unittest',
+                'itemid'    => 0,
+                'filepath'  => '/',
+                'filename'  => $filename,
+            ], $filerecord);
+
+        $fs = get_file_storage();
+        $file = $fs->create_file_from_string($filerecord, $filecontent);
+
+        return $file;
+    }
+
+    /**
+     * Ensure that get_conversions_for_file returns an existing conversion
+     * record with matching sourcefileid and targetformat.
+     */
+    public function test_get_conversions_for_file_existing_conversion_incomplete() {
+        $this->resetAfterTest();
+
+        $sourcefile = $this->create_stored_file();
+
+        $existing = new conversion(0, (object) [
+                'sourcefileid' => $sourcefile->get_id(),
+                'targetformat' => 'pdf',
+            ]);
+        $existing->create();
+
+        $conversions = conversion::get_conversions_for_file($sourcefile, 'pdf');
+
+        $this->assertCount(1, $conversions);
+
+        $conversion = array_shift($conversions);
+        $conversionfile = $conversion->get_sourcefile();
+
+        $this->assertEquals($sourcefile->get_id(), $conversionfile->get_id());
+        $this->assertFalse($conversion->get_destfile());
+    }
+
+    /**
+     * Ensure that get_conversions_for_file returns an existing conversion
+     * record with matching sourcefileid and targetformat when a second
+     * conversion to a different format exists.
+     */
+    public function test_get_conversions_for_file_existing_conversion_multiple_formats_incomplete() {
+        $this->resetAfterTest();
+
+        $sourcefile = $this->create_stored_file();
+
+        $existing = new conversion(0, (object) [
+                'sourcefileid' => $sourcefile->get_id(),
+                'targetformat' => 'pdf',
+            ]);
+        $existing->create();
+
+        $second = new conversion(0, (object) [
+                'sourcefileid' => $sourcefile->get_id(),
+                'targetformat' => 'doc',
+            ]);
+        $second->create();
+
+        $conversions = conversion::get_conversions_for_file($sourcefile, 'pdf');
+
+        $this->assertCount(1, $conversions);
+
+        $conversion = array_shift($conversions);
+        $conversionfile = $conversion->get_sourcefile();
+
+        $this->assertEquals($sourcefile->get_id(), $conversionfile->get_id());
+        $this->assertFalse($conversion->get_destfile());
+    }
+
+    /**
+     * Ensure that get_conversions_for_file returns an existing conversion
+     * record with matching sourcefileid and targetformat.
+     */
+    public function test_get_conversions_for_file_existing_conversion_complete() {
+        $this->resetAfterTest();
+
+        $sourcefile = $this->create_stored_file();
+        $destfile = $this->create_stored_file(
+            'example content',
+            $sourcefile->get_contenthash(),
+            [
+                'component' => 'core',
+                'filearea' => 'documentconversion',
+                'filepath' => '/pdf/',
+            ]);
+
+        $existing = new conversion(0, (object) [
+                'sourcefileid' => $sourcefile->get_id(),
+                'targetformat' => 'pdf',
+                'destfileid' => $destfile->get_id(),
+            ]);
+        $existing->create();
+
+        $conversions = conversion::get_conversions_for_file($sourcefile, 'pdf');
+
+        // Only one file should be returned.
+        $this->assertCount(1, $conversions);
+
+        $conversion = array_shift($conversions);
+
+        $this->assertEquals($sourcefile->get_id(), $conversion->get_sourcefile()->get_id());
+        $this->assertEquals($destfile->get_id(), $conversion->get_destfile()->get_id());
+    }
+
+    /**
+     * Ensure that get_conversions_for_file returns an existing conversion
+     * record with matching sourcefileid and targetformat.
+     */
+    public function test_get_conversions_for_file_existing_conversion_multiple_formats_complete() {
+        $this->resetAfterTest();
+
+        $sourcefile = $this->create_stored_file();
+        $destfile = $this->create_stored_file(
+            'example content',
+            $sourcefile->get_contenthash(),
+            [
+                'component' => 'core',
+                'filearea' => 'documentconversion',
+                'filepath' => '/pdf/',
+            ]);
+
+        $existing = new conversion(0, (object) [
+                'sourcefileid' => $sourcefile->get_id(),
+                'targetformat' => 'pdf',
+                'destfileid' => $destfile->get_id(),
+            ]);
+        $existing->create();
+
+        $second = new conversion(0, (object) [
+                'sourcefileid' => $sourcefile->get_id(),
+                'targetformat' => 'doc',
+            ]);
+        $second->create();
+
+        $conversions = conversion::get_conversions_for_file($sourcefile, 'pdf');
+
+        // Only one file should be returned.
+        $this->assertCount(1, $conversions);
+
+        $conversion = array_shift($conversions);
+
+        $this->assertEquals($sourcefile->get_id(), $conversion->get_sourcefile()->get_id());
+        $this->assertEquals($destfile->get_id(), $conversion->get_destfile()->get_id());
+    }
+
+    /**
+     * Ensure that get_conversions_for_file returns an existing conversion
+     * record does not exist, but the file has previously been converted.
+     */
+    public function test_get_conversions_for_file_existing_target() {
+        $this->resetAfterTest();
+
+        $sourcefile = $this->create_stored_file();
+        $destfile = $this->create_stored_file(
+            'example content',
+            $sourcefile->get_contenthash(),
+            [
+                'component' => 'core',
+                'filearea' => 'documentconversion',
+                'filepath' => '/pdf/',
+            ]);
+
+        $conversions = conversion::get_conversions_for_file($sourcefile, 'pdf');
+
+        $this->assertCount(1, $conversions);
+
+        $conversion = array_shift($conversions);
+        $conversionsource = $conversion->get_sourcefile();
+        $this->assertEquals($sourcefile->get_id(), $conversionsource->get_id());
+        $conversiondest = $conversion->get_destfile();
+        $this->assertEquals($destfile->get_id(), $conversiondest->get_id());
+    }
+
+    /**
+     * Ensure that set_sourcefile sets the correct fileid.
+     */
+    public function test_set_sourcefile() {
+        $this->resetAfterTest();
+
+        $sourcefile = $this->create_stored_file();
+        $conversion = new conversion(0, (object) []);
+
+        $conversion->set_sourcefile($sourcefile);
+
+        $this->assertEquals($sourcefile->get_id(), $conversion->get('sourcefileid'));
+        $this->assertNull($conversion->get('destfileid'));
+    }
+
+    /**
+     * Ensure that store_destfile_from_path stores the file as expected.
+     */
+    public function test_store_destfile_from_path() {
+        $this->resetAfterTest();
+
+        $sourcefile = $this->create_stored_file();
+        $conversion = new conversion(0, (object) [
+            'sourcefileid' => $sourcefile->get_id(),
+            'targetformat' => 'pdf',
+        ]);
+
+        $fixture = __FILE__;
+        $conversion->store_destfile_from_path($fixture);
+
+        $destfile = $conversion->get_destfile();
+        $this->assertEquals(file_get_contents($fixture), $destfile->get_content());
+    }
+
+    /**
+     * Ensure that store_destfile_from_path stores the file as expected.
+     */
+    public function test_store_destfile_from_path_delete_existing() {
+        $this->resetAfterTest();
+
+        $sourcefile = $this->create_stored_file();
+        $conversion = new conversion(0, (object) [
+            'sourcefileid' => $sourcefile->get_id(),
+            'targetformat' => 'pdf',
+        ]);
+
+        $record = [
+            'contextid' => \context_system::instance()->id,
+            'component' => 'core',
+            'filearea'  => 'documentconversion',
+            'itemid'    => 0,
+            'filepath'  => '/pdf/',
+        ];
+        $existingfile = $this->create_stored_file('foo', $sourcefile->get_contenthash(), $record);
+
+        $fixture = __FILE__;
+        $conversion->store_destfile_from_path($fixture);
+
+        $destfile = $conversion->get_destfile();
+        $this->assertEquals(file_get_contents($fixture), $destfile->get_content());
+    }
+
+    /**
+     * Ensure that store_destfile_from_path stores the file as expected.
+     */
+    public function test_store_destfile_from_string() {
+        $this->resetAfterTest();
+
+        $sourcefile = $this->create_stored_file();
+        $conversion = new conversion(0, (object) [
+            'sourcefileid' => $sourcefile->get_id(),
+            'targetformat' => 'pdf',
+        ]);
+
+        $fixture = 'Example content';
+        $conversion->store_destfile_from_string($fixture);
+
+        $destfile = $conversion->get_destfile();
+        $this->assertEquals($fixture, $destfile->get_content());
+    }
+
+    /**
+     * Ensure that store_destfile_from_string stores the file as expected when
+     * an existing destfile is found.
+     */
+    public function test_store_destfile_from_string_delete_existing() {
+        $this->resetAfterTest();
+
+        $sourcefile = $this->create_stored_file();
+        $conversion = new conversion(0, (object) [
+            'sourcefileid' => $sourcefile->get_id(),
+            'targetformat' => 'pdf',
+        ]);
+
+        $record = [
+            'contextid' => \context_system::instance()->id,
+            'component' => 'core',
+            'filearea'  => 'documentconversion',
+            'itemid'    => 0,
+            'filepath'  => '/pdf/',
+        ];
+        $existingfile = $this->create_stored_file('foo', $sourcefile->get_contenthash(), $record);
+
+        $fixture = 'Example content';
+        $conversion->store_destfile_from_string($fixture);
+
+        $destfile = $conversion->get_destfile();
+        $this->assertEquals($fixture, $destfile->get_content());
+    }
+
+    /**
+     * Ensure that the get_status functions cast the status to integer correctly.
+     */
+    public function test_get_status() {
+        $conversion = new conversion(0, (object) [
+            'status' => (string) 1,
+        ]);
+
+        $this->assertInternalType('integer', $conversion->get('status'));
+    }
+
+    /**
+     * Ensure that get_converter_instance returns false when no converter is set.
+     */
+    public function test_get_converter_instance_none_set() {
+        $conversion = new conversion(0, (object) []);
+        $this->assertFalse($conversion->get_converter_instance());
+    }
+
+    /**
+     * Ensure that get_converter_instance returns false when no valid converter is set.
+     */
+    public function test_get_converter_instance_invalid_set() {
+        $conversion = new conversion(0, (object) [
+            'converter' => '\\fileconverter_not_a_valid_converter\\converter',
+        ]);
+        $this->assertFalse($conversion->get_converter_instance());
+    }
+
+    /**
+     * Ensure that get_converter_instance returns an instance when a valid converter is set.
+     */
+    public function test_get_converter_instance_valid_set() {
+        $conversion = new conversion(0, (object) [
+            'converter' => \fileconverter_unoconv\converter::class,
+        ]);
+        $this->assertInstanceOf(\fileconverter_unoconv\converter::class, $conversion->get_converter_instance());
+    }
+
+    /**
+     * Test that all old conversion records are removed periodically.
+     */
+    public function test_remove_old_conversion_records_old() {
+        $this->resetAfterTest();
+        global $DB;
+
+        $sourcefile = $this->create_stored_file();
+        $conversion = new conversion(0, (object) [
+                'sourcefileid' => $sourcefile->get_id(),
+                'targetformat' => 'pdf',
+            ]);
+        $conversion->create();
+        $DB->set_field(conversion::TABLE, 'timemodified', time() - YEARSECS);
+
+        conversion::remove_old_conversion_records();
+
+        $this->assertEquals(0, $DB->count_records(conversion::TABLE));
+    }
+
+    /**
+     * Test that all old conversion records are removed periodically.
+     */
+    public function test_remove_old_conversion_records_young() {
+        $this->resetAfterTest();
+        global $DB;
+
+        $sourcefile = $this->create_stored_file();
+        $conversion = new conversion(0, (object) [
+                'sourcefileid' => $sourcefile->get_id(),
+                'targetformat' => 'pdf',
+            ]);
+        $conversion->create();
+        $DB->set_field(conversion::TABLE, 'timemodified', time() - DAYSECS);
+
+        conversion::remove_old_conversion_records();
+
+        $this->assertEquals(1, $DB->count_records(conversion::TABLE));
+    }
+}
diff --git a/files/tests/converter_test.php b/files/tests/converter_test.php
new file mode 100644 (file)
index 0000000..6f182c6
--- /dev/null
@@ -0,0 +1,926 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+
+/**
+ * PHPUnit tests for fileconverter API.
+ *
+ * @package    core_files
+ * @copyright  2017 Andrew nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+use core_files\conversion;
+use core_files\converter;
+
+/**
+ * PHPUnit tests for fileconverter API.
+ *
+ * @package    core_files
+ * @copyright  2017 Andrew nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_files_converter_testcase extends advanced_testcase {
+
+    /**
+     * Get a testable mock of the abstract files_converter class.
+     *
+     * @param   array   $mockedmethods A list of methods you intend to override
+     *                  If no methods are specified, only abstract functions are mocked.
+     * @return  \core_files\converter
+     */
+    protected function get_testable_mock($mockedmethods = []) {
+        $converter = $this->getMockBuilder(\core_files\converter::class)
+            ->setMethods($mockedmethods)
+            ->getMockForAbstractClass();
+
+        return $converter;
+    }
+
+    /**
+     * Get a testable mock of the conversion.
+     *
+     * @param   array   $mockedmethods A list of methods you intend to override
+     * @return  \core_files\conversion
+     */
+    protected function get_testable_conversion($mockedmethods = []) {
+        $conversion = $this->getMockBuilder(\core_files\conversion::class)
+            ->setMethods($mockedmethods)
+            ->setConstructorArgs([0, (object) []])
+            ->getMock();
+
+        return $conversion;
+    }
+
+    /**
+     * Get a testable mock of the abstract files_converter class.
+     *
+     * @param   array   $mockedmethods A list of methods you intend to override
+     *                  If no methods are specified, only abstract functions are mocked.
+     * @return  \core_files\converter_interface
+     */
+    protected function get_mocked_converter($mockedmethods = []) {
+        $converter = $this->getMockBuilder(\core_files\converter_interface::class)
+            ->setMethods($mockedmethods)
+            ->getMockForAbstractClass();
+
+        return $converter;
+    }
+
+    /**
+     * Helper to create a stored file objectw with the given supplied content.
+     *
+     * @param   string  $filecontent The content of the mocked file
+     * @param   string  $filename The file name to use in the stored_file
+     * @param   array   $mockedmethods A list of methods you intend to override
+     *                  If no methods are specified, only abstract functions are mocked.
+     * @return  stored_file
+     */
+    protected function get_stored_file($filecontent = 'content', $filename = null, $filerecord = [], $mockedmethods = null) {
+        global $CFG;
+
+        $contenthash = sha1($filecontent);
+        if (empty($filename)) {
+            $filename = $contenthash;
+        }
+
+        $filerecord['contenthash'] = $contenthash;
+        $filerecord['filesize'] = strlen($filecontent);
+        $filerecord['filename'] = $filename;
+        $filerecord['id'] = 42;
+
+        $file = $this->getMockBuilder(stored_file::class)
+            ->setMethods($mockedmethods)
+            ->setConstructorArgs([get_file_storage(), (object) $filerecord])
+            ->getMock();
+
+        return $file;
+    }
+
+    /**
+     * Helper to create a stored file object with the given supplied content.
+     *
+     * @param   string $filecontent The content of the mocked file
+     * @param   string $filename The file name to use in the stored_file
+     * @param   string $filerecord Any overrides to the filerecord
+     * @return  stored_file
+     */
+    protected function create_stored_file($filecontent = 'content', $filename = 'testfile.txt', $filerecord = []) {
+        $filerecord = array_merge([
+                'contextid' => context_system::instance()->id,
+                'component' => 'core',
+                'filearea'  => 'unittest',
+                'itemid'    => 0,
+                'filepath'  => '/',
+                'filename'  => $filename,
+            ], $filerecord);
+
+        $fs = get_file_storage();
+        $file = $fs->cre