comparison vendor/sabre/vobject/lib/Recur/RRuleIterator.php @ 7:430dbd5346f7

vendor sabre as distributed
author Charlie Root
date Sat, 13 Jan 2018 09:06:10 -0500
parents
children
comparison
equal deleted inserted replaced
6:cec75ba50afc 7:430dbd5346f7
1 <?php
2
3 namespace Sabre\VObject\Recur;
4
5 use DateTime;
6 use InvalidArgumentException;
7 use Iterator;
8 use Sabre\VObject\DateTimeParser;
9 use Sabre\VObject\Property;
10
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) 2011-2015 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 * Creates the Iterator
29 *
30 * @param string|array $rrule
31 * @param DateTime $start
32 */
33 public function __construct($rrule, DateTime $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 public function current() {
44
45 if (!$this->valid()) return null;
46 return clone $this->currentDate;
47
48 }
49
50 /**
51 * Returns the current item number
52 *
53 * @return int
54 */
55 public function key() {
56
57 return $this->counter;
58
59 }
60
61 /**
62 * Returns whether the current item is a valid item for the recurrence
63 * iterator. This will return false if we've gone beyond the UNTIL or COUNT
64 * statements.
65 *
66 * @return bool
67 */
68 public 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
77 /**
78 * Resets the iterator
79 *
80 * @return void
81 */
82 public function rewind() {
83
84 $this->currentDate = clone $this->startDate;
85 $this->counter = 0;
86
87 }
88
89 /**
90 * Goes on to the next iteration
91 *
92 * @return void
93 */
94 public function next() {
95
96 $previousStamp = $this->currentDate->getTimeStamp();
97
98 // Otherwise, we find the next event in the normal RRULE
99 // sequence.
100 switch($this->frequency) {
101
102 case 'hourly' :
103 $this->nextHourly();
104 break;
105
106 case 'daily' :
107 $this->nextDaily();
108 break;
109
110 case 'weekly' :
111 $this->nextWeekly();
112 break;
113
114 case 'monthly' :
115 $this->nextMonthly();
116 break;
117
118 case 'yearly' :
119 $this->nextYearly();
120 break;
121
122 }
123 $this->counter++;
124
125 }
126
127 /* End of Iterator implementation }}} */
128
129 /**
130 * Returns true if this recurring event never ends.
131 *
132 * @return bool
133 */
134 public function isInfinite() {
135
136 return !$this->count && !$this->until;
137
138 }
139
140 /**
141 * This method allows you to quickly go to the next occurrence after the
142 * specified date.
143 *
144 * @param DateTime $dt
145 * @return void
146 */
147 public function fastForward(\DateTime $dt) {
148
149 while($this->valid() && $this->currentDate < $dt ) {
150 $this->next();
151 }
152
153 }
154
155 /**
156 * The reference start date/time for the rrule.
157 *
158 * All calculations are based on this initial date.
159 *
160 * @var DateTime
161 */
162 protected $startDate;
163
164 /**
165 * The date of the current iteration. You can get this by calling
166 * ->current().
167 *
168 * @var DateTime
169 */
170 protected $currentDate;
171
172 /**
173 * Frequency is one of: secondly, minutely, hourly, daily, weekly, monthly,
174 * yearly.
175 *
176 * @var string
177 */
178 protected $frequency;
179
180 /**
181 * The number of recurrences, or 'null' if infinitely recurring.
182 *
183 * @var int
184 */
185 protected $count;
186
187 /**
188 * The interval.
189 *
190 * If for example frequency is set to daily, interval = 2 would mean every
191 * 2 days.
192 *
193 * @var int
194 */
195 protected $interval = 1;
196
197 /**
198 * The last instance of this recurrence, inclusively
199 *
200 * @var \DateTime|null
201 */
202 protected $until;
203
204 /**
205 * Which seconds to recur.
206 *
207 * This is an array of integers (between 0 and 60)
208 *
209 * @var array
210 */
211 protected $bySecond;
212
213 /**
214 * Which minutes to recur
215 *
216 * This is an array of integers (between 0 and 59)
217 *
218 * @var array
219 */
220 protected $byMinute;
221
222 /**
223 * Which hours to recur
224 *
225 * This is an array of integers (between 0 and 23)
226 *
227 * @var array
228 */
229 protected $byHour;
230
231 /**
232 * The current item in the list.
233 *
234 * You can get this number with the key() method.
235 *
236 * @var int
237 */
238 protected $counter = 0;
239
240 /**
241 * Which weekdays to recur.
242 *
243 * This is an array of weekdays
244 *
245 * This may also be preceeded by a positive or negative integer. If present,
246 * this indicates the nth occurrence of a specific day within the monthly or
247 * yearly rrule. For instance, -2TU indicates the second-last tuesday of
248 * the month, or year.
249 *
250 * @var array
251 */
252 protected $byDay;
253
254 /**
255 * Which days of the month to recur
256 *
257 * This is an array of days of the months (1-31). The value can also be
258 * negative. -5 for instance means the 5th last day of the month.
259 *
260 * @var array
261 */
262 protected $byMonthDay;
263
264 /**
265 * Which days of the year to recur.
266 *
267 * This is an array with days of the year (1 to 366). The values can also
268 * be negative. For instance, -1 will always represent the last day of the
269 * year. (December 31st).
270 *
271 * @var array
272 */
273 protected $byYearDay;
274
275 /**
276 * Which week numbers to recur.
277 *
278 * This is an array of integers from 1 to 53. The values can also be
279 * negative. -1 will always refer to the last week of the year.
280 *
281 * @var array
282 */
283 protected $byWeekNo;
284
285 /**
286 * Which months to recur.
287 *
288 * This is an array of integers from 1 to 12.
289 *
290 * @var array
291 */
292 protected $byMonth;
293
294 /**
295 * Which items in an existing st to recur.
296 *
297 * These numbers work together with an existing by* rule. It specifies
298 * exactly which items of the existing by-rule to filter.
299 *
300 * Valid values are 1 to 366 and -1 to -366. As an example, this can be
301 * used to recur the last workday of the month.
302 *
303 * This would be done by setting frequency to 'monthly', byDay to
304 * 'MO,TU,WE,TH,FR' and bySetPos to -1.
305 *
306 * @var array
307 */
308 protected $bySetPos;
309
310 /**
311 * When the week starts.
312 *
313 * @var string
314 */
315 protected $weekStart = 'MO';
316
317 /* Functions that advance the iterator {{{ */
318
319 /**
320 * Does the processing for advancing the iterator for hourly frequency.
321 *
322 * @return void
323 */
324 protected function nextHourly() {
325
326 $this->currentDate->modify('+' . $this->interval . ' hours');
327
328 }
329
330 /**
331 * Does the processing for advancing the iterator for daily frequency.
332 *
333 * @return void
334 */
335 protected function nextDaily() {
336
337 if (!$this->byHour && !$this->byDay) {
338 $this->currentDate->modify('+' . $this->interval . ' days');
339 return;
340 }
341
342 if (isset($this->byHour)) {
343 $recurrenceHours = $this->getHours();
344 }
345
346 if (isset($this->byDay)) {
347 $recurrenceDays = $this->getDays();
348 }
349
350 if (isset($this->byMonth)) {
351 $recurrenceMonths = $this->getMonths();
352 }
353
354 do {
355 if ($this->byHour) {
356 if ($this->currentDate->format('G') == '23') {
357 // to obey the interval rule
358 $this->currentDate->modify('+' . $this->interval-1 . ' days');
359 }
360
361 $this->currentDate->modify('+1 hours');
362
363 } else {
364 $this->currentDate->modify('+' . $this->interval . ' days');
365
366 }
367
368 // Current month of the year
369 $currentMonth = $this->currentDate->format('n');
370
371 // Current day of the week
372 $currentDay = $this->currentDate->format('w');
373
374 // Current hour of the day
375 $currentHour = $this->currentDate->format('G');
376
377 } while (
378 ($this->byDay && !in_array($currentDay, $recurrenceDays)) ||
379 ($this->byHour && !in_array($currentHour, $recurrenceHours)) ||
380 ($this->byMonth && !in_array($currentMonth, $recurrenceMonths))
381 );
382
383 }
384
385 /**
386 * Does the processing for advancing the iterator for weekly frequency.
387 *
388 * @return void
389 */
390 protected function nextWeekly() {
391
392 if (!$this->byHour && !$this->byDay) {
393 $this->currentDate->modify('+' . $this->interval . ' weeks');
394 return;
395 }
396
397 if ($this->byHour) {
398 $recurrenceHours = $this->getHours();
399 }
400
401 if ($this->byDay) {
402 $recurrenceDays = $this->getDays();
403 }
404
405 // First day of the week:
406 $firstDay = $this->dayMap[$this->weekStart];
407
408 do {
409
410 if ($this->byHour) {
411 $this->currentDate->modify('+1 hours');
412 } else {
413 $this->currentDate->modify('+1 days');
414 }
415
416 // Current day of the week
417 $currentDay = (int) $this->currentDate->format('w');
418
419 // Current hour of the day
420 $currentHour = (int) $this->currentDate->format('G');
421
422 // We need to roll over to the next week
423 if ($currentDay === $firstDay && (!$this->byHour || $currentHour == '0')) {
424 $this->currentDate->modify('+' . $this->interval-1 . ' weeks');
425
426 // We need to go to the first day of this week, but only if we
427 // are not already on this first day of this week.
428 if($this->currentDate->format('w') != $firstDay) {
429 $this->currentDate->modify('last ' . $this->dayNames[$this->dayMap[$this->weekStart]]);
430 }
431 }
432
433 // We have a match
434 } while (($this->byDay && !in_array($currentDay, $recurrenceDays)) || ($this->byHour && !in_array($currentHour, $recurrenceHours)));
435 }
436
437 /**
438 * Does the processing for advancing the iterator for monthly frequency.
439 *
440 * @return void
441 */
442 protected function nextMonthly() {
443
444 $currentDayOfMonth = $this->currentDate->format('j');
445 if (!$this->byMonthDay && !$this->byDay) {
446
447 // If the current day is higher than the 28th, rollover can
448 // occur to the next month. We Must skip these invalid
449 // entries.
450 if ($currentDayOfMonth < 29) {
451 $this->currentDate->modify('+' . $this->interval . ' months');
452 } else {
453 $increase = 0;
454 do {
455 $increase++;
456 $tempDate = clone $this->currentDate;
457 $tempDate->modify('+ ' . ($this->interval*$increase) . ' months');
458 } while ($tempDate->format('j') != $currentDayOfMonth);
459 $this->currentDate = $tempDate;
460 }
461 return;
462 }
463
464 while(true) {
465
466 $occurrences = $this->getMonthlyOccurrences();
467
468 foreach($occurrences as $occurrence) {
469
470 // The first occurrence thats higher than the current
471 // day of the month wins.
472 if ($occurrence > $currentDayOfMonth) {
473 break 2;
474 }
475
476 }
477
478 // If we made it all the way here, it means there were no
479 // valid occurrences, and we need to advance to the next
480 // month.
481 //
482 // This line does not currently work in hhvm. Temporary workaround
483 // follows:
484 // $this->currentDate->modify('first day of this month');
485 $this->currentDate = new \DateTime($this->currentDate->format('Y-m-1 H:i:s'), $this->currentDate->getTimezone());
486 // end of workaround
487 $this->currentDate->modify('+ ' . $this->interval . ' months');
488
489 // This goes to 0 because we need to start counting at the
490 // beginning.
491 $currentDayOfMonth = 0;
492
493 }
494
495 $this->currentDate->setDate($this->currentDate->format('Y'), $this->currentDate->format('n'), $occurrence);
496
497 }
498
499 /**
500 * Does the processing for advancing the iterator for yearly frequency.
501 *
502 * @return void
503 */
504 protected function nextYearly() {
505
506 $currentMonth = $this->currentDate->format('n');
507 $currentYear = $this->currentDate->format('Y');
508 $currentDayOfMonth = $this->currentDate->format('j');
509
510 // No sub-rules, so we just advance by year
511 if (!$this->byMonth) {
512
513 // Unless it was a leap day!
514 if ($currentMonth==2 && $currentDayOfMonth==29) {
515
516 $counter = 0;
517 do {
518 $counter++;
519 // Here we increase the year count by the interval, until
520 // we hit a date that's also in a leap year.
521 //
522 // We could just find the next interval that's dividable by
523 // 4, but that would ignore the rule that there's no leap
524 // year every year that's dividable by a 100, but not by
525 // 400. (1800, 1900, 2100). So we just rely on the datetime
526 // functions instead.
527 $nextDate = clone $this->currentDate;
528 $nextDate->modify('+ ' . ($this->interval*$counter) . ' years');
529 } while ($nextDate->format('n')!=2);
530 $this->currentDate = $nextDate;
531
532 return;
533
534 }
535
536 // The easiest form
537 $this->currentDate->modify('+' . $this->interval . ' years');
538 return;
539
540 }
541
542 $currentMonth = $this->currentDate->format('n');
543 $currentYear = $this->currentDate->format('Y');
544 $currentDayOfMonth = $this->currentDate->format('j');
545
546 $advancedToNewMonth = false;
547
548 // If we got a byDay or getMonthDay filter, we must first expand
549 // further.
550 if ($this->byDay || $this->byMonthDay) {
551
552 while(true) {
553
554 $occurrences = $this->getMonthlyOccurrences();
555
556 foreach($occurrences as $occurrence) {
557
558 // The first occurrence that's higher than the current
559 // day of the month wins.
560 // If we advanced to the next month or year, the first
561 // occurrence is always correct.
562 if ($occurrence > $currentDayOfMonth || $advancedToNewMonth) {
563 break 2;
564 }
565
566 }
567
568 // If we made it here, it means we need to advance to
569 // the next month or year.
570 $currentDayOfMonth = 1;
571 $advancedToNewMonth = true;
572 do {
573
574 $currentMonth++;
575 if ($currentMonth>12) {
576 $currentYear+=$this->interval;
577 $currentMonth = 1;
578 }
579 } while (!in_array($currentMonth, $this->byMonth));
580
581 $this->currentDate->setDate($currentYear, $currentMonth, $currentDayOfMonth);
582
583 }
584
585 // If we made it here, it means we got a valid occurrence
586 $this->currentDate->setDate($currentYear, $currentMonth, $occurrence);
587 return;
588
589 } else {
590
591 // These are the 'byMonth' rules, if there are no byDay or
592 // byMonthDay sub-rules.
593 do {
594
595 $currentMonth++;
596 if ($currentMonth>12) {
597 $currentYear+=$this->interval;
598 $currentMonth = 1;
599 }
600 } while (!in_array($currentMonth, $this->byMonth));
601 $this->currentDate->setDate($currentYear, $currentMonth, $currentDayOfMonth);
602
603 return;
604
605 }
606
607 }
608
609 /* }}} */
610
611 /**
612 * This method receives a string from an RRULE property, and populates this
613 * class with all the values.
614 *
615 * @param string|array $rrule
616 * @return void
617 */
618 protected function parseRRule($rrule) {
619
620 if (is_string($rrule)) {
621 $rrule = Property\ICalendar\Recur::stringToArray($rrule);
622 }
623
624 foreach($rrule as $key=>$value) {
625
626 $key = strtoupper($key);
627 switch($key) {
628
629 case 'FREQ' :
630 $value = strtolower($value);
631 if (!in_array(
632 $value,
633 array('secondly','minutely','hourly','daily','weekly','monthly','yearly')
634 )) {
635 throw new InvalidArgumentException('Unknown value for FREQ=' . strtoupper($value));
636 }
637 $this->frequency = $value;
638 break;
639
640 case 'UNTIL' :
641 $this->until = DateTimeParser::parse($value, $this->startDate->getTimezone());
642
643 // In some cases events are generated with an UNTIL=
644 // parameter before the actual start of the event.
645 //
646 // Not sure why this is happening. We assume that the
647 // intention was that the event only recurs once.
648 //
649 // So we are modifying the parameter so our code doesn't
650 // break.
651 if($this->until < $this->startDate) {
652 $this->until = $this->startDate;
653 }
654 break;
655
656 case 'INTERVAL' :
657 // No break
658
659 case 'COUNT' :
660 $val = (int)$value;
661 if ($val < 1) {
662 throw new \InvalidArgumentException(strtoupper($key) . ' in RRULE must be a positive integer!');
663 }
664 $key = strtolower($key);
665 $this->$key = $val;
666 break;
667
668 case 'BYSECOND' :
669 $this->bySecond = (array)$value;
670 break;
671
672 case 'BYMINUTE' :
673 $this->byMinute = (array)$value;
674 break;
675
676 case 'BYHOUR' :
677 $this->byHour = (array)$value;
678 break;
679
680 case 'BYDAY' :
681 $value = (array)$value;
682 foreach($value as $part) {
683 if (!preg_match('#^ (-|\+)? ([1-5])? (MO|TU|WE|TH|FR|SA|SU) $# xi', $part)) {
684 throw new \InvalidArgumentException('Invalid part in BYDAY clause: ' . $part);
685 }
686 }
687 $this->byDay = $value;
688 break;
689
690 case 'BYMONTHDAY' :
691 $this->byMonthDay = (array)$value;
692 break;
693
694 case 'BYYEARDAY' :
695 $this->byYearDay = (array)$value;
696 break;
697
698 case 'BYWEEKNO' :
699 $this->byWeekNo = (array)$value;
700 break;
701
702 case 'BYMONTH' :
703 $this->byMonth = (array)$value;
704 break;
705
706 case 'BYSETPOS' :
707 $this->bySetPos = (array)$value;
708 break;
709
710 case 'WKST' :
711 $this->weekStart = strtoupper($value);
712 break;
713
714 default:
715 throw new \InvalidArgumentException('Not supported: ' . strtoupper($key));
716
717 }
718
719 }
720
721 }
722
723 /**
724 * Mappings between the day number and english day name.
725 *
726 * @var array
727 */
728 protected $dayNames = array(
729 0 => 'Sunday',
730 1 => 'Monday',
731 2 => 'Tuesday',
732 3 => 'Wednesday',
733 4 => 'Thursday',
734 5 => 'Friday',
735 6 => 'Saturday',
736 );
737
738 /**
739 * Returns all the occurrences for a monthly frequency with a 'byDay' or
740 * 'byMonthDay' expansion for the current month.
741 *
742 * The returned list is an array of integers with the day of month (1-31).
743 *
744 * @return array
745 */
746 protected function getMonthlyOccurrences() {
747
748 $startDate = clone $this->currentDate;
749
750 $byDayResults = array();
751
752 // Our strategy is to simply go through the byDays, advance the date to
753 // that point and add it to the results.
754 if ($this->byDay) foreach($this->byDay as $day) {
755
756 $dayName = $this->dayNames[$this->dayMap[substr($day,-2)]];
757
758
759 // Dayname will be something like 'wednesday'. Now we need to find
760 // all wednesdays in this month.
761 $dayHits = array();
762
763 // workaround for missing 'first day of the month' support in hhvm
764 $checkDate = new \DateTime($startDate->format('Y-m-1'));
765 // workaround modify always advancing the date even if the current day is a $dayName in hhvm
766 if ($checkDate->format('l') !== $dayName) {
767 $checkDate->modify($dayName);
768 }
769
770 do {
771 $dayHits[] = $checkDate->format('j');
772 $checkDate->modify('next ' . $dayName);
773 } while ($checkDate->format('n') === $startDate->format('n'));
774
775 // So now we have 'all wednesdays' for month. It is however
776 // possible that the user only really wanted the 1st, 2nd or last
777 // wednesday.
778 if (strlen($day)>2) {
779 $offset = (int)substr($day,0,-2);
780
781 if ($offset>0) {
782 // It is possible that the day does not exist, such as a
783 // 5th or 6th wednesday of the month.
784 if (isset($dayHits[$offset-1])) {
785 $byDayResults[] = $dayHits[$offset-1];
786 }
787 } else {
788
789 // if it was negative we count from the end of the array
790 $byDayResults[] = $dayHits[count($dayHits) + $offset];
791 }
792 } else {
793 // There was no counter (first, second, last wednesdays), so we
794 // just need to add the all to the list).
795 $byDayResults = array_merge($byDayResults, $dayHits);
796
797 }
798
799 }
800
801 $byMonthDayResults = array();
802 if ($this->byMonthDay) foreach($this->byMonthDay as $monthDay) {
803
804 // Removing values that are out of range for this month
805 if ($monthDay > $startDate->format('t') ||
806 $monthDay < 0-$startDate->format('t')) {
807 continue;
808 }
809 if ($monthDay>0) {
810 $byMonthDayResults[] = $monthDay;
811 } else {
812 // Negative values
813 $byMonthDayResults[] = $startDate->format('t') + 1 + $monthDay;
814 }
815 }
816
817 // If there was just byDay or just byMonthDay, they just specify our
818 // (almost) final list. If both were provided, then byDay limits the
819 // list.
820 if ($this->byMonthDay && $this->byDay) {
821 $result = array_intersect($byMonthDayResults, $byDayResults);
822 } elseif ($this->byMonthDay) {
823 $result = $byMonthDayResults;
824 } else {
825 $result = $byDayResults;
826 }
827 $result = array_unique($result);
828 sort($result, SORT_NUMERIC);
829
830 // The last thing that needs checking is the BYSETPOS. If it's set, it
831 // means only certain items in the set survive the filter.
832 if (!$this->bySetPos) {
833 return $result;
834 }
835
836 $filteredResult = array();
837 foreach($this->bySetPos as $setPos) {
838
839 if ($setPos<0) {
840 $setPos = count($result)-($setPos+1);
841 }
842 if (isset($result[$setPos-1])) {
843 $filteredResult[] = $result[$setPos-1];
844 }
845 }
846
847 sort($filteredResult, SORT_NUMERIC);
848 return $filteredResult;
849
850 }
851
852 /**
853 * Simple mapping from iCalendar day names to day numbers
854 *
855 * @var array
856 */
857 protected $dayMap = array(
858 'SU' => 0,
859 'MO' => 1,
860 'TU' => 2,
861 'WE' => 3,
862 'TH' => 4,
863 'FR' => 5,
864 'SA' => 6,
865 );
866
867 protected function getHours()
868 {
869 $recurrenceHours = array();
870 foreach($this->byHour as $byHour) {
871 $recurrenceHours[] = $byHour;
872 }
873
874 return $recurrenceHours;
875 }
876
877 protected function getDays() {
878
879 $recurrenceDays = array();
880 foreach($this->byDay as $byDay) {
881
882 // The day may be preceeded with a positive (+n) or
883 // negative (-n) integer. However, this does not make
884 // sense in 'weekly' so we ignore it here.
885 $recurrenceDays[] = $this->dayMap[substr($byDay,-2)];
886
887 }
888
889 return $recurrenceDays;
890 }
891
892 protected function getMonths() {
893
894 $recurrenceMonths = array();
895 foreach($this->byMonth as $byMonth) {
896 $recurrenceMonths[] = $byMonth;
897 }
898
899 return $recurrenceMonths;
900 }
901 }