Remove all "FileHasObject" edge reads and writes
[phabricator.git] / src / applications / calendar / parser / data / PhutilCalendarRecurrenceRule.php
blob504f7d8e9eac635ec7eed673102a3756469fbd80
1 <?php
3 final class PhutilCalendarRecurrenceRule
4 extends PhutilCalendarRecurrenceSource {
6 private $startDateTime;
7 private $frequency;
8 private $frequencyScale;
9 private $interval = 1;
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;
20 private $count;
21 private $until;
23 private $cursorSecond;
24 private $cursorMinute;
25 private $cursorHour;
26 private $cursorHourState;
27 private $cursorWeek;
28 private $cursorWeekday;
29 private $cursorWeekState;
30 private $cursorDay;
31 private $cursorDayState;
32 private $cursorMonth;
33 private $cursorYear;
35 private $setSeconds;
36 private $setMinutes;
37 private $setHours;
38 private $setDays;
39 private $setMonths;
40 private $setWeeks;
41 private $setYears;
43 private $stateSecond;
44 private $stateMinute;
45 private $stateHour;
46 private $stateDay;
47 private $stateWeek;
48 private $stateMonth;
49 private $stateYear;
51 private $baseYear;
52 private $isAllDay;
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() {
90 $parts = array();
92 $parts['FREQ'] = $this->getFrequency();
94 $interval = $this->getInterval();
95 if ($interval != 1) {
96 $parts['INTERVAL'] = $interval;
99 $by_second = $this->getBySecond();
100 if ($by_second) {
101 $parts['BYSECOND'] = $by_second;
104 $by_minute = $this->getByMinute();
105 if ($by_minute) {
106 $parts['BYMINUTE'] = $by_minute;
109 $by_hour = $this->getByHour();
110 if ($by_hour) {
111 $parts['BYHOUR'] = $by_hour;
114 $by_day = $this->getByDay();
115 if ($by_day) {
116 $parts['BYDAY'] = $by_day;
119 $by_month = $this->getByMonth();
120 if ($by_month) {
121 $parts['BYMONTH'] = $by_month;
124 $by_monthday = $this->getByMonthDay();
125 if ($by_monthday) {
126 $parts['BYMONTHDAY'] = $by_monthday;
129 $by_yearday = $this->getByYearDay();
130 if ($by_yearday) {
131 $parts['BYYEARDAY'] = $by_yearday;
134 $by_weekno = $this->getByWeekNumber();
135 if ($by_weekno) {
136 $parts['BYWEEKNO'] = $by_weekno;
139 $by_setpos = $this->getBySetPosition();
140 if ($by_setpos) {
141 $parts['BYSETPOS'] = $by_setpos;
144 $wkst = $this->getWeekStart();
145 if ($wkst != self::WEEKDAY_MONDAY) {
146 $parts['WKST'] = $wkst;
149 $count = $this->getCount();
150 if ($count) {
151 $parts['COUNT'] = $count;
154 $until = $this->getUntil();
155 if ($until) {
156 $parts['UNTIL'] = $until->getISO8601();
159 return $parts;
162 public static function newFromDictionary(array $dict) {
163 static $expect;
164 if ($expect === null) {
165 $expect = array_fuse(
166 array(
167 'FREQ',
168 'INTERVAL',
169 'BYSECOND',
170 'BYMINUTE',
171 'BYHOUR',
172 'BYDAY',
173 'BYMONTH',
174 'BYMONTHDAY',
175 'BYYEARDAY',
176 'BYWEEKNO',
177 'BYSETPOS',
178 'WKST',
179 'UNTIL',
180 'COUNT',
184 foreach ($dict as $key => $value) {
185 if (empty($expect[$key])) {
186 throw new Exception(
187 pht(
188 'RRULE dictionary includes unknown key "%s". Expected keys '.
189 'are: %s.',
190 $key,
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');
210 if ($count) {
211 $rrule->setCount($count);
214 $until = idx($dict, 'UNTIL');
215 if ($until) {
216 $until = PhutilCalendarAbsoluteDateTime::newFromISO8601($until);
217 $rrule->setUntil($until);
220 return $rrule;
223 public function toRRULE() {
224 $dict = $this->toDictionary();
226 $parts = array();
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);
240 $dict = array();
241 foreach ($parts as $part) {
242 list($key, $value) = explode('=', $part, 2);
243 switch ($key) {
244 case 'FREQ':
245 case 'INTERVAL':
246 case 'WKST':
247 case 'COUNT':
248 case 'UNTIL';
249 break;
250 default:
251 $value = explode(',', $value);
252 break;
254 $dict[$key] = $value;
257 $int_lists = array_fuse(
258 array(
259 // NOTE: "BYDAY" is absent, and takes a list like "MO, TU, WE".
260 'BYSECOND',
261 'BYMINUTE',
262 'BYHOUR',
263 'BYMONTH',
264 'BYMONTHDAY',
265 'BYYEARDAY',
266 'BYWEEKNO',
267 'BYSETPOS',
270 $int_values = array_fuse(
271 array(
272 'COUNT',
273 'INTERVAL',
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)) {
280 throw new Exception(
281 pht(
282 'Unexpected value "%s" in "%s" RULE property: expected an '.
283 'integer.',
284 $value,
285 $key));
287 $dict[$key] = (int)$value;
290 if (isset($int_lists[$key])) {
291 foreach ($value as $k => $v) {
292 if (!preg_match('/^-?\d+\z/', $v)) {
293 throw new Exception(
294 pht(
295 'Unexpected value "%s" in "%s" RRULE property: expected '.
296 'only integers.',
298 $key));
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() {
314 static $map = array(
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,
324 return $map;
327 private static function getWeekdayIndex($weekday) {
328 $map = self::getWeekdayIndexMap();
329 if (!isset($map[$weekday])) {
330 $constants = array_keys($map);
331 throw new Exception(
332 pht(
333 'Weekday "%s" is not a valid weekday constant. Valid constants '.
334 'are: %s.',
335 $weekday,
336 implode(', ', $constants)));
339 return $map[$weekday];
342 public function setStartDateTime(PhutilCalendarDateTime $start) {
343 $this->startDateTime = $start;
344 return $this;
347 public function getStartDateTime() {
348 return $this->startDateTime;
351 public function setCount($count) {
352 if ($count < 1) {
353 throw new Exception(
354 pht(
355 'RRULE COUNT value "%s" is invalid: count must be at least 1.',
356 $count));
359 $this->count = $count;
360 return $this;
363 public function getCount() {
364 return $this->count;
367 public function setUntil(PhutilCalendarDateTime $until) {
368 $this->until = $until;
369 return $this;
372 public function getUntil() {
373 return $this->until;
376 public function setFrequency($frequency) {
377 static $map = array(
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])) {
388 throw new Exception(
389 pht(
390 'RRULE FREQ "%s" is invalid. Valid frequencies are: %s.',
391 $frequency,
392 implode(', ', array_keys($map))));
395 $this->frequency = $frequency;
396 $this->frequencyScale = $map[$frequency];
398 return $this;
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)) {
411 throw new Exception(
412 pht(
413 'RRULE INTERVAL "%s" is invalid: interval must be an integer.',
414 $interval));
417 if ($interval < 1) {
418 throw new Exception(
419 pht(
420 'RRULE INTERVAL "%s" is invalid: interval must be 1 or more.',
421 $interval));
424 $this->interval = $interval;
425 return $this;
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);
435 return $this;
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);
445 return $this;
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);
455 return $this;
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) {
468 $matches = null;
469 if (!preg_match($pattern, $value, $matches)) {
470 throw new Exception(
471 pht(
472 'RRULE BYDAY value "%s" is invalid: rule part must be in the '.
473 'expected form (like "MO", "-3TH", or "+2SU").',
474 $value));
477 // The maximum allowed value is 53, which corresponds to "the 53rd
478 // Monday every year" or similar when evaluated against a YEARLY rule.
480 $maximum = 53;
481 $magnitude = (int)$matches[1];
482 if ($magnitude > $maximum) {
483 throw new Exception(
484 pht(
485 'RRULE BYDAY value "%s" has an offset with magnitude "%s", but '.
486 'the maximum permitted value is "%s".',
487 $value,
488 $magnitude,
489 $maximum));
492 // Normalize "+3FR" into "3FR".
493 $by_day[$key] = ltrim($value, '+');
496 $this->byDay = array_fuse($by_day);
497 return $this;
500 public function getByDay() {
501 return $this->byDay;
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);
507 return $this;
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);
517 return $this;
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);
527 return $this;
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);
537 return $this;
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;
547 return $this;
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;
559 return $this;
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."
574 throw new Exception(
575 pht(
576 'RRULE specifies BYMONTHDAY with FREQ set to WEEKLY, which '.
577 'violates RFC5545.'));
578 break;
579 default:
580 break;
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."
592 throw new Exception(
593 pht(
594 'RRULE specifies BYYEARDAY with FREQ of DAILY, WEEKLY or '.
595 'MONTHLY, which violates RFC5545.'));
596 default:
597 break;
601 // TODO
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;
648 break;
649 case self::FREQUENCY_MONTHLY:
650 $this->cursorMonth -= $interval;
651 $this->rewindMonth();
652 break;
653 case self::FREQUENCY_WEEKLY:
654 $this->cursorWeek -= $interval;
655 $this->rewindWeek();
656 break;
657 case self::FREQUENCY_DAILY:
658 $this->cursorDay -= $interval;
659 $this->rewindDay();
660 break;
661 case self::FREQUENCY_HOURLY:
662 $this->cursorHour -= $interval;
663 $this->rewindHour();
664 break;
665 case self::FREQUENCY_MINUTELY:
666 $this->cursorMinute -= $interval;
667 $this->rewindMinute();
668 break;
669 case self::FREQUENCY_SECONDLY:
670 default:
671 throw new Exception(
672 pht(
673 'RRULE specifies BYSETPOS with FREQ "%s", but this is invalid.',
674 $frequency));
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()
703 && !$by_hour
704 && !$by_minute
705 && !$by_second
706 && ($scale > self::SCALE_HOURLY);
709 public function getNextEvent($cursor) {
710 while (true) {
711 $event = $this->generateNextEvent();
712 if (!$event) {
713 break;
716 $epoch = $event->getEpoch();
717 if ($this->minimumEpoch) {
718 if ($epoch < $this->minimumEpoch) {
719 continue;
723 if ($epoch < $cursor) {
724 continue;
727 break;
730 return $event;
733 private function generateNextEvent() {
734 if ($this->activeSet) {
735 return array_pop($this->activeSet);
738 $this->baseYear = $this->cursorYear;
740 $by_setpos = $this->getBySetPosition();
741 if ($by_setpos) {
742 $old_state = $this->getSetPositionState();
745 while (!$this->activeSet) {
746 $this->activeSet = $this->nextSet;
747 $this->nextSet = array();
749 while (true) {
750 if ($this->isAllDay) {
751 $this->nextDay();
752 } else {
753 $this->nextSecond();
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);
765 } else {
766 $result
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.
774 if (!$by_setpos) {
775 $this->activeSet[] = $result;
776 break;
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;
785 continue;
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;
792 break;
796 return array_pop($this->activeSet);
800 protected function nextSecond() {
801 if ($this->setSeconds) {
802 $this->stateSecond = array_pop($this->setSeconds);
803 return;
806 $frequency = $this->getFrequency();
807 $interval = $this->getInterval();
808 $is_secondly = ($frequency == self::FREQUENCY_SECONDLY);
809 $by_second = $this->getBySecond();
811 while (!$this->setSeconds) {
812 $this->nextMinute();
814 if ($is_secondly || $by_second) {
815 $seconds = $this->newSecondsSet(
816 ($is_secondly ? $interval : 1),
817 $by_second);
818 } else {
819 $seconds = array(
820 $this->cursorSecond,
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);
833 return;
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) {
843 $this->nextHour();
845 if ($is_minutely || $by_minute) {
846 $minutes = $this->newMinutesSet(
847 ($is_minutely ? $interval : 1),
848 $by_minute);
849 } else if ($scale < self::SCALE_MINUTELY) {
850 $minutes = $this->newMinutesSet(
852 array());
853 } else {
854 $minutes = array(
855 $this->cursorMinute,
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);
868 return;
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) {
878 $this->nextDay();
880 $is_dynamic = $is_hourly
881 || $by_hour
882 || ($scale < self::SCALE_HOURLY);
884 if ($is_dynamic) {
885 $hours = $this->newHoursSet(
886 ($is_hourly ? $interval : 1),
887 $by_hour);
888 } else {
889 $hours = array(
890 $this->cursorHour,
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);
904 return;
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) {
921 if ($is_weekly) {
922 $this->nextWeek();
923 } else {
924 $this->nextMonth();
927 // NOTE: We normally handle BYMONTH when iterating months, but it acts
928 // like a filter if FREQ=WEEKLY.
930 $is_dynamic = $is_daily
931 || $is_weekly
932 || $by_day
933 || $by_monthday
934 || $by_yearday
935 || $by_weekno
936 || ($by_month && $is_weekly)
937 || ($scale < self::SCALE_DAILY);
939 if ($is_dynamic) {
940 $weeks = $this->newDaysSet(
941 ($is_daily ? $interval : 1),
942 $by_day,
943 $by_monthday,
944 $by_yearday,
945 $by_weekno,
946 $by_month,
947 $week_start);
948 } else {
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
951 // it.
952 $year_map = $this->getYearMap($this->stateYear, $week_start);
953 if ($this->cursorDay > $year_map['monthDays'][$this->stateMonth]) {
954 $weeks = array(
955 array(),
957 } else {
958 $key = $this->stateMonth.'M'.$this->cursorDay.'D';
959 $weeks = array(
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);
984 return;
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) {
1006 $this->nextYear();
1008 $is_dynamic = $is_monthly
1009 || $by_month
1010 || $by_monthday
1011 || $by_yearday
1012 || $by_weekno
1013 || $by_day
1014 || ($scale < self::SCALE_MONTHLY);
1016 if ($is_dynamic) {
1017 $months = $this->newMonthsSet(
1018 ($is_monthly ? $interval : 1),
1019 $by_month);
1020 } else {
1021 $months = array(
1022 $this->cursorMonth,
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);
1035 return;
1038 $frequency = $this->getFrequency();
1039 $interval = $this->getInterval();
1040 $scale = $this->getFrequencyScale();
1041 $by_weekno = $this->getByWeekNumber();
1043 while (!$this->setWeeks) {
1044 $this->nextYear();
1046 $weeks = $this->newWeeksSet(
1047 $interval,
1048 $by_weekno);
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);
1062 if ($is_yearly) {
1063 $interval = $this->getInterval();
1064 } else {
1065 $interval = 1;
1068 $this->cursorYear = $this->cursorYear + $interval;
1070 if ($this->cursorYear > ($this->baseYear + 100)) {
1071 throw new Exception(
1072 pht(
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;
1086 return array();
1089 list($cursor, $result) = $this->newIteratorSet(
1090 $this->cursorSecond,
1091 $interval,
1092 $set,
1093 $seconds_in_minute);
1095 $this->cursorSecond = ($cursor - $seconds_in_minute);
1097 return $result;
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;
1106 return array();
1109 list($cursor, $result) = $this->newIteratorSet(
1110 $this->cursorMinute,
1111 $interval,
1112 $set,
1113 $minutes_in_hour);
1115 $this->cursorMinute = ($cursor - $minutes_in_hour);
1117 return $result;
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
1123 // events.
1124 $hours_in_day = 24;
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,
1130 self::SCALE_HOURLY,
1131 $interval,
1132 $this->getWeekStart());
1134 if ($skip) {
1135 return array();
1138 list($cursor, $result) = $this->newIteratorSet(
1139 $this->cursorHour,
1140 $interval,
1141 $set,
1142 $hours_in_day);
1144 $this->cursorHour = ($cursor - $hours_in_day);
1146 return $result;
1149 private function newWeeksSet($interval, $set) {
1150 $week_start = $this->getWeekStart();
1152 list($skip, $this->cursorWeekState) = $this->advanceCursorState(
1153 $this->cursorWeekState,
1154 self::SCALE_WEEKLY,
1155 $interval,
1156 $week_start);
1158 if ($skip) {
1159 return array();
1162 $year_map = $this->getYearMap($this->stateYear, $week_start);
1164 $result = array();
1165 while (true) {
1166 if (!isset($year_map['weekMap'][$this->cursorWeek])) {
1167 break;
1169 $result[] = $this->cursorWeek;
1170 $this->cursorWeek += $interval;
1173 $this->cursorWeek -= $year_map['weekCount'];
1175 return $result;
1178 private function newDaysSet(
1179 $interval_day,
1180 $by_day,
1181 $by_monthday,
1182 $by_yearday,
1183 $by_weekno,
1184 $by_month,
1185 $week_start) {
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();
1193 if ($is_weekly) {
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];
1201 } else {
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,
1207 self::SCALE_DAILY,
1208 $interval_day,
1209 $week_start);
1211 if (!$skip) {
1212 $year_map = $this->getYearMap($this->stateYear, $week_start);
1213 while (true) {
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.
1219 break;
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.
1234 if ($is_yearly) {
1235 if ($this->getByMonth()) {
1236 $is_yearly = false;
1237 $is_monthly = true;
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;
1244 if ($is_weekly) {
1245 if ($by_day) {
1246 $filter_weekday = false;
1250 $weeks = array();
1251 foreach ($selection as $key => $info) {
1252 if ($is_weekly) {
1253 if ($filter_weekday) {
1254 if ($info['weekday'] != $this->cursorWeekday) {
1255 continue;
1258 } else {
1259 if ($info['month'] != $this->stateMonth) {
1260 continue;
1264 if ($by_day) {
1265 if (empty($by_day[$info['weekday']])) {
1266 if ($is_yearly) {
1267 if (empty($by_day[$info['weekday.yearly']]) &&
1268 empty($by_day[$info['-weekday.yearly']])) {
1269 continue;
1271 } else if ($is_monthly) {
1272 if (empty($by_day[$info['weekday.monthly']]) &&
1273 empty($by_day[$info['-weekday.monthly']])) {
1274 continue;
1276 } else {
1277 continue;
1282 if ($by_monthday) {
1283 if (empty($by_monthday[$info['monthday']]) &&
1284 empty($by_monthday[$info['-monthday']])) {
1285 continue;
1289 if ($by_yearday) {
1290 if (empty($by_yearday[$info['yearday']]) &&
1291 empty($by_yearday[$info['-yearday']])) {
1292 continue;
1296 if ($by_weekno) {
1297 if (empty($by_weekno[$info['week']]) &&
1298 empty($by_weekno[$info['-week']])) {
1299 continue;
1303 if ($by_month) {
1304 if (empty($by_month[$info['month']])) {
1305 continue;
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;
1321 return array();
1324 list($cursor, $result) = $this->newIteratorSet(
1325 $this->cursorMonth,
1326 $interval,
1327 $set,
1328 $months_in_year + 1);
1330 $this->cursorMonth = ($cursor - $months_in_year);
1332 return $result;
1335 public static function getYearMap($year, $week_start) {
1336 static $maps = array();
1338 $key = "{$year}/{$week_start}";
1339 if (isset($maps[$key])) {
1340 return $maps[$key];
1343 $map = self::newYearMap($year, $week_start);
1344 $maps[$key] = $map;
1346 return $maps[$key];
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');
1361 if ($is_leap) {
1362 $max_day = 366;
1363 } else {
1364 $max_day = 365;
1367 $month_days = array(
1368 1 => 31,
1369 2 => $is_leap ? 29 : 28,
1370 3 => 31,
1371 4 => 30,
1372 5 => 31,
1373 6 => 30,
1374 7 => 31,
1375 8 => 31,
1376 9 => 30,
1377 10 => 31,
1378 11 => 30,
1379 12 => 31,
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;
1390 $first_week_size++;
1391 if ($first_weekday === $weekday_index) {
1392 break;
1396 if ($first_week_size >= 4) {
1397 $week_number = 1;
1398 } else {
1399 $week_number = 0;
1402 $info_map = array();
1404 $weekday_map = self::getWeekdayIndexMap();
1405 $weekday_map = array_flip($weekday_map);
1407 $yearly_counts = array();
1408 $monthly_counts = array();
1410 $month_number = 1;
1411 $month_day = 1;
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]++;
1426 $info = array(
1427 'year' => $year,
1428 'key' => $key,
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) {
1444 $week_number++;
1447 $month_day = ($month_day + 1);
1448 if ($month_day > $month_days[$month_number]) {
1449 $month_day = 1;
1450 $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'];
1467 } else {
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'];
1474 } else {
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'];
1483 if ($week === 0) {
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.
1491 $info['week'] = 1;
1492 $info['-week'] = -$next_year_weeks;
1493 } else {
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;
1521 return array(
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(
1533 pht(
1534 'Invalid iteration interval ("%d"), must be at least 1.',
1535 $interval));
1538 $result = array();
1539 $seen = array();
1541 $ii = $cursor;
1542 while (true) {
1543 if (!$set || isset($set[$ii])) {
1544 $result[] = $ii;
1547 $ii = ($ii + $interval);
1549 if ($ii >= $limit) {
1550 break;
1554 sort($result);
1555 $result = array_values($result);
1557 return array($ii, $result);
1560 private function applySetPos(array $values, array $setpos) {
1561 $select = array();
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);
1572 sort($select);
1573 $select = array_unique($select);
1575 return array_select_keys($values, $select);
1578 private function assertByRange(
1579 $source,
1580 array $values,
1581 $min,
1582 $max,
1583 $allow_zero = true) {
1585 foreach ($values as $value) {
1586 if (!is_int($value)) {
1587 throw new Exception(
1588 pht(
1589 'Value "%s" in RRULE "%s" parameter is invalid: values must be '.
1590 'integers.',
1591 $value,
1592 $source));
1595 if ($value < $min || $value > $max) {
1596 throw new Exception(
1597 pht(
1598 'Value "%s" in RRULE "%s" parameter is invalid: it must be '.
1599 'between %s and %s.',
1600 $value,
1601 $source,
1602 $min,
1603 $max));
1606 if (!$value && !$allow_zero) {
1607 throw new Exception(
1608 pht(
1609 'Value "%s" in RRULE "%s" parameter is invalid: it must not '.
1610 'be zero.',
1611 $value,
1612 $source));
1617 private function getSetPositionState() {
1618 $scale = $this->getFrequencyScale();
1620 $parts = array();
1621 $parts[] = $this->stateYear;
1623 if ($scale == self::SCALE_WEEKLY) {
1624 $parts[] = $this->stateWeek;
1625 } else {
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;
1672 $this->cursorDay--;
1673 $this->rewindDay();
1677 private function rewindMinute() {
1678 while ($this->cursorMinute < 0) {
1679 $this->cursorMinute += 60;
1680 $this->cursorHour--;
1681 $this->rewindHour();
1685 private function advanceCursorState(
1686 array $cursor,
1687 $scale,
1688 $interval,
1689 $week_start) {
1691 $state = array(
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
1701 // hour, day, etc.
1702 if ($interval == 1) {
1703 if ($this->isCursorBehind($cursor, $state, $scale)) {
1704 switch ($scale) {
1705 case self::SCALE_DAILY:
1706 $this->cursorDay = 1;
1707 break;
1708 case self::SCALE_HOURLY:
1709 $this->cursorHour = 0;
1710 break;
1711 case self::SCALE_WEEKLY:
1712 $this->cursorWeek = 1;
1713 break;
1717 return array(false, $state);
1720 $year_map = $this->getYearMap($cursor['year'], $week_start);
1721 while ($this->isCursorBehind($cursor, $state, $scale)) {
1722 switch ($scale) {
1723 case self::SCALE_DAILY:
1724 $cursor['day'] += $interval;
1725 break;
1726 case self::SCALE_HOURLY:
1727 $cursor['hour'] += $interval;
1728 break;
1729 case self::SCALE_WEEKLY:
1730 $cursor['week'] += $interval;
1731 break;
1734 if ($scale <= self::SCALE_HOURLY) {
1735 while ($cursor['hour'] >= 24) {
1736 $cursor['hour'] -= 24;
1737 $cursor['day']++;
1741 if ($scale == self::SCALE_WEEKLY) {
1742 while ($cursor['week'] > $year_map['weekCount']) {
1743 $cursor['week'] -= $year_map['weekCount'];
1744 $cursor['year']++;
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']];
1752 $cursor['month']++;
1753 if ($cursor['month'] > 12) {
1754 $cursor['month'] -= 12;
1755 $cursor['year']++;
1756 $year_map = $this->getYearMap($cursor['year'], $week_start);
1762 switch ($scale) {
1763 case self::SCALE_DAILY:
1764 $this->cursorDay = $cursor['day'];
1765 break;
1766 case self::SCALE_HOURLY:
1767 $this->cursorHour = $cursor['hour'];
1768 break;
1769 case self::SCALE_WEEKLY:
1770 $this->cursorWeek = $cursor['week'];
1771 break;
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']) {
1781 return true;
1782 } else if ($cursor['year'] > $state['year']) {
1783 return false;
1786 if ($scale == self::SCALE_WEEKLY) {
1787 return false;
1790 if ($cursor['month'] < $state['month']) {
1791 return true;
1792 } else if ($cursor['month'] > $state['month']) {
1793 return false;
1796 if ($scale >= self::SCALE_DAILY) {
1797 return false;
1800 if ($cursor['day'] < $state['day']) {
1801 return true;
1802 } else if ($cursor['day'] > $state['day']) {
1803 return false;
1806 if ($scale >= self::SCALE_HOURLY) {
1807 return false;
1810 if ($cursor['hour'] < $state['hour']) {
1811 return true;
1812 } else if ($cursor['hour'] > $state['hour']) {
1813 return false;
1816 return false;