1<?php
2
3namespace Sabre\VObject\Recur;
4
5use DateTime;
6use InvalidArgumentException;
7use Iterator;
8use Sabre\VObject\DateTimeParser;
9use 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) fruux GmbH (https://fruux.com/)
22 * @author Evert Pot (http://evertpot.com/)
23 * @license http://sabre.io/license/ Modified BSD License
24 */
25class 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                    // might not exist, fx. -5th tuesday
791                    if (isset($dayHits[count($dayHits) + $offset])) {
792                        $byDayResults[] = $dayHits[count($dayHits) + $offset];
793                    }
794                }
795            } else {
796                // There was no counter (first, second, last wednesdays), so we
797                // just need to add the all to the list).
798                $byDayResults = array_merge($byDayResults, $dayHits);
799
800            }
801
802        }
803
804        $byMonthDayResults = array();
805        if ($this->byMonthDay) foreach($this->byMonthDay as $monthDay) {
806
807            // Removing values that are out of range for this month
808            if ($monthDay > $startDate->format('t') ||
809                $monthDay < 0-$startDate->format('t')) {
810                    continue;
811            }
812            if ($monthDay>0) {
813                $byMonthDayResults[] = $monthDay;
814            } else {
815                // Negative values
816                $byMonthDayResults[] = $startDate->format('t') + 1 + $monthDay;
817            }
818        }
819
820        // If there was just byDay or just byMonthDay, they just specify our
821        // (almost) final list. If both were provided, then byDay limits the
822        // list.
823        if ($this->byMonthDay && $this->byDay) {
824            $result = array_intersect($byMonthDayResults, $byDayResults);
825        } elseif ($this->byMonthDay) {
826            $result = $byMonthDayResults;
827        } else {
828            $result = $byDayResults;
829        }
830        $result = array_unique($result);
831        sort($result, SORT_NUMERIC);
832
833        // The last thing that needs checking is the BYSETPOS. If it's set, it
834        // means only certain items in the set survive the filter.
835        if (!$this->bySetPos) {
836            return $result;
837        }
838
839        $filteredResult = array();
840        foreach($this->bySetPos as $setPos) {
841
842            if ($setPos<0) {
843                $setPos = count($result)+($setPos+1);
844            }
845            if (isset($result[$setPos-1])) {
846                $filteredResult[] = $result[$setPos-1];
847            }
848        }
849
850        sort($filteredResult, SORT_NUMERIC);
851        return $filteredResult;
852
853    }
854
855    /**
856     * Simple mapping from iCalendar day names to day numbers
857     *
858     * @var array
859     */
860    protected $dayMap = array(
861        'SU' => 0,
862        'MO' => 1,
863        'TU' => 2,
864        'WE' => 3,
865        'TH' => 4,
866        'FR' => 5,
867        'SA' => 6,
868    );
869
870    protected function getHours()
871    {
872        $recurrenceHours = array();
873        foreach($this->byHour as $byHour) {
874            $recurrenceHours[] = $byHour;
875        }
876
877        return $recurrenceHours;
878    }
879
880    protected function getDays() {
881
882        $recurrenceDays = array();
883        foreach($this->byDay as $byDay) {
884
885            // The day may be preceeded with a positive (+n) or
886            // negative (-n) integer. However, this does not make
887            // sense in 'weekly' so we ignore it here.
888            $recurrenceDays[] = $this->dayMap[substr($byDay,-2)];
889
890        }
891
892        return $recurrenceDays;
893    }
894
895    protected function getMonths() {
896
897        $recurrenceMonths = array();
898        foreach($this->byMonth as $byMonth) {
899            $recurrenceMonths[] = $byMonth;
900        }
901
902        return $recurrenceMonths;
903    }
904}
905