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