rrule_manager.php 48.4 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Defines calendar class to manage recurrence rule (rrule) during ical imports.
 *
 * @package core_calendar
 * @copyright 2014 onwards Ankit Agarwal
 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

namespace core_calendar;
26
27
28
29
30
31
32

use calendar_event;
use DateInterval;
use DateTime;
use moodle_exception;
use stdClass;

33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/calendar/lib.php');

/**
 * Defines calendar class to manage recurrence rule (rrule) during ical imports.
 *
 * Please refer to RFC 2445 {@link http://www.ietf.org/rfc/rfc2445.txt} for detail explanation of the logic.
 * Here is a basic extract from it to explain various params:-
 * recur = "FREQ"=freq *(
 *      ; either UNTIL or COUNT may appear in a 'recur',
 *      ; but UNTIL and COUNT MUST NOT occur in the same 'recur'
 *      ( ";" "UNTIL" "=" enddate ) /
 *      ( ";" "COUNT" "=" 1*DIGIT ) /
 *      ; the rest of these keywords are optional,
 *      ; but MUST NOT occur more than once
 *      ( ";" "INTERVAL" "=" 1*DIGIT )          /
 *      ( ";" "BYSECOND" "=" byseclist )        /
 *      ( ";" "BYMINUTE" "=" byminlist )        /
 *      ( ";" "BYHOUR" "=" byhrlist )           /
 *      ( ";" "BYDAY" "=" bywdaylist )          /
 *      ( ";" "BYMONTHDAY" "=" bymodaylist )    /
 *      ( ";" "BYYEARDAY" "=" byyrdaylist )     /
 *      ( ";" "BYWEEKNO" "=" bywknolist )       /
 *      ( ";" "BYMONTH" "=" bymolist )          /
 *      ( ";" "BYSETPOS" "=" bysplist )         /
 *      ( ";" "WKST" "=" weekday )              /
 *      ( ";" x-name "=" text )
 *   )
 *
 * freq       = "SECONDLY" / "MINUTELY" / "HOURLY" / "DAILY"
 * / "WEEKLY" / "MONTHLY" / "YEARLY"
 * enddate    = date
 * enddate    =/ date-time            ;An UTC value
 * byseclist  = seconds / ( seconds *("," seconds) )
 * seconds    = 1DIGIT / 2DIGIT       ;0 to 59
 * byminlist  = minutes / ( minutes *("," minutes) )
 * minutes    = 1DIGIT / 2DIGIT       ;0 to 59
 * byhrlist   = hour / ( hour *("," hour) )
 * hour       = 1DIGIT / 2DIGIT       ;0 to 23
 * bywdaylist = weekdaynum / ( weekdaynum *("," weekdaynum) )
 * weekdaynum = [([plus] ordwk / minus ordwk)] weekday
 * plus       = "+"
 * minus      = "-"
 * ordwk      = 1DIGIT / 2DIGIT       ;1 to 53
 * weekday    = "SU" / "MO" / "TU" / "WE" / "TH" / "FR" / "SA"
 *      ;Corresponding to SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY,
 *      ;FRIDAY, SATURDAY and SUNDAY days of the week.
 * bymodaylist = monthdaynum / ( monthdaynum *("," monthdaynum) )
 * monthdaynum = ([plus] ordmoday) / (minus ordmoday)
 * ordmoday   = 1DIGIT / 2DIGIT       ;1 to 31
 * byyrdaylist = yeardaynum / ( yeardaynum *("," yeardaynum) )
 * yeardaynum = ([plus] ordyrday) / (minus ordyrday)
 * ordyrday   = 1DIGIT / 2DIGIT / 3DIGIT      ;1 to 366
 * bywknolist = weeknum / ( weeknum *("," weeknum) )
 * weeknum    = ([plus] ordwk) / (minus ordwk)
 * bymolist   = monthnum / ( monthnum *("," monthnum) )
 * monthnum   = 1DIGIT / 2DIGIT       ;1 to 12
 * bysplist   = setposday / ( setposday *("," setposday) )
 * setposday  = yeardaynum
 *
 * @package core_calendar
 * @copyright 2014 onwards Ankit Agarwal <ankit.agrr@gmail.com>
 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class rrule_manager {

    /** const string Frequency constant */
    const FREQ_YEARLY = 'yearly';

    /** const string Frequency constant */
    const FREQ_MONTHLY = 'monthly';

    /** const string Frequency constant */
    const FREQ_WEEKLY = 'weekly';

    /** const string Frequency constant */
    const FREQ_DAILY = 'daily';

    /** const string Frequency constant */
    const FREQ_HOURLY = 'hourly';

    /** const string Frequency constant */
    const FREQ_MINUTELY = 'everyminute';

    /** const string Frequency constant */
    const FREQ_SECONDLY = 'everysecond';

    /** const string Day constant */
    const DAY_MONDAY = 'Monday';

    /** const string Day constant */
    const DAY_TUESDAY = 'Tuesday';

    /** const string Day constant */
    const DAY_WEDNESDAY = 'Wednesday';

    /** const string Day constant */
    const DAY_THURSDAY = 'Thursday';

    /** const string Day constant */
    const DAY_FRIDAY = 'Friday';

    /** const string Day constant */
    const DAY_SATURDAY = 'Saturday';

    /** const string Day constant */
    const DAY_SUNDAY = 'Sunday';

    /** const int For forever repeating events, repeat for this many years */
    const TIME_UNLIMITED_YEARS = 10;

