PluginProbe ʕ •ᴥ•ʔ
Booking for Appointments and Events Calendar – Amelia / 1.2.20
Booking for Appointments and Events Calendar – Amelia v1.2.20
2.4.4 2.4.3 2.4.2 2.4.1 2.4 trunk 1.2.1 1.2.10 1.2.11 1.2.12 1.2.13 1.2.14 1.2.15 1.2.16 1.2.17 1.2.18 1.2.19 1.2.2 1.2.20 1.2.21 1.2.22 1.2.23 1.2.24 1.2.25 1.2.26 1.2.27 1.2.28 1.2.29 1.2.3 1.2.30 1.2.31 1.2.32 1.2.33 1.2.34 1.2.35 1.2.36 1.2.37 1.2.38 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 2.0 2.0.1 2.0.2 2.1 2.1.1 2.1.2 2.1.3 2.2 2.2.1 2.3
ameliabooking / vendor / sabre / vobject / lib / Recur / RRuleIterator.php
ameliabooking / vendor / sabre / vobject / lib / Recur Last commit date
EventIterator.php 1 year ago MaxInstancesExceededException.php 1 year ago NoInstancesException.php 1 year ago RDateIterator.php 1 year ago RRuleIterator.php 1 year ago
RRuleIterator.php
1080 lines
1 <?php
2
3 namespace Sabre\VObject\Recur;
4
5 use DateTimeImmutable;
6 use DateTimeInterface;
7 use Iterator;
8 use Sabre\VObject\DateTimeParser;
9 use Sabre\VObject\InvalidDataException;
10 use Sabre\VObject\Property;
11
12 /**
13 * RRuleParser.
14 *
15 * This class receives an RRULE string, and allows you to iterate to get a list
16 * of dates in that recurrence.
17 *
18 * For instance, passing: FREQ=DAILY;LIMIT=5 will cause the iterator to contain
19 * 5 items, one for each day.
20 *
21 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
22 * @author Evert Pot (http://evertpot.com/)
23 * @license http://sabre.io/license/ Modified BSD License
24 */
25 class RRuleIterator implements Iterator
26 {
27 /**
28 * Constant denoting the upper limit on how long into the future
29 * we want to iterate. The value is a unix timestamp and currently
30 * corresponds to the datetime 9999-12-31 11:59:59 UTC.
31 */
32 const dateUpperLimit = 253402300799;
33
34 /**
35 * Creates the Iterator.
36 *
37 * @param string|array $rrule
38 */
39 public function __construct($rrule, DateTimeInterface $start)
40 {
41 $this->startDate = $start;
42 $this->parseRRule($rrule);
43 $this->currentDate = clone $this->startDate;
44 }
45
46 /* Implementation of the Iterator interface {{{ */
47
48 #[\ReturnTypeWillChange]
49 public function current()
50 {
51 if (!$this->valid()) {
52 return;
53 }
54
55 return clone $this->currentDate;
56 }
57
58 /**
59 * Returns the current item number.
60 *
61 * @return int
62 */
63 #[\ReturnTypeWillChange]
64 public function key()
65 {
66 return $this->counter;
67 }
68
69 /**
70 * Returns whether the current item is a valid item for the recurrence
71 * iterator. This will return false if we've gone beyond the UNTIL or COUNT
72 * statements.
73 *
74 * @return bool
75 */
76 #[\ReturnTypeWillChange]
77 public function valid()
78 {
79 if (null === $this->currentDate) {
80 return false;
81 }
82 if (!is_null($this->count)) {
83 return $this->counter < $this->count;
84 }
85
86 return is_null($this->until) || $this->currentDate <= $this->until;
87 }
88
89 /**
90 * Resets the iterator.
91 *
92 * @return void
93 */
94 #[\ReturnTypeWillChange]
95 public function rewind()
96 {
97 $this->currentDate = clone $this->startDate;
98 $this->counter = 0;
99 }
100
101 /**
102 * Goes on to the next iteration.
103 *
104 * @return void
105 */
106 #[\ReturnTypeWillChange]
107 public function next()
108 {
109 // Otherwise, we find the next event in the normal RRULE
110 // sequence.
111 switch ($this->frequency) {
112 case 'hourly':
113 $this->nextHourly();
114 break;
115
116 case 'daily':
117 $this->nextDaily();
118 break;
119
120 case 'weekly':
121 $this->nextWeekly();
122 break;
123
124 case 'monthly':
125 $this->nextMonthly();
126 break;
127
128 case 'yearly':
129 $this->nextYearly();
130 break;
131 }
132 ++$this->counter;
133 }
134
135 /* End of Iterator implementation }}} */
136
137 /**
138 * Returns true if this recurring event never ends.
139 *
140 * @return bool
141 */
142 public function isInfinite()
143 {
144 return !$this->count && !$this->until;
145 }
146
147 /**
148 * This method allows you to quickly go to the next occurrence after the
149 * specified date.
150 */
151 public function fastForward(DateTimeInterface $dt)
152 {
153 while ($this->valid() && $this->currentDate < $dt) {
154 $this->next();
155 }
156 }
157
158 /**
159 * The reference start date/time for the rrule.
160 *
161 * All calculations are based on this initial date.
162 *
163 * @var DateTimeInterface
164 */
165 protected $startDate;
166
167 /**
168 * The date of the current iteration. You can get this by calling
169 * ->current().
170 *
171 * @var DateTimeInterface
172 */
173 protected $currentDate;
174
175 /**
176 * The number of hours that the next occurrence of an event
177 * jumped forward, usually because summer time started and
178 * the requested time-of-day like 0230 did not exist on that
179 * day. And so the event was scheduled 1 hour later at 0330.
180 */
181 protected $hourJump = 0;
182
183 /**
184 * Frequency is one of: secondly, minutely, hourly, daily, weekly, monthly,
185 * yearly.
186 *
187 * @var string
188 */
189 protected $frequency;
190
191 /**
192 * The number of recurrences, or 'null' if infinitely recurring.
193 *
194 * @var int
195 */
196 protected $count;
197
198 /**
199 * The interval.
200 *
201 * If for example frequency is set to daily, interval = 2 would mean every
202 * 2 days.
203 *
204 * @var int
205 */
206 protected $interval = 1;
207
208 /**
209 * The last instance of this recurrence, inclusively.
210 *
211 * @var DateTimeInterface|null
212 */
213 protected $until;
214
215 /**
216 * Which seconds to recur.
217 *
218 * This is an array of integers (between 0 and 60)
219 *
220 * @var array
221 */
222 protected $bySecond;
223
224 /**
225 * Which minutes to recur.
226 *
227 * This is an array of integers (between 0 and 59)
228 *
229 * @var array
230 */
231 protected $byMinute;
232
233 /**
234 * Which hours to recur.
235 *
236 * This is an array of integers (between 0 and 23)
237 *
238 * @var array
239 */
240 protected $byHour;
241
242 /**
243 * The current item in the list.
244 *
245 * You can get this number with the key() method.
246 *
247 * @var int
248 */
249 protected $counter = 0;
250
251 /**
252 * Which weekdays to recur.
253 *
254 * This is an array of weekdays
255 *
256 * This may also be preceded by a positive or negative integer. If present,
257 * this indicates the nth occurrence of a specific day within the monthly or
258 * yearly rrule. For instance, -2TU indicates the second-last tuesday of
259 * the month, or year.
260 *
261 * @var array
262 */
263 protected $byDay;
264
265 /**
266 * Which days of the month to recur.
267 *
268 * This is an array of days of the months (1-31). The value can also be
269 * negative. -5 for instance means the 5th last day of the month.
270 *
271 * @var array
272 */
273 protected $byMonthDay;
274
275 /**
276 * Which days of the year to recur.
277 *
278 * This is an array with days of the year (1 to 366). The values can also
279 * be negative. For instance, -1 will always represent the last day of the
280 * year. (December 31st).
281 *
282 * @var array
283 */
284 protected $byYearDay;
285
286 /**
287 * Which week numbers to recur.
288 *
289 * This is an array of integers from 1 to 53. The values can also be
290 * negative. -1 will always refer to the last week of the year.
291 *
292 * @var array
293 */
294 protected $byWeekNo;
295
296 /**
297 * Which months to recur.
298 *
299 * This is an array of integers from 1 to 12.
300 *
301 * @var array
302 */
303 protected $byMonth;
304
305 /**
306 * Which items in an existing st to recur.
307 *
308 * These numbers work together with an existing by* rule. It specifies
309 * exactly which items of the existing by-rule to filter.
310 *
311 * Valid values are 1 to 366 and -1 to -366. As an example, this can be
312 * used to recur the last workday of the month.
313 *
314 * This would be done by setting frequency to 'monthly', byDay to
315 * 'MO,TU,WE,TH,FR' and bySetPos to -1.
316 *
317 * @var array
318 */
319 protected $bySetPos;
320
321 /**
322 * When the week starts.
323 *
324 * @var string
325 */
326 protected $weekStart = 'MO';
327
328 /* Functions that advance the iterator {{{ */
329
330 /**
331 * Gets the original start time of the RRULE.
332 *
333 * The value is formatted as a string with 24-hour:minute:second
334 */
335 protected function startTime(): string
336 {
337 return $this->startDate->format('H:i:s');
338 }
339
340 /**
341 * Advances currentDate by the interval.
342 * The time is set from the original startDate.
343 * If the recurrence is on a day when summer time started, then the
344 * time on that day may have jumped forward, for example, from 0230 to 0330.
345 * Using the original time means that the next recurrence will be calculated
346 * based on the original start time and the day/week/month/year interval.
347 * So the start time of the next occurrence can correctly revert to 0230.
348 */
349 protected function advanceTheDate(string $interval): void
350 {
351 $this->currentDate = $this->currentDate->modify($interval.' '.$this->startTime());
352 }
353
354 /**
355 * Does the processing for adjusting the time of multi-hourly events when summer time starts.
356 */
357 protected function adjustForTimeJumpsOfHourlyEvent(DateTimeInterface $previousEventDateTime): void
358 {
359 if (0 === $this->hourJump) {
360 // Remember if the clock time jumped forward on the next occurrence.
361 // That happens if the next event time is on a day when summer time starts
362 // and the event time is in the non-existent hour of the day.
363 // For example, an event that normally starts at 02:30 will
364 // have to start at 03:30 on that day.
365 // If the interval is just 1 hour, then there is no "jumping back" to do.
366 // The events that day will happen, for example, at 0030 0130 0330 0430 0530...
367 if ($this->interval > 1) {
368 $expectedHourOfNextDate = ((int) $previousEventDateTime->format('G') + $this->interval) % 24;
369 $actualHourOfNextDate = (int) $this->currentDate->format('G');
370 $this->hourJump = $actualHourOfNextDate - $expectedHourOfNextDate;
371 }
372 } else {
373 // The hour "jumped" for the previous occurrence, to avoid the non-existent time.
374 // currentDate got set ahead by (usually) 1 hour on that day.
375 // Adjust it back for this next occurrence.
376 $this->currentDate = $this->currentDate->sub(new \DateInterval('PT'.$this->hourJump.'H'));
377 $this->hourJump = 0;
378 }
379 }
380
381 /**
382 * Does the processing for advancing the iterator for hourly frequency.
383 */
384 protected function nextHourly()
385 {
386 $previousEventDateTime = clone $this->currentDate;
387 $this->currentDate = $this->currentDate->modify('+'.$this->interval.' hours');
388 $this->adjustForTimeJumpsOfHourlyEvent($previousEventDateTime);
389 }
390
391 /**
392 * Does the processing for advancing the iterator for daily frequency.
393 */
394 protected function nextDaily()
395 {
396 if (!$this->byHour && !$this->byDay) {
397 $this->advanceTheDate('+'.$this->interval.' days');
398
399 return;
400 }
401
402 $recurrenceHours = [];
403 if (!empty($this->byHour)) {
404 $recurrenceHours = $this->getHours();
405 }
406
407 $recurrenceDays = [];
408 if (!empty($this->byDay)) {
409 $recurrenceDays = $this->getDays();
410 }
411
412 $recurrenceMonths = [];
413 if (!empty($this->byMonth)) {
414 $recurrenceMonths = $this->getMonths();
415 }
416
417 do {
418 if ($this->byHour) {
419 if ('23' == $this->currentDate->format('G')) {
420 // to obey the interval rule
421 $this->currentDate = $this->currentDate->modify('+'.($this->interval - 1).' days');
422 }
423
424 $this->currentDate = $this->currentDate->modify('+1 hours');
425 } else {
426 $this->currentDate = $this->currentDate->modify('+'.$this->interval.' days');
427 }
428
429 // Current month of the year
430 $currentMonth = $this->currentDate->format('n');
431
432 // Current day of the week
433 $currentDay = $this->currentDate->format('w');
434
435 // Current hour of the day
436 $currentHour = $this->currentDate->format('G');
437
438 if ($this->currentDate->getTimestamp() > self::dateUpperLimit) {
439 $this->currentDate = null;
440
441 return;
442 }
443 } while (
444 ($this->byDay && !in_array($currentDay, $recurrenceDays)) ||
445 ($this->byHour && !in_array($currentHour, $recurrenceHours)) ||
446 ($this->byMonth && !in_array($currentMonth, $recurrenceMonths))
447 );
448 }
449
450 /**
451 * Does the processing for advancing the iterator for weekly frequency.
452 */
453 protected function nextWeekly()
454 {
455 if (!$this->byHour && !$this->byDay) {
456 $this->advanceTheDate('+'.$this->interval.' weeks');
457
458 return;
459 }
460
461 $recurrenceHours = [];
462 if ($this->byHour) {
463 $recurrenceHours = $this->getHours();
464 }
465
466 $recurrenceDays = [];
467 if ($this->byDay) {
468 $recurrenceDays = $this->getDays();
469 }
470
471 // First day of the week:
472 $firstDay = $this->dayMap[$this->weekStart];
473
474 do {
475 if ($this->byHour) {
476 $this->currentDate = $this->currentDate->modify('+1 hours');
477 } else {
478 $this->advanceTheDate('+1 days');
479 }
480
481 // Current day of the week
482 $currentDay = (int) $this->currentDate->format('w');
483
484 // Current hour of the day
485 $currentHour = (int) $this->currentDate->format('G');
486
487 // We need to roll over to the next week
488 if ($currentDay === $firstDay && (!$this->byHour || '0' == $currentHour)) {
489 $this->currentDate = $this->currentDate->modify('+'.($this->interval - 1).' weeks');
490
491 // We need to go to the first day of this week, but only if we
492 // are not already on this first day of this week.
493 if ($this->currentDate->format('w') != $firstDay) {
494 $this->currentDate = $this->currentDate->modify('last '.$this->dayNames[$this->dayMap[$this->weekStart]]);
495 }
496 }
497
498 // We have a match
499 } while (($this->byDay && !in_array($currentDay, $recurrenceDays)) || ($this->byHour && !in_array($currentHour, $recurrenceHours)));
500 }
501
502 /**
503 * Does the processing for advancing the iterator for monthly frequency.
504 */
505 protected function nextMonthly()
506 {
507 $currentDayOfMonth = $this->currentDate->format('j');
508 if (!$this->byMonthDay && !$this->byDay) {
509 // If the current day is higher than the 28th, rollover can
510 // occur to the next month. We Must skip these invalid
511 // entries.
512 if ($currentDayOfMonth < 29) {
513 $this->advanceTheDate('+'.$this->interval.' months');
514 } else {
515 $increase = 0;
516 do {
517 ++$increase;
518 $tempDate = clone $this->currentDate;
519 $tempDate = $tempDate->modify('+ '.($this->interval * $increase).' months '.$this->startTime());
520 } while ($tempDate->format('j') != $currentDayOfMonth);
521 $this->currentDate = $tempDate;
522 }
523
524 return;
525 }
526
527 $occurrence = -1;
528 while (true) {
529 $occurrences = $this->getMonthlyOccurrences();
530
531 foreach ($occurrences as $occurrence) {
532 // The first occurrence thats higher than the current
533 // day of the month wins.
534 if ($occurrence > $currentDayOfMonth) {
535 break 2;
536 }
537 }
538
539 // If we made it all the way here, it means there were no
540 // valid occurrences, and we need to advance to the next
541 // month.
542 //
543 // This line does not currently work in hhvm. Temporary workaround
544 // follows:
545 // $this->currentDate->modify('first day of this month');
546 $this->currentDate = new DateTimeImmutable($this->currentDate->format('Y-m-1 H:i:s'), $this->currentDate->getTimezone());
547 // end of workaround
548 $this->currentDate = $this->currentDate->modify('+ '.$this->interval.' months');
549
550 // This goes to 0 because we need to start counting at the
551 // beginning.
552 $currentDayOfMonth = 0;
553
554 // For some reason the "until" parameter was not being used here,
555 // that's why the workaround of the 10000 year bug was needed at all
556 // let's stop it before the "until" parameter date
557 if ($this->until && $this->currentDate->getTimestamp() >= $this->until->getTimestamp()) {
558 return;
559 }
560
561 // To prevent running this forever (better: until we hit the max date of DateTimeImmutable) we simply
562 // stop at 9999-12-31. Looks like the year 10000 problem is not solved in php ....
563 if ($this->currentDate->getTimestamp() > self::dateUpperLimit) {
564 $this->currentDate = null;
565
566 return;
567 }
568 }
569
570 // Set the currentDate to the year and month that we are in, and the day of the month that we have selected.
571 // That day could be a day when summer time starts, and if the time of the event is, for example, 0230,
572 // then 0230 will not be a valid time on that day. So always apply the start time from the original startDate.
573 // The "modify" method will set the time forward to 0330, for example, if needed.
574 $this->currentDate = $this->currentDate->setDate(
575 (int) $this->currentDate->format('Y'),
576 (int) $this->currentDate->format('n'),
577 (int) $occurrence
578 )->modify($this->startTime());
579 }
580
581 /**
582 * Does the processing for advancing the iterator for yearly frequency.
583 */
584 protected function nextYearly()
585 {
586 $currentMonth = $this->currentDate->format('n');
587 $currentYear = $this->currentDate->format('Y');
588 $currentDayOfMonth = $this->currentDate->format('j');
589
590 // No sub-rules, so we just advance by year
591 if (empty($this->byMonth)) {
592 // Unless it was a leap day!
593 if (2 == $currentMonth && 29 == $currentDayOfMonth) {
594 $counter = 0;
595 do {
596 ++$counter;
597 // Here we increase the year count by the interval, until
598 // we hit a date that's also in a leap year.
599 //
600 // We could just find the next interval that's dividable by
601 // 4, but that would ignore the rule that there's no leap
602 // year every year that's dividable by a 100, but not by
603 // 400. (1800, 1900, 2100). So we just rely on the datetime
604 // functions instead.
605 $nextDate = clone $this->currentDate;
606 $nextDate = $nextDate->modify('+ '.($this->interval * $counter).' years');
607 } while (2 != $nextDate->format('n'));
608
609 $this->currentDate = $nextDate;
610
611 return;
612 }
613
614 if (null !== $this->byWeekNo) { // byWeekNo is an array with values from -53 to -1, or 1 to 53
615 $dayOffsets = [];
616 if ($this->byDay) {
617 foreach ($this->byDay as $byDay) {
618 $dayOffsets[] = $this->dayMap[$byDay];
619 }
620 } else { // default is Monday
621 $dayOffsets[] = 1;
622 }
623
624 $currentYear = $this->currentDate->format('Y');
625
626 while (true) {
627 $checkDates = [];
628
629 // loop through all WeekNo and Days to check all the combinations
630 foreach ($this->byWeekNo as $byWeekNo) {
631 foreach ($dayOffsets as $dayOffset) {
632 $date = clone $this->currentDate;
633 $date = $date->setISODate($currentYear, $byWeekNo, $dayOffset);
634
635 if ($date > $this->currentDate) {
636 $checkDates[] = $date;
637 }
638 }
639 }
640
641 if (count($checkDates) > 0) {
642 $this->currentDate = min($checkDates);
643
644 return;
645 }
646
647 // if there is no date found, check the next year
648 $currentYear += $this->interval;
649 }
650 }
651
652 if (null !== $this->byYearDay) { // byYearDay is an array with values from -366 to -1, or 1 to 366
653 $dayOffsets = [];
654 if ($this->byDay) {
655 foreach ($this->byDay as $byDay) {
656 $dayOffsets[] = $this->dayMap[$byDay];
657 }
658 } else { // default is Monday-Sunday
659 $dayOffsets = [1, 2, 3, 4, 5, 6, 7];
660 }
661
662 $currentYear = $this->currentDate->format('Y');
663
664 while (true) {
665 $checkDates = [];
666
667 // loop through all YearDay and Days to check all the combinations
668 foreach ($this->byYearDay as $byYearDay) {
669 $date = clone $this->currentDate;
670 if ($byYearDay > 0) {
671 $date = $date->setDate($currentYear, 1, 1);
672 $date = $date->add(new \DateInterval('P'.($byYearDay - 1).'D'));
673 } else {
674 $date = $date->setDate($currentYear, 12, 31);
675 $date = $date->sub(new \DateInterval('P'.abs($byYearDay + 1).'D'));
676 }
677
678 if ($date > $this->currentDate && in_array($date->format('N'), $dayOffsets)) {
679 $checkDates[] = $date;
680 }
681 }
682
683 if (count($checkDates) > 0) {
684 $this->currentDate = min($checkDates);
685
686 return;
687 }
688
689 // if there is no date found, check the next year
690 $currentYear += $this->interval;
691 }
692 }
693
694 // The easiest form
695 $this->advanceTheDate('+'.$this->interval.' years');
696
697 return;
698 }
699
700 $currentMonth = $this->currentDate->format('n');
701 $currentYear = $this->currentDate->format('Y');
702 $currentDayOfMonth = $this->currentDate->format('j');
703
704 $advancedToNewMonth = false;
705
706 // If we got a byDay or getMonthDay filter, we must first expand
707 // further.
708 if ($this->byDay || $this->byMonthDay) {
709 $occurrence = -1;
710 while (true) {
711 $occurrences = $this->getMonthlyOccurrences();
712
713 foreach ($occurrences as $occurrence) {
714 // The first occurrence that's higher than the current
715 // day of the month wins.
716 // If we advanced to the next month or year, the first
717 // occurrence is always correct.
718 if ($occurrence > $currentDayOfMonth || $advancedToNewMonth) {
719 // only consider byMonth matches,
720 // otherwise, we don't follow RRule correctly
721 if (in_array($currentMonth, $this->byMonth)) {
722 break 2;
723 }
724 }
725 }
726
727 // If we made it here, it means we need to advance to
728 // the next month or year.
729 $currentDayOfMonth = 1;
730 $advancedToNewMonth = true;
731 do {
732 ++$currentMonth;
733 if ($currentMonth > 12) {
734 $currentYear += $this->interval;
735 $currentMonth = 1;
736 }
737 } while (!in_array($currentMonth, $this->byMonth));
738
739 $this->currentDate = $this->currentDate->setDate(
740 (int) $currentYear,
741 (int) $currentMonth,
742 (int) $currentDayOfMonth
743 );
744
745 // To prevent running this forever (better: until we hit the max date of DateTimeImmutable) we simply
746 // stop at 9999-12-31. Looks like the year 10000 problem is not solved in php ....
747 if ($this->currentDate->getTimestamp() > self::dateUpperLimit) {
748 $this->currentDate = null;
749
750 return;
751 }
752 }
753
754 // If we made it here, it means we got a valid occurrence
755 $this->currentDate = $this->currentDate->setDate(
756 (int) $currentYear,
757 (int) $currentMonth,
758 (int) $occurrence
759 )->modify($this->startTime());
760
761 return;
762 } else {
763 // These are the 'byMonth' rules, if there are no byDay or
764 // byMonthDay sub-rules.
765 do {
766 ++$currentMonth;
767 if ($currentMonth > 12) {
768 $currentYear += $this->interval;
769 $currentMonth = 1;
770 }
771 } while (!in_array($currentMonth, $this->byMonth));
772 $this->currentDate = $this->currentDate->setDate(
773 (int) $currentYear,
774 (int) $currentMonth,
775 (int) $currentDayOfMonth
776 )->modify($this->startTime());
777
778 return;
779 }
780 }
781
782 /* }}} */
783
784 /**
785 * This method receives a string from an RRULE property, and populates this
786 * class with all the values.
787 *
788 * @param string|array $rrule
789 */
790 protected function parseRRule($rrule)
791 {
792 if (is_string($rrule)) {
793 $rrule = Property\ICalendar\Recur::stringToArray($rrule);
794 }
795
796 foreach ($rrule as $key => $value) {
797 $key = strtoupper($key);
798 switch ($key) {
799 case 'FREQ':
800 $value = strtolower($value);
801 if (!in_array(
802 $value,
803 ['secondly', 'minutely', 'hourly', 'daily', 'weekly', 'monthly', 'yearly']
804 )) {
805 throw new InvalidDataException('Unknown value for FREQ='.strtoupper($value));
806 }
807 $this->frequency = $value;
808 break;
809
810 case 'UNTIL':
811 $this->until = DateTimeParser::parse($value, $this->startDate->getTimezone());
812
813 // In some cases events are generated with an UNTIL=
814 // parameter before the actual start of the event.
815 //
816 // Not sure why this is happening. We assume that the
817 // intention was that the event only recurs once.
818 //
819 // So we are modifying the parameter so our code doesn't
820 // break.
821 if ($this->until < $this->startDate) {
822 $this->until = $this->startDate;
823 }
824 break;
825
826 case 'INTERVAL':
827 case 'COUNT':
828 $val = (int) $value;
829 if ($val < 1) {
830 throw new InvalidDataException(strtoupper($key).' in RRULE must be a positive integer!');
831 }
832 $key = strtolower($key);
833 $this->$key = $val;
834 break;
835
836 case 'BYSECOND':
837 $this->bySecond = (array) $value;
838 break;
839
840 case 'BYMINUTE':
841 $this->byMinute = (array) $value;
842 break;
843
844 case 'BYHOUR':
845 $this->byHour = (array) $value;
846 break;
847
848 case 'BYDAY':
849 $value = (array) $value;
850 foreach ($value as $part) {
851 if (!preg_match('#^ (-|\+)? ([1-5])? (MO|TU|WE|TH|FR|SA|SU) $# xi', $part)) {
852 throw new InvalidDataException('Invalid part in BYDAY clause: '.$part);
853 }
854 }
855 $this->byDay = $value;
856 break;
857
858 case 'BYMONTHDAY':
859 $this->byMonthDay = (array) $value;
860 break;
861
862 case 'BYYEARDAY':
863 $this->byYearDay = (array) $value;
864 foreach ($this->byYearDay as $byYearDay) {
865 if (!is_numeric($byYearDay) || (int) $byYearDay < -366 || 0 == (int) $byYearDay || (int) $byYearDay > 366) {
866 throw new InvalidDataException('BYYEARDAY in RRULE must have value(s) from 1 to 366, or -366 to -1!');
867 }
868 }
869 break;
870
871 case 'BYWEEKNO':
872 $this->byWeekNo = (array) $value;
873 foreach ($this->byWeekNo as $byWeekNo) {
874 if (!is_numeric($byWeekNo) || (int) $byWeekNo < -53 || 0 == (int) $byWeekNo || (int) $byWeekNo > 53) {
875 throw new InvalidDataException('BYWEEKNO in RRULE must have value(s) from 1 to 53, or -53 to -1!');
876 }
877 }
878 break;
879
880 case 'BYMONTH':
881 $this->byMonth = (array) $value;
882 foreach ($this->byMonth as $byMonth) {
883 if (!is_numeric($byMonth) || (int) $byMonth < 1 || (int) $byMonth > 12) {
884 throw new InvalidDataException('BYMONTH in RRULE must have value(s) between 1 and 12!');
885 }
886 }
887 break;
888
889 case 'BYSETPOS':
890 $this->bySetPos = (array) $value;
891 break;
892
893 case 'WKST':
894 $this->weekStart = strtoupper($value);
895 break;
896
897 default:
898 throw new InvalidDataException('Not supported: '.strtoupper($key));
899 }
900 }
901 }
902
903 /**
904 * Mappings between the day number and english day name.
905 *
906 * @var array
907 */
908 protected $dayNames = [
909 0 => 'Sunday',
910 1 => 'Monday',
911 2 => 'Tuesday',
912 3 => 'Wednesday',
913 4 => 'Thursday',
914 5 => 'Friday',
915 6 => 'Saturday',
916 ];
917
918 /**
919 * Returns all the occurrences for a monthly frequency with a 'byDay' or
920 * 'byMonthDay' expansion for the current month.
921 *
922 * The returned list is an array of integers with the day of month (1-31).
923 *
924 * @return array
925 */
926 protected function getMonthlyOccurrences()
927 {
928 $startDate = clone $this->currentDate;
929
930 $byDayResults = [];
931
932 // Our strategy is to simply go through the byDays, advance the date to
933 // that point and add it to the results.
934 if ($this->byDay) {
935 foreach ($this->byDay as $day) {
936 $dayName = $this->dayNames[$this->dayMap[substr($day, -2)]];
937
938 // Dayname will be something like 'wednesday'. Now we need to find
939 // all wednesdays in this month.
940 $dayHits = [];
941
942 // workaround for missing 'first day of the month' support in hhvm
943 $checkDate = new \DateTime($startDate->format('Y-m-1'));
944 // workaround modify always advancing the date even if the current day is a $dayName in hhvm
945 if ($checkDate->format('l') !== $dayName) {
946 $checkDate = $checkDate->modify($dayName);
947 }
948
949 do {
950 $dayHits[] = $checkDate->format('j');
951 $checkDate = $checkDate->modify('next '.$dayName);
952 } while ($checkDate->format('n') === $startDate->format('n'));
953
954 // So now we have 'all wednesdays' for month. It is however
955 // possible that the user only really wanted the 1st, 2nd or last
956 // wednesday.
957 if (strlen($day) > 2) {
958 $offset = (int) substr($day, 0, -2);
959
960 if ($offset > 0) {
961 // It is possible that the day does not exist, such as a
962 // 5th or 6th wednesday of the month.
963 if (isset($dayHits[$offset - 1])) {
964 $byDayResults[] = $dayHits[$offset - 1];
965 }
966 } else {
967 // if it was negative we count from the end of the array
968 // might not exist, fx. -5th tuesday
969 if (isset($dayHits[count($dayHits) + $offset])) {
970 $byDayResults[] = $dayHits[count($dayHits) + $offset];
971 }
972 }
973 } else {
974 // There was no counter (first, second, last wednesdays), so we
975 // just need to add the all to the list).
976 $byDayResults = array_merge($byDayResults, $dayHits);
977 }
978 }
979 }
980
981 $byMonthDayResults = [];
982 if ($this->byMonthDay) {
983 foreach ($this->byMonthDay as $monthDay) {
984 // Removing values that are out of range for this month
985 if ($monthDay > $startDate->format('t') ||
986 $monthDay < 0 - $startDate->format('t')) {
987 continue;
988 }
989 if ($monthDay > 0) {
990 $byMonthDayResults[] = $monthDay;
991 } else {
992 // Negative values
993 $byMonthDayResults[] = $startDate->format('t') + 1 + $monthDay;
994 }
995 }
996 }
997
998 // If there was just byDay or just byMonthDay, they just specify our
999 // (almost) final list. If both were provided, then byDay limits the
1000 // list.
1001 if ($this->byMonthDay && $this->byDay) {
1002 $result = array_intersect($byMonthDayResults, $byDayResults);
1003 } elseif ($this->byMonthDay) {
1004 $result = $byMonthDayResults;
1005 } else {
1006 $result = $byDayResults;
1007 }
1008 $result = array_unique($result);
1009 sort($result, SORT_NUMERIC);
1010
1011 // The last thing that needs checking is the BYSETPOS. If it's set, it
1012 // means only certain items in the set survive the filter.
1013 if (!$this->bySetPos) {
1014 return $result;
1015 }
1016
1017 $filteredResult = [];
1018 foreach ($this->bySetPos as $setPos) {
1019 if ($setPos < 0) {
1020 $setPos = count($result) + ($setPos + 1);
1021 }
1022 if (isset($result[$setPos - 1])) {
1023 $filteredResult[] = $result[$setPos - 1];
1024 }
1025 }
1026
1027 sort($filteredResult, SORT_NUMERIC);
1028
1029 return $filteredResult;
1030 }
1031
1032 /**
1033 * Simple mapping from iCalendar day names to day numbers.
1034 *
1035 * @var array
1036 */
1037 protected $dayMap = [
1038 'SU' => 0,
1039 'MO' => 1,
1040 'TU' => 2,
1041 'WE' => 3,
1042 'TH' => 4,
1043 'FR' => 5,
1044 'SA' => 6,
1045 ];
1046
1047 protected function getHours()
1048 {
1049 $recurrenceHours = [];
1050 foreach ($this->byHour as $byHour) {
1051 $recurrenceHours[] = $byHour;
1052 }
1053
1054 return $recurrenceHours;
1055 }
1056
1057 protected function getDays()
1058 {
1059 $recurrenceDays = [];
1060 foreach ($this->byDay as $byDay) {
1061 // The day may be preceded with a positive (+n) or
1062 // negative (-n) integer. However, this does not make
1063 // sense in 'weekly' so we ignore it here.
1064 $recurrenceDays[] = $this->dayMap[substr($byDay, -2)];
1065 }
1066
1067 return $recurrenceDays;
1068 }
1069
1070 protected function getMonths()
1071 {
1072 $recurrenceMonths = [];
1073 foreach ($this->byMonth as $byMonth) {
1074 $recurrenceMonths[] = $byMonth;
1075 }
1076
1077 return $recurrenceMonths;
1078 }
1079 }
1080