Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
 
25
namespace core_calendar;
26
 
27
use calendar_event;
28
use DateInterval;
29
use DateTime;
30
use moodle_exception;
31
use stdClass;
32
 
33
defined('MOODLE_INTERNAL') || die();
34
require_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
 */
97
class 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
 
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
 
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
 
197
    /** @var string Week start rule. Default is Monday. */
198
    protected $wkst = self::DAY_MONDAY;
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
        }
220
        // Validate the rules as a whole.
221
        $this->validate_rules();
222
    }
223
 
224
    /**
225
     * Create events for specified rrule.
226
     *
227
     * @param calendar_event $passedevent Properties of event to create.
228
     * @throws moodle_exception
229
     */
230
    public function create_events($passedevent) {
231
        global $DB;
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
 
247
        // Update the parent event. Make sure that its repeat ID is the same as its ID.
248
        $calevent = new calendar_event($eventrec);
249
        $updatedata = new stdClass();
250
        $updatedata->repeatid = $event->id;
251
        // Also, adjust the parent event's timestart, if necessary.
252
        if (count($eventtimes) > 0 && !in_array($eventrec->timestart, $eventtimes)) {
253
            $updatedata->timestart = reset($eventtimes);
254
        }
255
        $calevent->update($updatedata, false);
256
        $eventrec->timestart = $calevent->timestart;
257
 
258
        // Create the recurring calendar events.
259
        $this->create_recurring_events($eventrec, $eventtimes);
260
    }
261
 
262
    /**
263
     * Parse a property of the recurrence rule.
264
     *
265
     * @param string $prop property string with type-value pair
266
     * @throws moodle_exception
267
     */
268
    protected function parse_rrule_property($prop) {
269
        list($property, $value) = explode('=', $prop);
270
        switch ($property) {
271
            case 'FREQ' :
272
                $this->set_frequency($value);
273
                break;
274
            case 'UNTIL' :
275
                $this->set_until($value);
276
                break;
277
            CASE 'COUNT' :
278
                $this->set_count($value);
279
                break;
280
            CASE 'INTERVAL' :
281
                $this->set_interval($value);
282
                break;
283
            CASE 'BYSECOND' :
284
                $this->set_bysecond($value);
285
                break;
286
            CASE 'BYMINUTE' :
287
                $this->set_byminute($value);
288
                break;
289
            CASE 'BYHOUR' :
290
                $this->set_byhour($value);
291
                break;
292
            CASE 'BYDAY' :
293
                $this->set_byday($value);
294
                break;
295
            CASE 'BYMONTHDAY' :
296
                $this->set_bymonthday($value);
297
                break;
298
            CASE 'BYYEARDAY' :
299
                $this->set_byyearday($value);
300
                break;
301
            CASE 'BYWEEKNO' :
302
                $this->set_byweekno($value);
303
                break;
304
            CASE 'BYMONTH' :
305
                $this->set_bymonth($value);
306
                break;
307
            CASE 'BYSETPOS' :
308
                $this->set_bysetpos($value);
309
                break;
310
            CASE 'WKST' :
311
                $this->wkst = $this->get_day($value);
312
                break;
313
            default:
314
                // We should never get here, something is very wrong.
315
                throw new moodle_exception('errorrrule', 'calendar');
316
        }
317
    }
318
 
319
    /**
320
     * Sets Frequency property.
321
     *
322
     * @param string $freq Frequency of event
323
     * @throws moodle_exception
324
     */
325
    protected function set_frequency($freq) {
326
        switch ($freq) {
327
            case 'YEARLY':
328
                $this->freq = self::FREQ_YEARLY;
329
                break;
330
            case 'MONTHLY':
331
                $this->freq = self::FREQ_MONTHLY;
332
                break;
333
            case 'WEEKLY':
334
                $this->freq = self::FREQ_WEEKLY;
335
                break;
336
            case 'DAILY':
337
                $this->freq = self::FREQ_DAILY;
338
                break;
339
            case 'HOURLY':
340
                $this->freq = self::FREQ_HOURLY;
341
                break;
342
            case 'MINUTELY':
343
                $this->freq = self::FREQ_MINUTELY;
344
                break;
345
            case 'SECONDLY':
346
                $this->freq = self::FREQ_SECONDLY;
347
                break;
348
            default:
349
                // We should never get here, something is very wrong.
350
                throw new moodle_exception('errorrrulefreq', 'calendar');
351
        }
352
    }