144
145
146
147
148
149
150
151
152
153
154
    /** const array Array of days in a week. */
    const DAYS_OF_WEEK = [
        'MO' => self::DAY_MONDAY,
        'TU' => self::DAY_TUESDAY,
        'WE' => self::DAY_WEDNESDAY,
        'TH' => self::DAY_THURSDAY,
        'FR' => self::DAY_FRIDAY,
        'SA' => self::DAY_SATURDAY,
        'SU' => self::DAY_SUNDAY,
    ];

155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
    /** @var string string representing the recurrence rule */
    protected $rrule;

    /** @var string Frequency of event */
    protected $freq;

    /** @var int defines a timestamp value which bounds the recurrence rule in an inclusive manner.*/
    protected $until = 0;

    /** @var int Defines the number of occurrences at which to range-bound the recurrence */
    protected $count = 0;

    /** @var int This rule part contains a positive integer representing how often the recurrence rule repeats */
    protected $interval = 1;

    /** @var array List of second rules */
    protected $bysecond = array();

    /** @var array List of Minute rules */
    protected $byminute = array();

    /** @var array List of hour rules */
    protected $byhour = array();

    /** @var array List of day rules */
    protected $byday = array();

    /** @var array List of monthday rules */
    protected $bymonthday = array();

    /** @var array List of yearday rules */
    protected $byyearday = array();

    /** @var array List of weekno rules */
    protected $byweekno = array();

    /** @var array List of month rules */
    protected $bymonth = array();

    /** @var array List of setpos rules */
    protected $bysetpos = array();

