diff --git a/calendar/classes/rrule_manager.php b/calendar/classes/rrule_manager.php index 81c31172172..774716458f7 100644 --- a/calendar/classes/rrule_manager.php +++ b/calendar/classes/rrule_manager.php @@ -23,6 +23,14 @@ */ namespace core_calendar; + +use calendar_event; +use DateInterval; +use DateTime; +use moodle_exception; +use NumberFormatter; +use stdClass; + defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/calendar/lib.php'); @@ -134,6 +142,17 @@ class rrule_manager { /** const int For forever repeating events, repeat for this many years */ const TIME_UNLIMITED_YEARS = 10; + /** @var array Array of days in a week. */ + protected $daysofweek = [ + '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 +195,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 +218,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 +270,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 +318,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 +345,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 +353,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 +382,965 @@ 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); + } + + /** + * 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; + } + + /** + * 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; + } + + /** + * 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. + * + * 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 set_byday($byday) { + $weekdays = array_keys($this->daysofweek); + $days = explode(',', $byday); + $bydayrules = []; + foreach ($days as $day) { + $suffix = substr($day, -2); + if (!in_array($suffix, $weekdays)) { + throw new moodle_exception('errorinvalidbydaysuffix', 'calendar'); + } + + $bydayrule = new stdClass(); + $bydayrule->day = substr($suffix, -2); + $bydayrule->value = (int)str_replace($suffix, '', $day); + + $bydayrules[] = $bydayrule; } - // 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(); + $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'); + } + $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'); + } + $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'); + } + $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; + } + + /** + * 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 string $bysetpos Comma-separated list of values. + * @throws moodle_exception + */ + 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; + } + + /** + * 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'); + } + + // 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'); + } + + // 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'); + } + } + + // 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; + } + + // 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); + } + } + + /** + * 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 The event object. + * @return array The list of timestamps that obey the given RRULE. + */ + protected function generate_recurring_event_times($event) { + $interval = $this->get_interval(); + + // Candidate event times. + $eventtimes = []; + + $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 { + // 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--; + } + $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); + } + 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; + } + // End if event time is beyond the until limit. + if ($time > $until) { + break; + } + $results[] = $time; + } + + return $results; + } + + /** + * Generates a DateInterval object based on the FREQ and INTERVAL rules. + * + * @return DateInterval + * @throws moodle_exception + */ + 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; + } + + /** + * Filter event times based on the BYWEEKNO rule. + * + * @param int[] $eventdates Timestamps of event times to be filtered. + * @return int[] Array of filtered timestamps. + */ + 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; + } + + $filteredbymonthday = []; + foreach ($eventdates as $time) { + $eventdatetime = new DateTime(date('Y-m-d', $time)); + foreach ($this->bymonthday as $monthday) { + // 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; + } + } + } + } + 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 = []; + + $formatter = new NumberFormatter('en_utf8', NumberFormatter::SPELLOUT); + $formatter->setTextAttribute(NumberFormatter::DEFAULT_RULESET, "%spellout-ordinal"); + + $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 = $this->daysofweek[$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; + } + + 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)) { + // Nth week day of the year. + $expecteddate = new DateTime($tmpdatetime->format('Y') . '-01-01'); + $count = $day->value; + $expecteddate->modify("+$count $dayname"); + if ($expecteddate->format('Y-m-d') === $tmpdatetime->format('Y-m-d')) { + $filteredbyday[] = $time; + break; + } + } else { + // Nth week day of the month. + $expectedmonthyear = $tmpdatetime->format('F Y'); + $expectedordinal = $formatter->format($day->value); + $expecteddate = new DateTime("$expectedordinal $dayname of $expectedmonthyear"); + if ($expecteddate->format('Y-m-d') === $tmpdatetime->format('Y-m-d')) { + $filteredbyday[] = $time; + break; + } + } + + } else { + // 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; + } + + /** + * Applies BYHOUR, BYMINUTE and BYSECOND rules to the calculated event dates. + * Defaults to the DTSTART's hour/minute/second component when not defined. + * + * @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 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')]; + } + + $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; + } + + /** + * 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; + } + } + 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); + } + $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_DAILY : - $this->create_repeated_events($eventrec, DAYSECS); + case self::FREQ_YEARLY: + $nextintervalspec = 'P1Y'; + $timestart = date('Y-01-01', $eventtime); break; - case self::FREQ_WEEKLY : - $this->create_weekly_events($eventrec); + case self::FREQ_MONTHLY: + $nextintervalspec = 'P1M'; + $timestart = date('Y-m-01', $eventtime); break; - case self::FREQ_MONTHLY : - $this->create_monthly_events($eventrec); + 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_YEARLY : - $this->create_yearly_events($eventrec); + case self::FREQ_DAILY: + $nextintervalspec = 'P1D'; + $timestart = date('Y-m-d', $eventtime); break; - default : + 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('errorrulefreq', 'calendar'); - + 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, + ]; } - - /** - * Create repeated events. - * - * @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. - */ - protected function create_repeated_events($event, $timediff, $currenttime = false) { - - $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; - - // Multiply by interval if used. - if ($this->interval) { - $timediff *= $this->interval; - } - if (!$currenttime) { - $event->timestart += $timediff; - } - - // 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++, $event->timestart += $timediff) { - unset($event->id); // It is set during creation. - \calendar_event::create($event, false); - } - } 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); - } - for (; $event->timestart < $until; $event->timestart += $timediff) { - unset($event->id); // It is set during creation. - \calendar_event::create($event, false); - } - } - } - - /** - * Create repeated events based on offsets. - * - * @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. - */ - protected function create_repeated_events_by_offsets($event, $secsoffset, $dayoffset, $monthoffset, $yearoffset, $start, - $currenttime = false) { - - $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; - - if (!$currenttime) { - // Skip one event, since parent event is a part of this chain. - $event->timestart = strtotime("+$monthoffset months +$yearoffset years", $event->timestart); - } - - // 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); - } - } 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 ); - } - 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); - - } - } - } - - /** - * Create repeated events based on offsets from a fixed start date. - * - * @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. - */ - protected function create_repeated_events_by_offsets_from_fixedstart($event, $secsoffset, $prefix, $monthoffset, - $yearoffset, $start, $currenttime = false) { - - $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. - 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; - } 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. - } - 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; - } - } 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 ); - } - 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; - } - } - } - - /** - * Create events for weekly frequency. - * - * @param \stdClass $event Event properties to create event - */ - 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); - } - } - } - - /** - * Create events for monthly frequency. - * - * @param \stdClass $event Event properties to create event - */ - 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; - } - // 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)) { - 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); - } - } - } 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; - } - $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); - } 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); - } - - } - } - } - - /** - * Create events for yearly frequency. - * - * @param \stdClass $event Event properties to create event - */ - protected function create_yearly_events($event) { - - // 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)); - - 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); - } - } 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; - } - $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); - } - } - } - } -} \ No newline at end of file +} diff --git a/calendar/tests/rrule_manager_test.php b/calendar/tests/rrule_manager_test.php new file mode 100644 index 00000000000..57a293bb855 --- /dev/null +++ b/calendar/tests/rrule_manager_test.php @@ -0,0 +1,2716 @@ +. + +/** + * Defines test class to test manage rrule during ical imports. + * + * @package core_calendar + * @category test + * @copyright 2014 onwards Ankit Agarwal + * @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 + * @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->setExpectedException('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->setExpectedException('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->setExpectedException('moodle_exception'); + $mang->parse_rrule(); + } + + /** + * Test parsing of INTERVAL rule. + */ + public function test_interval_validation() { + $rrule = "INTERVAL=0"; + $mang = new rrule_manager($rrule); + $this->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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->setExpectedException('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)); + + $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)); + $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 index 5232ab70ace..00000000000 --- a/calendar/tests/rrule_manager_tests.php +++ /dev/null @@ -1,535 +0,0 @@ -. - -/** - * Defines test class to test manage rrule during ical imports. - * - * @package core_calendar - * @category test - * @copyright 2014 onwards Ankit Agarwal - * @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. - */ - public function test_parse_rrule_validation() { - - $rrule = "RANDOM=PROPERTY;"; - $this->setExpectedException('moodle_exception'); - $mang = new core_tests_calendar_rrule_manager($rrule); - $mang->parse_rrule(); - } - - /** - * Test exception is thrown for invalid frequency. - */ - public function test_freq_validation() { - - $rrule = "FREQ=RANDOMLY;"; - $this->setExpectedException('moodle_exception'); - $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 - * @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'); - } -} diff --git a/lang/en/calendar.php b/lang/en/calendar.php index f60a5bf6719..0288482d574 100644 --- a/lang/en/calendar.php +++ b/lang/en/calendar.php @@ -61,12 +61,26 @@ $string['erroraddingevent'] = 'Failed to add event'; $string['errorbadsubscription'] = 'Calendar subscription not found.'; $string['errorbeforecoursestart'] = 'Cannot set event before course start date'; $string['errorcannotimport'] = 'You cannot set up a calendar subscription at this time.'; +$string['errorhasuntilandcount'] = 'Either UNTIL or COUNT may appear in a recurrence rule, but UNTIL and COUNT MUST NOT occur in the same recurrence rule.'; +$string['errorinvalidbydaysuffix'] = 'Valid values for the day of the week parts of the BYDAY rule are MO, TU, WE, TH, FR, SA and SU'; +$string['errorinvalidbydayprefix'] = 'Integer values preceding BYDAY rules can only be present for MONTHLY or YEARLY RRULE.'; +$string['errorinvalidbyhour'] = 'Valid values for the BYHOUR rule are 0 to 59.'; +$string['errorinvalidinterval'] = 'The value for the INTERVAL rule must be a positive integer.'; +$string['errorinvalidbyminute'] = 'Valid values for the BYMINUTE rule are 0 to 59.'; +$string['errorinvalidbymonth'] = 'Valid values for the BYMONTH rule are 1 to 12.'; +$string['errorinvalidbymonthday'] = 'Valid values for the BYMONTHDAY rule are 1 to 31 or -31 to -1.'; +$string['errorinvalidbysetpos'] = 'Valid values for the BYSETPOS rule are 1 to 366 or -366 to -1.'; +$string['errorinvalidbyweekno'] = 'Valid values for the BYWEEKNO rule are 1 to 53 or -53 to -1.'; +$string['errorinvalidbyyearday'] = 'Valid values for the BYYEARDAY rule are 1 to 366 or -366 to -1.'; +$string['errorinvalidbysecond'] = 'Valid values for the BYSECOND rule are 0 to 59.'; $string['errorinvaliddate'] = 'Invalid date'; $string['errorinvalidminutes'] = 'Specify duration in minutes by giving a number between 1 and 999.'; $string['errorinvalidrepeats'] = 'Specify the number of events by giving a number between 1 and 99.'; $string['errorinvalidicalurl'] = 'The given iCal URL is invalid.'; +$string['errormustbeusedwithotherbyrule'] = 'The BYSETPOS rule must only be used in conjunction with another BYxxx rule part.'; $string['errornodescription'] = 'Description is required'; $string['errornoeventname'] = 'Name is required'; +$string['errornonyearlyfreqwithbyweekno'] = 'The BYWEEKNO rule is only valid for YEARLY rules.'; $string['errorrequiredurlorfile'] = 'Either a URL or a file is required to import a calendar.'; $string['errorrrule'] = 'The passed rrule seems incorrect'; $string['errorrrulefreq'] = 'The rrule has an invalid frequency parameter';