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