197
198
    /** @var string Week start rule. Default is Monday. */
    protected $wkst = self::DAY_MONDAY;
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219

    /**
     * Constructor for the class
     *
     * @param string $rrule Recurrence rule
     */
    public function __construct($rrule) {
        $this->rrule = $rrule;
    }

    /**
     * Parse the recurrence rule and setup all properties.
     */
    public function parse_rrule() {
        $rules = explode(';', $this->rrule);
        if (empty($rules)) {
            return;
        }
        foreach ($rules as $rule) {
            $this->parse_rrule_property($rule);
        }
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
        // Validate the rules as a whole.
        $this->validate_rules();
    }

    /**
     * Create events for specified rrule.
     *
     * @param calendar_event $passedevent Properties of event to create.
     * @throws moodle_exception
     */
    public function create_events($passedevent) {
        global $DB;

        $event = clone($passedevent);
        // If Frequency is not set, there is nothing to do.
        if (empty($this->freq)) {
            return;
        }

        // Delete all child events in case of an update. This should be faster than verifying if the event exists and updating it.
        $where = "repeatid = ? AND id != ?";
        $DB->delete_records_select('event', $where, array($event->id, $event->id));
        $eventrec = $event->properties();

        // Generate timestamps that obey the rrule.
        $eventtimes = $this->generate_recurring_event_times($eventrec);

        // Adjust the parent event's timestart, if necessary.
        if (count($eventtimes) > 0 && !in_array($eventrec->timestart, $eventtimes)) {
            $calevent = new calendar_event($eventrec);
            $updatedata = (object)['timestart' => $eventtimes[0], 'repeatid' => $eventrec->id];
            $calevent->update($updatedata, false);
            $eventrec->timestart = $calevent->timestart;
        }

        // Create the recurring calendar events.
        $this->create_recurring_events($eventrec, $eventtimes);
257
258
259
260
261
262
    }

    /**
     * Parse a property of the recurrence rule.
     *
     * @param string $prop property string with type-value pair
263
     * @throws moodle_exception
264
265
266
267
268
269
270
271
     */
    protected function parse_rrule_property($prop) {
        list($property, $value) = explode('=', $prop);
        switch ($property) {
            case 'FREQ' :
                $this->set_frequency($value);
                break;
            case 'UNTIL' :
272
                $this->set_until($value);
273
274
                break;
            CASE 'COUNT' :
275
                $this->set_count($value);
276
277
                break;
            CASE 'INTERVAL' :
278
                $this->set_interval($value);
279
280
                break;
            CASE 'BYSECOND' :
281
                $this->set_bysecond($value);
282
283
                break;
            CASE 'BYMINUTE' :
284
                $this->set_byminute($value);
285
286
                break;
            CASE 'BYHOUR' :
287
                $this->set_byhour($value);
288
289
                break;
            CASE 'BYDAY' :
290
                $this->set_byday($value);
291
292
                break;
            CASE 'BYMONTHDAY' :
293
                $this->set_bymonthday($value);
294
295
                break;
            CASE 'BYYEARDAY' :
296
                $this->set_byyearday($value);
297
298
                break;
            CASE 'BYWEEKNO' :
299
                $this->set_byweekno($value);
300
301
                break;
            CASE 'BYMONTH' :
302
                $this->set_bymonth($value);
303
304
                break;
            CASE 'BYSETPOS' :
305
                $this->set_bysetpos($value);
306
307
308
309
310
311
                break;
            CASE 'WKST' :
                $this->wkst = $this->get_day($value);
                break;
            default:
                // We should never get here, something is very wrong.
312
                throw new moodle_exception('errorrrule', 'calendar');
313
314
315
316
317
318
319
        }
    }

    /**
     * Sets Frequency property.
     *
     * @param string $freq Frequency of event
320
     * @throws moodle_exception
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
     */
    protected function set_frequency($freq) {
        switch ($freq) {
            case 'YEARLY':
                $this->freq = self::FREQ_YEARLY;
                break;
            case 'MONTHLY':
                $this->freq = self::FREQ_MONTHLY;
                break;
            case 'WEEKLY':
                $this->freq = self::FREQ_WEEKLY;
                break;
            case 'DAILY':
                $this->freq = self::FREQ_DAILY;
                break;
            case 'HOURLY':
                $this->freq = self::FREQ_HOURLY;
                break;
            case 'MINUTELY':
                $this->freq = self::FREQ_MINUTELY;
                break;
            case 'SECONDLY':
                $this->freq = self::FREQ_SECONDLY;
                break;
            default:
                // We should never get here, something is very wrong.
347
                throw new moodle_exception('errorrrulefreq', 'calendar');
348
349
350
351
352
353
354
        }
    }

    /**
     * Gets the day from day string.
     *
     * @param string $daystring Day string (MO, TU, etc)
355
     * @throws moodle_exception
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
     *
     * @return string Day represented by the parameter.
     */
    protected function get_day($daystring) {
        switch ($daystring) {
            case 'MO':
                return self::DAY_MONDAY;
                break;
            case 'TU':
                return self::DAY_TUESDAY;
                break;
            case 'WE':
                return self::DAY_WEDNESDAY;
                break;
            case 'TH':
                return self::DAY_THURSDAY;
                break;
            case 'FR':
                return self::DAY_FRIDAY;
                break;
            case 'SA':
                return self::DAY_SATURDAY;
                break;
            case 'SU':
                return self::DAY_SUNDAY;
                break;
            default:
                // We should never get here, something is very wrong.
384
                throw new moodle_exception('errorrruleday', 'calendar');
385
386
387
388
        }
    }

    /**
389
     * Sets the UNTIL rule.
390
     *
391
392
     * @param string $until The date string representation of the UNTIL rule.
     * @throws moodle_exception
393
     */
394
395
396
    protected function set_until($until) {
        $this->until = strtotime($until);
    }
397

398
399
400
401
402
403
404
405
406
    /**
     * Sets the COUNT rule.
     *
     * @param string $count The count value.
     * @throws moodle_exception
     */
    protected function set_count($count) {
        $this->count = intval($count);
    }
407

