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