ILIAS  release_5-4 Revision v5.4.26-12-gabc799a52e6
RRuleIterator.php
Go to the documentation of this file.
1<?php
2
3namespace Sabre\VObject\Recur;
4
5use DateTimeImmutable;
6use DateTimeInterface;
7use Iterator;
11
25class RRuleIterator implements Iterator {
26
33 function __construct($rrule, DateTimeInterface $start) {
34
35 $this->startDate = $start;
36 $this->parseRRule($rrule);
37 $this->currentDate = clone $this->startDate;
38
39 }
40
41 /* Implementation of the Iterator interface {{{ */
42
43 function current() {
44
45 if (!$this->valid()) return;
46 return clone $this->currentDate;
47
48 }
49
55 function key() {
56
57 return $this->counter;
58
59 }
60
68 function valid() {
69
70 if (!is_null($this->count)) {
71 return $this->counter < $this->count;
72 }
73 return is_null($this->until) || $this->currentDate <= $this->until;
74
75 }
76
82 function rewind() {
83
84 $this->currentDate = clone $this->startDate;
85 $this->counter = 0;
86
87 }
88
94 function next() {
95
96 // Otherwise, we find the next event in the normal RRULE
97 // sequence.
98 switch ($this->frequency) {
99
100 case 'hourly' :
101 $this->nextHourly();
102 break;
103
104 case 'daily' :
105 $this->nextDaily();
106 break;
107
108 case 'weekly' :
109 $this->nextWeekly();
110 break;
111
112 case 'monthly' :
113 $this->nextMonthly();
114 break;
115
116 case 'yearly' :
117 $this->nextYearly();
118 break;
119
120 }
121 $this->counter++;
122
123 }
124
125 /* End of Iterator implementation }}} */
126
132 function isInfinite() {
133
134 return !$this->count && !$this->until;
135
136 }
137
146 function fastForward(DateTimeInterface $dt) {
147
148 while ($this->valid() && $this->currentDate < $dt) {
149 $this->next();
150 }
151
152 }
153
161 protected $startDate;
162
169 protected $currentDate;
170
177 protected $frequency;
178
184 protected $count;
185
194 protected $interval = 1;
195
201 protected $until;
202
210 protected $bySecond;
211
219 protected $byMinute;
220
228 protected $byHour;
229
237 protected $counter = 0;
238
251 protected $byDay;
252
261 protected $byMonthDay;
262
272 protected $byYearDay;
273
282 protected $byWeekNo;
283
291 protected $byMonth;
292
307 protected $bySetPos;
308
314 protected $weekStart = 'MO';
315
316 /* Functions that advance the iterator {{{ */
317
323 protected function nextHourly() {
324
325 $this->currentDate = $this->currentDate->modify('+' . $this->interval . ' hours');
326
327 }
328
334 protected function nextDaily() {
335
336 if (!$this->byHour && !$this->byDay) {
337 $this->currentDate = $this->currentDate->modify('+' . $this->interval . ' days');
338 return;
339 }
340
341 if (!empty($this->byHour)) {
342 $recurrenceHours = $this->getHours();
343 }
344
345 if (!empty($this->byDay)) {
346 $recurrenceDays = $this->getDays();
347 }
348
349 if (!empty($this->byMonth)) {
350 $recurrenceMonths = $this->getMonths();
351 }
352
353 do {
354 if ($this->byHour) {
355 if ($this->currentDate->format('G') == '23') {
356 // to obey the interval rule
357 $this->currentDate = $this->currentDate->modify('+' . $this->interval - 1 . ' days');
358 }
359
360 $this->currentDate = $this->currentDate->modify('+1 hours');
361
362 } else {
363 $this->currentDate = $this->currentDate->modify('+' . $this->interval . ' days');
364
365 }
366
367 // Current month of the year
368 $currentMonth = $this->currentDate->format('n');
369
370 // Current day of the week
371 $currentDay = $this->currentDate->format('w');
372
373 // Current hour of the day
374 $currentHour = $this->currentDate->format('G');
375
376 } while (
377 ($this->byDay && !in_array($currentDay, $recurrenceDays)) ||
378 ($this->byHour && !in_array($currentHour, $recurrenceHours)) ||
379 ($this->byMonth && !in_array($currentMonth, $recurrenceMonths))
380 );
381
382 }
383
389 protected function nextWeekly() {
390
391 if (!$this->byHour && !$this->byDay) {
392 $this->currentDate = $this->currentDate->modify('+' . $this->interval . ' weeks');
393 return;
394 }
395
396 if ($this->byHour) {
397 $recurrenceHours = $this->getHours();
398 }
399
400 if ($this->byDay) {
401 $recurrenceDays = $this->getDays();
402 }
403
404 // First day of the week:
405 $firstDay = $this->dayMap[$this->weekStart];
406
407 do {
408
409 if ($this->byHour) {
410 $this->currentDate = $this->currentDate->modify('+1 hours');
411 } else {
412 $this->currentDate = $this->currentDate->modify('+1 days');
413 }
414
415 // Current day of the week
416 $currentDay = (int)$this->currentDate->format('w');
417
418 // Current hour of the day
419 $currentHour = (int)$this->currentDate->format('G');
420
421 // We need to roll over to the next week
422 if ($currentDay === $firstDay && (!$this->byHour || $currentHour == '0')) {
423 $this->currentDate = $this->currentDate->modify('+' . $this->interval - 1 . ' weeks');
424
425 // We need to go to the first day of this week, but only if we
426 // are not already on this first day of this week.
427 if ($this->currentDate->format('w') != $firstDay) {
428 $this->currentDate = $this->currentDate->modify('last ' . $this->dayNames[$this->dayMap[$this->weekStart]]);
429 }
430 }
431
432 // We have a match
433 } while (($this->byDay && !in_array($currentDay, $recurrenceDays)) || ($this->byHour && !in_array($currentHour, $recurrenceHours)));
434 }
435
441 protected function nextMonthly() {
442
443 $currentDayOfMonth = $this->currentDate->format('j');
444 if (!$this->byMonthDay && !$this->byDay) {
445
446 // If the current day is higher than the 28th, rollover can
447 // occur to the next month. We Must skip these invalid
448 // entries.
449 if ($currentDayOfMonth < 29) {
450 $this->currentDate = $this->currentDate->modify('+' . $this->interval . ' months');
451 } else {
452 $increase = 0;
453 do {
454 $increase++;
455 $tempDate = clone $this->currentDate;
456 $tempDate = $tempDate->modify('+ ' . ($this->interval * $increase) . ' months');
457 } while ($tempDate->format('j') != $currentDayOfMonth);
458 $this->currentDate = $tempDate;
459 }
460 return;
461 }
462
463 while (true) {
464
465 $occurrences = $this->getMonthlyOccurrences();
466
467 foreach ($occurrences as $occurrence) {
468
469 // The first occurrence thats higher than the current
470 // day of the month wins.
471 if ($occurrence > $currentDayOfMonth) {
472 break 2;
473 }
474
475 }
476
477 // If we made it all the way here, it means there were no
478 // valid occurrences, and we need to advance to the next
479 // month.
480 //
481 // This line does not currently work in hhvm. Temporary workaround
482 // follows:
483 // $this->currentDate->modify('first day of this month');
484 $this->currentDate = new DateTimeImmutable($this->currentDate->format('Y-m-1 H:i:s'), $this->currentDate->getTimezone());
485 // end of workaround
486 $this->currentDate = $this->currentDate->modify('+ ' . $this->interval . ' months');
487
488 // This goes to 0 because we need to start counting at the
489 // beginning.
490 $currentDayOfMonth = 0;
491
492 }
493
494 $this->currentDate = $this->currentDate->setDate(
495 (int)$this->currentDate->format('Y'),
496 (int)$this->currentDate->format('n'),
497 (int)$occurrence
498 );
499
500 }
501
507 protected function nextYearly() {
508
509 $currentMonth = $this->currentDate->format('n');
510 $currentYear = $this->currentDate->format('Y');
511 $currentDayOfMonth = $this->currentDate->format('j');
512
513 // No sub-rules, so we just advance by year
514 if (empty($this->byMonth)) {
515
516 // Unless it was a leap day!
517 if ($currentMonth == 2 && $currentDayOfMonth == 29) {
518
519 $counter = 0;
520 do {
521 $counter++;
522 // Here we increase the year count by the interval, until
523 // we hit a date that's also in a leap year.
524 //
525 // We could just find the next interval that's dividable by
526 // 4, but that would ignore the rule that there's no leap
527 // year every year that's dividable by a 100, but not by
528 // 400. (1800, 1900, 2100). So we just rely on the datetime
529 // functions instead.
530 $nextDate = clone $this->currentDate;
531 $nextDate = $nextDate->modify('+ ' . ($this->interval * $counter) . ' years');
532 } while ($nextDate->format('n') != 2);
533
534 $this->currentDate = $nextDate;
535
536 return;
537
538 }
539
540 if ($this->byWeekNo !== null) { // byWeekNo is an array with values from -53 to -1, or 1 to 53
541 $dayOffsets = [];
542 if ($this->byDay) {
543 foreach ($this->byDay as $byDay) {
544 $dayOffsets[] = $this->dayMap[$byDay];
545 }
546 } else { // default is Monday
547 $dayOffsets[] = 1;
548 }
549
550 $currentYear = $this->currentDate->format('Y');
551
552 while (true) {
553 $checkDates = [];
554
555 // loop through all WeekNo and Days to check all the combinations
556 foreach ($this->byWeekNo as $byWeekNo) {
557 foreach ($dayOffsets as $dayOffset) {
558 $date = clone $this->currentDate;
559 $date->setISODate($currentYear, $byWeekNo, $dayOffset);
560
561 if ($date > $this->currentDate) {
562 $checkDates[] = $date;
563 }
564 }
565 }
566
567 if (count($checkDates) > 0) {
568 $this->currentDate = min($checkDates);
569 return;
570 }
571
572 // if there is no date found, check the next year
573 $currentYear += $this->interval;
574 }
575 }
576
577 if ($this->byYearDay !== null) { // byYearDay is an array with values from -366 to -1, or 1 to 366
578 $dayOffsets = [];
579 if ($this->byDay) {
580 foreach ($this->byDay as $byDay) {
581 $dayOffsets[] = $this->dayMap[$byDay];
582 }
583 } else { // default is Monday-Sunday
584 $dayOffsets = [1,2,3,4,5,6,7];
585 }
586
587 $currentYear = $this->currentDate->format('Y');
588
589 while (true) {
590 $checkDates = [];
591
592 // loop through all YearDay and Days to check all the combinations
593 foreach ($this->byYearDay as $byYearDay) {
594 $date = clone $this->currentDate;
595 $date = $date->setDate($currentYear, 1, 1);
596 if ($byYearDay > 0) {
597 $date = $date->add(new \DateInterval('P' . $byYearDay . 'D'));
598 } else {
599 $date = $date->sub(new \DateInterval('P' . abs($byYearDay) . 'D'));
600 }
601
602 if ($date > $this->currentDate && in_array($date->format('N'), $dayOffsets)) {
603 $checkDates[] = $date;
604 }
605 }
606
607 if (count($checkDates) > 0) {
608 $this->currentDate = min($checkDates);
609 return;
610 }
611
612 // if there is no date found, check the next year
613 $currentYear += $this->interval;
614 }
615 }
616
617 // The easiest form
618 $this->currentDate = $this->currentDate->modify('+' . $this->interval . ' years');
619 return;
620
621 }
622
623 $currentMonth = $this->currentDate->format('n');
624 $currentYear = $this->currentDate->format('Y');
625 $currentDayOfMonth = $this->currentDate->format('j');
626
627 $advancedToNewMonth = false;
628
629 // If we got a byDay or getMonthDay filter, we must first expand
630 // further.
631 if ($this->byDay || $this->byMonthDay) {
632
633 while (true) {
634
635 $occurrences = $this->getMonthlyOccurrences();
636
637 foreach ($occurrences as $occurrence) {
638
639 // The first occurrence that's higher than the current
640 // day of the month wins.
641 // If we advanced to the next month or year, the first
642 // occurrence is always correct.
643 if ($occurrence > $currentDayOfMonth || $advancedToNewMonth) {
644 break 2;
645 }
646
647 }
648
649 // If we made it here, it means we need to advance to
650 // the next month or year.
651 $currentDayOfMonth = 1;
652 $advancedToNewMonth = true;
653 do {
654
655 $currentMonth++;
656 if ($currentMonth > 12) {
657 $currentYear += $this->interval;
658 $currentMonth = 1;
659 }
660 } while (!in_array($currentMonth, $this->byMonth));
661
662 $this->currentDate = $this->currentDate->setDate(
663 (int)$currentYear,
664 (int)$currentMonth,
665 (int)$currentDayOfMonth
666 );
667
668 }
669
670 // If we made it here, it means we got a valid occurrence
671 $this->currentDate = $this->currentDate->setDate(
672 (int)$currentYear,
673 (int)$currentMonth,
674 (int)$occurrence
675 );
676 return;
677
678 } else {
679
680 // These are the 'byMonth' rules, if there are no byDay or
681 // byMonthDay sub-rules.
682 do {
683
684 $currentMonth++;
685 if ($currentMonth > 12) {
686 $currentYear += $this->interval;
687 $currentMonth = 1;
688 }
689 } while (!in_array($currentMonth, $this->byMonth));
690 $this->currentDate = $this->currentDate->setDate(
691 (int)$currentYear,
692 (int)$currentMonth,
693 (int)$currentDayOfMonth
694 );
695
696 return;
697
698 }
699
700 }
701
702 /* }}} */
703
712 protected function parseRRule($rrule) {
713
714 if (is_string($rrule)) {
716 }
717
718 foreach ($rrule as $key => $value) {
719
720 $key = strtoupper($key);
721 switch ($key) {
722
723 case 'FREQ' :
724 $value = strtolower($value);
725 if (!in_array(
726 $value,
727 ['secondly', 'minutely', 'hourly', 'daily', 'weekly', 'monthly', 'yearly']
728 )) {
729 throw new InvalidDataException('Unknown value for FREQ=' . strtoupper($value));
730 }
731 $this->frequency = $value;
732 break;
733
734 case 'UNTIL' :
735 $this->until = DateTimeParser::parse($value, $this->startDate->getTimezone());
736
737 // In some cases events are generated with an UNTIL=
738 // parameter before the actual start of the event.
739 //
740 // Not sure why this is happening. We assume that the
741 // intention was that the event only recurs once.
742 //
743 // So we are modifying the parameter so our code doesn't
744 // break.
745 if ($this->until < $this->startDate) {
746 $this->until = $this->startDate;
747 }
748 break;
749
750 case 'INTERVAL' :
751 // No break
752
753 case 'COUNT' :
754 $val = (int)$value;
755 if ($val < 1) {
756 throw new InvalidDataException(strtoupper($key) . ' in RRULE must be a positive integer!');
757 }
758 $key = strtolower($key);
759 $this->$key = $val;
760 break;
761
762 case 'BYSECOND' :
763 $this->bySecond = (array)$value;
764 break;
765
766 case 'BYMINUTE' :
767 $this->byMinute = (array)$value;
768 break;
769
770 case 'BYHOUR' :
771 $this->byHour = (array)$value;
772 break;
773
774 case 'BYDAY' :
775 $value = (array)$value;
776 foreach ($value as $part) {
777 if (!preg_match('#^ (-|\+)? ([1-5])? (MO|TU|WE|TH|FR|SA|SU) $# xi', $part)) {
778 throw new InvalidDataException('Invalid part in BYDAY clause: ' . $part);
779 }
780 }
781 $this->byDay = $value;
782 break;
783
784 case 'BYMONTHDAY' :
785 $this->byMonthDay = (array)$value;
786 break;
787
788 case 'BYYEARDAY' :
789 $this->byYearDay = (array)$value;
790 foreach ($this->byYearDay as $byYearDay) {
791 if (!is_numeric($byYearDay) || (int)$byYearDay < -366 || (int)$byYearDay == 0 || (int)$byYearDay > 366) {
792 throw new InvalidDataException('BYYEARDAY in RRULE must have value(s) from 1 to 366, or -366 to -1!');
793 }
794 }
795 break;
796
797 case 'BYWEEKNO' :
798 $this->byWeekNo = (array)$value;
799 foreach ($this->byWeekNo as $byWeekNo) {
800 if (!is_numeric($byWeekNo) || (int)$byWeekNo < -53 || (int)$byWeekNo == 0 || (int)$byWeekNo > 53) {
801 throw new InvalidDataException('BYWEEKNO in RRULE must have value(s) from 1 to 53, or -53 to -1!');
802 }
803 }
804 break;
805
806 case 'BYMONTH' :
807 $this->byMonth = (array)$value;
808 foreach ($this->byMonth as $byMonth) {
809 if (!is_numeric($byMonth) || (int)$byMonth < 1 || (int)$byMonth > 12) {
810 throw new InvalidDataException('BYMONTH in RRULE must have value(s) betweeen 1 and 12!');
811 }
812 }
813 break;
814
815 case 'BYSETPOS' :
816 $this->bySetPos = (array)$value;
817 break;
818
819 case 'WKST' :
820 $this->weekStart = strtoupper($value);
821 break;
822
823 default:
824 throw new InvalidDataException('Not supported: ' . strtoupper($key));
825
826 }
827
828 }
829
830 }
831
837 protected $dayNames = [
838 0 => 'Sunday',
839 1 => 'Monday',
840 2 => 'Tuesday',
841 3 => 'Wednesday',
842 4 => 'Thursday',
843 5 => 'Friday',
844 6 => 'Saturday',
845 ];
846
855 protected function getMonthlyOccurrences() {
856
858
859 $byDayResults = [];
860
861 // Our strategy is to simply go through the byDays, advance the date to
862 // that point and add it to the results.
863 if ($this->byDay) foreach ($this->byDay as $day) {
864
865 $dayName = $this->dayNames[$this->dayMap[substr($day, -2)]];
866
867
868 // Dayname will be something like 'wednesday'. Now we need to find
869 // all wednesdays in this month.
870 $dayHits = [];
871
872 // workaround for missing 'first day of the month' support in hhvm
873 $checkDate = new \DateTime($startDate->format('Y-m-1'));
874 // workaround modify always advancing the date even if the current day is a $dayName in hhvm
875 if ($checkDate->format('l') !== $dayName) {
876 $checkDate = $checkDate->modify($dayName);
877 }
878
879 do {
880 $dayHits[] = $checkDate->format('j');
881 $checkDate = $checkDate->modify('next ' . $dayName);
882 } while ($checkDate->format('n') === $startDate->format('n'));
883
884 // So now we have 'all wednesdays' for month. It is however
885 // possible that the user only really wanted the 1st, 2nd or last
886 // wednesday.
887 if (strlen($day) > 2) {
888 $offset = (int)substr($day, 0, -2);
889
890 if ($offset > 0) {
891 // It is possible that the day does not exist, such as a
892 // 5th or 6th wednesday of the month.
893 if (isset($dayHits[$offset - 1])) {
894 $byDayResults[] = $dayHits[$offset - 1];
895 }
896 } else {
897
898 // if it was negative we count from the end of the array
899 // might not exist, fx. -5th tuesday
900 if (isset($dayHits[count($dayHits) + $offset])) {
901 $byDayResults[] = $dayHits[count($dayHits) + $offset];
902 }
903 }
904 } else {
905 // There was no counter (first, second, last wednesdays), so we
906 // just need to add the all to the list).
907 $byDayResults = array_merge($byDayResults, $dayHits);
908
909 }
910
911 }
912
913 $byMonthDayResults = [];
914 if ($this->byMonthDay) foreach ($this->byMonthDay as $monthDay) {
915
916 // Removing values that are out of range for this month
917 if ($monthDay > $startDate->format('t') ||
918 $monthDay < 0 - $startDate->format('t')) {
919 continue;
920 }
921 if ($monthDay > 0) {
922 $byMonthDayResults[] = $monthDay;
923 } else {
924 // Negative values
925 $byMonthDayResults[] = $startDate->format('t') + 1 + $monthDay;
926 }
927 }
928
929 // If there was just byDay or just byMonthDay, they just specify our
930 // (almost) final list. If both were provided, then byDay limits the
931 // list.
932 if ($this->byMonthDay && $this->byDay) {
933 $result = array_intersect($byMonthDayResults, $byDayResults);
934 } elseif ($this->byMonthDay) {
935 $result = $byMonthDayResults;
936 } else {
937 $result = $byDayResults;
938 }
939 $result = array_unique($result);
940 sort($result, SORT_NUMERIC);
941
942 // The last thing that needs checking is the BYSETPOS. If it's set, it
943 // means only certain items in the set survive the filter.
944 if (!$this->bySetPos) {
945 return $result;
946 }
947
948 $filteredResult = [];
949 foreach ($this->bySetPos as $setPos) {
950
951 if ($setPos < 0) {
952 $setPos = count($result) + ($setPos + 1);
953 }
954 if (isset($result[$setPos - 1])) {
955 $filteredResult[] = $result[$setPos - 1];
956 }
957 }
958
959 sort($filteredResult, SORT_NUMERIC);
960 return $filteredResult;
961
962 }
963
969 protected $dayMap = [
970 'SU' => 0,
971 'MO' => 1,
972 'TU' => 2,
973 'WE' => 3,
974 'TH' => 4,
975 'FR' => 5,
976 'SA' => 6,
977 ];
978
979 protected function getHours() {
980
981 $recurrenceHours = [];
982 foreach ($this->byHour as $byHour) {
983 $recurrenceHours[] = $byHour;
984 }
985
986 return $recurrenceHours;
987 }
988
989 protected function getDays() {
990
991 $recurrenceDays = [];
992 foreach ($this->byDay as $byDay) {
993
994 // The day may be preceeded with a positive (+n) or
995 // negative (-n) integer. However, this does not make
996 // sense in 'weekly' so we ignore it here.
997 $recurrenceDays[] = $this->dayMap[substr($byDay, -2)];
998
999 }
1000
1001 return $recurrenceDays;
1002 }
1003
1004 protected function getMonths() {
1005
1006 $recurrenceMonths = [];
1007 foreach ($this->byMonth as $byMonth) {
1008 $recurrenceMonths[] = $byMonth;
1009 }
1010
1011 return $recurrenceMonths;
1012 }
1013}
$result
An exception for terminatinating execution or to throw for unit testing.
static parse($date, $referenceTz=null)
Parses either a Date or DateTime, or Duration value.
This exception is thrown whenever an invalid value is found anywhere in a iCalendar or vCard object.
static stringToArray($value)
Parses an RRULE value string, and turns it into a struct-ish array.
Definition: Recur.php:209
nextDaily()
Does the processing for advancing the iterator for daily frequency.
key()
Returns the current item number.
nextMonthly()
Does the processing for advancing the iterator for monthly frequency.
nextHourly()
Does the processing for advancing the iterator for hourly frequency.
next()
Goes on to the next iteration.
isInfinite()
Returns true if this recurring event never ends.
getMonthlyOccurrences()
Returns all the occurrences for a monthly frequency with a 'byDay' or 'byMonthDay' expansion for the ...
fastForward(DateTimeInterface $dt)
This method allows you to quickly go to the next occurrence after the specified date.
__construct($rrule, DateTimeInterface $start)
Creates the Iterator.
valid()
Returns whether the current item is a valid item for the recurrence iterator.
nextWeekly()
Does the processing for advancing the iterator for weekly frequency.
nextYearly()
Does the processing for advancing the iterator for yearly frequency.
parseRRule($rrule)
This method receives a string from an RRULE property, and populates this class with all the values.
rewind()
Resets the iterator.
$key
Definition: croninfo.php:18
$start
Definition: bench.php:8