408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
    /**
     * Sets the INTERVAL rule.
     *
     * The INTERVAL rule part contains a positive integer representing how often the recurrence rule repeats.
     * The default value is "1", meaning:
     *  - every second for a SECONDLY rule, or
     *  - every minute for a MINUTELY rule,
     *  - every hour for an HOURLY rule,
     *  - every day for a DAILY rule,
     *  - every week for a WEEKLY rule,
     *  - every month for a MONTHLY rule and
     *  - every year for a YEARLY rule.
     *
     * @param string $intervalstr The value for the interval rule.
     * @throws moodle_exception
     */
    protected function set_interval($intervalstr) {
        $interval = intval($intervalstr);
        if ($interval < 1) {
            throw new moodle_exception('errorinvalidinterval', 'calendar');
        }
        $this->interval = $interval;
    }
431

432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
    /**
     * Sets the BYSECOND rule.
     *
     * The BYSECOND rule part specifies a comma-separated list of seconds within a minute.
     * Valid values are 0 to 59.
     *
     * @param string $bysecond Comma-separated list of seconds within a minute.
     * @throws moodle_exception
     */
    protected function set_bysecond($bysecond) {
        $seconds = explode(',', $bysecond);
        $bysecondrules = [];
        foreach ($seconds as $second) {
            if ($second < 0 || $second > 59) {
                throw new moodle_exception('errorinvalidbysecond', 'calendar');
            }
            $bysecondrules[] = (int)$second;
        }
        $this->bysecond = $bysecondrules;
    }
452

453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
    /**
     * Sets the BYMINUTE rule.
     *
     * The BYMINUTE rule part specifies a comma-separated list of seconds within an hour.
     * Valid values are 0 to 59.
     *
     * @param string $byminute Comma-separated list of minutes within an hour.
     * @throws moodle_exception
     */
    protected function set_byminute($byminute) {
        $minutes = explode(',', $byminute);
        $byminuterules = [];
        foreach ($minutes as $minute) {
            if ($minute < 0 || $minute > 59) {
                throw new moodle_exception('errorinvalidbyminute', 'calendar');
            }
            $byminuterules[] = (int)$minute;
470
        }
471
472
        $this->byminute = $byminuterules;
    }
473

474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
    /**
     * Sets the BYHOUR rule.
     *
     * The BYHOUR rule part specifies a comma-separated list of hours of the day.
     * Valid values are 0 to 23.
     *
     * @param string $byhour Comma-separated list of hours of the day.
     * @throws moodle_exception
     */
    protected function set_byhour($byhour) {
        $hours = explode(',', $byhour);
        $byhourrules = [];
        foreach ($hours as $hour) {
            if ($hour < 0 || $hour > 23) {
                throw new moodle_exception('errorinvalidbyhour', 'calendar');
            }
            $byhourrules[] = (int)$hour;
        }
        $this->byhour = $byhourrules;
493
494
495
    }

    /**
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
     * Sets the BYDAY rule.
     *
     * The BYDAY rule part specifies a comma-separated list of days of the week;
     *  - MO indicates Monday;
     *  - TU indicates Tuesday;
     *  - WE indicates Wednesday;
     *  - TH indicates Thursday;
     *  - FR indicates Friday;
     *  - SA indicates Saturday;
     *  - SU indicates Sunday.
     *
     * Each BYDAY value can also be preceded by a positive (+n) or negative (-n) integer.
     * If present, this indicates the nth occurrence of the specific day within the MONTHLY or YEARLY RRULE.
     * For example, within a MONTHLY rule, +1MO (or simply 1MO) represents the first Monday within the month,
     * whereas -1MO represents the last Monday of the month.
     * If an integer modifier is not present, it means all days of this type within the specified frequency.
     * For example, within a MONTHLY rule, MO represents all Mondays within the month.
513
     *
514
515
     * @param string $byday Comma-separated list of days of the week.
     * @throws moodle_exception
516
     */
