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