Merge branch 'wip-mdl-51555' of https://github.com/rajeshtaneja/moodle
[moodle.git] / calendar / classes / rrule_manager.php
blob81c3117217268a200642cfc7abb6e88cc8534725
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17 /**
18 * Defines calendar class to manage recurrence rule (rrule) during ical imports.
20 * @package core_calendar
21 * @copyright 2014 onwards Ankit Agarwal
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 namespace core_calendar;
26 defined('MOODLE_INTERNAL') || die();
27 require_once($CFG->dirroot . '/calendar/lib.php');
29 /**
30 * Defines calendar class to manage recurrence rule (rrule) during ical imports.
32 * Please refer to RFC 2445 {@link http://www.ietf.org/rfc/rfc2445.txt} for detail explanation of the logic.
33 * Here is a basic extract from it to explain various params:-
34 * recur = "FREQ"=freq *(
35 * ; either UNTIL or COUNT may appear in a 'recur',
36 * ; but UNTIL and COUNT MUST NOT occur in the same 'recur'
37 * ( ";" "UNTIL" "=" enddate ) /
38 * ( ";" "COUNT" "=" 1*DIGIT ) /
39 * ; the rest of these keywords are optional,
40 * ; but MUST NOT occur more than once
41 * ( ";" "INTERVAL" "=" 1*DIGIT ) /
42 * ( ";" "BYSECOND" "=" byseclist ) /
43 * ( ";" "BYMINUTE" "=" byminlist ) /
44 * ( ";" "BYHOUR" "=" byhrlist ) /
45 * ( ";" "BYDAY" "=" bywdaylist ) /
46 * ( ";" "BYMONTHDAY" "=" bymodaylist ) /
47 * ( ";" "BYYEARDAY" "=" byyrdaylist ) /
48 * ( ";" "BYWEEKNO" "=" bywknolist ) /
49 * ( ";" "BYMONTH" "=" bymolist ) /
50 * ( ";" "BYSETPOS" "=" bysplist ) /
51 * ( ";" "WKST" "=" weekday ) /
52 * ( ";" x-name "=" text )
53 * )
55 * freq = "SECONDLY" / "MINUTELY" / "HOURLY" / "DAILY"
56 * / "WEEKLY" / "MONTHLY" / "YEARLY"
57 * enddate = date
58 * enddate =/ date-time ;An UTC value
59 * byseclist = seconds / ( seconds *("," seconds) )
60 * seconds = 1DIGIT / 2DIGIT ;0 to 59
61 * byminlist = minutes / ( minutes *("," minutes) )
62 * minutes = 1DIGIT / 2DIGIT ;0 to 59
63 * byhrlist = hour / ( hour *("," hour) )
64 * hour = 1DIGIT / 2DIGIT ;0 to 23
65 * bywdaylist = weekdaynum / ( weekdaynum *("," weekdaynum) )
66 * weekdaynum = [([plus] ordwk / minus ordwk)] weekday
67 * plus = "+"
68 * minus = "-"
69 * ordwk = 1DIGIT / 2DIGIT ;1 to 53
70 * weekday = "SU" / "MO" / "TU" / "WE" / "TH" / "FR" / "SA"
71 * ;Corresponding to SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY,
72 * ;FRIDAY, SATURDAY and SUNDAY days of the week.
73 * bymodaylist = monthdaynum / ( monthdaynum *("," monthdaynum) )
74 * monthdaynum = ([plus] ordmoday) / (minus ordmoday)
75 * ordmoday = 1DIGIT / 2DIGIT ;1 to 31
76 * byyrdaylist = yeardaynum / ( yeardaynum *("," yeardaynum) )
77 * yeardaynum = ([plus] ordyrday) / (minus ordyrday)
78 * ordyrday = 1DIGIT / 2DIGIT / 3DIGIT ;1 to 366
79 * bywknolist = weeknum / ( weeknum *("," weeknum) )
80 * weeknum = ([plus] ordwk) / (minus ordwk)
81 * bymolist = monthnum / ( monthnum *("," monthnum) )
82 * monthnum = 1DIGIT / 2DIGIT ;1 to 12
83 * bysplist = setposday / ( setposday *("," setposday) )
84 * setposday = yeardaynum
86 * @package core_calendar
87 * @copyright 2014 onwards Ankit Agarwal <ankit.agrr@gmail.com>
88 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
90 class rrule_manager {
92 /** const string Frequency constant */
93 const FREQ_YEARLY = 'yearly';
95 /** const string Frequency constant */
96 const FREQ_MONTHLY = 'monthly';
98 /** const string Frequency constant */
99 const FREQ_WEEKLY = 'weekly';
101 /** const string Frequency constant */
102 const FREQ_DAILY = 'daily';
104 /** const string Frequency constant */
105 const FREQ_HOURLY = 'hourly';
107 /** const string Frequency constant */
108 const FREQ_MINUTELY = 'everyminute';
110 /** const string Frequency constant */
111 const FREQ_SECONDLY = 'everysecond';
113 /** const string Day constant */
114 const DAY_MONDAY = 'Monday';
116 /** const string Day constant */
117 const DAY_TUESDAY = 'Tuesday';
119 /** const string Day constant */
120 const DAY_WEDNESDAY = 'Wednesday';
122 /** const string Day constant */
123 const DAY_THURSDAY = 'Thursday';
125 /** const string Day constant */
126 const DAY_FRIDAY = 'Friday';
128 /** const string Day constant */
129 const DAY_SATURDAY = 'Saturday';
131 /** const string Day constant */
132 const DAY_SUNDAY = 'Sunday';
134 /** const int For forever repeating events, repeat for this many years */
135 const TIME_UNLIMITED_YEARS = 10;
137 /** @var string string representing the recurrence rule */
138 protected $rrule;
140 /** @var string Frequency of event */
141 protected $freq;
143 /** @var int defines a timestamp value which bounds the recurrence rule in an inclusive manner.*/
144 protected $until = 0;
146 /** @var int Defines the number of occurrences at which to range-bound the recurrence */
147 protected $count = 0;
149 /** @var int This rule part contains a positive integer representing how often the recurrence rule repeats */
150 protected $interval = 1;
152 /** @var array List of second rules */
153 protected $bysecond = array();
155 /** @var array List of Minute rules */
156 protected $byminute = array();
158 /** @var array List of hour rules */
159 protected $byhour = array();
161 /** @var array List of day rules */
162 protected $byday = array();
164 /** @var array List of monthday rules */
165 protected $bymonthday = array();
167 /** @var array List of yearday rules */
168 protected $byyearday = array();
170 /** @var array List of weekno rules */
171 protected $byweekno = array();
173 /** @var array List of month rules */
174 protected $bymonth = array();
176 /** @var array List of setpos rules */
177 protected $bysetpos = array();
179 /** @var array week start rules */
180 protected $wkst;
183 * Constructor for the class
185 * @param string $rrule Recurrence rule
187 public function __construct($rrule) {
188 $this->rrule = $rrule;
192 * Parse the recurrence rule and setup all properties.
194 public function parse_rrule() {
195 $rules = explode(';', $this->rrule);
196 if (empty($rules)) {
197 return;
199 foreach ($rules as $rule) {
200 $this->parse_rrule_property($rule);
205 * Parse a property of the recurrence rule.
207 * @param string $prop property string with type-value pair
208 * @throws \moodle_exception
210 protected function parse_rrule_property($prop) {
211 list($property, $value) = explode('=', $prop);
212 switch ($property) {
213 case 'FREQ' :
214 $this->set_frequency($value);
215 break;
216 case 'UNTIL' :
217 $this->until = strtotime($value);
218 break;
219 CASE 'COUNT' :
220 $this->count = intval($value);
221 break;
222 CASE 'INTERVAL' :
223 $this->interval = intval($value);
224 break;
225 CASE 'BYSECOND' :
226 $this->bysecond = explode(',', $value);
227 break;
228 CASE 'BYMINUTE' :
229 $this->byminute = explode(',', $value);
230 break;
231 CASE 'BYHOUR' :
232 $this->byhour = explode(',', $value);
233 break;
234 CASE 'BYDAY' :
235 $this->byday = explode(',', $value);
236 break;
237 CASE 'BYMONTHDAY' :
238 $this->bymonthday = explode(',', $value);
239 break;
240 CASE 'BYYEARDAY' :
241 $this->byyearday = explode(',', $value);
242 break;
243 CASE 'BYWEEKNO' :
244 $this->byweekno = explode(',', $value);
245 break;
246 CASE 'BYMONTH' :
247 $this->bymonth = explode(',', $value);
248 break;
249 CASE 'BYSETPOS' :
250 $this->bysetpos = explode(',', $value);
251 break;
252 CASE 'WKST' :
253 $this->wkst = $this->get_day($value);
254 break;
255 default:
256 // We should never get here, something is very wrong.
257 throw new \moodle_exception('errorrrule', 'calendar');
262 * Sets Frequency property.
264 * @param string $freq Frequency of event
265 * @throws \moodle_exception
267 protected function set_frequency($freq) {
268 switch ($freq) {
269 case 'YEARLY':
270 $this->freq = self::FREQ_YEARLY;
271 break;
272 case 'MONTHLY':
273 $this->freq = self::FREQ_MONTHLY;
274 break;
275 case 'WEEKLY':
276 $this->freq = self::FREQ_WEEKLY;
277 break;
278 case 'DAILY':
279 $this->freq = self::FREQ_DAILY;
280 break;
281 case 'HOURLY':
282 $this->freq = self::FREQ_HOURLY;
283 break;
284 case 'MINUTELY':
285 $this->freq = self::FREQ_MINUTELY;
286 break;
287 case 'SECONDLY':
288 $this->freq = self::FREQ_SECONDLY;
289 break;
290 default:
291 // We should never get here, something is very wrong.
292 throw new \moodle_exception('errorrrulefreq', 'calendar');
297 * Gets the day from day string.
299 * @param string $daystring Day string (MO, TU, etc)
300 * @throws \moodle_exception
302 * @return string Day represented by the parameter.
304 protected function get_day($daystring) {
305 switch ($daystring) {
306 case 'MO':
307 return self::DAY_MONDAY;
308 break;
309 case 'TU':
310 return self::DAY_TUESDAY;
311 break;
312 case 'WE':
313 return self::DAY_WEDNESDAY;
314 break;
315 case 'TH':
316 return self::DAY_THURSDAY;
317 break;
318 case 'FR':
319 return self::DAY_FRIDAY;
320 break;
321 case 'SA':
322 return self::DAY_SATURDAY;
323 break;
324 case 'SU':
325 return self::DAY_SUNDAY;
326 break;
327 default:
328 // We should never get here, something is very wrong.
329 throw new \moodle_exception('errorrruleday', 'calendar');
334 * Create events for specified rrule.
336 * @param \calendar_event $passedevent Properties of event to create.
337 * @throws \moodle_exception
339 public function create_events($passedevent) {
340 global $DB;
342 $event = clone($passedevent);
343 // If Frequency is not set, there is nothing to do.
344 if (empty($this->freq)) {
345 return;
348 // Delete all child events in case of an update. This should be faster than verifying if the event exists and updating it.
349 $where = "repeatid = ? AND id != ?";
350 $DB->delete_records_select('event', $where, array($event->id, $event->id));
351 $eventrec = $event->properties();
353 switch ($this->freq) {
354 case self::FREQ_DAILY :
355 $this->create_repeated_events($eventrec, DAYSECS);
356 break;
357 case self::FREQ_WEEKLY :
358 $this->create_weekly_events($eventrec);
359 break;
360 case self::FREQ_MONTHLY :
361 $this->create_monthly_events($eventrec);
362 break;
363 case self::FREQ_YEARLY :
364 $this->create_yearly_events($eventrec);
365 break;
366 default :
367 // We should never get here, something is very wrong.
368 throw new \moodle_exception('errorrulefreq', 'calendar');
375 * Create repeated events.
377 * @param \stdClass $event Event properties to create event
378 * @param int $timediff Time difference between events in seconds
379 * @param bool $currenttime If set, the event timestart is used as the timestart for the first event,
380 * else timestart + timediff used as the timestart for the first event. Set to true if
381 * parent event is not a part of this chain.
383 protected function create_repeated_events($event, $timediff, $currenttime = false) {
385 $event = clone($event); // We don't want to edit the master record.
386 $event->repeatid = $event->id; // Set parent id for all events.
387 unset($event->id); // We want new events created, not update the existing one.
388 unset($event->uuid); // uuid should be unique.
389 $count = $this->count;
391 // Multiply by interval if used.
392 if ($this->interval) {
393 $timediff *= $this->interval;
395 if (!$currenttime) {
396 $event->timestart += $timediff;
399 // Create events.
400 if ($count > 0) {
401 // Count specified, use it.
402 if (!$currenttime) {
403 $count--; // Already a parent event has been created.
405 for ($i = 0; $i < $count; $i++, $event->timestart += $timediff) {
406 unset($event->id); // It is set during creation.
407 \calendar_event::create($event, false);
409 } else {
410 // No count specified, use datetime constraints.
411 $until = $this->until;
412 if (empty($until)) {
413 // Forever event. We don't have any such concept in Moodle, hence we repeat it for a constant time.
414 $until = time() + (YEARSECS * self::TIME_UNLIMITED_YEARS);
416 for (; $event->timestart < $until; $event->timestart += $timediff) {
417 unset($event->id); // It is set during creation.
418 \calendar_event::create($event, false);
424 * Create repeated events based on offsets.
426 * @param \stdClass $event
427 * @param int $secsoffset Seconds since the start of the day that this event occurs
428 * @param int $dayoffset Day offset.
429 * @param int $monthoffset Months offset.
430 * @param int $yearoffset Years offset.
431 * @param int $start timestamp to apply offsets onto.
432 * @param bool $currenttime If set, the event timestart is used as the timestart for the first event,
433 * else timestart + timediff(monthly offset + yearly offset) used as the timestart for the first
434 * event.Set to true if parent event is not a part of this chain.
436 protected function create_repeated_events_by_offsets($event, $secsoffset, $dayoffset, $monthoffset, $yearoffset, $start,
437 $currenttime = false) {
439 $event = clone($event); // We don't want to edit the master record.
440 $event->repeatid = $event->id; // Set parent id for all events.
441 unset($event->id); // We want new events created, not update the existing one.
442 unset($event->uuid); // uuid should be unique.
443 $count = $this->count;
444 // First event time in this chain.
445 $event->timestart = strtotime("+$dayoffset days", $start) + $secsoffset;
447 if (!$currenttime) {
448 // Skip one event, since parent event is a part of this chain.
449 $event->timestart = strtotime("+$monthoffset months +$yearoffset years", $event->timestart);
452 // Create events.
453 if ($count > 0) {
454 // Count specified, use it.
455 if (!$currenttime) {
456 $count--; // Already a parent event has been created.
458 for ($i = 0; $i < $count; $i++) {
459 unset($event->id); // It is set during creation.
460 \calendar_event::create($event, false);
461 $event->timestart = strtotime("+$monthoffset months +$yearoffset years", $event->timestart);
463 } else {
464 // No count specified, use datetime constraints.
465 $until = $this->until;
466 if (empty($until)) {
467 // Forever event. We don't have any such concept in Moodle, hence we repeat it for a constant time.
468 $until = time() + (YEARSECS * self::TIME_UNLIMITED_YEARS );
470 for (; $event->timestart < $until;) {
471 unset($event->id); // It is set during creation.
472 \calendar_event::create($event, false);
473 $event->timestart = strtotime("+$monthoffset months +$yearoffset years", $event->timestart);
480 * Create repeated events based on offsets from a fixed start date.
482 * @param \stdClass $event
483 * @param int $secsoffset Seconds since the start of the day that this event occurs
484 * @param string $prefix Prefix string to add to strtotime while calculating next date for the event.
485 * @param int $monthoffset Months offset.
486 * @param int $yearoffset Years offset.
487 * @param int $start timestamp to apply offsets onto.
488 * @param bool $currenttime If set, the event timestart is used as the timestart + offset for the first event,
489 * else timestart + timediff(monthly offset + yearly offset) + offset used as the timestart for the
490 * first event, from the given fixed start time. Set to true if parent event is not a part of this
491 * chain.
493 protected function create_repeated_events_by_offsets_from_fixedstart($event, $secsoffset, $prefix, $monthoffset,
494 $yearoffset, $start, $currenttime = false) {
496 $event = clone($event); // We don't want to edit the master record.
497 $event->repeatid = $event->id; // Set parent id for all events.
498 unset($event->id); // We want new events created, not update the existing one.
499 unset($event->uuid); // uuid should be unique.
500 $count = $this->count;
502 // First event time in this chain.
503 if (!$currenttime) {
504 // Skip one event, since parent event is a part of this chain.
505 $moffset = $monthoffset;
506 $yoffset = $yearoffset;
507 $event->timestart = strtotime("+$monthoffset months +$yearoffset years", $start);
508 $event->timestart = strtotime($prefix, $event->timestart) + $secsoffset;
509 } else {
510 $moffset = 0;
511 $yoffset = 0;
512 $event->timestart = strtotime($prefix, $event->timestart) + $secsoffset;
514 // Create events.
515 if ($count > 0) {
516 // Count specified, use it.
517 if (!$currenttime) {
518 $count--; // Already a parent event has been created.
520 for ($i = 0; $i < $count; $i++) {
521 unset($event->id); // It is set during creation.
522 \calendar_event::create($event, false);
523 $moffset += $monthoffset;
524 $yoffset += $yearoffset;
525 $event->timestart = strtotime("+$moffset months +$yoffset years", $start);
526 $event->timestart = strtotime($prefix, $event->timestart) + $secsoffset;
528 } else {
529 // No count specified, use datetime constraints.
530 $until = $this->until;
531 if (empty($until)) {
532 // Forever event. We don't have any such concept in Moodle, hence we repeat it for a constant time.
533 $until = time() + (YEARSECS * self::TIME_UNLIMITED_YEARS );
535 for (; $event->timestart < $until;) {
536 unset($event->id); // It is set during creation.
537 \calendar_event::create($event, false);
538 $moffset += $monthoffset;
539 $yoffset += $yearoffset;
540 $event->timestart = strtotime("+$moffset months +$yoffset years", $start);
541 $event->timestart = strtotime($prefix, $event->timestart) + $secsoffset;
547 * Create events for weekly frequency.
549 * @param \stdClass $event Event properties to create event
551 protected function create_weekly_events($event) {
552 // If by day is not present, it means all days of the week.
553 if (empty($this->byday)) {
554 $this->byday = array('MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU');
556 // This much seconds after the start of the day.
557 $offset = $event->timestart - mktime(0, 0, 0, date("n", $event->timestart), date("j", $event->timestart), date("Y",
558 $event->timestart));
559 foreach ($this->byday as $daystring) {
560 $day = $this->get_day($daystring);
561 if (date('l', $event->timestart) == $day) {
562 // Parent event is a part of this day chain.
563 $this->create_repeated_events($event, WEEKSECS, false);
564 } else {
565 // Parent event is not a part of this day chain.
566 $cpyevent = clone($event); // We don't want to change timestart of master record.
567 $cpyevent->timestart = strtotime("+$offset seconds next $day", $cpyevent->timestart);
568 $this->create_repeated_events($cpyevent, WEEKSECS, true);
574 * Create events for monthly frequency.
576 * @param \stdClass $event Event properties to create event
578 protected function create_monthly_events($event) {
579 // Either bymonthday or byday should be set.
580 if (empty($this->bymonthday) && empty($this->byday)
581 || !empty($this->bymonthday) && !empty($this->byday)) {
582 return;
584 // This much seconds after the start of the day.
585 $offset = $event->timestart - mktime(0, 0, 0, date("n", $event->timestart), date("j", $event->timestart), date("Y",
586 $event->timestart));
587 $monthstart = mktime(0, 0, 0, date("n", $event->timestart), 1, date("Y", $event->timestart));
588 if (!empty($this->bymonthday)) {
589 foreach ($this->bymonthday as $monthday) {
590 $dayoffset = $monthday - 1; // Number of days we want to add to the first day.
591 if ($monthday == date("j", $event->timestart)) {
592 // Parent event is a part of this day chain.
593 $this->create_repeated_events_by_offsets($event, $offset, $dayoffset, $this->interval, 0, $monthstart,
594 false);
595 } else {
596 // Parent event is not a part of this day chain.
597 $this->create_repeated_events_by_offsets($event, $offset, $dayoffset, $this->interval, 0, $monthstart, true);
600 } else {
601 foreach ($this->byday as $dayrule) {
602 $day = substr($dayrule, strlen($dayrule) - 2); // Last two chars.
603 $prefix = str_replace($day, '', $dayrule);
604 if (empty($prefix) || !is_numeric($prefix)) {
605 return;
607 $day = $this->get_day($day);
608 if ($day == date('l', $event->timestart)) {
609 // Parent event is a part of this day chain.
610 $this->create_repeated_events_by_offsets_from_fixedstart($event, $offset, "$prefix $day", $this->interval, 0,
611 $monthstart, false);
612 } else {
613 // Parent event is not a part of this day chain.
614 $this->create_repeated_events_by_offsets_from_fixedstart($event, $offset, "$prefix $day", $this->interval, 0,
615 $monthstart, true);
623 * Create events for yearly frequency.
625 * @param \stdClass $event Event properties to create event
627 protected function create_yearly_events($event) {
629 // This much seconds after the start of the month.
630 $offset = $event->timestart - mktime(0, 0, 0, date("n", $event->timestart), date("j", $event->timestart), date("Y",
631 $event->timestart));
633 if (empty($this->bymonth)) {
634 // Event's month is taken if not specified.
635 $this->bymonth = array(date("n", $event->timestart));
637 foreach ($this->bymonth as $month) {
638 if (empty($this->byday)) {
639 // If byday is not present, the rule must represent the same month as the event start date. Basically we only
640 // have to add + $this->interval number of years to get the next event date.
641 if ($month == date("n", $event->timestart)) {
642 // Parent event is a part of this month chain.
643 $this->create_repeated_events_by_offsets($event, 0, 0, 0, $this->interval, $event->timestart, false);
645 } else {
646 $dayrule = reset($this->byday);
647 $day = substr($dayrule, strlen($dayrule) - 2); // Last two chars.
648 $prefix = str_replace($day, '', $dayrule);
649 if (empty($prefix) || !is_numeric($prefix)) {
650 return;
652 $day = $this->get_day($day);
653 $monthstart = mktime(0, 0, 0, $month, 1, date("Y", $event->timestart));
654 if ($day == date('l', $event->timestart)) {
655 // Parent event is a part of this day chain.
656 $this->create_repeated_events_by_offsets_from_fixedstart($event, $offset, "$prefix $day", 0,
657 $this->interval, $monthstart, false);
658 } else {
659 // Parent event is not a part of this day chain.
660 $this->create_repeated_events_by_offsets_from_fixedstart($event, $offset, "$prefix $day", 0,
661 $this->interval, $monthstart, true);