517
518
519
520
521
522
523
524
525
    protected function set_byday($byday) {
        $weekdays = array_keys(self::DAYS_OF_WEEK);
        $days = explode(',', $byday);
        $bydayrules = [];
        foreach ($days as $day) {
            $suffix = substr($day, -2);
            if (!in_array($suffix, $weekdays)) {
                throw new moodle_exception('errorinvalidbydaysuffix', 'calendar');
            }
526

527
528
529
            $bydayrule = new stdClass();
            $bydayrule->day = substr($suffix, -2);
            $bydayrule->value = (int)str_replace($suffix, '', $day);
530

531
            $bydayrules[] = $bydayrule;
532
533
        }

534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
        $this->byday = $bydayrules;
    }

    /**
     * Sets the BYMONTHDAY rule.
     *
     * The BYMONTHDAY rule part specifies a comma-separated list of days of the month.
     * Valid values are 1 to 31 or -31 to -1. For example, -10 represents the tenth to the last day of the month.
     *
     * @param string $bymonthday Comma-separated list of days of the month.
     * @throws moodle_exception
     */
    protected function set_bymonthday($bymonthday) {
        $monthdays = explode(',', $bymonthday);
        $bymonthdayrules = [];
        foreach ($monthdays as $day) {
            // Valid values are 1 to 31 or -31 to -1.
            if ($day < -31 || $day > 31 || $day == 0) {
                throw new moodle_exception('errorinvalidbymonthday', 'calendar');
553
            }
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
            $bymonthdayrules[] = (int)$day;
        }

        // Sort these MONTHDAY rules in ascending order.
        sort($bymonthdayrules);

        $this->bymonthday = $bymonthdayrules;
    }

    /**
     * Sets the BYYEARDAY rule.
     *
     * The BYYEARDAY rule part specifies a comma-separated list of days of the year.
     * Valid values are 1 to 366 or -366 to -1. For example, -1 represents the last day of the year (December 31st)
     * and -306 represents the 306th to the last day of the year (March 1st).
     *
     * @param string $byyearday Comma-separated list of days of the year.
     * @throws moodle_exception
     */
    protected function set_byyearday($byyearday) {
        $yeardays = explode(',', $byyearday);
        $byyeardayrules = [];
        foreach ($yeardays as $day) {
            // Valid values are 1 to 366 or -366 to -1.
            if ($day < -366 || $day > 366 || $day == 0) {
                throw new moodle_exception('errorinvalidbyyearday', 'calendar');
580
            }
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
            $byyeardayrules[] = (int)$day;
        }
        $this->byyearday = $byyeardayrules;
    }

    /**
     * Sets the BYWEEKNO rule.
     *
     * The BYWEEKNO rule part specifies a comma-separated list of ordinals specifying weeks of the year.
     * Valid values are 1 to 53 or -53 to -1. This corresponds to weeks according to week numbering as defined in [ISO 8601].
     * A week is defined as a seven day period, starting on the day of the week defined to be the week start (see WKST).
     * Week number one of the calendar year is the first week which contains at least four (4) days in that calendar year.
     * This rule part is only valid for YEARLY rules. For example, 3 represents the third week of the year.
     *
     * 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
     * is January 1.
     *
     * @param string $byweekno Comma-separated list of number of weeks.
     * @throws moodle_exception
     */
    protected function set_byweekno($byweekno) {
        $weeknumbers = explode(',', $byweekno);
        $byweeknorules = [];
        foreach ($weeknumbers as $week) {
            // Valid values are 1 to 53 or -53 to -1.
            if ($week < -53 || $week > 53 || $week == 0) {
                throw new moodle_exception('errorinvalidbyweekno', 'calendar');
608
            }
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
            $byweeknorules[] = (int)$week;
        }
        $this->byweekno = $byweeknorules;
    }

    /**
     * Sets the BYMONTH rule.
     *
     * The BYMONTH rule part specifies a comma-separated list of months of the year.
     * Valid values are 1 to 12.
     *
     * @param string $bymonth Comma-separated list of months of the year.
     * @throws moodle_exception
     */
    protected function set_bymonth($bymonth) {
        $months = explode(',', $bymonth);
        $bymonthrules = [];
        foreach ($months as $month) {
            // Valid values are 1 to 12.
            if ($month < 1 || $month > 12) {
                throw new moodle_exception('errorinvalidbymonth', 'calendar');
630
            }
631
            $bymonthrules[] = (int)$month;
632
        }
633
        $this->bymonth = $bymonthrules;
634
635
636
    }

    /**
637
     * Sets the BYSETPOS rule.
638
     *
639
640
641
642
643
644
645
646
     * The BYSETPOS rule part specifies a comma-separated list of values which corresponds to the nth occurrence within the set of
     * events specified by the rule. Valid values are 1 to 366 or -366 to -1.
     * It MUST only be used in conjunction with another BYxxx rule part.
     *
     * For example "the last work day of the month" could be represented as: RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1
     *
     * @param string $bysetpos Comma-separated list of values.
     * @throws moodle_exception
647
     */
