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