353
 
354
    /**
355
     * Gets the day from day string.
356
     *
357
     * @param string $daystring Day string (MO, TU, etc)
358
     * @throws moodle_exception
359
     *
360
     * @return string Day represented by the parameter.
361
     */
362
    protected function get_day($daystring) {
363
        switch ($daystring) {
364
            case 'MO':
365
                return self::DAY_MONDAY;
366
                break;
367
            case 'TU':
368
                return self::DAY_TUESDAY;
369
                break;
370
            case 'WE':
371
                return self::DAY_WEDNESDAY;
372
                break;
373
            case 'TH':
374
                return self::DAY_THURSDAY;
375
                break;
376
            case 'FR':
377
                return self::DAY_FRIDAY;
378
                break;
379
            case 'SA':
380
                return self::DAY_SATURDAY;
381
                break;
382
            case 'SU':
383
                return self::DAY_SUNDAY;
384
                break;
385
            default:
386
                // We should never get here, something is very wrong.
387
                throw new moodle_exception('errorrruleday', 'calendar');
388
        }
389
    }
390
 
391
    /**
392
     * Sets the UNTIL rule.
393
     *
394
     * @param string $until The date string representation of the UNTIL rule.
395
     * @throws moodle_exception
396
     */
397
    protected function set_until($until) {
398
        $this->until = strtotime($until);
399
    }
400
 
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
    }
410
 
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
    }
434
 
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
    }
455
 
456
    /**
457
     * Sets the BYMINUTE rule.
458
     *
459
     * The BYMINUTE rule part specifies a comma-separated list of seconds within an hour.
460
     * Valid values are 0 to 59.
461
     *
462
     * @param string $byminute Comma-separated list of minutes within an hour.
463
     * @throws moodle_exception
464
     */
465
    protected function set_byminute($byminute) {
466
        $minutes = explode(',', $byminute);
467
        $byminuterules = [];
468
        foreach ($minutes as $minute) {
469
            if ($minute < 0 || $minute > 59) {
470
                throw new moodle_exception('errorinvalidbyminute', 'calendar');
471
            }
472
            $byminuterules[] = (int)$minute;
473
        }
474
        $this->byminute = $byminuterules;
475
    }
476
 
477
    /**
478
     * Sets the BYHOUR rule.
479
     *
480
     * The BYHOUR rule part specifies a comma-separated list of hours of the day.
481
     * Valid values are 0 to 23.
482
     *
483
     * @param string $byhour Comma-separated list of hours of the day.
484
     * @throws moodle_exception
485
     */
486
    protected function set_byhour($byhour) {
487
        $hours = explode(',', $byhour);
488
        $byhourrules = [];
489
        foreach ($hours as $hour) {
490
            if ($hour < 0 || $hour > 23) {
491
                throw new moodle_exception('errorinvalidbyhour', 'calendar');
492
            }
493
            $byhourrules[] = (int)$hour;
494
        }
495
        $this->byhour = $byhourrules;
496
    }
497
 
498
    /**
499
     * Sets the BYDAY rule.
500
     *
501
     * The BYDAY rule part specifies a comma-separated list of days of the week;
502
     *  - MO indicates Monday;
503
     *  - TU indicates Tuesday;
504
     *  - WE indicates Wednesday;
505
     *  - TH indicates Thursday;
506
     *  - FR indicates Friday;
507
     *  - SA indicates Saturday;
508
     *  - SU indicates Sunday.
509
     *
510
     * Each BYDAY value can also be preceded by a positive (+n) or negative (-n) integer.
511
     * If present, this indicates the nth occurrence of the specific day within the MONTHLY or YEARLY RRULE.
512
     * For example, within a MONTHLY rule, +1MO (or simply 1MO) represents the first Monday within the month,
513
     * whereas -1MO represents the last Monday of the month.
514
     * If an integer modifier is not present, it means all days of this type within the specified frequency.
515
     * For example, within a MONTHLY rule, MO represents all Mondays within the month.
516
     *
517
     * @param string $byday Comma-separated list of days of the week.
518
     * @throws moodle_exception
519
     */