648
649
650
651
652
653
654
655
656
657
658
659
    protected function set_bysetpos($bysetpos) {
        $setposes = explode(',', $bysetpos);
        $bysetposrules = [];
        foreach ($setposes as $pos) {
            // Valid values are 1 to 366 or -366 to -1.
            if ($pos < -366 || $pos > 366 || $pos == 0) {
                throw new moodle_exception('errorinvalidbysetpos', 'calendar');
            }
            $bysetposrules[] = (int)$pos;
        }
        $this->bysetpos = $bysetposrules;
    }
660

661
662
663
664
665
666
667
668
669
670
    /**
     * Validate the rules as a whole.
     *
     * @throws moodle_exception
     */
    protected function validate_rules() {
        // UNTIL and COUNT cannot be in the same recurrence rule.
        if (!empty($this->until) && !empty($this->count)) {
            throw new moodle_exception('errorhasuntilandcount', 'calendar');
        }
671

672
673
674
675
676
        // BYSETPOS only be used in conjunction with another BYxxx rule part.
        if (!empty($this->bysetpos) && empty($this->bymonth) && empty($this->bymonthday) && empty($this->bysecond)
            && empty($this->byday) && empty($this->byweekno) && empty($this->byhour) && empty($this->byminute)
            && empty($this->byyearday)) {
            throw new moodle_exception('errormustbeusedwithotherbyrule', 'calendar');
677
678
        }

679
680
681
682
        // Integer values preceding BYDAY rules can only be present for MONTHLY or YEARLY RRULE.
        foreach ($this->byday as $bydayrule) {
            if (!empty($bydayrule->value) && $this->freq != self::FREQ_MONTHLY && $this->freq != self::FREQ_YEARLY) {
                throw new moodle_exception('errorinvalidbydayprefix', 'calendar');
683
            }
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
        }

        // The BYWEEKNO rule is only valid for YEARLY rules.
        if (!empty($this->byweekno) && $this->freq != self::FREQ_YEARLY) {
            throw new moodle_exception('errornonyearlyfreqwithbyweekno', 'calendar');
        }
    }

    /**
     * Creates calendar events for the recurring events.
     *
     * @param stdClass $event The parent event.
     * @param int[] $eventtimes The timestamps of the recurring events.
     */
    protected function create_recurring_events($event, $eventtimes) {
        $count = false;
        if ($this->count) {
            $count = $this->count;
        }

        foreach ($eventtimes as $time) {
            // Skip if time is the same time with the parent event's timestamp.
            if ($time == $event->timestart) {
                continue;
708
709
            }

710
711
712
713
714
715
            // Decrement count, if set.
            if ($count !== false) {
                $count--;
                if ($count == 0) {
                    break;
                }
716
            }
717
718
719
720
721
722
723

            // Create the recurring event.
            $cloneevent = clone($event);
            $cloneevent->repeatid = $event->id;
            $cloneevent->timestart = $time;
            unset($cloneevent->id);
            calendar_event::create($cloneevent, false);
724
725
726
727
        }
    }

    /**
728
     * Generates recurring events based on the parent event and the RRULE set.
729
     *
730
731
732
733
734
735
736
     * If multiple BYxxx rule parts are specified, then after evaluating the specified FREQ and INTERVAL rule parts,
     * the BYxxx rule parts are applied to the current set of evaluated occurrences in the following order:
     * BYMONTH, BYWEEKNO, BYYEARDAY, BYMONTHDAY, BYDAY, BYHOUR, BYMINUTE, BYSECOND and BYSETPOS;
     * then COUNT and UNTIL are evaluated.
     *
     * @param stdClass $event The event object.
     * @return array The list of timestamps that obey the given RRULE.
737
     */
