MDL-65318 core_calendar: Additional fixes
[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 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.
38  *
39  * Please refer to RFC 2445 {@link http://www.ietf.org/rfc/rfc2445.txt} for detail explanation of the logic.
40  * Here is a basic extract from it to explain various params:-
41  * recur = "FREQ"=freq *(
42  *      ; either UNTIL or COUNT may appear in a 'recur',
43  *      ; but UNTIL and COUNT MUST NOT occur in the same 'recur'
44  *      ( ";" "UNTIL" "=" enddate ) /
45  *      ( ";" "COUNT" "=" 1*DIGIT ) /
46  *      ; the rest of these keywords are optional,
47  *      ; but MUST NOT occur more than once
48  *      ( ";" "INTERVAL" "=" 1*DIGIT )          /
49  *      ( ";" "BYSECOND" "=" byseclist )        /
50  *      ( ";" "BYMINUTE" "=" byminlist )        /
51  *      ( ";" "BYHOUR" "=" byhrlist )           /
52  *      ( ";" "BYDAY" "=" bywdaylist )          /
53  *      ( ";" "BYMONTHDAY" "=" bymodaylist )    /
54  *      ( ";" "BYYEARDAY" "=" byyrdaylist )     /
55  *      ( ";" "BYWEEKNO" "=" bywknolist )       /
56  *      ( ";" "BYMONTH" "=" bymolist )          /
57  *      ( ";" "BYSETPOS" "=" bysplist )         /
58  *      ( ";" "WKST" "=" weekday )              /
59  *      ( ";" x-name "=" text )
60  *   )
61  *
62  * freq       = "SECONDLY" / "MINUTELY" / "HOURLY" / "DAILY"
63  * / "WEEKLY" / "MONTHLY" / "YEARLY"
64  * enddate    = date
65  * enddate    =/ date-time            ;An UTC value
66  * byseclist  = seconds / ( seconds *("," seconds) )
67  * seconds    = 1DIGIT / 2DIGIT       ;0 to 59
68  * byminlist  = minutes / ( minutes *("," minutes) )
69  * minutes    = 1DIGIT / 2DIGIT       ;0 to 59
70  * byhrlist   = hour / ( hour *("," hour) )
71  * hour       = 1DIGIT / 2DIGIT       ;0 to 23
72  * bywdaylist = weekdaynum / ( weekdaynum *("," weekdaynum) )
73  * weekdaynum = [([plus] ordwk / minus ordwk)] weekday
74  * plus       = "+"
75  * minus      = "-"
76  * ordwk      = 1DIGIT / 2DIGIT       ;1 to 53
77  * weekday    = "SU" / "MO" / "TU" / "WE" / "TH" / "FR" / "SA"
78  *      ;Corresponding to SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY,
79  *      ;FRIDAY, SATURDAY and SUNDAY days of the week.
80  * bymodaylist = monthdaynum / ( monthdaynum *("," monthdaynum) )
81  * monthdaynum = ([plus] ordmoday) / (minus ordmoday)
82  * ordmoday   = 1DIGIT / 2DIGIT       ;1 to 31
83  * byyrdaylist = yeardaynum / ( yeardaynum *("," yeardaynum) )
84  * yeardaynum = ([plus] ordyrday) / (minus ordyrday)
85  * ordyrday   = 1DIGIT / 2DIGIT / 3DIGIT      ;1 to 366
86  * bywknolist = weeknum / ( weeknum *("," weeknum) )
87  * weeknum    = ([plus] ordwk) / (minus ordwk)
88  * bymolist   = monthnum / ( monthnum *("," monthnum) )
89  * monthnum   = 1DIGIT / 2DIGIT       ;1 to 12
90  * bysplist   = setposday / ( setposday *("," setposday) )
91  * setposday  = yeardaynum
92  *
93  * @package core_calendar
94  * @copyright 2014 onwards Ankit Agarwal <ankit.agrr@gmail.com>
95  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
96  */
97 class rrule_manager {
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,
153     ];
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;
200     /**
201      * Constructor for the class
202      *
203      * @param string $rrule Recurrence rule
204      */
205     public function __construct($rrule) {
206         $this->rrule = $rrule;
207     }
209     /**
210      * Parse the recurrence rule and setup all properties.
211      */
212     public function parse_rrule() {
213         $rules = explode(';', $this->rrule);
214         if (empty($rules)) {
215             return;
216         }
217         foreach ($rules as $rule) {
218             $this->parse_rrule_property($rule);
219         }
220         // Validate the rules as a whole.
221         $this->validate_rules();
222     }
224     /**
225      * Create events for specified rrule.
226      *
227      * @param calendar_event $passedevent Properties of event to create.
228      * @throws moodle_exception
229      */
230     public function create_events($passedevent) {
231         global $DB;
233         $event = clone($passedevent);
234         // If Frequency is not set, there is nothing to do.
235         if (empty($this->freq)) {
236             return;
237         }
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);
254         }
255         $calevent->update($updatedata, false);
256         $eventrec->timestart = $calevent->timestart;
258         // Create the recurring calendar events.
259         $this->create_recurring_events($eventrec, $eventtimes);
260     }
262     /**
263      * Parse a property of the recurrence rule.
264      *
265      * @param string $prop property string with type-value pair
266      * @throws moodle_exception
267      */
268     protected function parse_rrule_property($prop) {
269         list($property, $value) = explode('=', $prop);
270         switch ($property) {
271             case 'FREQ' :
272                 $this->set_frequency($value);
273                 break;
274             case 'UNTIL' :
275                 $this->set_until($value);
276                 break;
277             CASE 'COUNT' :
278                 $this->set_count($value);
279                 break;
280             CASE 'INTERVAL' :
281                 $this->set_interval($value);
282                 break;
283             CASE 'BYSECOND' :
284                 $this->set_bysecond($value);
285                 break;
286             CASE 'BYMINUTE' :
287                 $this->set_byminute($value);
288                 break;
289             CASE 'BYHOUR' :
290                 $this->set_byhour($value);
291                 break;
292             CASE 'BYDAY' :
293                 $this->set_byday($value);
294                 break;
295             CASE 'BYMONTHDAY' :
296                 $this->set_bymonthday($value);
297                 break;
298             CASE 'BYYEARDAY' :
299                 $this->set_byyearday($value);
300                 break;
301             CASE 'BYWEEKNO' :
302                 $this->set_byweekno($value);
303                 break;
304             CASE 'BYMONTH' :
305                 $this->set_bymonth($value);
306                 break;
307             CASE 'BYSETPOS' :
308                 $this->set_bysetpos($value);
309                 break;
310             CASE 'WKST' :
311                 $this->wkst = $this->get_day($value);
312                 break;
313             default:
314                 // We should never get here, something is very wrong.
315                 throw new moodle_exception('errorrrule', 'calendar');
316         }
317     }
319     /**
320      * Sets Frequency property.
321      *
322      * @param string $freq Frequency of event
323      * @throws moodle_exception
324      */
325     protected function set_frequency($freq) {
326         switch ($freq) {
327             case 'YEARLY':
328                 $this->freq = self::FREQ_YEARLY;
329                 break;
330             case 'MONTHLY':
331                 $this->freq = self::FREQ_MONTHLY;
332                 break;
333             case 'WEEKLY':
334                 $this->freq = self::FREQ_WEEKLY;
335                 break;
336             case 'DAILY':
337                 $this->freq = self::FREQ_DAILY;
338                 break;
339             case 'HOURLY':
340                 $this->freq = self::FREQ_HOURLY;
341                 break;
342             case 'MINUTELY':
343                 $this->freq = self::FREQ_MINUTELY;
344                 break;
345             case 'SECONDLY':
346                 $this->freq = self::FREQ_SECONDLY;
347                 break;
348             default:
349                 // We should never get here, something is very wrong.
350                 throw new moodle_exception('errorrrulefreq', 'calendar');
351         }
352     }
354     /**
355      * Gets the day from day string.
356      *
357      * @param string $daystring Day string (MO, TU, etc)
358      * @throws moodle_exception
359      *
360      * @return string Day represented by the parameter.
361      */
362     protected function get_day($daystring) {
363         switch ($daystring) {
364             case 'MO':
365                 return self::DAY_MONDAY;
366                 break;
367             case 'TU':
368                 return self::DAY_TUESDAY;
369                 break;
370             case 'WE':
371                 return self::DAY_WEDNESDAY;
372                 break;
373             case 'TH':
374                 return self::DAY_THURSDAY;
375                 break;
376             case 'FR':
377                 return self::DAY_FRIDAY;
378                 break;
379             case 'SA':
380                 return self::DAY_SATURDAY;
381                 break;
382             case 'SU':
383                 return self::DAY_SUNDAY;
384                 break;
385             default:
386                 // We should never get here, something is very wrong.
387                 throw new moodle_exception('errorrruleday', 'calendar');
388         }
389     }
391     /**
392      * Sets the UNTIL rule.
393      *
394      * @param string $until The date string representation of the UNTIL rule.
395      * @throws moodle_exception
396      */
397     protected function set_until($until) {
398         $this->until = strtotime($until);
399     }
401     /**
402      * Sets the COUNT rule.
403      *
404      * @param string $count The count value.
405      * @throws moodle_exception
406      */
407     protected function set_count($count) {
408         $this->count = intval($count);
409     }
411     /**
412      * Sets the INTERVAL rule.
413      *
414      * The INTERVAL rule part contains a positive integer representing how often the recurrence rule repeats.
415      * The default value is "1", meaning:
416      *  - every second for a SECONDLY rule, or
417      *  - every minute for a MINUTELY rule,
418      *  - every hour for an HOURLY rule,
419      *  - every day for a DAILY rule,
420      *  - every week for a WEEKLY rule,
421      *  - every month for a MONTHLY rule and
422      *  - every year for a YEARLY rule.
423      *
424      * @param string $intervalstr The value for the interval rule.
425      * @throws moodle_exception
426      */
427     protected function set_interval($intervalstr) {
428         $interval = intval($intervalstr);
429         if ($interval < 1) {
430             throw new moodle_exception('errorinvalidinterval', 'calendar');
431         }
432         $this->interval = $interval;
433     }
435     /**
436      * Sets the BYSECOND rule.
437      *
438      * The BYSECOND rule part specifies a comma-separated list of seconds within a minute.
439      * Valid values are 0 to 59.
440      *
441      * @param string $bysecond Comma-separated list of seconds within a minute.
442      * @throws moodle_exception
443      */
444     protected function set_bysecond($bysecond) {
445         $seconds = explode(',', $bysecond);
446         $bysecondrules = [];
447         foreach ($seconds as $second) {
448             if ($second < 0 || $second > 59) {
449                 throw new moodle_exception('errorinvalidbysecond', 'calendar');
450             }
451             $bysecondrules[] = (int)$second;
452         }
453         $this->bysecond = $bysecondrules;
454     }
456     /**
457      * Sets the BYMINUTE rule.
458      *
459      * The BYMINUTE rule part specifies a comma-separated list of seconds within an hour.
460      * Valid values are 0 to 59.
461      *
462      * @param string $byminute Comma-separated list of minutes within an hour.
463      * @throws moodle_exception
464      */
465     protected function set_byminute($byminute) {
466         $minutes = explode(',', $byminute);
467         $byminuterules = [];
468         foreach ($minutes as $minute) {
469             if ($minute < 0 || $minute > 59) {
470                 throw new moodle_exception('errorinvalidbyminute', 'calendar');
471             }
472             $byminuterules[] = (int)$minute;
473         }
474         $this->byminute = $byminuterules;
475     }
477     /**
478      * Sets the BYHOUR rule.
479      *
480      * The BYHOUR rule part specifies a comma-separated list of hours of the day.
481      * Valid values are 0 to 23.
482      *
483      * @param string $byhour Comma-separated list of hours of the day.
484      * @throws moodle_exception
485      */
486     protected function set_byhour($byhour) {
487         $hours = explode(',', $byhour);
488         $byhourrules = [];
489         foreach ($hours as $hour) {
490             if ($hour < 0 || $hour > 23) {
491                 throw new moodle_exception('errorinvalidbyhour', 'calendar');
492             }
493             $byhourrules[] = (int)$hour;
494         }
495         $this->byhour = $byhourrules;
496     }
498     /**
499      * Sets the BYDAY rule.
500      *
501      * The BYDAY rule part specifies a comma-separated list of days of the week;
502      *  - MO indicates Monday;
503      *  - TU indicates Tuesday;
504      *  - WE indicates Wednesday;
505      *  - TH indicates Thursday;
506      *  - FR indicates Friday;
507      *  - SA indicates Saturday;
508      *  - SU indicates Sunday.
509      *
510      * Each BYDAY value can also be preceded by a positive (+n) or negative (-n) integer.
511      * If present, this indicates the nth occurrence of the specific day within the MONTHLY or YEARLY RRULE.
512      * For example, within a MONTHLY rule, +1MO (or simply 1MO) represents the first Monday within the month,
513      * whereas -1MO represents the last Monday of the month.
514      * If an integer modifier is not present, it means all days of this type within the specified frequency.
515      * For example, within a MONTHLY rule, MO represents all Mondays within the month.
516      *
517      * @param string $byday Comma-separated list of days of the week.
518      * @throws moodle_exception
519      */
520     protected function set_byday($byday) {
521         $weekdays = array_keys(self::DAYS_OF_WEEK);
522         $days = explode(',', $byday);
523         $bydayrules = [];
524         foreach ($days as $day) {
525             $suffix = substr($day, -2);
526             if (!in_array($suffix, $weekdays)) {
527                 throw new moodle_exception('errorinvalidbydaysuffix', 'calendar');
528             }
530             $bydayrule = new stdClass();
531             $bydayrule->day = substr($suffix, -2);
532             $bydayrule->value = (int)str_replace($suffix, '', $day);
534             $bydayrules[] = $bydayrule;
535         }
537         $this->byday = $bydayrules;
538     }
540     /**
541      * Sets the BYMONTHDAY rule.
542      *
543      * The BYMONTHDAY rule part specifies a comma-separated list of days of the month.
544      * Valid values are 1 to 31 or -31 to -1. For example, -10 represents the tenth to the last day of the month.
545      *
546      * @param string $bymonthday Comma-separated list of days of the month.
547      * @throws moodle_exception
548      */
549     protected function set_bymonthday($bymonthday) {
550         $monthdays = explode(',', $bymonthday);
551         $bymonthdayrules = [];
552         foreach ($monthdays as $day) {
553             // Valid values are 1 to 31 or -31 to -1.
554             if ($day < -31 || $day > 31 || $day == 0) {
555                 throw new moodle_exception('errorinvalidbymonthday', 'calendar');
556             }
557             $bymonthdayrules[] = (int)$day;
558         }
560         // Sort these MONTHDAY rules in ascending order.
561         sort($bymonthdayrules);
563         $this->bymonthday = $bymonthdayrules;
564     }
566     /**
567      * Sets the BYYEARDAY rule.
568      *
569      * The BYYEARDAY rule part specifies a comma-separated list of days of the year.
570      * Valid values are 1 to 366 or -366 to -1. For example, -1 represents the last day of the year (December 31st)
571      * and -306 represents the 306th to the last day of the year (March 1st).
572      *
573      * @param string $byyearday Comma-separated list of days of the year.
574      * @throws moodle_exception
575      */
576     protected function set_byyearday($byyearday) {
577         $yeardays = explode(',', $byyearday);
578         $byyeardayrules = [];
579         foreach ($yeardays as $day) {
580             // Valid values are 1 to 366 or -366 to -1.
581             if ($day < -366 || $day > 366 || $day == 0) {
582                 throw new moodle_exception('errorinvalidbyyearday', 'calendar');
583             }
584             $byyeardayrules[] = (int)$day;
585         }
586         $this->byyearday = $byyeardayrules;
587     }
589     /**
590      * Sets the BYWEEKNO rule.
591      *
592      * The BYWEEKNO rule part specifies a comma-separated list of ordinals specifying weeks of the year.
593      * Valid values are 1 to 53 or -53 to -1. This corresponds to weeks according to week numbering as defined in [ISO 8601].
594      * A week is defined as a seven day period, starting on the day of the week defined to be the week start (see WKST).
595      * Week number one of the calendar year is the first week which contains at least four (4) days in that calendar year.
596      * This rule part is only valid for YEARLY rules. For example, 3 represents the third week of the year.
597      *
598      * Note: Assuming a Monday week start, week 53 can only occur when Thursday is January 1 or if it is a leap year and Wednesday
599      * is January 1.
600      *
601      * @param string $byweekno Comma-separated list of number of weeks.
602      * @throws moodle_exception
603      */
604     protected function set_byweekno($byweekno) {
605         $weeknumbers = explode(',', $byweekno);
606         $byweeknorules = [];
607         foreach ($weeknumbers as $week) {
608             // Valid values are 1 to 53 or -53 to -1.
609             if ($week < -53 || $week > 53 || $week == 0) {
610                 throw new moodle_exception('errorinvalidbyweekno', 'calendar');
611             }
612             $byweeknorules[] = (int)$week;
613         }
614         $this->byweekno = $byweeknorules;
615     }
617     /**
618      * Sets the BYMONTH rule.
619      *
620      * The BYMONTH rule part specifies a comma-separated list of months of the year.
621      * Valid values are 1 to 12.
622      *
623      * @param string $bymonth Comma-separated list of months of the year.
624      * @throws moodle_exception
625      */
626     protected function set_bymonth($bymonth) {
627         $months = explode(',', $bymonth);
628         $bymonthrules = [];
629         foreach ($months as $month) {
630             // Valid values are 1 to 12.
631             if ($month < 1 || $month > 12) {
632                 throw new moodle_exception('errorinvalidbymonth', 'calendar');
633             }
634             $bymonthrules[] = (int)$month;
635         }
636         $this->bymonth = $bymonthrules;
637     }
639     /**
640      * Sets the BYSETPOS rule.
641      *
642      * The BYSETPOS rule part specifies a comma-separated list of values which corresponds to the nth occurrence within the set of
643      * events specified by the rule. Valid values are 1 to 366 or -366 to -1.
644      * It MUST only be used in conjunction with another BYxxx rule part.
645      *
646      * For example "the last work day of the month" could be represented as: RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1
647      *
648      * @param string $bysetpos Comma-separated list of values.
649      * @throws moodle_exception
650      */
651     protected function set_bysetpos($bysetpos) {
652         $setposes = explode(',', $bysetpos);
653         $bysetposrules = [];
654         foreach ($setposes as $pos) {
655             // Valid values are 1 to 366 or -366 to -1.
656             if ($pos < -366 || $pos > 366 || $pos == 0) {
657                 throw new moodle_exception('errorinvalidbysetpos', 'calendar');
658             }
659             $bysetposrules[] = (int)$pos;
660         }
661         $this->bysetpos = $bysetposrules;
662     }
664     /**
665      * Validate the rules as a whole.
666      *
667      * @throws moodle_exception
668      */
669     protected function validate_rules() {
670         // UNTIL and COUNT cannot be in the same recurrence rule.
671         if (!empty($this->until) && !empty($this->count)) {
672             throw new moodle_exception('errorhasuntilandcount', 'calendar');
673         }
675         // BYSETPOS only be used in conjunction with another BYxxx rule part.
676         if (!empty($this->bysetpos) && empty($this->bymonth) && empty($this->bymonthday) && empty($this->bysecond)
677             && empty($this->byday) && empty($this->byweekno) && empty($this->byhour) && empty($this->byminute)
678             && empty($this->byyearday)) {
679             throw new moodle_exception('errormustbeusedwithotherbyrule', 'calendar');
680         }
682         // Integer values preceding BYDAY rules can only be present for MONTHLY or YEARLY RRULE.
683         foreach ($this->byday as $bydayrule) {
684             if (!empty($bydayrule->value) && $this->freq != self::FREQ_MONTHLY && $this->freq != self::FREQ_YEARLY) {
685                 throw new moodle_exception('errorinvalidbydayprefix', 'calendar');
686             }
687         }
689         // The BYWEEKNO rule is only valid for YEARLY rules.
690         if (!empty($this->byweekno) && $this->freq != self::FREQ_YEARLY) {
691             throw new moodle_exception('errornonyearlyfreqwithbyweekno', 'calendar');
692         }
693     }
695     /**
696      * Creates calendar events for the recurring events.
697      *
698      * @param stdClass $event The parent event.
699      * @param int[] $eventtimes The timestamps of the recurring events.
700      */
701     protected function create_recurring_events($event, $eventtimes) {
702         $count = false;
703         if ($this->count) {
704             $count = $this->count;
705         }
707         foreach ($eventtimes as $time) {
708             // Skip if time is the same time with the parent event's timestamp.
709             if ($time == $event->timestart) {
710                 continue;
711             }
713             // Decrement count, if set.
714             if ($count !== false) {
715                 $count--;
716                 if ($count == 0) {
717                     break;
718                 }
719             }
721             // Create the recurring event.
722             $cloneevent = clone($event);
723             $cloneevent->repeatid = $event->id;
724             $cloneevent->timestart = $time;
725             unset($cloneevent->id);
726             // UUID should only be set on the first instance of the recurring events.
727             unset($cloneevent->uuid);
728             calendar_event::create($cloneevent, false);
729         }
731         // If COUNT rule is defined and the number of the generated event times is less than the the COUNT rule,
732         // repeat the processing until the COUNT rule is satisfied.
733         if ($count !== false && $count > 0) {
734             // Set count to the remaining counts.
735             $this->count = $count;
736             // Clone the original event, but set the timestart to the last generated event time.
737             $tmpevent = clone($event);
738             $tmpevent->timestart = end($eventtimes);
739             // Generate the additional event times.
740             $additionaleventtimes = $this->generate_recurring_event_times($tmpevent);
741             // Create the additional events.
742             $this->create_recurring_events($event, $additionaleventtimes);
743         }
744     }
746     /**
747      * Generates recurring events based on the parent event and the RRULE set.
748      *
749      * If multiple BYxxx rule parts are specified, then after evaluating the specified FREQ and INTERVAL rule parts,
750      * the BYxxx rule parts are applied to the current set of evaluated occurrences in the following order:
751      * BYMONTH, BYWEEKNO, BYYEARDAY, BYMONTHDAY, BYDAY, BYHOUR, BYMINUTE, BYSECOND and BYSETPOS;
752      * then COUNT and UNTIL are evaluated.
753      *
754      * @param stdClass $event The event object.
755      * @return array The list of timestamps that obey the given RRULE.
756      */
757     protected function generate_recurring_event_times($event) {
758         $interval = $this->get_interval();
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();
776             }
777         } else {
778             // If count is defined, let's define a tentative until date. We'll just trim the number of events later.
779             $untildate = clone($eventdatetime);
780             $count = $this->count;
781             while ($count >= 0) {
782                 $untildate->add($interval);
783                 $count--;
784             }
785             $until = $untildate->getTimestamp();
786         }
788         // No filters applied. Generate recurring events right away.
789         if (!$this->has_by_rules()) {
790             // Get initial list of prospective events.
791             $tmpstart = clone($eventdatetime);
792             while ($tmpstart->getTimestamp() <= $until) {
793                 $eventtimes[] = $tmpstart->getTimestamp();
794                 $tmpstart->add($interval);
795             }
796             return $eventtimes;
797         }
799         // Get all of potential dates covered by the periods from the event's start date until the last.
800         $dailyinterval = new DateInterval('P1D');
801         $boundslist = $this->get_period_bounds_list($eventdatetime->getTimestamp(), $until);
802         foreach ($boundslist as $bounds) {
803             $tmpdate = new DateTime(date('Y-m-d H:i:s', $bounds->start));
804             while ($tmpdate->getTimestamp() >= $bounds->start && $tmpdate->getTimestamp() < $bounds->next) {
805                 $eventtimes[] = $tmpdate->getTimestamp();
806                 $tmpdate->add($dailyinterval);
807             }
808         }
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')];
822         }
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;
845             }
846             // End if event time is beyond the until limit.
847             if ($time > $until) {
848                 break;
849             }
850             $results[] = $time;
851         }
853         return $results;
854     }
856     /**
857      * Generates a DateInterval object based on the FREQ and INTERVAL rules.
858      *
859      * @return DateInterval
860      * @throws moodle_exception
861      */
862     protected function get_interval() {
863         $intervalspec = null;
864         switch ($this->freq) {
865             case self::FREQ_YEARLY:
866                 $intervalspec = 'P' . $this->interval . 'Y';
867                 break;
868             case self::FREQ_MONTHLY:
869                 $intervalspec = 'P' . $this->interval . 'M';
870                 break;
871             case self::FREQ_WEEKLY:
872                 $intervalspec = 'P' . $this->interval . 'W';
873                 break;
874             case self::FREQ_DAILY:
875                 $intervalspec = 'P' . $this->interval . 'D';
876                 break;
877             case self::FREQ_HOURLY:
878                 $intervalspec = 'PT' . $this->interval . 'H';
879                 break;
880             case self::FREQ_MINUTELY:
881                 $intervalspec = 'PT' . $this->interval . 'M';
882                 break;
883             case self::FREQ_SECONDLY:
884                 $intervalspec = 'PT' . $this->interval . 'S';
885                 break;
886             default:
887                 // We should never get here, something is very wrong.
888                 throw new moodle_exception('errorrrulefreq', 'calendar');
889         }
891         return new DateInterval($intervalspec);
892     }
894     /**
895      * Determines whether the RRULE has BYxxx rules or not.
896      *
897      * @return bool True if there is one or more BYxxx rules to process. False, otherwise.
898      */
899     protected function has_by_rules() {
900         return !empty($this->bymonth) || !empty($this->bymonthday) || !empty($this->bysecond) || !empty($this->byday)
901             || !empty($this->byweekno) || !empty($this->byhour) || !empty($this->byminute) || !empty($this->byyearday);
902     }
904     /**
905      * Filter event times based on the BYMONTH rule.
906      *
907      * @param int[] $eventdates Timestamps of event times to be filtered.
908      * @return int[] Array of filtered timestamps.
909      */
910     protected function filter_by_month($eventdates) {
911         if (empty($this->bymonth)) {
912             return $eventdates;
913         }
915         $filteredbymonth = [];
916         foreach ($eventdates as $time) {
917             foreach ($this->bymonth as $month) {
918                 $prospectmonth = date('n', $time);
919                 if ($month == $prospectmonth) {
920                     $filteredbymonth[] = $time;
921                     break;
922                 }
923             }
924         }
925         return $filteredbymonth;
926     }
928     /**
929      * Filter event times based on the BYWEEKNO rule.
930      *
931      * @param int[] $eventdates Timestamps of event times to be filtered.
932      * @return int[] Array of filtered timestamps.
933      */
934     protected function filter_by_weekno($eventdates) {
935         if (empty($this->byweekno)) {
936             return $eventdates;
937         }
939         $filteredbyweekno = [];
940         $weeklyinterval = null;
941         foreach ($eventdates as $time) {
942             $tmpdate = new DateTime(date('Y-m-d H:i:s', $time));
943             foreach ($this->byweekno as $weekno) {
944                 if ($weekno > 0) {
945                     if ($tmpdate->format('W') == $weekno) {
946                         $filteredbyweekno[] = $time;
947                         break;
948                     }
949                 } else if ($weekno < 0) {
950                     if ($weeklyinterval === null) {
951                         $weeklyinterval = new DateInterval('P1W');
952                     }
953                     $weekstart = new DateTime();
954                     $weekstart->setISODate($tmpdate->format('Y'), $weekno);
955                     $weeknext = clone($weekstart);
956                     $weeknext->add($weeklyinterval);
958                     $tmptimestamp = $tmpdate->getTimestamp();
960                     if ($tmptimestamp >= $weekstart->getTimestamp() && $tmptimestamp < $weeknext->getTimestamp()) {
961                         $filteredbyweekno[] = $time;
962                         break;
963                     }
964                 }
965             }
966         }
967         return $filteredbyweekno;
968     }
970     /**
971      * Filter event times based on the BYYEARDAY rule.
972      *
973      * @param int[] $eventdates Timestamps of event times to be filtered.
974      * @return int[] Array of filtered timestamps.
975      */
976     protected function filter_by_yearday($eventdates) {
977         if (empty($this->byyearday)) {
978             return $eventdates;
979         }
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;
994                     }
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;
1004                     }
1005                 }
1006             }
1007         }
1008         return $filteredbyyearday;
1009     }
1011     /**
1012      * Filter event times based on the BYMONTHDAY rule.
1013      *
1014      * @param int[] $eventdates The event times to be filtered.
1015      * @return int[] Array of filtered timestamps.
1016      */
1017     protected function filter_by_monthday($eventdates) {
1018         if (empty($this->bymonthday)) {
1019             return $eventdates;
1020         }
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;
1034                     }
1035                 } else if ($monthday < 0) {
1036                     $tmpdate = clone($eventdatetime);
1037                     // Reset to the first day of the month.
1038                     $tmpdate->modify('first day of this month');
1039                     // Then go to last day of the month.
1040                     $tmpdate->modify('last day of this month');
1041                     if ($daysoffset > 0) {
1042                         // Then subtract the monthday value.
1043                         $tmpdate->sub($dayinterval);
1044                     }
1045                     if ($eventdatetime->format('j') == $tmpdate->format('j')) {
1046                         $filteredbymonthday[] = $time;
1047                         break;
1048                     }
1049                 }
1050             }
1051         }
1052         return $filteredbymonthday;
1053     }
1055     /**
1056      * Filter event times based on the BYDAY rule.
1057      *
1058      * @param stdClass $event The parent event.
1059      * @param int[] $eventdates The event times to be filtered.
1060      * @param int $until Event times generation limit date.
1061      * @return int[] Array of filtered timestamps.
1062      */
1063     protected function filter_by_day($event, $eventdates, $until) {
1064         if (empty($this->byday)) {
1065             return $eventdates;
1066         }
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;
1082                 }
1084                 if (empty($day->value)) {
1085                     // No modifier value. Applies to all weekdays of the given period.
1086                     $filteredbyday[] = $time;
1087                     break;
1088                 } else if ($day->value > 0) {
1089                     // Positive value.
1090                     if ($this->freq === self::FREQ_YEARLY && empty($this->bymonth)) {
1091                         // Get the first day of the year.
1092                         $firstdaydate = $tmpdatetime->format('Y') . '-01-01';
1093                     } else {
1094                         // Get the first day of the month.
1095                         $firstdaydate = $tmpdatetime->format('Y-m') . '-01';
1096                     }
1097                     $expecteddate = new DateTime($firstdaydate);
1098                     $count = $day->value;
1099                     // Get the nth week day of the year/month.
1100                     $expecteddate->modify("+$count $dayname");
1101                     if ($expecteddate->format('Y-m-d') === $tmpdatetime->format('Y-m-d')) {
1102                         $filteredbyday[] = $time;
1103                         break;
1104                     }
1106                 } else {
1107                     // Negative value.
1108                     $count = $day->value;
1109                     if ($this->freq === self::FREQ_YEARLY && empty($this->bymonth)) {
1110                         // The -Nth week day of the year.
1111                         $eventyear = (int)$tmpdatetime->format('Y');
1112                         // Get temporary DateTime object starting from the first day of the next year.
1113                         $expecteddate = new DateTime((++$eventyear) . '-01-01');
1114                         while ($count < 0) {
1115                             // Get the start of the previous week.
1116                             $expecteddate->modify('last ' . $this->wkst);
1117                             $tmpexpecteddate = clone($expecteddate);
1118                             if ($tmpexpecteddate->format('l') !== $dayname) {
1119                                 $tmpexpecteddate->modify('next ' . $dayname);
1120                             }
1121                             if ($this->in_bounds($tmpexpecteddate->getTimestamp(), $bounds)) {
1122                                 $expecteddate = $tmpexpecteddate;
1123                                 $count++;
1124                             }
1125                         }
1126                         if ($expecteddate->format('l') !== $dayname) {
1127                             $expecteddate->modify('next ' . $dayname);
1128                         }
1129                         if ($expecteddate->getTimestamp() == $time) {
1130                             $filteredbyday[] = $time;
1131                             break;
1132                         }
1134                     } else {
1135                         // The -Nth week day of the month.
1136                         $expectedmonthyear = $tmpdatetime->format('F Y');
1137                         $expecteddate = new DateTime("first day of $expectedmonthyear");
1138                         $expecteddate->add($nextmonthinterval);
1139                         while ($count < 0) {
1140                             // Get the start of the previous week.
1141                             $expecteddate->modify('last ' . $this->wkst);
1142                             $tmpexpecteddate = clone($expecteddate);
1143                             if ($tmpexpecteddate->format('l') !== $dayname) {
1144                                 $tmpexpecteddate->modify('next ' . $dayname);
1145                             }
1146                             if ($this->in_bounds($tmpexpecteddate->getTimestamp(), $bounds)) {
1147                                 $expecteddate = $tmpexpecteddate;
1148                                 $count++;
1149                             }
1150                         }
1152                         // Compare the expected date with the event's timestamp.
1153                         if ($expecteddate->getTimestamp() == $time) {
1154                             $filteredbyday[] = $time;
1155                             break;
1156                         }
1157                     }
1158                 }
1159             }
1160         }
1161         return $filteredbyday;
1162     }
1164     /**
1165      * Applies BYHOUR, BYMINUTE and BYSECOND rules to the calculated event dates.
1166      * Defaults to the DTSTART's hour/minute/second component when not defined.
1167      *
1168      * @param DateTime $eventdatetime The parent event DateTime object pertaining to the DTSTART.
1169      * @param int[] $eventdates Array of candidate event date timestamps.
1170      * @return array List of updated event timestamps that contain the time component of the event times.
1171      */
1172     protected function apply_hour_minute_second_rules(DateTime $eventdatetime, $eventdates) {
1173         // If BYHOUR rules are not set, set the hour of the events from the DTSTART's hour component.
1174         if (empty($this->byhour)) {
1175             $this->byhour = [$eventdatetime->format('G')];
1176         }
1177         // If BYMINUTE rules are not set, set the hour of the events from the DTSTART's minute component.
1178         if (empty($this->byminute)) {
1179             $this->byminute = [(int)$eventdatetime->format('i')];
1180         }
1181         // If BYSECOND rules are not set, set the hour of the events from the DTSTART's second component.
1182         if (empty($this->bysecond)) {
1183             $this->bysecond = [(int)$eventdatetime->format('s')];
1184         }
1186         $results = [];
1187         foreach ($eventdates as $time) {
1188             $datetime = new DateTime(date('Y-m-d', $time));
1189             foreach ($this->byhour as $hour) {
1190                 foreach ($this->byminute as $minute) {
1191                     foreach ($this->bysecond as $second) {
1192                         $datetime->setTime($hour, $minute, $second);
1193                         $results[] = $datetime->getTimestamp();
1194                     }
1195                 }
1196             }
1197         }
1198         return $results;
1199     }
1201     /**
1202      * Filter event times based on the BYSETPOS rule.
1203      *
1204      * @param stdClass $event The parent event.
1205      * @param int[] $eventtimes The event times to be filtered.
1206      * @param int $until Event times generation limit date.
1207      * @return int[] Array of filtered timestamps.
1208      */
1209     protected function filter_by_setpos($event, $eventtimes, $until) {
1210         if (empty($this->bysetpos)) {
1211             return $eventtimes;
1212         }
1214         $filteredbysetpos = [];
1215         $boundslist = $this->get_period_bounds_list($event->timestart, $until);
1216         sort($eventtimes);
1217         foreach ($boundslist as $bounds) {
1218             // Generate a list of candidate event times based that are covered in a period's bounds.
1219             $prospecttimes = [];
1220             foreach ($eventtimes as $time) {
1221                 if ($time >= $bounds->start && $time < $bounds->next) {
1222                     $prospecttimes[] = $time;
1223                 }
1224             }
1225             if (empty($prospecttimes)) {
1226                 continue;
1227             }
1228             // Add the event times that correspond to the set position rule into the filtered results.
1229             foreach ($this->bysetpos as $pos) {
1230                 $tmptimes = $prospecttimes;
1231                 if ($pos < 0) {
1232                     rsort($tmptimes);
1233                 }
1234                 $index = abs($pos) - 1;
1235                 if (isset($tmptimes[$index])) {
1236                     $filteredbysetpos[] = $tmptimes[$index];
1237                 }
1238             }
1239         }
1240         return $filteredbysetpos;
1241     }
1243     /**
1244      * Gets the list of period boundaries covered by the recurring events.
1245      *
1246      * @param int $eventtime The event timestamp.
1247      * @param int $until The end timestamp.
1248      * @return array List of period bounds, with start and next properties.
1249      */
1250     protected function get_period_bounds_list($eventtime, $until) {
1251         $interval = $this->get_interval();
1252         $periodbounds = $this->get_period_boundaries($eventtime);
1253         $periodstart = $periodbounds['start'];
1254         $periodafter = $periodbounds['next'];
1255         $bounds = [];
1256         if ($until !== null) {
1257             while ($periodstart->getTimestamp() < $until) {
1258                 $bounds[] = (object)[
1259                     'start' => $periodstart->getTimestamp(),
1260                     'next' => $periodafter->getTimestamp()
1261                 ];
1262                 $periodstart->add($interval);
1263                 $periodafter->add($interval);
1264             }
1265         } else {
1266             $count = $this->count;
1267             while ($count > 0) {
1268                 $bounds[] = (object)[
1269                     'start' => $periodstart->getTimestamp(),
1270                     'next' => $periodafter->getTimestamp()
1271                 ];
1272                 $periodstart->add($interval);
1273                 $periodafter->add($interval);
1274                 $count--;
1275             }
1276         }
1278         return $bounds;
1279     }
1281     /**
1282      * Determine whether the date-time in question is within the bounds of the periods that are covered by the RRULE.
1283      *
1284      * @param int $time The timestamp to be evaluated.
1285      * @param array $bounds Array of period boundaries covered by the RRULE.
1286      * @return bool
1287      */
1288     protected function in_bounds($time, $bounds) {
1289         foreach ($bounds as $bound) {
1290             if ($time >= $bound->start && $time < $bound->next) {
1291                 return true;
1292             }
1293         }
1294         return false;
1295     }
1297     /**
1298      * Determines the start and end DateTime objects that serve as references to determine whether a calculated event timestamp
1299      * falls on the period defined by these DateTimes objects.
1300      *
1301      * @param int $eventtime Unix timestamp of the event time.
1302      * @return DateTime[]
1303      * @throws moodle_exception
1304      */
1305     protected function get_period_boundaries($eventtime) {
1306         $nextintervalspec = null;
1308         switch ($this->freq) {
1309             case self::FREQ_YEARLY:
1310                 $nextintervalspec = 'P1Y';
1311                 $timestart = date('Y-01-01', $eventtime);
1312                 break;
1313             case self::FREQ_MONTHLY:
1314                 $nextintervalspec = 'P1M';
1315                 $timestart = date('Y-m-01', $eventtime);
1316                 break;
1317             case self::FREQ_WEEKLY:
1318                 $nextintervalspec = 'P1W';
1319                 if (date('l', $eventtime) === $this->wkst) {
1320                     $weekstarttime = $eventtime;
1321                 } else {
1322                     $weekstarttime = strtotime('last ' . $this->wkst, $eventtime);
1323                 }
1324                 $timestart = date('Y-m-d', $weekstarttime);
1325                 break;
1326             case self::FREQ_DAILY:
1327                 $nextintervalspec = 'P1D';
1328                 $timestart = date('Y-m-d', $eventtime);
1329                 break;
1330             case self::FREQ_HOURLY:
1331                 $nextintervalspec = 'PT1H';
1332                 $timestart = date('Y-m-d H:00:00', $eventtime);
1333                 break;
1334             case self::FREQ_MINUTELY:
1335                 $nextintervalspec = 'PT1M';
1336                 $timestart = date('Y-m-d H:i:00', $eventtime);
1337                 break;
1338             case self::FREQ_SECONDLY:
1339                 $nextintervalspec = 'PT1S';
1340                 $timestart = date('Y-m-d H:i:s', $eventtime);
1341                 break;
1342             default:
1343                 // We should never get here, something is very wrong.
1344                 throw new moodle_exception('errorrrulefreq', 'calendar');
1345         }
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,
1355         ];
1356     }