520
    protected function set_byday($byday) {
521
        $weekdays = array_keys(self::DAYS_OF_WEEK);
522
        $days = explode(',', $byday);
523
        $bydayrules = [];
524
        foreach ($days as $day) {
525
            $suffix = substr($day, -2);
526
            if (!in_array($suffix, $weekdays)) {
527
                throw new moodle_exception('errorinvalidbydaysuffix', 'calendar');
528
            }
529
 
530
            $bydayrule = new stdClass();
531
            $bydayrule->day = substr($suffix, -2);
532
            $bydayrule->value = (int)str_replace($suffix, '', $day);
533
 
534
            $bydayrules[] = $bydayrule;
535
        }
536
 
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');
556
            }
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');
583
            }
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');
611
            }
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');
633
            }
634
            $bymonthrules[] = (int)$month;
635
        }
636
        $this->bymonth = $bymonthrules;
637
    }
638
 
639
    /**
640
     * Sets the BYSETPOS rule.
641
     *
642
     * The BYSETPOS rule part specifies a comma-separated list of values which corresponds to the nth occurrence within the set of
643
     * events specified by the rule. Valid values are 1 to 366 or -366 to -1.
644
     * It MUST only be used in conjunction with another BYxxx rule part.
645
     *
646
     * For example "the last work day of the month" could be represented as: RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1
647
     *
648
     * @param string $bysetpos Comma-separated list of values.
649
     * @throws moodle_exception
650
     */
651
    protected function set_bysetpos($bysetpos) {
652
        $setposes = explode(',', $bysetpos);
653
        $bysetposrules = [];
654
        foreach ($setposes as $pos) {
655
            // Valid values are 1 to 366 or -366 to -1.
656
            if ($pos < -366 || $pos > 366 || $pos == 0) {
657
                throw new moodle_exception('errorinvalidbysetpos', 'calendar');
658
            }
659
            $bysetposrules[] = (int)$pos;
660
        }
661
        $this->bysetpos = $bysetposrules;
662
    }
663
 
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
        }
674
 
675
        // BYSETPOS only be used in conjunction with another BYxxx rule part.
676
        if (!empty($this->bysetpos) && empty($this->bymonth) && empty($this->bymonthday) && empty($this->bysecond)
677
            && empty($this->byday) && empty($this->byweekno) && empty($this->byhour) && empty($this->byminute)
678
            && empty($this->byyearday)) {
679
            throw new moodle_exception('errormustbeusedwithotherbyrule', 'calendar');
680
        }
681
 
682
        // Integer values preceding BYDAY rules can only be present for MONTHLY or YEARLY RRULE.
683
        foreach ($this->byday as $bydayrule) {
684
            if (!empty($bydayrule->value) && $this->freq != self::FREQ_MONTHLY && $this->freq != self::FREQ_YEARLY) {
685
                throw new moodle_exception('errorinvalidbydayprefix', 'calendar');
686
            }
687
        }
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;
711
            }
712
 
713
            // Decrement count, if set.
714
            if ($count !== false) {
715
                $count--;
716
                if ($count == 0) {
717
                    break;
718
                }
719
            }
720
 
721
            // Create the recurring event.
722
            $cloneevent = clone($event);
723
            $cloneevent->repeatid = $event->id;
724
            $cloneevent->timestart = $time;
725
            unset($cloneevent->id);
726
            // UUID should only be set on the first instance of the recurring events.
727
            unset($cloneevent->uuid);
728
            calendar_event::create($cloneevent, false);
729
        }
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
        }
744
    }
745
 
746
    /**
747
     * Generates recurring events based on the parent event and the RRULE set.
748
     *
749
     * If multiple BYxxx rule parts are specified, then after evaluating the specified FREQ and INTERVAL rule parts,
750
     * the BYxxx rule parts are applied to the current set of evaluated occurrences in the following order:
751
     * BYMONTH, BYWEEKNO, BYYEARDAY, BYMONTHDAY, BYDAY, BYHOUR, BYMINUTE, BYSECOND and BYSETPOS;
752
     * then COUNT and UNTIL are evaluated.
753
     *
754
     * @param stdClass $event The event object.
755
     * @return array The list of timestamps that obey the given RRULE.
756
     */
