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