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