757
    protected function generate_recurring_event_times($event) {
758
        $interval = $this->get_interval();
759
 
760
        // Candidate event times.
761
        $eventtimes = [];
762
 
763
        $eventdatetime = new DateTime(date('Y-m-d H:i:s', $event->timestart));
764
 
765
        $until = null;
766
        if (empty($this->count)) {
767
            if ($this->until) {
768
                $until = $this->until;
769
            } else {
770
                // Forever event. However, since there's no such thing as 'forever' (at least not in Moodle),
771
                // we only repeat the events until 10 years from the current time.
772
                $untildate = new DateTime();
773
                $foreverinterval = new DateInterval('P' . self::TIME_UNLIMITED_YEARS . 'Y');
774
                $untildate->add($foreverinterval);
775
                $until = $untildate->getTimestamp();
776
            }
777
        } else {
778
            // If count is defined, let's define a tentative until date. We'll just trim the number of events later.
779
            $untildate = clone($eventdatetime);
780
            $count = $this->count;
781
            while ($count >= 0) {
782
                $untildate->add($interval);
783
                $count--;
784
            }
785
            $until = $untildate->getTimestamp();
786
        }
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);
795
            }
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);
807
            }
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;
849
            }
850
            $results[] = $time;
851
        }
852
 
853
        return $results;
854
    }
855
 
856
    /**
857
     * Generates a DateInterval object based on the FREQ and INTERVAL rules.
858
     *
859
     * @return DateInterval
860
     * @throws moodle_exception
861
     */
862
    protected function get_interval() {
863
        $intervalspec = null;
864
        switch ($this->freq) {
865
            case self::FREQ_YEARLY:
866
                $intervalspec = 'P' . $this->interval . 'Y';
867
                break;
868
            case self::FREQ_MONTHLY:
869
                $intervalspec = 'P' . $this->interval . 'M';
870
                break;
871
            case self::FREQ_WEEKLY:
872
                $intervalspec = 'P' . $this->interval . 'W';
873
                break;
874
            case self::FREQ_DAILY:
875
                $intervalspec = 'P' . $this->interval . 'D';
876
                break;
877
            case self::FREQ_HOURLY:
878
                $intervalspec = 'PT' . $this->interval . 'H';
879
                break;
880
            case self::FREQ_MINUTELY:
881
                $intervalspec = 'PT' . $this->interval . 'M';
882
                break;
883
            case self::FREQ_SECONDLY:
884
                $intervalspec = 'PT' . $this->interval . 'S';
885
                break;
886
            default:
887
                // We should never get here, something is very wrong.
888
                throw new moodle_exception('errorrrulefreq', 'calendar');
889
        }
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
                }
923
            }
924
        }
925
        return $filteredbymonth;
926
    }
927
 
928
    /**
929
     * Filter event times based on the BYWEEKNO rule.
930
     *
931
     * @param int[] $eventdates Timestamps of event times to be filtered.
932
     * @return int[] Array of filtered timestamps.
933
     */
934
    protected function filter_by_weekno($eventdates) {
935
        if (empty($this->byweekno)) {
936
            return $eventdates;
937
        }
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;
1020
        }
1021
 
1022
        $filteredbymonthday = [];
1023
        foreach ($eventdates as $time) {
1024
            $eventdatetime = new DateTime(date('Y-m-d', $time));
1025
            foreach ($this->bymonthday as $monthday) {
1026
                // Days to add/subtract.
1027
                $daysoffset = abs($monthday) - 1;
1028
                $dayinterval = new DateInterval("P{$daysoffset}D");
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
                    }
1049
                }
1050
            }
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
 
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;
1082
                }
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)) {
1091
                        // Get the first day of the year.
1092
                        $firstdaydate = $tmpdatetime->format('Y') . '-01-01';
1093
                    } else {
1094
                        // Get the first day of the month.
1095
                        $firstdaydate = $tmpdatetime->format('Y-m') . '-01';
1096
                    }
