3 final class PhutilCalendarRecurrenceRule
4 extends PhutilCalendarRecurrenceSource
{
6 private $startDateTime;
8 private $frequencyScale;
10 private $bySecond = array();
11 private $byMinute = array();
12 private $byHour = array();
13 private $byDay = array();
14 private $byMonthDay = array();
15 private $byYearDay = array();
16 private $byWeekNumber = array();
17 private $byMonth = array();
18 private $bySetPosition = array();
19 private $weekStart = self
::WEEKDAY_MONDAY
;
23 private $cursorSecond;
24 private $cursorMinute;
26 private $cursorHourState;
28 private $cursorWeekday;
29 private $cursorWeekState;
31 private $cursorDayState;
53 private $activeSet = array();
54 private $nextSet = array();
55 private $minimumEpoch;
57 const FREQUENCY_SECONDLY
= 'SECONDLY';
58 const FREQUENCY_MINUTELY
= 'MINUTELY';
59 const FREQUENCY_HOURLY
= 'HOURLY';
60 const FREQUENCY_DAILY
= 'DAILY';
61 const FREQUENCY_WEEKLY
= 'WEEKLY';
62 const FREQUENCY_MONTHLY
= 'MONTHLY';
63 const FREQUENCY_YEARLY
= 'YEARLY';
65 const SCALE_SECONDLY
= 1;
66 const SCALE_MINUTELY
= 2;
67 const SCALE_HOURLY
= 3;
68 const SCALE_DAILY
= 4;
69 const SCALE_WEEKLY
= 5;
70 const SCALE_MONTHLY
= 6;
71 const SCALE_YEARLY
= 7;
73 const WEEKDAY_SUNDAY
= 'SU';
74 const WEEKDAY_MONDAY
= 'MO';
75 const WEEKDAY_TUESDAY
= 'TU';
76 const WEEKDAY_WEDNESDAY
= 'WE';
77 const WEEKDAY_THURSDAY
= 'TH';
78 const WEEKDAY_FRIDAY
= 'FR';
79 const WEEKDAY_SATURDAY
= 'SA';
81 const WEEKINDEX_SUNDAY
= 0;
82 const WEEKINDEX_MONDAY
= 1;
83 const WEEKINDEX_TUESDAY
= 2;
84 const WEEKINDEX_WEDNESDAY
= 3;
85 const WEEKINDEX_THURSDAY
= 4;
86 const WEEKINDEX_FRIDAY
= 5;
87 const WEEKINDEX_SATURDAY
= 6;
89 public function toDictionary() {
92 $parts['FREQ'] = $this->getFrequency();
94 $interval = $this->getInterval();
96 $parts['INTERVAL'] = $interval;
99 $by_second = $this->getBySecond();
101 $parts['BYSECOND'] = $by_second;
104 $by_minute = $this->getByMinute();
106 $parts['BYMINUTE'] = $by_minute;
109 $by_hour = $this->getByHour();
111 $parts['BYHOUR'] = $by_hour;
114 $by_day = $this->getByDay();
116 $parts['BYDAY'] = $by_day;
119 $by_month = $this->getByMonth();
121 $parts['BYMONTH'] = $by_month;
124 $by_monthday = $this->getByMonthDay();
126 $parts['BYMONTHDAY'] = $by_monthday;
129 $by_yearday = $this->getByYearDay();
131 $parts['BYYEARDAY'] = $by_yearday;
134 $by_weekno = $this->getByWeekNumber();
136 $parts['BYWEEKNO'] = $by_weekno;
139 $by_setpos = $this->getBySetPosition();
141 $parts['BYSETPOS'] = $by_setpos;
144 $wkst = $this->getWeekStart();
145 if ($wkst != self
::WEEKDAY_MONDAY
) {
146 $parts['WKST'] = $wkst;
149 $count = $this->getCount();
151 $parts['COUNT'] = $count;
154 $until = $this->getUntil();
156 $parts['UNTIL'] = $until->getISO8601();
162 public static function newFromDictionary(array $dict) {
164 if ($expect === null) {
165 $expect = array_fuse(
184 foreach ($dict as $key => $value) {
185 if (empty($expect[$key])) {
188 'RRULE dictionary includes unknown key "%s". Expected keys '.
191 implode(', ', array_keys($expect))));
195 $rrule = id(new self())
196 ->setFrequency(idx($dict, 'FREQ'))
197 ->setInterval(idx($dict, 'INTERVAL', 1))
198 ->setBySecond(idx($dict, 'BYSECOND', array()))
199 ->setByMinute(idx($dict, 'BYMINUTE', array()))
200 ->setByHour(idx($dict, 'BYHOUR', array()))
201 ->setByDay(idx($dict, 'BYDAY', array()))
202 ->setByMonth(idx($dict, 'BYMONTH', array()))
203 ->setByMonthDay(idx($dict, 'BYMONTHDAY', array()))
204 ->setByYearDay(idx($dict, 'BYYEARDAY', array()))
205 ->setByWeekNumber(idx($dict, 'BYWEEKNO', array()))
206 ->setBySetPosition(idx($dict, 'BYSETPOS', array()))
207 ->setWeekStart(idx($dict, 'WKST', self
::WEEKDAY_MONDAY
));
209 $count = idx($dict, 'COUNT');
211 $rrule->setCount($count);
214 $until = idx($dict, 'UNTIL');
216 $until = PhutilCalendarAbsoluteDateTime
::newFromISO8601($until);
217 $rrule->setUntil($until);
223 public function toRRULE() {
224 $dict = $this->toDictionary();
227 foreach ($dict as $key => $value) {
228 if (is_array($value)) {
229 $value = implode(',', $value);
231 $parts[] = "{$key}={$value}";
234 return implode(';', $parts);
237 public static function newFromRRULE($rrule) {
238 $parts = explode(';', $rrule);
241 foreach ($parts as $part) {
242 list($key, $value) = explode('=', $part, 2);
251 $value = explode(',', $value);
254 $dict[$key] = $value;
257 $int_lists = array_fuse(
259 // NOTE: "BYDAY" is absent, and takes a list like "MO, TU, WE".
270 $int_values = array_fuse(
276 foreach ($dict as $key => $value) {
277 if (isset($int_values[$key])) {
278 // None of these values may be negative.
279 if (!preg_match('/^\d+\z/', $value)) {
282 'Unexpected value "%s" in "%s" RULE property: expected an '.
287 $dict[$key] = (int)$value;
290 if (isset($int_lists[$key])) {
291 foreach ($value as $k => $v) {
292 if (!preg_match('/^-?\d+\z/', $v)) {
295 'Unexpected value "%s" in "%s" RRULE property: expected '.
300 $value[$k] = (int)$v;
302 $dict[$key] = $value;
306 return self
::newFromDictionary($dict);
309 private static function getAllWeekdayConstants() {
310 return array_keys(self
::getWeekdayIndexMap());
313 private static function getWeekdayIndexMap() {
315 self
::WEEKDAY_SUNDAY
=> self
::WEEKINDEX_SUNDAY
,
316 self
::WEEKDAY_MONDAY
=> self
::WEEKINDEX_MONDAY
,
317 self
::WEEKDAY_TUESDAY
=> self
::WEEKINDEX_TUESDAY
,
318 self
::WEEKDAY_WEDNESDAY
=> self
::WEEKINDEX_WEDNESDAY
,
319 self
::WEEKDAY_THURSDAY
=> self
::WEEKINDEX_THURSDAY
,
320 self
::WEEKDAY_FRIDAY
=> self
::WEEKINDEX_FRIDAY
,
321 self
::WEEKDAY_SATURDAY
=> self
::WEEKINDEX_SATURDAY
,
327 private static function getWeekdayIndex($weekday) {
328 $map = self
::getWeekdayIndexMap();
329 if (!isset($map[$weekday])) {
330 $constants = array_keys($map);
333 'Weekday "%s" is not a valid weekday constant. Valid constants '.
336 implode(', ', $constants)));
339 return $map[$weekday];
342 public function setStartDateTime(PhutilCalendarDateTime
$start) {
343 $this->startDateTime
= $start;
347 public function getStartDateTime() {
348 return $this->startDateTime
;
351 public function setCount($count) {
355 'RRULE COUNT value "%s" is invalid: count must be at least 1.',
359 $this->count
= $count;
363 public function getCount() {
367 public function setUntil(PhutilCalendarDateTime
$until) {
368 $this->until
= $until;
372 public function getUntil() {
376 public function setFrequency($frequency) {
378 self
::FREQUENCY_SECONDLY
=> self
::SCALE_SECONDLY
,
379 self
::FREQUENCY_MINUTELY
=> self
::SCALE_MINUTELY
,
380 self
::FREQUENCY_HOURLY
=> self
::SCALE_HOURLY
,
381 self
::FREQUENCY_DAILY
=> self
::SCALE_DAILY
,
382 self
::FREQUENCY_WEEKLY
=> self
::SCALE_WEEKLY
,
383 self
::FREQUENCY_MONTHLY
=> self
::SCALE_MONTHLY
,
384 self
::FREQUENCY_YEARLY
=> self
::SCALE_YEARLY
,
387 if (empty($map[$frequency])) {
390 'RRULE FREQ "%s" is invalid. Valid frequencies are: %s.',
392 implode(', ', array_keys($map))));
395 $this->frequency
= $frequency;
396 $this->frequencyScale
= $map[$frequency];
401 public function getFrequency() {
402 return $this->frequency
;
405 public function getFrequencyScale() {
406 return $this->frequencyScale
;
409 public function setInterval($interval) {
410 if (!is_int($interval)) {
413 'RRULE INTERVAL "%s" is invalid: interval must be an integer.',
420 'RRULE INTERVAL "%s" is invalid: interval must be 1 or more.',
424 $this->interval
= $interval;
428 public function getInterval() {
429 return $this->interval
;
432 public function setBySecond(array $by_second) {
433 $this->assertByRange('BYSECOND', $by_second, 0, 60);
434 $this->bySecond
= array_fuse($by_second);
438 public function getBySecond() {
439 return $this->bySecond
;
442 public function setByMinute(array $by_minute) {
443 $this->assertByRange('BYMINUTE', $by_minute, 0, 59);
444 $this->byMinute
= array_fuse($by_minute);
448 public function getByMinute() {
449 return $this->byMinute
;
452 public function setByHour(array $by_hour) {
453 $this->assertByRange('BYHOUR', $by_hour, 0, 23);
454 $this->byHour
= array_fuse($by_hour);
458 public function getByHour() {
459 return $this->byHour
;
462 public function setByDay(array $by_day) {
463 $constants = self
::getAllWeekdayConstants();
464 $constants = implode('|', $constants);
466 $pattern = '/^(?:[+-]?([1-9]\d?))?('.$constants.')\z/';
467 foreach ($by_day as $key => $value) {
469 if (!preg_match($pattern, $value, $matches)) {
472 'RRULE BYDAY value "%s" is invalid: rule part must be in the '.
473 'expected form (like "MO", "-3TH", or "+2SU").',
477 // The maximum allowed value is 53, which corresponds to "the 53rd
478 // Monday every year" or similar when evaluated against a YEARLY rule.
481 $magnitude = (int)$matches[1];
482 if ($magnitude > $maximum) {
485 'RRULE BYDAY value "%s" has an offset with magnitude "%s", but '.
486 'the maximum permitted value is "%s".',
492 // Normalize "+3FR" into "3FR".
493 $by_day[$key] = ltrim($value, '+');
496 $this->byDay
= array_fuse($by_day);
500 public function getByDay() {
504 public function setByMonthDay(array $by_month_day) {
505 $this->assertByRange('BYMONTHDAY', $by_month_day, -31, 31, false);
506 $this->byMonthDay
= array_fuse($by_month_day);
510 public function getByMonthDay() {
511 return $this->byMonthDay
;
514 public function setByYearDay($by_year_day) {
515 $this->assertByRange('BYYEARDAY', $by_year_day, -366, 366, false);
516 $this->byYearDay
= array_fuse($by_year_day);
520 public function getByYearDay() {
521 return $this->byYearDay
;
524 public function setByMonth(array $by_month) {
525 $this->assertByRange('BYMONTH', $by_month, 1, 12);
526 $this->byMonth
= array_fuse($by_month);
530 public function getByMonth() {
531 return $this->byMonth
;
534 public function setByWeekNumber(array $by_week_number) {
535 $this->assertByRange('BYWEEKNO', $by_week_number, -53, 53, false);
536 $this->byWeekNumber
= array_fuse($by_week_number);
540 public function getByWeekNumber() {
541 return $this->byWeekNumber
;
544 public function setBySetPosition(array $by_set_position) {
545 $this->assertByRange('BYSETPOS', $by_set_position, -366, 366, false);
546 $this->bySetPosition
= $by_set_position;
550 public function getBySetPosition() {
551 return $this->bySetPosition
;
554 public function setWeekStart($week_start) {
555 // Make sure this is a valid weekday constant.
556 self
::getWeekdayIndex($week_start);
558 $this->weekStart
= $week_start;
562 public function getWeekStart() {
563 return $this->weekStart
;
566 public function resetSource() {
567 $frequency = $this->getFrequency();
569 if ($this->getByMonthDay()) {
570 switch ($frequency) {
571 case self
::FREQUENCY_WEEKLY
:
572 // RFC5545: "The BYMONTHDAY rule part MUST NOT be specified when the
573 // FREQ rule part is set to WEEKLY."
576 'RRULE specifies BYMONTHDAY with FREQ set to WEEKLY, which '.
577 'violates RFC5545.'));
585 if ($this->getByYearDay()) {
586 switch ($frequency) {
587 case self
::FREQUENCY_DAILY
:
588 case self
::FREQUENCY_WEEKLY
:
589 case self
::FREQUENCY_MONTHLY
:
590 // RFC5545: "The BYYEARDAY rule part MUST NOT be specified when the
591 // FREQ rule part is set to DAILY, WEEKLY, or MONTHLY."
594 'RRULE specifies BYYEARDAY with FREQ of DAILY, WEEKLY or '.
595 'MONTHLY, which violates RFC5545.'));
602 // RFC5545: "The BYDAY rule part MUST NOT be specified with a numeric
603 // value when the FREQ rule part is not set to MONTHLY or YEARLY."
604 // RFC5545: "Furthermore, the BYDAY rule part MUST NOT be specified with a
605 // numeric value with the FREQ rule part set to YEARLY when the BYWEEKNO
606 // rule part is specified."
609 $date = $this->getStartDateTime();
611 $this->cursorSecond
= $date->getSecond();
612 $this->cursorMinute
= $date->getMinute();
613 $this->cursorHour
= $date->getHour();
615 $this->cursorDay
= $date->getDay();
616 $this->cursorMonth
= $date->getMonth();
617 $this->cursorYear
= $date->getYear();
619 $year_map = $this->getYearMap($this->cursorYear
, $this->getWeekStart());
620 $key = $this->cursorMonth
.'M'.$this->cursorDay
.'D';
621 $this->cursorWeek
= $year_map['info'][$key]['week'];
622 $this->cursorWeekday
= $year_map['info'][$key]['weekday'];
624 $this->setSeconds
= array();
625 $this->setMinutes
= array();
626 $this->setHours
= array();
627 $this->setDays
= array();
628 $this->setMonths
= array();
629 $this->setYears
= array();
631 $this->stateSecond
= null;
632 $this->stateMinute
= null;
633 $this->stateHour
= null;
634 $this->stateDay
= null;
635 $this->stateWeek
= null;
636 $this->stateMonth
= null;
637 $this->stateYear
= null;
639 // If we have a BYSETPOS, we need to generate the entire set before we
640 // can filter it and return results. Normally, we start generating at
641 // the start date, but we need to go back one interval to generate
642 // BYSETPOS events so we can make sure the entire set is generated.
643 if ($this->getBySetPosition()) {
644 $interval = $this->getInterval();
645 switch ($frequency) {
646 case self
::FREQUENCY_YEARLY
:
647 $this->cursorYear
-= $interval;
649 case self
::FREQUENCY_MONTHLY
:
650 $this->cursorMonth
-= $interval;
651 $this->rewindMonth();
653 case self
::FREQUENCY_WEEKLY
:
654 $this->cursorWeek
-= $interval;
657 case self
::FREQUENCY_DAILY
:
658 $this->cursorDay
-= $interval;
661 case self
::FREQUENCY_HOURLY
:
662 $this->cursorHour
-= $interval;
665 case self
::FREQUENCY_MINUTELY
:
666 $this->cursorMinute
-= $interval;
667 $this->rewindMinute();
669 case self
::FREQUENCY_SECONDLY
:
673 'RRULE specifies BYSETPOS with FREQ "%s", but this is invalid.',
678 // We can generate events from before the cursor when evaluating rules
679 // with BYSETPOS or FREQ=WEEKLY.
680 $this->minimumEpoch
= $this->getStartDateTime()->getEpoch();
682 $cursor_state = array(
683 'year' => $this->cursorYear
,
684 'month' => $this->cursorMonth
,
685 'week' => $this->cursorWeek
,
686 'day' => $this->cursorDay
,
687 'hour' => $this->cursorHour
,
690 $this->cursorDayState
= $cursor_state;
691 $this->cursorWeekState
= $cursor_state;
692 $this->cursorHourState
= $cursor_state;
694 $by_hour = $this->getByHour();
695 $by_minute = $this->getByMinute();
696 $by_second = $this->getBySecond();
698 $scale = $this->getFrequencyScale();
700 // We return all-day events if the start date is an all-day event and we
701 // don't have more granular selectors or a more granular frequency.
702 $this->isAllDay
= $date->getIsAllDay()
706 && ($scale > self
::SCALE_HOURLY
);
709 public function getNextEvent($cursor) {
711 $event = $this->generateNextEvent();
716 $epoch = $event->getEpoch();
717 if ($this->minimumEpoch
) {
718 if ($epoch < $this->minimumEpoch
) {
723 if ($epoch < $cursor) {
733 private function generateNextEvent() {
734 if ($this->activeSet
) {
735 return array_pop($this->activeSet
);
738 $this->baseYear
= $this->cursorYear
;
740 $by_setpos = $this->getBySetPosition();
742 $old_state = $this->getSetPositionState();
745 while (!$this->activeSet
) {
746 $this->activeSet
= $this->nextSet
;
747 $this->nextSet
= array();
750 if ($this->isAllDay
) {
756 $result = id(new PhutilCalendarAbsoluteDateTime())
757 ->setTimezone($this->getStartDateTime()->getTimezone())
758 ->setViewerTimezone($this->getViewerTimezone())
759 ->setYear($this->stateYear
)
760 ->setMonth($this->stateMonth
)
761 ->setDay($this->stateDay
);
763 if ($this->isAllDay
) {
764 $result->setIsAllDay(true);
767 ->setHour($this->stateHour
)
768 ->setMinute($this->stateMinute
)
769 ->setSecond($this->stateSecond
);
772 // If we don't have BYSETPOS, we're all done. We put this into the
773 // set and will immediately return it.
775 $this->activeSet
[] = $result;
779 // Otherwise, check if we've completed a set. The set is complete if
780 // the state has moved past the span we were examining (for example,
781 // with a YEARLY event, if the state is now in the next year).
782 $new_state = $this->getSetPositionState();
783 if ($new_state == $old_state) {
784 $this->activeSet
[] = $result;
788 $this->activeSet
= $this->applySetPos($this->activeSet
, $by_setpos);
789 $this->activeSet
= array_reverse($this->activeSet
);
790 $this->nextSet
[] = $result;
791 $old_state = $new_state;
796 return array_pop($this->activeSet
);
800 protected function nextSecond() {
801 if ($this->setSeconds
) {
802 $this->stateSecond
= array_pop($this->setSeconds
);
806 $frequency = $this->getFrequency();
807 $interval = $this->getInterval();
808 $is_secondly = ($frequency == self
::FREQUENCY_SECONDLY
);
809 $by_second = $this->getBySecond();
811 while (!$this->setSeconds
) {
814 if ($is_secondly ||
$by_second) {
815 $seconds = $this->newSecondsSet(
816 ($is_secondly ?
$interval : 1),
824 $this->setSeconds
= array_reverse($seconds);
827 $this->stateSecond
= array_pop($this->setSeconds
);
830 protected function nextMinute() {
831 if ($this->setMinutes
) {
832 $this->stateMinute
= array_pop($this->setMinutes
);
836 $frequency = $this->getFrequency();
837 $interval = $this->getInterval();
838 $scale = $this->getFrequencyScale();
839 $is_minutely = ($frequency === self
::FREQUENCY_MINUTELY
);
840 $by_minute = $this->getByMinute();
842 while (!$this->setMinutes
) {
845 if ($is_minutely ||
$by_minute) {
846 $minutes = $this->newMinutesSet(
847 ($is_minutely ?
$interval : 1),
849 } else if ($scale < self
::SCALE_MINUTELY
) {
850 $minutes = $this->newMinutesSet(
859 $this->setMinutes
= array_reverse($minutes);
862 $this->stateMinute
= array_pop($this->setMinutes
);
865 protected function nextHour() {
866 if ($this->setHours
) {
867 $this->stateHour
= array_pop($this->setHours
);
871 $frequency = $this->getFrequency();
872 $interval = $this->getInterval();
873 $scale = $this->getFrequencyScale();
874 $is_hourly = ($frequency === self
::FREQUENCY_HOURLY
);
875 $by_hour = $this->getByHour();
877 while (!$this->setHours
) {
880 $is_dynamic = $is_hourly
882 ||
($scale < self
::SCALE_HOURLY
);
885 $hours = $this->newHoursSet(
886 ($is_hourly ?
$interval : 1),
894 $this->setHours
= array_reverse($hours);
897 $this->stateHour
= array_pop($this->setHours
);
900 protected function nextDay() {
901 if ($this->setDays
) {
902 $info = array_pop($this->setDays
);
903 $this->setDayState($info);
907 $frequency = $this->getFrequency();
908 $interval = $this->getInterval();
909 $scale = $this->getFrequencyScale();
910 $is_daily = ($frequency === self
::FREQUENCY_DAILY
);
911 $is_weekly = ($frequency === self
::FREQUENCY_WEEKLY
);
913 $by_day = $this->getByDay();
914 $by_monthday = $this->getByMonthDay();
915 $by_yearday = $this->getByYearDay();
916 $by_weekno = $this->getByWeekNumber();
917 $by_month = $this->getByMonth();
918 $week_start = $this->getWeekStart();
920 while (!$this->setDays
) {
927 // NOTE: We normally handle BYMONTH when iterating months, but it acts
928 // like a filter if FREQ=WEEKLY.
930 $is_dynamic = $is_daily
936 ||
($by_month && $is_weekly)
937 ||
($scale < self
::SCALE_DAILY
);
940 $weeks = $this->newDaysSet(
941 ($is_daily ?
$interval : 1),
949 // The cursor day may not actually exist in the current month, so
950 // make sure the day is valid before we generate a set which contains
952 $year_map = $this->getYearMap($this->stateYear
, $week_start);
953 if ($this->cursorDay
> $year_map['monthDays'][$this->stateMonth
]) {
958 $key = $this->stateMonth
.'M'.$this->cursorDay
.'D';
960 array($year_map['info'][$key]),
965 // Unpack the weeks into days.
966 $days = array_mergev($weeks);
968 $this->setDays
= array_reverse($days);
971 $info = array_pop($this->setDays
);
972 $this->setDayState($info);
975 private function setDayState(array $info) {
976 $this->stateDay
= $info['monthday'];
977 $this->stateWeek
= $info['week'];
978 $this->stateMonth
= $info['month'];
981 protected function nextMonth() {
982 if ($this->setMonths
) {
983 $this->stateMonth
= array_pop($this->setMonths
);
987 $frequency = $this->getFrequency();
988 $interval = $this->getInterval();
989 $scale = $this->getFrequencyScale();
990 $is_monthly = ($frequency === self
::FREQUENCY_MONTHLY
);
992 $by_month = $this->getByMonth();
994 // If we have a BYMONTHDAY, we consider that set of days in every month.
995 // For example, "FREQ=YEARLY;BYMONTHDAY=3" means "the third day of every
996 // month", so we need to expand the month set if the constraint is present.
997 $by_monthday = $this->getByMonthDay();
999 // Likewise, we need to generate all months if we have BYYEARDAY or
1000 // BYWEEKNO or BYDAY.
1001 $by_yearday = $this->getByYearDay();
1002 $by_weekno = $this->getByWeekNumber();
1003 $by_day = $this->getByDay();
1005 while (!$this->setMonths
) {
1008 $is_dynamic = $is_monthly
1014 ||
($scale < self
::SCALE_MONTHLY
);
1017 $months = $this->newMonthsSet(
1018 ($is_monthly ?
$interval : 1),
1026 $this->setMonths
= array_reverse($months);
1029 $this->stateMonth
= array_pop($this->setMonths
);
1032 protected function nextWeek() {
1033 if ($this->setWeeks
) {
1034 $this->stateWeek
= array_pop($this->setWeeks
);
1038 $frequency = $this->getFrequency();
1039 $interval = $this->getInterval();
1040 $scale = $this->getFrequencyScale();
1041 $by_weekno = $this->getByWeekNumber();
1043 while (!$this->setWeeks
) {
1046 $weeks = $this->newWeeksSet(
1050 $this->setWeeks
= array_reverse($weeks);
1053 $this->stateWeek
= array_pop($this->setWeeks
);
1056 protected function nextYear() {
1057 $this->stateYear
= $this->cursorYear
;
1059 $frequency = $this->getFrequency();
1060 $is_yearly = ($frequency === self
::FREQUENCY_YEARLY
);
1063 $interval = $this->getInterval();
1068 $this->cursorYear
= $this->cursorYear +
$interval;
1070 if ($this->cursorYear
> ($this->baseYear +
100)) {
1071 throw new Exception(
1073 'RRULE evaluation failed to generate more events in the next 100 '.
1074 'years. This RRULE is likely invalid or degenerate.'));
1079 private function newSecondsSet($interval, $set) {
1080 // TODO: This doesn't account for leap seconds. In theory, it probably
1081 // should, although this shouldn't impact any real events.
1082 $seconds_in_minute = 60;
1084 if ($this->cursorSecond
>= $seconds_in_minute) {
1085 $this->cursorSecond
-= $seconds_in_minute;
1089 list($cursor, $result) = $this->newIteratorSet(
1090 $this->cursorSecond
,
1093 $seconds_in_minute);
1095 $this->cursorSecond
= ($cursor - $seconds_in_minute);
1100 private function newMinutesSet($interval, $set) {
1101 // NOTE: This value is legitimately a constant! Amazing!
1102 $minutes_in_hour = 60;
1104 if ($this->cursorMinute
>= $minutes_in_hour) {
1105 $this->cursorMinute
-= $minutes_in_hour;
1109 list($cursor, $result) = $this->newIteratorSet(
1110 $this->cursorMinute
,
1115 $this->cursorMinute
= ($cursor - $minutes_in_hour);
1120 private function newHoursSet($interval, $set) {
1121 // TODO: This doesn't account for hours caused by daylight savings time.
1122 // It probably should, although this seems unlikely to impact any real
1126 // If the hour cursor is behind the current time, we need to forward it in
1127 // INTERVAL increments so we end up with the right offset.
1128 list($skip, $this->cursorHourState
) = $this->advanceCursorState(
1129 $this->cursorHourState
,
1132 $this->getWeekStart());
1138 list($cursor, $result) = $this->newIteratorSet(
1144 $this->cursorHour
= ($cursor - $hours_in_day);
1149 private function newWeeksSet($interval, $set) {
1150 $week_start = $this->getWeekStart();
1152 list($skip, $this->cursorWeekState
) = $this->advanceCursorState(
1153 $this->cursorWeekState
,
1162 $year_map = $this->getYearMap($this->stateYear
, $week_start);
1166 if (!isset($year_map['weekMap'][$this->cursorWeek
])) {
1169 $result[] = $this->cursorWeek
;
1170 $this->cursorWeek +
= $interval;
1173 $this->cursorWeek
-= $year_map['weekCount'];
1178 private function newDaysSet(
1187 $frequency = $this->getFrequency();
1188 $is_yearly = ($frequency == self
::FREQUENCY_YEARLY
);
1189 $is_monthly = ($frequency == self
::FREQUENCY_MONTHLY
);
1190 $is_weekly = ($frequency == self
::FREQUENCY_WEEKLY
);
1192 $selection = array();
1194 $year_map = $this->getYearMap($this->stateYear
, $week_start);
1196 if (isset($year_map['weekMap'][$this->stateWeek
])) {
1197 foreach ($year_map['weekMap'][$this->stateWeek
] as $key) {
1198 $selection[] = $year_map['info'][$key];
1202 // If the day cursor is behind the current year and month, we need to
1203 // forward it in INTERVAL increments so we end up with the right offset
1204 // in the current month.
1205 list($skip, $this->cursorDayState
) = $this->advanceCursorState(
1206 $this->cursorDayState
,
1212 $year_map = $this->getYearMap($this->stateYear
, $week_start);
1214 $month_idx = $this->stateMonth
;
1215 $month_days = $year_map['monthDays'][$month_idx];
1216 if ($this->cursorDay
> $month_days) {
1217 // NOTE: The year map is now out of date, but we're about to break
1218 // out of the loop anyway so it doesn't matter.
1222 $day_idx = $this->cursorDay
;
1224 $key = "{$month_idx}M{$day_idx}D";
1225 $selection[] = $year_map['info'][$key];
1227 $this->cursorDay +
= $interval_day;
1232 // As a special case, BYDAY applies to relative month offsets if BYMONTH
1233 // is present in a YEARLY rule.
1235 if ($this->getByMonth()) {
1241 // As a special case, BYDAY makes us examine all week days. This doesn't
1242 // check BYMONTHDAY or BYYEARDAY because they are not valid with WEEKLY.
1243 $filter_weekday = true;
1246 $filter_weekday = false;
1251 foreach ($selection as $key => $info) {
1253 if ($filter_weekday) {
1254 if ($info['weekday'] != $this->cursorWeekday
) {
1259 if ($info['month'] != $this->stateMonth
) {
1265 if (empty($by_day[$info['weekday']])) {
1267 if (empty($by_day[$info['weekday.yearly']]) &&
1268 empty($by_day[$info['-weekday.yearly']])) {
1271 } else if ($is_monthly) {
1272 if (empty($by_day[$info['weekday.monthly']]) &&
1273 empty($by_day[$info['-weekday.monthly']])) {
1283 if (empty($by_monthday[$info['monthday']]) &&
1284 empty($by_monthday[$info['-monthday']])) {
1290 if (empty($by_yearday[$info['yearday']]) &&
1291 empty($by_yearday[$info['-yearday']])) {
1297 if (empty($by_weekno[$info['week']]) &&
1298 empty($by_weekno[$info['-week']])) {
1304 if (empty($by_month[$info['month']])) {
1309 $weeks[$info['week']][] = $info;
1312 return array_values($weeks);
1315 private function newMonthsSet($interval, $set) {
1316 // NOTE: This value is also a real constant! Wow!
1317 $months_in_year = 12;
1319 if ($this->cursorMonth
> $months_in_year) {
1320 $this->cursorMonth
-= $months_in_year;
1324 list($cursor, $result) = $this->newIteratorSet(
1328 $months_in_year +
1);
1330 $this->cursorMonth
= ($cursor - $months_in_year);
1335 public static function getYearMap($year, $week_start) {
1336 static $maps = array();
1338 $key = "{$year}/{$week_start}";
1339 if (isset($maps[$key])) {
1343 $map = self
::newYearMap($year, $week_start);
1349 private static function newYearMap($year, $weekday_start) {
1350 $weekday_index = self
::getWeekdayIndex($weekday_start);
1352 $is_leap = (($year %
4 === 0) && ($year %
100 !== 0)) ||
1353 ($year %
400 === 0);
1355 // There may be some clever way to figure out which day of the week a given
1356 // year starts on and avoid the cost of a DateTime construction, but I
1357 // wasn't able to turn it up and we only need to do this once per year.
1358 $datetime = new DateTime("{$year}-01-01", new DateTimeZone('UTC'));
1359 $weekday = (int)$datetime->format('w');
1367 $month_days = array(
1369 2 => $is_leap ?
29 : 28,
1382 // Per the spec, the first week of the year must contain at least four
1383 // days. If the week starts on a Monday but the year starts on a Saturday,
1384 // the first couple of days don't count as a week. In this case, the first
1385 // week will begin on January 3.
1386 $first_week_size = 0;
1387 $first_weekday = $weekday;
1388 for ($year_day = 1; $year_day <= $max_day; $year_day++
) {
1389 $first_weekday = ($first_weekday +
1) %
7;
1391 if ($first_weekday === $weekday_index) {
1396 if ($first_week_size >= 4) {
1402 $info_map = array();
1404 $weekday_map = self
::getWeekdayIndexMap();
1405 $weekday_map = array_flip($weekday_map);
1407 $yearly_counts = array();
1408 $monthly_counts = array();
1412 for ($year_day = 1; $year_day <= $max_day; $year_day++
) {
1413 $key = "{$month_number}M{$month_day}D";
1415 $short_day = $weekday_map[$weekday];
1416 if (empty($yearly_counts[$short_day])) {
1417 $yearly_counts[$short_day] = 0;
1419 $yearly_counts[$short_day]++
;
1421 if (empty($monthly_counts[$month_number][$short_day])) {
1422 $monthly_counts[$month_number][$short_day] = 0;
1424 $monthly_counts[$month_number][$short_day]++
;
1429 'month' => $month_number,
1430 'monthday' => $month_day,
1431 '-monthday' => -$month_days[$month_number] +
$month_day - 1,
1432 'yearday' => $year_day,
1433 '-yearday' => -$max_day +
$year_day - 1,
1434 'week' => $week_number,
1435 'weekday' => $short_day,
1436 'weekday.yearly' => $yearly_counts[$short_day],
1437 'weekday.monthly' => $monthly_counts[$month_number][$short_day],
1440 $info_map[$key] = $info;
1442 $weekday = ($weekday +
1) %
7;
1443 if ($weekday === $weekday_index) {
1447 $month_day = ($month_day +
1);
1448 if ($month_day > $month_days[$month_number]) {
1454 // Check how long the final week is. If it doesn't have four days, this
1455 // is really the first week of the next year.
1456 $final_week = array();
1457 foreach ($info_map as $key => $info) {
1458 if ($info['week'] == $week_number) {
1459 $final_week[] = $key;
1463 if (count($final_week) < 4) {
1464 $week_number = $week_number - 1;
1465 $next_year = self
::getYearMap($year +
1, $weekday_start);
1466 $next_year_weeks = $next_year['weekCount'];
1468 $next_year_weeks = null;
1471 if ($first_week_size < 4) {
1472 $last_year = self
::getYearMap($year - 1, $weekday_start);
1473 $last_year_weeks = $last_year['weekCount'];
1475 $last_year_weeks = null;
1478 // Now that we know how many weeks the year has, we can compute the
1479 // negative offsets.
1480 foreach ($info_map as $key => $info) {
1481 $week = $info['week'];
1484 // If this day is part of the first partial week of the year, give
1485 // it the week number of the last week of the prior year instead.
1486 $info['week'] = $last_year_weeks;
1487 $info['-week'] = -1;
1488 } else if ($week > $week_number) {
1489 // If this day is part of the last partial week of the year, give
1490 // it week numbers from the next year.
1492 $info['-week'] = -$next_year_weeks;
1494 $info['-week'] = -$week_number +
$week - 1;
1497 // Do all the arithmetic to figure out if this is the -19th Thursday
1498 // in the year and such.
1499 $month_number = $info['month'];
1500 $short_day = $info['weekday'];
1501 $monthly_count = $monthly_counts[$month_number][$short_day];
1502 $monthly_index = $info['weekday.monthly'];
1503 $info['-weekday.monthly'] = -$monthly_count +
$monthly_index - 1;
1504 $info['-weekday.monthly'] .= $short_day;
1505 $info['weekday.monthly'] .= $short_day;
1507 $yearly_count = $yearly_counts[$short_day];
1508 $yearly_index = $info['weekday.yearly'];
1509 $info['-weekday.yearly'] = -$yearly_count +
$yearly_index - 1;
1510 $info['-weekday.yearly'] .= $short_day;
1511 $info['weekday.yearly'] .= $short_day;
1513 $info_map[$key] = $info;
1516 $week_map = array();
1517 foreach ($info_map as $key => $info) {
1518 $week_map[$info['week']][] = $key;
1522 'info' => $info_map,
1523 'weekCount' => $week_number,
1524 'dayCount' => $max_day,
1525 'monthDays' => $month_days,
1526 'weekMap' => $week_map,
1530 private function newIteratorSet($cursor, $interval, $set, $limit) {
1531 if ($interval < 1) {
1532 throw new Exception(
1534 'Invalid iteration interval ("%d"), must be at least 1.',
1543 if (!$set ||
isset($set[$ii])) {
1547 $ii = ($ii +
$interval);
1549 if ($ii >= $limit) {
1555 $result = array_values($result);
1557 return array($ii, $result);
1560 private function applySetPos(array $values, array $setpos) {
1563 $count = count($values);
1564 foreach ($setpos as $pos) {
1565 if ($pos > 0 && $pos <= $count) {
1566 $select[] = ($pos - 1);
1567 } else if ($pos < 0 && $pos >= -$count) {
1568 $select[] = ($count +
$pos);
1573 $select = array_unique($select);
1575 return array_select_keys($values, $select);
1578 private function assertByRange(
1583 $allow_zero = true) {
1585 foreach ($values as $value) {
1586 if (!is_int($value)) {
1587 throw new Exception(
1589 'Value "%s" in RRULE "%s" parameter is invalid: values must be '.
1595 if ($value < $min ||
$value > $max) {
1596 throw new Exception(
1598 'Value "%s" in RRULE "%s" parameter is invalid: it must be '.
1599 'between %s and %s.',
1606 if (!$value && !$allow_zero) {
1607 throw new Exception(
1609 'Value "%s" in RRULE "%s" parameter is invalid: it must not '.
1617 private function getSetPositionState() {
1618 $scale = $this->getFrequencyScale();
1621 $parts[] = $this->stateYear
;
1623 if ($scale == self
::SCALE_WEEKLY
) {
1624 $parts[] = $this->stateWeek
;
1626 if ($scale < self
::SCALE_YEARLY
) {
1627 $parts[] = $this->stateMonth
;
1629 if ($scale < self
::SCALE_MONTHLY
) {
1630 $parts[] = $this->stateDay
;
1632 if ($scale < self
::SCALE_DAILY
) {
1633 $parts[] = $this->stateHour
;
1635 if ($scale < self
::SCALE_HOURLY
) {
1636 $parts[] = $this->stateMinute
;
1640 return implode('/', $parts);
1643 private function rewindMonth() {
1644 while ($this->cursorMonth
< 1) {
1645 $this->cursorYear
--;
1646 $this->cursorMonth +
= 12;
1650 private function rewindWeek() {
1651 $week_start = $this->getWeekStart();
1652 while ($this->cursorWeek
< 1) {
1653 $this->cursorYear
--;
1654 $year_map = $this->getYearMap($this->cursorYear
, $week_start);
1655 $this->cursorWeek +
= $year_map['weekCount'];
1659 private function rewindDay() {
1660 $week_start = $this->getWeekStart();
1661 while ($this->cursorDay
< 1) {
1662 $year_map = $this->getYearMap($this->cursorYear
, $week_start);
1663 $this->cursorDay +
= $year_map['monthDays'][$this->cursorMonth
];
1664 $this->cursorMonth
--;
1665 $this->rewindMonth();
1669 private function rewindHour() {
1670 while ($this->cursorHour
< 0) {
1671 $this->cursorHour +
= 24;
1677 private function rewindMinute() {
1678 while ($this->cursorMinute
< 0) {
1679 $this->cursorMinute +
= 60;
1680 $this->cursorHour
--;
1681 $this->rewindHour();
1685 private function advanceCursorState(
1692 'year' => $this->stateYear
,
1693 'month' => $this->stateMonth
,
1694 'week' => $this->stateWeek
,
1695 'day' => $this->stateDay
,
1696 'hour' => $this->stateHour
,
1699 // In the common case when the interval is 1, we'll visit every possible
1700 // value so we don't need to do any math and can just jump to the first
1702 if ($interval == 1) {
1703 if ($this->isCursorBehind($cursor, $state, $scale)) {
1705 case self
::SCALE_DAILY
:
1706 $this->cursorDay
= 1;
1708 case self
::SCALE_HOURLY
:
1709 $this->cursorHour
= 0;
1711 case self
::SCALE_WEEKLY
:
1712 $this->cursorWeek
= 1;
1717 return array(false, $state);
1720 $year_map = $this->getYearMap($cursor['year'], $week_start);
1721 while ($this->isCursorBehind($cursor, $state, $scale)) {
1723 case self
::SCALE_DAILY
:
1724 $cursor['day'] +
= $interval;
1726 case self
::SCALE_HOURLY
:
1727 $cursor['hour'] +
= $interval;
1729 case self
::SCALE_WEEKLY
:
1730 $cursor['week'] +
= $interval;
1734 if ($scale <= self
::SCALE_HOURLY
) {
1735 while ($cursor['hour'] >= 24) {
1736 $cursor['hour'] -= 24;
1741 if ($scale == self
::SCALE_WEEKLY
) {
1742 while ($cursor['week'] > $year_map['weekCount']) {
1743 $cursor['week'] -= $year_map['weekCount'];
1745 $year_map = $this->getYearMap($cursor['year'], $week_start);
1749 if ($scale <= self
::SCALE_DAILY
) {
1750 while ($cursor['day'] > $year_map['monthDays'][$cursor['month']]) {
1751 $cursor['day'] -= $year_map['monthDays'][$cursor['month']];
1753 if ($cursor['month'] > 12) {
1754 $cursor['month'] -= 12;
1756 $year_map = $this->getYearMap($cursor['year'], $week_start);
1763 case self
::SCALE_DAILY
:
1764 $this->cursorDay
= $cursor['day'];
1766 case self
::SCALE_HOURLY
:
1767 $this->cursorHour
= $cursor['hour'];
1769 case self
::SCALE_WEEKLY
:
1770 $this->cursorWeek
= $cursor['week'];
1774 $skip = $this->isCursorBehind($state, $cursor, $scale);
1776 return array($skip, $cursor);
1779 private function isCursorBehind(array $cursor, array $state, $scale) {
1780 if ($cursor['year'] < $state['year']) {
1782 } else if ($cursor['year'] > $state['year']) {
1786 if ($scale == self
::SCALE_WEEKLY
) {
1790 if ($cursor['month'] < $state['month']) {
1792 } else if ($cursor['month'] > $state['month']) {
1796 if ($scale >= self
::SCALE_DAILY
) {
1800 if ($cursor['day'] < $state['day']) {
1802 } else if ($cursor['day'] > $state['day']) {
1806 if ($scale >= self
::SCALE_HOURLY
) {
1810 if ($cursor['hour'] < $state['hour']) {
1812 } else if ($cursor['hour'] > $state['hour']) {