738
739
740
741
742
    protected function generate_recurring_event_times($event) {
        $interval = $this->get_interval();

        // Candidate event times.
        $eventtimes = [];
743

744
        $eventdatetime = new DateTime(date('Y-m-d H:i:s', $event->timestart));
745

746
747
748
749
750
751
752
753
754
755
756
757
        $until = null;
        if (empty($this->count)) {
            if ($this->until) {
                $until = $this->until;
            } else {
                // Forever event. However, since there's no such thing as 'forever' (at least not in Moodle),
                // we only repeat the events until 10 years from the current time.
                $untildate = new DateTime();
                $foreverinterval = new DateInterval('P' . self::TIME_UNLIMITED_YEARS . 'Y');
                $untildate->add($foreverinterval);
                $until = $untildate->getTimestamp();
            }
758
        } else {
759
760
761
762
763
764
            // If count is defined, let's define a tentative until date. We'll just trim the number of events later.
            $untildate = clone($eventdatetime);
            $count = $this->count;
            while ($count >= 0) {
                $untildate->add($interval);
                $count--;
765
            }
766
767
768
769
770
771
772
773
774
775
            $until = $untildate->getTimestamp();
        }

        // No filters applied. Generate recurring events right away.
        if (!$this->has_by_rules()) {
            // Get initial list of prospective events.
            $tmpstart = clone($eventdatetime);
            while ($tmpstart->getTimestamp() <= $until) {
                $eventtimes[] = $tmpstart->getTimestamp();
                $tmpstart->add($interval);
776
            }
777
778
779
780
781
782
783
784
785
786
787
            return $eventtimes;
        }

        // Get all of potential dates covered by the periods from the event's start date until the last.
        $dailyinterval = new DateInterval('P1D');
        $boundslist = $this->get_period_bounds_list($eventdatetime->getTimestamp(), $until);
        foreach ($boundslist as $bounds) {
            $tmpdate = new DateTime(date('Y-m-d H:i:s', $bounds->start));
            while ($tmpdate->getTimestamp() >= $bounds->start && $tmpdate->getTimestamp() < $bounds->next) {
                $eventtimes[] = $tmpdate->getTimestamp();
                $tmpdate->add($dailyinterval);
788
            }
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
        }

        // Evaluate BYMONTH rules.
        $eventtimes = $this->filter_by_month($eventtimes);

        // Evaluate BYWEEKNO rules.
        $eventtimes = $this->filter_by_weekno($eventtimes);

        // Evaluate BYYEARDAY rules.
        $eventtimes = $this->filter_by_yearday($eventtimes);

        // If BYYEARDAY, BYMONTHDAY and BYDAY are not set, default to BYMONTHDAY based on the DTSTART's day.
        if ($this->freq != self::FREQ_DAILY && empty($this->byyearday) && empty($this->bymonthday) && empty($this->byday)) {
            $this->bymonthday = [$eventdatetime->format('j')];
        }

        // Evaluate BYMONTHDAY rules.
        $eventtimes = $this->filter_by_monthday($eventtimes);

        // Evaluate BYDAY rules.
        $eventtimes = $this->filter_by_day($event, $eventtimes, $until);

        // Evaluate BYHOUR rules.
        $eventtimes = $this->apply_hour_minute_second_rules($eventdatetime, $eventtimes);

        // Evaluate BYSETPOS rules.
        $eventtimes = $this->filter_by_setpos($event, $eventtimes, $until);

        // Sort event times in ascending order.
        sort($eventtimes);

        // Finally, filter candidate event times to make sure they are within the DTSTART and UNTIL/tentative until boundaries.
        $results = [];
        foreach ($eventtimes as $time) {
            // Skip out-of-range events.
            if ($time < $eventdatetime->getTimestamp()) {
                continue;
            }
            // End if event time is beyond the until limit.
            if ($time > $until) {
                break;
830
            }
831
            $results[] = $time;
832
        }
833
834

        return $results;
835
836
837
    }

    /**
838
     * Generates a DateInterval object based on the FREQ and INTERVAL rules.
839
     *
840
841
     * @return DateInterval
     * @throws moodle_exception
842
     */
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
    protected function get_interval() {
        $intervalspec = null;
        switch ($this->freq) {
            case self::FREQ_YEARLY:
                $intervalspec = 'P' . $this->interval . 'Y';
                break;
            case self::FREQ_MONTHLY:
                $intervalspec = 'P' . $this->interval . 'M';
                break;
            case self::FREQ_WEEKLY:
                $intervalspec = 'P' . $this->interval . 'W';
                break;
            case self::FREQ_DAILY:
                $intervalspec = 'P' . $this->interval . 'D';
                break;
            case self::FREQ_HOURLY:
                $intervalspec = 'PT' . $this->interval . 'H';
                break;
            case self::FREQ_MINUTELY:
                $intervalspec = 'PT' . $this->interval . 'M';
                break;
            case self::FREQ_SECONDLY:
                $intervalspec = 'PT' . $this->interval . 'S';
                break;
            default:
                // We should never get here, something is very wrong.
                throw new moodle_exception('errorrrulefreq', 'calendar');
        }

        return new DateInterval($intervalspec);
    }

    /**
     * Determines whether the RRULE has BYxxx rules or not.
     *
     * @return bool True if there is one or more BYxxx rules to process. False, otherwise.
     */
    protected function has_by_rules() {
        return !empty($this->bymonth) || !empty($this->bymonthday) || !empty($this->bysecond) || !empty($this->byday)
            || !empty($this->byweekno) || !empty($this->byhour) || !empty($this->byminute) || !empty($this->byyearday);
    }

    /**
     * Filter event times based on the BYMONTH rule.
     *
     * @param int[] $eventdates Timestamps of event times to be filtered.
     * @return int[] Array of filtered timestamps.
     */
    protected function filter_by_month($eventdates) {
        if (empty($this->bymonth)) {
            return $eventdates;
        }

        $filteredbymonth = [];
        foreach ($eventdates as $time) {
            foreach ($this->bymonth as $month) {
                $prospectmonth = date('n', $time);
                if ($month == $prospectmonth) {
                    $filteredbymonth[] = $time;
                    break;
                }
904
905
            }
        }
906
        return $filteredbymonth;
907
908
909
    }

    /**
910
     * Filter event times based on the BYWEEKNO rule.
911
     *
912
913
     * @param int[] $eventdates Timestamps of event times to be filtered.
     * @return int[] Array of filtered timestamps.
914
     */
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
    protected function filter_by_weekno($eventdates) {
        if (empty($this->byweekno)) {
            return $eventdates;
        }

        $filteredbyweekno = [];
        $weeklyinterval = null;
        foreach ($eventdates as $time) {
            $tmpdate = new DateTime(date('Y-m-d H:i:s', $time));
            foreach ($this->byweekno as $weekno) {
                if ($weekno > 0) {
                    if ($tmpdate->format('W') == $weekno) {
                        $filteredbyweekno[] = $time;
                        break;
                    }
                } else if ($weekno < 0) {
                    if ($weeklyinterval === null) {
                        $weeklyinterval = new DateInterval('P1W');
                    }
                    $weekstart = new DateTime();
                    $weekstart->setISODate($tmpdate->format('Y'), $weekno);
                    $weeknext = clone($weekstart);
                    $weeknext->add($weeklyinterval);

                    $tmptimestamp = $tmpdate->getTimestamp();

                    if ($tmptimestamp >= $weekstart->getTimestamp() && $tmptimestamp < $weeknext->getTimestamp()) {
                        $filteredbyweekno[] = $time;
                        break;
                    }
                }
            }
        }
        return $filteredbyweekno;
    }

    /**
     * Filter event times based on the BYYEARDAY rule.
     *
     * @param int[] $eventdates Timestamps of event times to be filtered.
     * @return int[] Array of filtered timestamps.
     */
    protected function filter_by_yearday($eventdates) {
        if (empty($this->byyearday)) {
            return $eventdates;
        }

        $filteredbyyearday = [];
        foreach ($eventdates as $time) {
            $tmpdate = new DateTime(date('Y-m-d', $time));

            foreach ($this->byyearday as $yearday) {
                $dayoffset = abs($yearday) - 1;
                $dayoffsetinterval = new DateInterval("P{$dayoffset}D");

                if ($yearday > 0) {
                    $tmpyearday = (int)$tmpdate->format('z') + 1;
                    if ($tmpyearday == $yearday) {
                        $filteredbyyearday[] = $time;
                        break;
                    }
                } else if ($yearday < 0) {
                    $yeardaydate = new DateTime('last day of ' . $tmpdate->format('Y'));
                    $yeardaydate->sub($dayoffsetinterval);

                    $tmpdate->getTimestamp();

                    if ($yeardaydate->format('z') == $tmpdate->format('z')) {
                        $filteredbyyearday[] = $time;
                        break;
                    }
                }
            }
        }
        return $filteredbyyearday;
    }

    /**
     * Filter event times based on the BYMONTHDAY rule.
     *
     * @param int[] $eventdates The event times to be filtered.
     * @return int[] Array of filtered timestamps.
     */
    protected function filter_by_monthday($eventdates) {
        if (empty($this->bymonthday)) {
            return $eventdates;
For faster browsing, not all history is shown. View entire blame