Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.
   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * Defines calendar class to manage recurrence rule (rrule) during ical imports.
  19   *
  20   * @package core_calendar
  21   * @copyright 2014 onwards Ankit Agarwal
  22   * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  namespace core_calendar;
  26  
  27  use calendar_event;
  28  use DateInterval;
  29  use DateTime;
  30  use moodle_exception;
  31  use stdClass;
  32  
  33  defined('MOODLE_INTERNAL') || die();
  34  require_once($CFG->dirroot . '/calendar/lib.php');
  35  
  36  /**
  37   * Defines calendar class to manage recurrence rule (rrule) during ical imports.
  38   *
  39   * Please refer to RFC 2445 {@link http://www.ietf.org/rfc/rfc2445.txt} for detail explanation of the logic.
  40   * Here is a basic extract from it to explain various params:-
  41   * recur = "FREQ"=freq *(
  42   *      ; either UNTIL or COUNT may appear in a 'recur',
  43   *      ; but UNTIL and COUNT MUST NOT occur in the same 'recur'
  44   *      ( ";" "UNTIL" "=" enddate ) /
  45   *      ( ";" "COUNT" "=" 1*DIGIT ) /
  46   *      ; the rest of these keywords are optional,
  47   *      ; but MUST NOT occur more than once
  48   *      ( ";" "INTERVAL" "=" 1*DIGIT )          /
  49   *      ( ";" "BYSECOND" "=" byseclist )        /
  50   *      ( ";" "BYMINUTE" "=" byminlist )        /
  51   *      ( ";" "BYHOUR" "=" byhrlist )           /
  52   *      ( ";" "BYDAY" "=" bywdaylist )          /
  53   *      ( ";" "BYMONTHDAY" "=" bymodaylist )    /
  54   *      ( ";" "BYYEARDAY" "=" byyrdaylist )     /
  55   *      ( ";" "BYWEEKNO" "=" bywknolist )       /
  56   *      ( ";" "BYMONTH" "=" bymolist )          /
  57   *      ( ";" "BYSETPOS" "=" bysplist )         /
  58   *      ( ";" "WKST" "=" weekday )              /
  59   *      ( ";" x-name "=" text )
  60   *   )
  61   *
  62   * freq       = "SECONDLY" / "MINUTELY" / "HOURLY" / "DAILY"
  63   * / "WEEKLY" / "MONTHLY" / "YEARLY"
  64   * enddate    = date
  65   * enddate    =/ date-time            ;An UTC value
  66   * byseclist  = seconds / ( seconds *("," seconds) )
  67   * seconds    = 1DIGIT / 2DIGIT       ;0 to 59
  68   * byminlist  = minutes / ( minutes *("," minutes) )
  69   * minutes    = 1DIGIT / 2DIGIT       ;0 to 59
  70   * byhrlist   = hour / ( hour *("," hour) )
  71   * hour       = 1DIGIT / 2DIGIT       ;0 to 23
  72   * bywdaylist = weekdaynum / ( weekdaynum *("," weekdaynum) )
  73   * weekdaynum = [([plus] ordwk / minus ordwk)] weekday
  74   * plus       = "+"
  75   * minus      = "-"
  76   * ordwk      = 1DIGIT / 2DIGIT       ;1 to 53
  77   * weekday    = "SU" / "MO" / "TU" / "WE" / "TH" / "FR" / "SA"
  78   *      ;Corresponding to SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY,
  79   *      ;FRIDAY, SATURDAY and SUNDAY days of the week.
  80   * bymodaylist = monthdaynum / ( monthdaynum *("," monthdaynum) )
  81   * monthdaynum = ([plus] ordmoday) / (minus ordmoday)
  82   * ordmoday   = 1DIGIT / 2DIGIT       ;1 to 31
  83   * byyrdaylist = yeardaynum / ( yeardaynum *("," yeardaynum) )
  84   * yeardaynum = ([plus] ordyrday) / (minus ordyrday)
  85   * ordyrday   = 1DIGIT / 2DIGIT / 3DIGIT      ;1 to 366
  86   * bywknolist = weeknum / ( weeknum *("," weeknum) )
  87   * weeknum    = ([plus] ordwk) / (minus ordwk)
  88   * bymolist   = monthnum / ( monthnum *("," monthnum) )
  89   * monthnum   = 1DIGIT / 2DIGIT       ;1 to 12
  90   * bysplist   = setposday / ( setposday *("," setposday) )
  91   * setposday  = yeardaynum
  92   *
  93   * @package core_calendar
  94   * @copyright 2014 onwards Ankit Agarwal <ankit.agrr@gmail.com>
  95   * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  96   */
  97  class rrule_manager {
  98  
  99      /** const string Frequency constant */
 100      const FREQ_YEARLY = 'yearly';
 101  
 102      /** const string Frequency constant */
 103      const FREQ_MONTHLY = 'monthly';
 104  
 105      /** const string Frequency constant */
 106      const FREQ_WEEKLY = 'weekly';
 107  
 108      /** const string Frequency constant */
 109      const FREQ_DAILY = 'daily';
 110  
 111      /** const string Frequency constant */
 112      const FREQ_HOURLY = 'hourly';
 113  
 114      /** const string Frequency constant */
 115      const FREQ_MINUTELY = 'everyminute';
 116  
 117      /** const string Frequency constant */
 118      const FREQ_SECONDLY = 'everysecond';
 119  
 120      /** const string Day constant */
 121      const DAY_MONDAY = 'Monday';
 122  
 123      /** const string Day constant */
 124      const DAY_TUESDAY = 'Tuesday';
 125  
 126      /** const string Day constant */
 127      const DAY_WEDNESDAY = 'Wednesday';
 128  
 129      /** const string Day constant */
 130      const DAY_THURSDAY = 'Thursday';
 131  
 132      /** const string Day constant */
 133      const DAY_FRIDAY = 'Friday';
 134  
 135      /** const string Day constant */
 136      const DAY_SATURDAY = 'Saturday';
 137  
 138      /** const string Day constant */
 139      const DAY_SUNDAY = 'Sunday';
 140  
 141      /** const int For forever repeating events, repeat for this many years */
 142      const TIME_UNLIMITED_YEARS = 10;
 143  
 144      /** const array Array of days in a week. */
 145      const DAYS_OF_WEEK = [
 146          'MO' => self::DAY_MONDAY,
 147          'TU' => self::DAY_TUESDAY,
 148          'WE' => self::DAY_WEDNESDAY,
 149          'TH' => self::DAY_THURSDAY,
 150          'FR' => self::DAY_FRIDAY,
 151          'SA' => self::DAY_SATURDAY,
 152          'SU' => self::DAY_SUNDAY,
 153      ];
 154  
 155      /** @var string string representing the recurrence rule */
 156      protected $rrule;
 157  
 158      /** @var string Frequency of event */
 159      protected $freq;
 160  
 161      /** @var int defines a timestamp value which bounds the recurrence rule in an inclusive manner.*/
 162      protected $until = 0;
 163  
 164      /** @var int Defines the number of occurrences at which to range-bound the recurrence */
 165      protected $count = 0;
 166  
 167      /** @var int This rule part contains a positive integer representing how often the recurrence rule repeats */
 168      protected $interval = 1;
 169  
 170      /** @var array List of second rules */
 171      protected $bysecond = array();
 172  
 173      /** @var array List of Minute rules */
 174      protected $byminute = array();
 175  
 176      /** @var array List of hour rules */
 177      protected $byhour = array();
 178  
 179      /** @var array List of day rules */
 180      protected $byday = array();
 181  
 182      /** @var array List of monthday rules */
 183      protected $bymonthday = array();
 184  
 185      /** @var array List of yearday rules */
 186      protected $byyearday = array();
 187  
 188      /** @var array List of weekno rules */
 189      protected $byweekno = array();
 190  
 191      /** @var array List of month rules */
 192      protected $bymonth = array();
 193  
 194      /** @var array List of setpos rules */
 195      protected $bysetpos = array();
 196  
 197      /** @var string Week start rule. Default is Monday. */
 198      protected $wkst = self::DAY_MONDAY;
 199  
 200      /**
 201       * Constructor for the class
 202       *
 203       * @param string $rrule Recurrence rule
 204       */
 205      public function __construct($rrule) {
 206          $this->rrule = $rrule;
 207      }
 208  
 209      /**
 210       * Parse the recurrence rule and setup all properties.
 211       */
 212      public function parse_rrule() {
 213          $rules = explode(';', $this->rrule);
 214          if (empty($rules)) {
 215              return;
 216          }
 217          foreach ($rules as $rule) {
 218              $this->parse_rrule_property($rule);
 219          }
 220          // Validate the rules as a whole.
 221          $this->validate_rules();
 222      }
 223  
 224      /**
 225       * Create events for specified rrule.
 226       *
 227       * @param calendar_event $passedevent Properties of event to create.
 228       * @throws moodle_exception
 229       */
 230      public function create_events($passedevent) {
 231          global $DB;
 232  
 233          $event = clone($passedevent);
 234          // If Frequency is not set, there is nothing to do.
 235          if (empty($this->freq)) {
 236              return;
 237          }
 238  
 239          // Delete all child events in case of an update. This should be faster than verifying if the event exists and updating it.
 240          $where = "repeatid = ? AND id != ?";
 241          $DB->delete_records_select('event', $where, array($event->id, $event->id));
 242          $eventrec = $event->properties();
 243  
 244          // Generate timestamps that obey the rrule.
 245          $eventtimes = $this->generate_recurring_event_times($eventrec);
 246  
 247          // Update the parent event. Make sure that its repeat ID is the same as its ID.
 248          $calevent = new calendar_event($eventrec);
 249          $updatedata = new stdClass();
 250          $updatedata->repeatid = $event->id;
 251          // Also, adjust the parent event's timestart, if necessary.
 252          if (count($eventtimes) > 0 && !in_array($eventrec->timestart, $eventtimes)) {
 253              $updatedata->timestart = reset($eventtimes);
 254          }
 255          $calevent->update($updatedata, false);
 256          $eventrec->timestart = $calevent->timestart;
 257  
 258          // Create the recurring calendar events.
 259          $this->create_recurring_events($eventrec, $eventtimes);
 260      }
 261  
 262      /**
 263       * Parse a property of the recurrence rule.
 264       *
 265       * @param string $prop property string with type-value pair
 266       * @throws moodle_exception
 267       */
 268      protected function parse_rrule_property($prop) {
 269          list($property, $value) = explode('=', $prop);
 270          switch ($property) {
 271              case 'FREQ' :
 272                  $this->set_frequency($value);
 273                  break;
 274              case 'UNTIL' :
 275                  $this->set_until($value);
 276                  break;
 277              CASE 'COUNT' :
 278                  $this->set_count($value);
 279                  break;
 280              CASE 'INTERVAL' :
 281                  $this->set_interval($value);
 282                  break;
 283              CASE 'BYSECOND' :
 284                  $this->set_bysecond($value);
 285                  break;
 286              CASE 'BYMINUTE' :
 287                  $this->set_byminute($value);
 288                  break;
 289              CASE 'BYHOUR' :
 290                  $this->set_byhour($value);
 291                  break;
 292              CASE 'BYDAY' :
 293                  $this->set_byday($value);
 294                  break;
 295              CASE 'BYMONTHDAY' :
 296                  $this->set_bymonthday($value);
 297                  break;
 298              CASE 'BYYEARDAY' :
 299                  $this->set_byyearday($value);
 300                  break;
 301              CASE 'BYWEEKNO' :
 302                  $this->set_byweekno($value);
 303                  break;
 304              CASE 'BYMONTH' :
 305                  $this->set_bymonth($value);
 306                  break;
 307              CASE 'BYSETPOS' :
 308                  $this->set_bysetpos($value);
 309                  break;
 310              CASE 'WKST' :
 311                  $this->wkst = $this->get_day($value);
 312                  break;
 313              default:
 314                  // We should never get here, something is very wrong.
 315                  throw new moodle_exception('errorrrule', 'calendar');
 316          }
 317      }
 318  
 319      /**
 320       * Sets Frequency property.
 321       *
 322       * @param string $freq Frequency of event
 323       * @throws moodle_exception
 324       */
 325      protected function set_frequency($freq) {
 326          switch ($freq) {
 327              case 'YEARLY':
 328                  $this->freq = self::FREQ_YEARLY;
 329                  break;
 330              case 'MONTHLY':
 331                  $this->freq = self::FREQ_MONTHLY;
 332                  break;
 333              case 'WEEKLY':
 334                  $this->freq = self::FREQ_WEEKLY;
 335                  break;
 336              case 'DAILY':
 337                  $this->freq = self::FREQ_DAILY;
 338                  break;
 339              case 'HOURLY':
 340                  $this->freq = self::FREQ_HOURLY;
 341                  break;
 342              case 'MINUTELY':
 343                  $this->freq = self::FREQ_MINUTELY;
 344                  break;
 345              case 'SECONDLY':
 346                  $this->freq = self::FREQ_SECONDLY;
 347                  break;
 348              default:
 349                  // We should never get here, something is very wrong.
 350                  throw new moodle_exception('errorrrulefreq', 'calendar');
 351          }
 352      }
 353  
 354      /**
 355       * Gets the day from day string.
 356       *
 357       * @param string $daystring Day string (MO, TU, etc)
 358       * @throws moodle_exception
 359       *
 360       * @return string Day represented by the parameter.
 361       */
 362      protected function get_day($daystring) {
 363          switch ($daystring) {
 364              case 'MO':
 365                  return self::DAY_MONDAY;
 366                  break;
 367              case 'TU':
 368                  return self::DAY_TUESDAY;
 369                  break;
 370              case 'WE':
 371                  return self::DAY_WEDNESDAY;
 372                  break;
 373              case 'TH':
 374                  return self::DAY_THURSDAY;
 375                  break;
 376              case 'FR':
 377                  return self::DAY_FRIDAY;
 378                  break;
 379              case 'SA':
 380                  return self::DAY_SATURDAY;
 381                  break;
 382              case 'SU':
 383                  return self::DAY_SUNDAY;
 384                  break;
 385              default:
 386                  // We should never get here, something is very wrong.
 387                  throw new moodle_exception('errorrruleday', 'calendar');
 388          }
 389      }
 390  
 391      /**
 392       * Sets the UNTIL rule.
 393       *
 394       * @param string $until The date string representation of the UNTIL rule.
 395       * @throws moodle_exception
 396       */
 397      protected function set_until($until) {
 398          $this->until = strtotime($until);
 399      }
 400  
 401      /**
 402       * Sets the COUNT rule.
 403       *
 404       * @param string $count The count value.
 405       * @throws moodle_exception
 406       */
 407      protected function set_count($count) {
 408          $this->count = intval($count);
 409      }
 410  
 411      /**
 412       * Sets the INTERVAL rule.
 413       *
 414       * The INTERVAL rule part contains a positive integer representing how often the recurrence rule repeats.
 415       * The default value is "1", meaning:
 416       *  - every second for a SECONDLY rule, or
 417       *  - every minute for a MINUTELY rule,
 418       *  - every hour for an HOURLY rule,
 419       *  - every day for a DAILY rule,
 420       *  - every week for a WEEKLY rule,
 421       *  - every month for a MONTHLY rule and
 422       *  - every year for a YEARLY rule.
 423       *
 424       * @param string $intervalstr The value for the interval rule.
 425       * @throws moodle_exception
 426       */
 427      protected function set_interval($intervalstr) {
 428          $interval = intval($intervalstr);
 429          if ($interval < 1) {
 430              throw new moodle_exception('errorinvalidinterval', 'calendar');
 431          }
 432          $this->interval = $interval;
 433      }
 434  
 435      /**
 436       * Sets the BYSECOND rule.
 437       *
 438       * The BYSECOND rule part specifies a comma-separated list of seconds within a minute.
 439       * Valid values are 0 to 59.
 440       *
 441       * @param string $bysecond Comma-separated list of seconds within a minute.
 442       * @throws moodle_exception
 443       */
 444      protected function set_bysecond($bysecond) {
 445          $seconds = explode(',', $bysecond);
 446          $bysecondrules = [];
 447          foreach ($seconds as $second) {
 448              if ($second < 0 || $second > 59) {
 449                  throw new moodle_exception('errorinvalidbysecond', 'calendar');
 450              }
 451              $bysecondrules[] = (int)$second;
 452          }
 453          $this->bysecond = $bysecondrules;
 454      }
 455  
 456      /**
 457       * Sets the BYMINUTE rule.
 458       *
 459       * The BYMINUTE rule part specifies a comma-separated list of seconds within an hour.
 460       * Valid values are 0 to 59.
 461       *
 462       * @param string $byminute Comma-separated list of minutes within an hour.
 463       * @throws moodle_exception
 464       */
 465      protected function set_byminute($byminute) {
 466          $minutes = explode(',', $byminute);
 467          $byminuterules = [];
 468          foreach ($minutes as $minute) {
 469              if ($minute < 0 || $minute > 59) {
 470                  throw new moodle_exception('errorinvalidbyminute', 'calendar');
 471              }
 472              $byminuterules[] = (int)$minute;
 473          }
 474          $this->byminute = $byminuterules;
 475      }
 476  
 477      /**
 478       * Sets the BYHOUR rule.
 479       *
 480       * The BYHOUR rule part specifies a comma-separated list of hours of the day.
 481       * Valid values are 0 to 23.
 482       *
 483       * @param string $byhour Comma-separated list of hours of the day.
 484       * @throws moodle_exception
 485       */
 486      protected function set_byhour($byhour) {
 487          $hours = explode(',', $byhour);
 488          $byhourrules = [];
 489          foreach ($hours as $hour) {
 490              if ($hour < 0 || $hour > 23) {
 491                  throw new moodle_exception('errorinvalidbyhour', 'calendar');
 492              }
 493              $byhourrules[] = (int)$hour;
 494          }
 495          $this->byhour = $byhourrules;
 496      }
 497  
 498      /**
 499       * Sets the BYDAY rule.
 500       *
 501       * The BYDAY rule part specifies a comma-separated list of days of the week;
 502       *  - MO indicates Monday;
 503       *  - TU indicates Tuesday;
 504       *  - WE indicates Wednesday;
 505       *  - TH indicates Thursday;
 506       *  - FR indicates Friday;
 507       *  - SA indicates Saturday;
 508       *  - SU indicates Sunday.
 509       *
 510       * Each BYDAY value can also be preceded by a positive (+n) or negative (-n) integer.
 511       * If present, this indicates the nth occurrence of the specific day within the MONTHLY or YEARLY RRULE.
 512       * For example, within a MONTHLY rule, +1MO (or simply 1MO) represents the first Monday within the month,
 513       * whereas -1MO represents the last Monday of the month.
 514       * If an integer modifier is not present, it means all days of this type within the specified frequency.
 515       * For example, within a MONTHLY rule, MO represents all Mondays within the month.
 516       *
 517       * @param string $byday Comma-separated list of days of the week.
 518       * @throws moodle_exception
 519       */
 520      protected function set_byday($byday) {
 521          $weekdays = array_keys(self::DAYS_OF_WEEK);
 522          $days = explode(',', $byday);
 523          $bydayrules = [];
 524          foreach ($days as $day) {
 525              $suffix = substr($day, -2);
 526              if (!in_array($suffix, $weekdays)) {
 527                  throw new moodle_exception('errorinvalidbydaysuffix', 'calendar');
 528              }
 529  
 530              $bydayrule = new stdClass();
 531              $bydayrule->day = substr($suffix, -2);
 532              $bydayrule->value = (int)str_replace($suffix, '', $day);
 533  
 534              $bydayrules[] = $bydayrule;
 535          }
 536  
 537          $this->byday = $bydayrules;
 538      }
 539  
 540      /**
 541       * Sets the BYMONTHDAY rule.
 542       *
 543       * The BYMONTHDAY rule part specifies a comma-separated list of days of the month.
 544       * Valid values are 1 to 31 or -31 to -1. For example, -10 represents the tenth to the last day of the month.
 545       *
 546       * @param string $bymonthday Comma-separated list of days of the month.
 547       * @throws moodle_exception
 548       */
 549      protected function set_bymonthday($bymonthday) {
 550          $monthdays = explode(',', $bymonthday);
 551          $bymonthdayrules = [];
 552          foreach ($monthdays as $day) {
 553              // Valid values are 1 to 31 or -31 to -1.
 554              if ($day < -31 || $day > 31 || $day == 0) {
 555                  throw new moodle_exception('errorinvalidbymonthday', 'calendar');
 556              }
 557              $bymonthdayrules[] = (int)$day;
 558          }
 559  
 560          // Sort these MONTHDAY rules in ascending order.
 561          sort($bymonthdayrules);
 562  
 563          $this->bymonthday = $bymonthdayrules;
 564      }
 565  
 566      /**
 567       * Sets the BYYEARDAY rule.
 568       *
 569       * The BYYEARDAY rule part specifies a comma-separated list of days of the year.
 570       * Valid values are 1 to 366 or -366 to -1. For example, -1 represents the last day of the year (December 31st)
 571       * and -306 represents the 306th to the last day of the year (March 1st).
 572       *
 573       * @param string $byyearday Comma-separated list of days of the year.
 574       * @throws moodle_exception
 575       */
 576      protected function set_byyearday($byyearday) {
 577          $yeardays = explode(',', $byyearday);
 578          $byyeardayrules = [];
 579          foreach ($yeardays as $day) {
 580              // Valid values are 1 to 366 or -366 to -1.
 581              if ($day < -366 || $day > 366 || $day == 0) {
 582                  throw new moodle_exception('errorinvalidbyyearday', 'calendar');
 583              }
 584              $byyeardayrules[] = (int)$day;
 585          }
 586          $this->byyearday = $byyeardayrules;
 587      }
 588  
 589      /**
 590       * Sets the BYWEEKNO rule.
 591       *
 592       * The BYWEEKNO rule part specifies a comma-separated list of ordinals specifying weeks of the year.
 593       * Valid values are 1 to 53 or -53 to -1. This corresponds to weeks according to week numbering as defined in [ISO 8601].
 594       * A week is defined as a seven day period, starting on the day of the week defined to be the week start (see WKST).
 595       * Week number one of the calendar year is the first week which contains at least four (4) days in that calendar year.
 596       * This rule part is only valid for YEARLY rules. For example, 3 represents the third week of the year.
 597       *
 598       * 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
 599       * is January 1.
 600       *
 601       * @param string $byweekno Comma-separated list of number of weeks.
 602       * @throws moodle_exception
 603       */
 604      protected function set_byweekno($byweekno) {
 605          $weeknumbers = explode(',', $byweekno);
 606          $byweeknorules = [];
 607          foreach ($weeknumbers as $week) {
 608              // Valid values are 1 to 53 or -53 to -1.
 609              if ($week < -53 || $week > 53 || $week == 0) {
 610                  throw new moodle_exception('errorinvalidbyweekno', 'calendar');
 611              }
 612              $byweeknorules[] = (int)$week;
 613          }
 614          $this->byweekno = $byweeknorules;
 615      }
 616  
 617      /**
 618       * Sets the BYMONTH rule.
 619       *
 620       * The BYMONTH rule part specifies a comma-separated list of months of the year.
 621       * Valid values are 1 to 12.
 622       *
 623       * @param string $bymonth Comma-separated list of months of the year.
 624       * @throws moodle_exception
 625       */
 626      protected function set_bymonth($bymonth) {
 627          $months = explode(',', $bymonth);
 628          $bymonthrules = [];
 629          foreach ($months as $month) {
 630              // Valid values are 1 to 12.
 631              if ($month < 1 || $month > 12) {
 632                  throw new moodle_exception('errorinvalidbymonth', 'calendar');
 633              }
 634              $bymonthrules[] = (int)$month;
 635          }
 636          $this->bymonth = $bymonthrules;
 637      }
 638  
 639      /**
 640       * Sets the BYSETPOS rule.
 641       *
 642       * The BYSETPOS rule part specifies a comma-separated list of values which corresponds to the nth occurrence within the set of
 643       * events specified by the rule. Valid values are 1 to 366 or -366 to -1.
 644       * It MUST only be used in conjunction with another BYxxx rule part.
 645       *
 646       * For example "the last work day of the month" could be represented as: RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1
 647       *
 648       * @param string $bysetpos Comma-separated list of values.
 649       * @throws moodle_exception
 650       */
 651      protected function set_bysetpos($bysetpos) {
 652          $setposes = explode(',', $bysetpos);
 653          $bysetposrules = [];
 654          foreach ($setposes as $pos) {
 655              // Valid values are 1 to 366 or -366 to -1.
 656              if ($pos < -366 || $pos > 366 || $pos == 0) {
 657                  throw new moodle_exception('errorinvalidbysetpos', 'calendar');
 658              }
 659              $bysetposrules[] = (int)$pos;
 660          }
 661          $this->bysetpos = $bysetposrules;
 662      }
 663  
 664      /**
 665       * Validate the rules as a whole.
 666       *
 667       * @throws moodle_exception
 668       */
 669      protected function validate_rules() {
 670          // UNTIL and COUNT cannot be in the same recurrence rule.
 671          if (!empty($this->until) && !empty($this->count)) {
 672              throw new moodle_exception('errorhasuntilandcount', 'calendar');
 673          }
 674  
 675          // BYSETPOS only be used in conjunction with another BYxxx rule part.
 676          if (!empty($this->bysetpos) && empty($this->bymonth) && empty($this->bymonthday) && empty($this->bysecond)
 677              && empty($this->byday) && empty($this->byweekno) && empty($this->byhour) && empty($this->byminute)
 678              && empty($this->byyearday)) {
 679              throw new moodle_exception('errormustbeusedwithotherbyrule', 'calendar');
 680          }
 681  
 682          // Integer values preceding BYDAY rules can only be present for MONTHLY or YEARLY RRULE.
 683          foreach ($this->byday as $bydayrule) {
 684              if (!empty($bydayrule->value) && $this->freq != self::FREQ_MONTHLY && $this->freq != self::FREQ_YEARLY) {
 685                  throw new moodle_exception('errorinvalidbydayprefix', 'calendar');
 686              }
 687          }
 688  
 689          // The BYWEEKNO rule is only valid for YEARLY rules.
 690          if (!empty($this->byweekno) && $this->freq != self::FREQ_YEARLY) {
 691              throw new moodle_exception('errornonyearlyfreqwithbyweekno', 'calendar');
 692          }
 693      }
 694  
 695      /**
 696       * Creates calendar events for the recurring events.
 697       *
 698       * @param stdClass $event The parent event.
 699       * @param int[] $eventtimes The timestamps of the recurring events.
 700       */
 701      protected function create_recurring_events($event, $eventtimes) {
 702          $count = false;
 703          if ($this->count) {
 704              $count = $this->count;
 705          }
 706  
 707          foreach ($eventtimes as $time) {
 708              // Skip if time is the same time with the parent event's timestamp.
 709              if ($time == $event->timestart) {
 710                  continue;
 711              }
 712  
 713              // Decrement count, if set.
 714              if ($count !== false) {
 715                  $count--;
 716                  if ($count == 0) {
 717                      break;
 718                  }
 719              }
 720  
 721              // Create the recurring event.
 722              $cloneevent = clone($event);
 723              $cloneevent->repeatid = $event->id;
 724              $cloneevent->timestart = $time;
 725              unset($cloneevent->id);
 726              // UUID should only be set on the first instance of the recurring events.
 727              unset($cloneevent->uuid);
 728              calendar_event::create($cloneevent, false);
 729          }
 730  
 731          // If COUNT rule is defined and the number of the generated event times is less than the the COUNT rule,
 732          // repeat the processing until the COUNT rule is satisfied.
 733          if ($count !== false && $count > 0) {
 734              // Set count to the remaining counts.
 735              $this->count = $count;
 736              // Clone the original event, but set the timestart to the last generated event time.
 737              $tmpevent = clone($event);
 738              $tmpevent->timestart = end($eventtimes);
 739              // Generate the additional event times.
 740              $additionaleventtimes = $this->generate_recurring_event_times($tmpevent);
 741              // Create the additional events.
 742              $this->create_recurring_events($event, $additionaleventtimes);
 743          }
 744      }
 745  
 746      /**
 747       * Generates recurring events based on the parent event and the RRULE set.
 748       *
 749       * If multiple BYxxx rule parts are specified, then after evaluating the specified FREQ and INTERVAL rule parts,
 750       * the BYxxx rule parts are applied to the current set of evaluated occurrences in the following order:
 751       * BYMONTH, BYWEEKNO, BYYEARDAY, BYMONTHDAY, BYDAY, BYHOUR, BYMINUTE, BYSECOND and BYSETPOS;
 752       * then COUNT and UNTIL are evaluated.
 753       *
 754       * @param stdClass $event The event object.
 755       * @return array The list of timestamps that obey the given RRULE.
 756       */
 757      protected function generate_recurring_event_times($event) {
 758          $interval = $this->get_interval();
 759  
 760          // Candidate event times.
 761          $eventtimes = [];
 762  
 763          $eventdatetime = new DateTime(date('Y-m-d H:i:s', $event->timestart));
 764  
 765          $until = null;
 766          if (empty($this->count)) {
 767              if ($this->until) {
 768                  $until = $this->until;
 769              } else {
 770                  // Forever event. However, since there's no such thing as 'forever' (at least not in Moodle),
 771                  // we only repeat the events until 10 years from the current time.
 772                  $untildate = new DateTime();
 773                  $foreverinterval = new DateInterval('P' . self::TIME_UNLIMITED_YEARS . 'Y');
 774                  $untildate->add($foreverinterval);
 775                  $until = $untildate->getTimestamp();
 776              }
 777          } else {
 778              // If count is defined, let's define a tentative until date. We'll just trim the number of events later.
 779              $untildate = clone($eventdatetime);
 780              $count = $this->count;
 781              while ($count >= 0) {
 782                  $untildate->add($interval);
 783                  $count--;
 784              }
 785              $until = $untildate->getTimestamp();
 786          }
 787  
 788          // No filters applied. Generate recurring events right away.
 789          if (!$this->has_by_rules()) {
 790              // Get initial list of prospective events.
 791              $tmpstart = clone($eventdatetime);
 792              while ($tmpstart->getTimestamp() <= $until) {
 793                  $eventtimes[] = $tmpstart->getTimestamp();
 794                  $tmpstart->add($interval);
 795              }
 796              return $eventtimes;
 797          }
 798  
 799          // Get all of potential dates covered by the periods from the event's start date until the last.
 800          $dailyinterval = new DateInterval('P1D');
 801          $boundslist = $this->get_period_bounds_list($eventdatetime->getTimestamp(), $until);
 802          foreach ($boundslist as $bounds) {
 803              $tmpdate = new DateTime(date('Y-m-d H:i:s', $bounds->start));
 804              while ($tmpdate->getTimestamp() >= $bounds->start && $tmpdate->getTimestamp() < $bounds->next) {
 805                  $eventtimes[] = $tmpdate->getTimestamp();
 806                  $tmpdate->add($dailyinterval);
 807              }
 808          }
 809  
 810          // Evaluate BYMONTH rules.
 811          $eventtimes = $this->filter_by_month($eventtimes);
 812  
 813          // Evaluate BYWEEKNO rules.
 814          $eventtimes = $this->filter_by_weekno($eventtimes);
 815  
 816          // Evaluate BYYEARDAY rules.
 817          $eventtimes = $this->filter_by_yearday($eventtimes);
 818  
 819          // If BYYEARDAY, BYMONTHDAY and BYDAY are not set, default to BYMONTHDAY based on the DTSTART's day.
 820          if ($this->freq != self::FREQ_DAILY && empty($this->byyearday) && empty($this->bymonthday) && empty($this->byday)) {
 821              $this->bymonthday = [$eventdatetime->format('j')];
 822          }
 823  
 824          // Evaluate BYMONTHDAY rules.
 825          $eventtimes = $this->filter_by_monthday($eventtimes);
 826  
 827          // Evaluate BYDAY rules.
 828          $eventtimes = $this->filter_by_day($event, $eventtimes, $until);
 829  
 830          // Evaluate BYHOUR rules.
 831          $eventtimes = $this->apply_hour_minute_second_rules($eventdatetime, $eventtimes);
 832  
 833          // Evaluate BYSETPOS rules.
 834          $eventtimes = $this->filter_by_setpos($event, $eventtimes, $until);
 835  
 836          // Sort event times in ascending order.
 837          sort($eventtimes);
 838  
 839          // Finally, filter candidate event times to make sure they are within the DTSTART and UNTIL/tentative until boundaries.
 840          $results = [];
 841          foreach ($eventtimes as $time) {
 842              // Skip out-of-range events.
 843              if ($time < $eventdatetime->getTimestamp()) {
 844                  continue;
 845              }
 846              // End if event time is beyond the until limit.
 847              if ($time > $until) {
 848                  break;
 849              }
 850              $results[] = $time;
 851          }
 852  
 853          return $results;
 854      }
 855  
 856      /**
 857       * Generates a DateInterval object based on the FREQ and INTERVAL rules.
 858       *
 859       * @return DateInterval
 860       * @throws moodle_exception
 861       */
 862      protected function get_interval() {
 863          $intervalspec = null;
 864          switch ($this->freq) {
 865              case self::FREQ_YEARLY:
 866                  $intervalspec = 'P' . $this->interval . 'Y';
 867                  break;
 868              case self::FREQ_MONTHLY:
 869                  $intervalspec = 'P' . $this->interval . 'M';
 870                  break;
 871              case self::FREQ_WEEKLY:
 872                  $intervalspec = 'P' . $this->interval . 'W';
 873                  break;
 874              case self::FREQ_DAILY:
 875                  $intervalspec = 'P' . $this->interval . 'D';
 876                  break;
 877              case self::FREQ_HOURLY:
 878                  $intervalspec = 'PT' . $this->interval . 'H';
 879                  break;
 880              case self::FREQ_MINUTELY:
 881                  $intervalspec = 'PT' . $this->interval . 'M';
 882                  break;
 883              case self::FREQ_SECONDLY:
 884                  $intervalspec = 'PT' . $this->interval . 'S';
 885                  break;
 886              default:
 887                  // We should never get here, something is very wrong.
 888                  throw new moodle_exception('errorrrulefreq', 'calendar');
 889          }
 890  
 891          return new DateInterval($intervalspec);
 892      }
 893  
 894      /**
 895       * Determines whether the RRULE has BYxxx rules or not.
 896       *
 897       * @return bool True if there is one or more BYxxx rules to process. False, otherwise.
 898       */
 899      protected function has_by_rules() {
 900          return !empty($this->bymonth) || !empty($this->bymonthday) || !empty($this->bysecond) || !empty($this->byday)
 901              || !empty($this->byweekno) || !empty($this->byhour) || !empty($this->byminute) || !empty($this->byyearday);
 902      }
 903  
 904      /**
 905       * Filter event times based on the BYMONTH rule.
 906       *
 907       * @param int[] $eventdates Timestamps of event times to be filtered.
 908       * @return int[] Array of filtered timestamps.
 909       */
 910      protected function filter_by_month($eventdates) {
 911          if (empty($this->bymonth)) {
 912              return $eventdates;
 913          }
 914  
 915          $filteredbymonth = [];
 916          foreach ($eventdates as $time) {
 917              foreach ($this->bymonth as $month) {
 918                  $prospectmonth = date('n', $time);
 919                  if ($month == $prospectmonth) {
 920                      $filteredbymonth[] = $time;
 921                      break;
 922                  }
 923              }
 924          }
 925          return $filteredbymonth;
 926      }
 927  
 928      /**
 929       * Filter event times based on the BYWEEKNO rule.
 930       *
 931       * @param int[] $eventdates Timestamps of event times to be filtered.
 932       * @return int[] Array of filtered timestamps.
 933       */
 934      protected function filter_by_weekno($eventdates) {
 935          if (empty($this->byweekno)) {
 936              return $eventdates;
 937          }
 938  
 939          $filteredbyweekno = [];
 940          $weeklyinterval = null;
 941          foreach ($eventdates as $time) {
 942              $tmpdate = new DateTime(date('Y-m-d H:i:s', $time));
 943              foreach ($this->byweekno as $weekno) {
 944                  if ($weekno > 0) {
 945                      if ($tmpdate->format('W') == $weekno) {
 946                          $filteredbyweekno[] = $time;
 947                          break;
 948                      }
 949                  } else if ($weekno < 0) {
 950                      if ($weeklyinterval === null) {
 951                          $weeklyinterval = new DateInterval('P1W');
 952                      }
 953                      $weekstart = new DateTime();
 954                      $weekstart->setISODate($tmpdate->format('Y'), $weekno);
 955                      $weeknext = clone($weekstart);
 956                      $weeknext->add($weeklyinterval);
 957  
 958                      $tmptimestamp = $tmpdate->getTimestamp();
 959  
 960                      if ($tmptimestamp >= $weekstart->getTimestamp() && $tmptimestamp < $weeknext->getTimestamp()) {
 961                          $filteredbyweekno[] = $time;
 962                          break;
 963                      }
 964                  }
 965              }
 966          }
 967          return $filteredbyweekno;
 968      }
 969  
 970      /**
 971       * Filter event times based on the BYYEARDAY rule.
 972       *
 973       * @param int[] $eventdates Timestamps of event times to be filtered.
 974       * @return int[] Array of filtered timestamps.
 975       */
 976      protected function filter_by_yearday($eventdates) {
 977          if (empty($this->byyearday)) {
 978              return $eventdates;
 979          }
 980  
 981          $filteredbyyearday = [];
 982          foreach ($eventdates as $time) {
 983              $tmpdate = new DateTime(date('Y-m-d', $time));
 984  
 985              foreach ($this->byyearday as $yearday) {
 986                  $dayoffset = abs($yearday) - 1;
 987                  $dayoffsetinterval = new DateInterval("P{$dayoffset}D");
 988  
 989                  if ($yearday > 0) {
 990                      $tmpyearday = (int)$tmpdate->format('z') + 1;
 991                      if ($tmpyearday == $yearday) {
 992                          $filteredbyyearday[] = $time;
 993                          break;
 994                      }
 995                  } else if ($yearday < 0) {
 996                      $yeardaydate = new DateTime('last day of ' . $tmpdate->format('Y'));
 997                      $yeardaydate->sub($dayoffsetinterval);
 998  
 999                      $tmpdate->getTimestamp();
1000  
1001                      if ($yeardaydate->format('z') == $tmpdate->format('z')) {
1002                          $filteredbyyearday[] = $time;
1003                          break;
1004                      }
1005                  }
1006              }
1007          }
1008          return $filteredbyyearday;
1009      }
1010  
1011      /**
1012       * Filter event times based on the BYMONTHDAY rule.
1013       *
1014       * @param int[] $eventdates The event times to be filtered.
1015       * @return int[] Array of filtered timestamps.
1016       */
1017      protected function filter_by_monthday($eventdates) {
1018          if (empty($this->bymonthday)) {
1019              return $eventdates;
1020          }
1021  
1022          $filteredbymonthday = [];
1023          foreach ($eventdates as $time) {
1024              $eventdatetime = new DateTime(date('Y-m-d', $time));
1025              foreach ($this->bymonthday as $monthday) {
1026                  // Days to add/subtract.
1027                  $daysoffset = abs($monthday) - 1;
1028                  $dayinterval = new DateInterval("P{$daysoffset}D");
1029  
1030                  if ($monthday > 0) {
1031                      if ($eventdatetime->format('j') == $monthday) {
1032                          $filteredbymonthday[] = $time;
1033                          break;
1034                      }
1035                  } else if ($monthday < 0) {
1036                      $tmpdate = clone($eventdatetime);
1037                      // Reset to the first day of the month.
1038                      $tmpdate->modify('first day of this month');
1039                      // Then go to last day of the month.
1040                      $tmpdate->modify('last day of this month');
1041                      if ($daysoffset > 0) {
1042                          // Then subtract the monthday value.
1043                          $tmpdate->sub($dayinterval);
1044                      }
1045                      if ($eventdatetime->format('j') == $tmpdate->format('j')) {
1046                          $filteredbymonthday[] = $time;
1047                          break;
1048                      }
1049                  }
1050              }
1051          }
1052          return $filteredbymonthday;
1053      }
1054  
1055      /**
1056       * Filter event times based on the BYDAY rule.
1057       *
1058       * @param stdClass $event The parent event.
1059       * @param int[] $eventdates The event times to be filtered.
1060       * @param int $until Event times generation limit date.
1061       * @return int[] Array of filtered timestamps.
1062       */
1063      protected function filter_by_day($event, $eventdates, $until) {
1064          if (empty($this->byday)) {
1065              return $eventdates;
1066          }
1067  
1068          $filteredbyday = [];
1069  
1070          $bounds = $this->get_period_bounds_list($event->timestart, $until);
1071  
1072          $nextmonthinterval = new DateInterval('P1M');
1073          foreach ($eventdates as $time) {
1074              $tmpdatetime = new DateTime(date('Y-m-d', $time));
1075  
1076              foreach ($this->byday as $day) {
1077                  $dayname = self::DAYS_OF_WEEK[$day->day];
1078  
1079                  // Skip if they day name of the event time does not match the day part of the BYDAY rule.
1080                  if ($tmpdatetime->format('l') !== $dayname) {
1081                      continue;
1082                  }
1083  
1084                  if (empty($day->value)) {
1085                      // No modifier value. Applies to all weekdays of the given period.
1086                      $filteredbyday[] = $time;
1087                      break;
1088                  } else if ($day->value > 0) {
1089                      // Positive value.
1090                      if ($this->freq === self::FREQ_YEARLY && empty($this->bymonth)) {
1091                          // Get the first day of the year.
1092                          $firstdaydate = $tmpdatetime->format('Y') . '-01-01';
1093                      } else {
1094                          // Get the first day of the month.
1095                          $firstdaydate = $tmpdatetime->format('Y-m') . '-01';
1096                      }
1097                      $expecteddate = new DateTime($firstdaydate);
1098                      $count = $day->value;
1099                      // Get the nth week day of the year/month.
1100                      $expecteddate->modify("+$count $dayname");
1101                      if ($expecteddate->format('Y-m-d') === $tmpdatetime->format('Y-m-d')) {
1102                          $filteredbyday[] = $time;
1103                          break;
1104                      }
1105  
1106                  } else {
1107                      // Negative value.
1108                      $count = $day->value;
1109                      if ($this->freq === self::FREQ_YEARLY && empty($this->bymonth)) {
1110                          // The -Nth week day of the year.
1111                          $eventyear = (int)$tmpdatetime->format('Y');
1112                          // Get temporary DateTime object starting from the first day of the next year.
1113                          $expecteddate = new DateTime((++$eventyear) . '-01-01');
1114                          while ($count < 0) {
1115                              // Get the start of the previous week.
1116                              $expecteddate->modify('last ' . $this->wkst);
1117                              $tmpexpecteddate = clone($expecteddate);
1118                              if ($tmpexpecteddate->format('l') !== $dayname) {
1119                                  $tmpexpecteddate->modify('next ' . $dayname);
1120                              }
1121                              if ($this->in_bounds($tmpexpecteddate->getTimestamp(), $bounds)) {
1122                                  $expecteddate = $tmpexpecteddate;
1123                                  $count++;
1124                              }
1125                          }
1126                          if ($expecteddate->format('l') !== $dayname) {
1127                              $expecteddate->modify('next ' . $dayname);
1128                          }
1129                          if ($expecteddate->getTimestamp() == $time) {
1130                              $filteredbyday[] = $time;
1131                              break;
1132                          }
1133  
1134                      } else {
1135                          // The -Nth week day of the month.
1136                          $expectedmonthyear = $tmpdatetime->format('F Y');
1137                          $expecteddate = new DateTime("first day of $expectedmonthyear");
1138                          $expecteddate->add($nextmonthinterval);
1139                          while ($count < 0) {
1140                              // Get the start of the previous week.
1141                              $expecteddate->modify('last ' . $this->wkst);
1142                              $tmpexpecteddate = clone($expecteddate);
1143                              if ($tmpexpecteddate->format('l') !== $dayname) {
1144                                  $tmpexpecteddate->modify('next ' . $dayname);
1145                              }
1146                              if ($this->in_bounds($tmpexpecteddate->getTimestamp(), $bounds)) {
1147                                  $expecteddate = $tmpexpecteddate;
1148                                  $count++;
1149                              }
1150                          }
1151  
1152                          // Compare the expected date with the event's timestamp.
1153                          if ($expecteddate->getTimestamp() == $time) {
1154                              $filteredbyday[] = $time;
1155                              break;
1156                          }
1157                      }
1158                  }
1159              }
1160          }
1161          return $filteredbyday;
1162      }
1163  
1164      /**
1165       * Applies BYHOUR, BYMINUTE and BYSECOND rules to the calculated event dates.
1166       * Defaults to the DTSTART's hour/minute/second component when not defined.
1167       *
1168       * @param DateTime $eventdatetime The parent event DateTime object pertaining to the DTSTART.
1169       * @param int[] $eventdates Array of candidate event date timestamps.
1170       * @return array List of updated event timestamps that contain the time component of the event times.
1171       */
1172      protected function apply_hour_minute_second_rules(DateTime $eventdatetime, $eventdates) {
1173          // If BYHOUR rules are not set, set the hour of the events from the DTSTART's hour component.
1174          if (empty($this->byhour)) {
1175              $this->byhour = [$eventdatetime->format('G')];
1176          }
1177          // If BYMINUTE rules are not set, set the hour of the events from the DTSTART's minute component.
1178          if (empty($this->byminute)) {
1179              $this->byminute = [(int)$eventdatetime->format('i')];
1180          }
1181          // If BYSECOND rules are not set, set the hour of the events from the DTSTART's second component.
1182          if (empty($this->bysecond)) {
1183              $this->bysecond = [(int)$eventdatetime->format('s')];
1184          }
1185  
1186          $results = [];
1187          foreach ($eventdates as $time) {
1188              $datetime = new DateTime(date('Y-m-d', $time));
1189              foreach ($this->byhour as $hour) {
1190                  foreach ($this->byminute as $minute) {
1191                      foreach ($this->bysecond as $second) {
1192                          $datetime->setTime($hour, $minute, $second);
1193                          $results[] = $datetime->getTimestamp();
1194                      }
1195                  }
1196              }
1197          }
1198          return $results;
1199      }
1200  
1201      /**
1202       * Filter event times based on the BYSETPOS rule.
1203       *
1204       * @param stdClass $event The parent event.
1205       * @param int[] $eventtimes The event times to be filtered.
1206       * @param int $until Event times generation limit date.
1207       * @return int[] Array of filtered timestamps.
1208       */
1209      protected function filter_by_setpos($event, $eventtimes, $until) {
1210          if (empty($this->bysetpos)) {
1211              return $eventtimes;
1212          }
1213  
1214          $filteredbysetpos = [];
1215          $boundslist = $this->get_period_bounds_list($event->timestart, $until);
1216          sort($eventtimes);
1217          foreach ($boundslist as $bounds) {
1218              // Generate a list of candidate event times based that are covered in a period's bounds.
1219              $prospecttimes = [];
1220              foreach ($eventtimes as $time) {
1221                  if ($time >= $bounds->start && $time < $bounds->next) {
1222                      $prospecttimes[] = $time;
1223                  }
1224              }
1225              if (empty($prospecttimes)) {
1226                  continue;
1227              }
1228              // Add the event times that correspond to the set position rule into the filtered results.
1229              foreach ($this->bysetpos as $pos) {
1230                  $tmptimes = $prospecttimes;
1231                  if ($pos < 0) {
1232                      rsort($tmptimes);
1233                  }
1234                  $index = abs($pos) - 1;
1235                  if (isset($tmptimes[$index])) {
1236                      $filteredbysetpos[] = $tmptimes[$index];
1237                  }
1238              }
1239          }
1240          return $filteredbysetpos;
1241      }
1242  
1243      /**
1244       * Gets the list of period boundaries covered by the recurring events.
1245       *
1246       * @param int $eventtime The event timestamp.
1247       * @param int $until The end timestamp.
1248       * @return array List of period bounds, with start and next properties.
1249       */
1250      protected function get_period_bounds_list($eventtime, $until) {
1251          $interval = $this->get_interval();
1252          $periodbounds = $this->get_period_boundaries($eventtime);
1253          $periodstart = $periodbounds['start'];
1254          $periodafter = $periodbounds['next'];
1255          $bounds = [];
1256          if ($until !== null) {
1257              while ($periodstart->getTimestamp() < $until) {
1258                  $bounds[] = (object)[
1259                      'start' => $periodstart->getTimestamp(),
1260                      'next' => $periodafter->getTimestamp()
1261                  ];
1262                  $periodstart->add($interval);
1263                  $periodafter->add($interval);
1264              }
1265          } else {
1266              $count = $this->count;
1267              while ($count > 0) {
1268                  $bounds[] = (object)[
1269                      'start' => $periodstart->getTimestamp(),
1270                      'next' => $periodafter->getTimestamp()
1271                  ];
1272                  $periodstart->add($interval);
1273                  $periodafter->add($interval);
1274                  $count--;
1275              }
1276          }
1277  
1278          return $bounds;
1279      }
1280  
1281      /**
1282       * Determine whether the date-time in question is within the bounds of the periods that are covered by the RRULE.
1283       *
1284       * @param int $time The timestamp to be evaluated.
1285       * @param array $bounds Array of period boundaries covered by the RRULE.
1286       * @return bool
1287       */
1288      protected function in_bounds($time, $bounds) {
1289          foreach ($bounds as $bound) {
1290              if ($time >= $bound->start && $time < $bound->next) {
1291                  return true;
1292              }
1293          }
1294          return false;
1295      }
1296  
1297      /**
1298       * Determines the start and end DateTime objects that serve as references to determine whether a calculated event timestamp
1299       * falls on the period defined by these DateTimes objects.
1300       *
1301       * @param int $eventtime Unix timestamp of the event time.
1302       * @return DateTime[]
1303       * @throws moodle_exception
1304       */
1305      protected function get_period_boundaries($eventtime) {
1306          $nextintervalspec = null;
1307  
1308          switch ($this->freq) {
1309              case self::FREQ_YEARLY:
1310                  $nextintervalspec = 'P1Y';
1311                  $timestart = date('Y-01-01', $eventtime);
1312                  break;
1313              case self::FREQ_MONTHLY:
1314                  $nextintervalspec = 'P1M';
1315                  $timestart = date('Y-m-01', $eventtime);
1316                  break;
1317              case self::FREQ_WEEKLY:
1318                  $nextintervalspec = 'P1W';
1319                  if (date('l', $eventtime) === $this->wkst) {
1320                      $weekstarttime = $eventtime;
1321                  } else {
1322                      $weekstarttime = strtotime('last ' . $this->wkst, $eventtime);
1323                  }
1324                  $timestart = date('Y-m-d', $weekstarttime);
1325                  break;
1326              case self::FREQ_DAILY:
1327                  $nextintervalspec = 'P1D';
1328                  $timestart = date('Y-m-d', $eventtime);
1329                  break;
1330              case self::FREQ_HOURLY:
1331                  $nextintervalspec = 'PT1H';
1332                  $timestart = date('Y-m-d H:00:00', $eventtime);
1333                  break;
1334              case self::FREQ_MINUTELY:
1335                  $nextintervalspec = 'PT1M';
1336                  $timestart = date('Y-m-d H:i:00', $eventtime);
1337                  break;
1338              case self::FREQ_SECONDLY:
1339                  $nextintervalspec = 'PT1S';
1340                  $timestart = date('Y-m-d H:i:s', $eventtime);
1341                  break;
1342              default:
1343                  // We should never get here, something is very wrong.
1344                  throw new moodle_exception('errorrrulefreq', 'calendar');
1345          }
1346  
1347          $eventstart = new DateTime($timestart);
1348          $eventnext = clone($eventstart);
1349          $nextinterval = new DateInterval($nextintervalspec);
1350          $eventnext->add($nextinterval);
1351  
1352          return [
1353              'start' => $eventstart,
1354              'next' => $eventnext,
1355          ];
1356      }
1357  }