1097
                    $expecteddate = new DateTime($firstdaydate);
1098
                    $count = $day->value;
1099
                    // Get the nth week day of the year/month.
1100
                    $expecteddate->modify("+$count $dayname");
1101
                    if ($expecteddate->format('Y-m-d') === $tmpdatetime->format('Y-m-d')) {
1102
                        $filteredbyday[] = $time;
1103
                        break;
1104
                    }
1105
 
1106
                } else {
1107
                    // Negative value.
1108
                    $count = $day->value;
1109
                    if ($this->freq === self::FREQ_YEARLY && empty($this->bymonth)) {
1110
                        // The -Nth week day of the year.
1111
                        $eventyear = (int)$tmpdatetime->format('Y');
1112
                        // Get temporary DateTime object starting from the first day of the next year.
1113
                        $expecteddate = new DateTime((++$eventyear) . '-01-01');
1114
                        while ($count < 0) {
1115
                            // Get the start of the previous week.
1116
                            $expecteddate->modify('last ' . $this->wkst);
1117
                            $tmpexpecteddate = clone($expecteddate);
1118
                            if ($tmpexpecteddate->format('l') !== $dayname) {
1119
                                $tmpexpecteddate->modify('next ' . $dayname);
1120
                            }
1121
                            if ($this->in_bounds($tmpexpecteddate->getTimestamp(), $bounds)) {
1122
                                $expecteddate = $tmpexpecteddate;
1123
                                $count++;
1124
                            }
1125
                        }
1126
                        if ($expecteddate->format('l') !== $dayname) {
1127
                            $expecteddate->modify('next ' . $dayname);
1128
                        }
1129
                        if ($expecteddate->getTimestamp() == $time) {
1130
                            $filteredbyday[] = $time;
1131
                            break;
1132
                        }
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
                        }
1151
 
1152
                        // Compare the expected date with the event's timestamp.
1153
                        if ($expecteddate->getTimestamp() == $time) {
1154
                            $filteredbyday[] = $time;
1155
                            break;
1156
                        }
1157
                    }
1158
                }
1159
            }
1160
        }
1161
        return $filteredbyday;
1162
    }
1163
 
1164
    /**
1165
     * Applies BYHOUR, BYMINUTE and BYSECOND rules to the calculated event dates.
1166
     * Defaults to the DTSTART's hour/minute/second component when not defined.
1167
     *
1168
     * @param DateTime $eventdatetime The parent event DateTime object pertaining to the DTSTART.
1169
     * @param int[] $eventdates Array of candidate event date timestamps.
1170
     * @return array List of updated event timestamps that contain the time component of the event times.
1171
     */
1172
    protected function apply_hour_minute_second_rules(DateTime $eventdatetime, $eventdates) {
1173
        // If BYHOUR rules are not set, set the hour of the events from the DTSTART's hour component.
1174
        if (empty($this->byhour)) {
1175
            $this->byhour = [$eventdatetime->format('G')];
1176
        }
1177
        // If BYMINUTE rules are not set, set the hour of the events from the DTSTART's minute component.
1178
        if (empty($this->byminute)) {
1179
            $this->byminute = [(int)$eventdatetime->format('i')];
1180
        }
1181
        // If BYSECOND rules are not set, set the hour of the events from the DTSTART's second component.
1182
        if (empty($this->bysecond)) {
1183
            $this->bysecond = [(int)$eventdatetime->format('s')];
1184
        }
1185
 
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
    }
1200
 
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;
1223
                }
1224
            }
1225
            if (empty($prospecttimes)) {
1226
                continue;
1227
            }
1228
            // Add the event times that correspond to the set position rule into the filtered results.
1229
            foreach ($this->bysetpos as $pos) {
1230
                $tmptimes = $prospecttimes;
1231
                if ($pos < 0) {
1232
                    rsort($tmptimes);
1233
                }
1234
                $index = abs($pos) - 1;
1235
                if (isset($tmptimes[$index])) {
1236
                    $filteredbysetpos[] = $tmptimes[$index];
1237
                }
1238
            }
1239
        }
1240
        return $filteredbysetpos;
1241
    }
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
        ];
1356
    }
1357
}