@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3final class PhutilCalendarRecurrenceRule
4 extends PhutilCalendarRecurrenceSource {
5
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;
22
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;
34
35 private $setSeconds;
36 private $setMinutes;
37 private $setHours;
38 private $setDays;
39 private $setMonths;
40 private $setWeeks;
41 private $setYears;
42
43 private $stateSecond;
44 private $stateMinute;
45 private $stateHour;
46 private $stateDay;
47 private $stateWeek;
48 private $stateMonth;
49 private $stateYear;
50
51 private $baseYear;
52 private $isAllDay;
53 private $activeSet = array();
54 private $nextSet = array();
55 private $minimumEpoch;
56
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';
64
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;
72
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';
80
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;
88
89 public function toDictionary() {
90 $parts = array();
91
92 $parts['FREQ'] = $this->getFrequency();
93
94 $interval = $this->getInterval();
95 if ($interval != 1) {
96 $parts['INTERVAL'] = $interval;
97 }
98
99 $by_second = $this->getBySecond();
100 if ($by_second) {
101 $parts['BYSECOND'] = $by_second;
102 }
103
104 $by_minute = $this->getByMinute();
105 if ($by_minute) {
106 $parts['BYMINUTE'] = $by_minute;
107 }
108
109 $by_hour = $this->getByHour();
110 if ($by_hour) {
111 $parts['BYHOUR'] = $by_hour;
112 }
113
114 $by_day = $this->getByDay();
115 if ($by_day) {
116 $parts['BYDAY'] = $by_day;
117 }
118
119 $by_month = $this->getByMonth();
120 if ($by_month) {
121 $parts['BYMONTH'] = $by_month;
122 }
123
124 $by_monthday = $this->getByMonthDay();
125 if ($by_monthday) {
126 $parts['BYMONTHDAY'] = $by_monthday;
127 }
128
129 $by_yearday = $this->getByYearDay();
130 if ($by_yearday) {
131 $parts['BYYEARDAY'] = $by_yearday;
132 }
133
134 $by_weekno = $this->getByWeekNumber();
135 if ($by_weekno) {
136 $parts['BYWEEKNO'] = $by_weekno;
137 }
138
139 $by_setpos = $this->getBySetPosition();
140 if ($by_setpos) {
141 $parts['BYSETPOS'] = $by_setpos;
142 }
143
144 $wkst = $this->getWeekStart();
145 if ($wkst != self::WEEKDAY_MONDAY) {
146 $parts['WKST'] = $wkst;
147 }
148
149 $count = $this->getCount();
150 if ($count) {
151 $parts['COUNT'] = $count;
152 }
153
154 $until = $this->getUntil();
155 if ($until) {
156 $parts['UNTIL'] = $until->getISO8601();
157 }
158
159 return $parts;
160 }
161
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',
181 ));
182 }
183
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))));
192 }
193 }
194
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));
208
209 $count = idx($dict, 'COUNT');
210 if ($count) {
211 $rrule->setCount($count);
212 }
213
214 $until = idx($dict, 'UNTIL');
215 if ($until) {
216 $until = PhutilCalendarAbsoluteDateTime::newFromISO8601($until);
217 $rrule->setUntil($until);
218 }
219
220 return $rrule;
221 }
222
223 public function toRRULE() {
224 $dict = $this->toDictionary();
225
226 $parts = array();
227 foreach ($dict as $key => $value) {
228 if (is_array($value)) {
229 $value = implode(',', $value);
230 }
231 $parts[] = "{$key}={$value}";
232 }
233
234 return implode(';', $parts);
235 }
236
237 public static function newFromRRULE($rrule) {
238 $parts = explode(';', $rrule);
239
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;
253 }
254 $dict[$key] = $value;
255 }
256
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',
268 ));
269
270 $int_values = array_fuse(
271 array(
272 'COUNT',
273 'INTERVAL',
274 ));
275
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));
286 }
287 $dict[$key] = (int)$value;
288 }
289
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.',
297 $v,
298 $key));
299 }
300 $value[$k] = (int)$v;
301 }
302 $dict[$key] = $value;
303 }
304 }
305
306 return self::newFromDictionary($dict);
307 }
308
309 private static function getAllWeekdayConstants() {
310 return array_keys(self::getWeekdayIndexMap());
311 }
312
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,
322 );
323
324 return $map;
325 }
326
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)));
337 }
338
339 return $map[$weekday];
340 }
341
342 public function setStartDateTime(PhutilCalendarDateTime $start) {
343 $this->startDateTime = $start;
344 return $this;
345 }
346
347 public function getStartDateTime() {
348 return $this->startDateTime;
349 }
350
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));
357 }
358
359 $this->count = $count;
360 return $this;
361 }
362
363 public function getCount() {
364 return $this->count;
365 }
366
367 public function setUntil(PhutilCalendarDateTime $until) {
368 $this->until = $until;
369 return $this;
370 }
371
372 public function getUntil() {
373 return $this->until;
374 }
375
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,
385 );
386
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))));
393 }
394
395 $this->frequency = $frequency;
396 $this->frequencyScale = $map[$frequency];
397
398 return $this;
399 }
400
401 public function getFrequency() {
402 return $this->frequency;
403 }
404
405 public function getFrequencyScale() {
406 return $this->frequencyScale;
407 }
408
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));
415 }
416
417 if ($interval < 1) {
418 throw new Exception(
419 pht(
420 'RRULE INTERVAL "%s" is invalid: interval must be 1 or more.',
421 $interval));
422 }
423
424 $this->interval = $interval;
425 return $this;
426 }
427
428 public function getInterval() {
429 return $this->interval;
430 }
431
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;
436 }
437
438 public function getBySecond() {
439 return $this->bySecond;
440 }
441
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;
446 }
447
448 public function getByMinute() {
449 return $this->byMinute;
450 }
451
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;
456 }
457
458 public function getByHour() {
459 return $this->byHour;
460 }
461
462 public function setByDay(array $by_day) {
463 $constants = self::getAllWeekdayConstants();
464 $constants = implode('|', $constants);
465
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));
475 }
476
477 // The maximum allowed value is 53, which corresponds to "the 53rd
478 // Monday every year" or similar when evaluated against a YEARLY rule.
479
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));
490 }
491
492 // Normalize "+3FR" into "3FR".
493 $by_day[$key] = ltrim($value, '+');
494 }
495
496 $this->byDay = array_fuse($by_day);
497 return $this;
498 }
499
500 public function getByDay() {
501 return $this->byDay;
502 }
503
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;
508 }
509
510 public function getByMonthDay() {
511 return $this->byMonthDay;
512 }
513
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;
518 }
519
520 public function getByYearDay() {
521 return $this->byYearDay;
522 }
523
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;
528 }
529
530 public function getByMonth() {
531 return $this->byMonth;
532 }
533
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;
538 }
539
540 public function getByWeekNumber() {
541 return $this->byWeekNumber;
542 }
543
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;
548 }
549
550 public function getBySetPosition() {
551 return $this->bySetPosition;
552 }
553
554 public function setWeekStart($week_start) {
555 // Make sure this is a valid weekday constant.
556 self::getWeekdayIndex($week_start);
557
558 $this->weekStart = $week_start;
559 return $this;
560 }
561
562 public function getWeekStart() {
563 return $this->weekStart;
564 }
565
566 public function resetSource() {
567 $frequency = $this->getFrequency();
568
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 default:
579 break;
580 }
581
582 }
583
584 if ($this->getByYearDay()) {
585 switch ($frequency) {
586 case self::FREQUENCY_DAILY:
587 case self::FREQUENCY_WEEKLY:
588 case self::FREQUENCY_MONTHLY:
589 // RFC5545: "The BYYEARDAY rule part MUST NOT be specified when the
590 // FREQ rule part is set to DAILY, WEEKLY, or MONTHLY."
591 throw new Exception(
592 pht(
593 'RRULE specifies BYYEARDAY with FREQ of DAILY, WEEKLY or '.
594 'MONTHLY, which violates RFC5545.'));
595 default:
596 break;
597 }
598 }
599
600 // TODO
601 // RFC5545: "The BYDAY rule part MUST NOT be specified with a numeric
602 // value when the FREQ rule part is not set to MONTHLY or YEARLY."
603 // RFC5545: "Furthermore, the BYDAY rule part MUST NOT be specified with a
604 // numeric value with the FREQ rule part set to YEARLY when the BYWEEKNO
605 // rule part is specified."
606
607
608 $date = $this->getStartDateTime();
609
610 $this->cursorSecond = $date->getSecond();
611 $this->cursorMinute = $date->getMinute();
612 $this->cursorHour = $date->getHour();
613
614 $this->cursorDay = $date->getDay();
615 $this->cursorMonth = $date->getMonth();
616 $this->cursorYear = $date->getYear();
617
618 $year_map = $this->getYearMap($this->cursorYear, $this->getWeekStart());
619 $key = $this->cursorMonth.'M'.$this->cursorDay.'D';
620 $this->cursorWeek = $year_map['info'][$key]['week'];
621 $this->cursorWeekday = $year_map['info'][$key]['weekday'];
622
623 $this->setSeconds = array();
624 $this->setMinutes = array();
625 $this->setHours = array();
626 $this->setDays = array();
627 $this->setMonths = array();
628 $this->setYears = array();
629
630 $this->stateSecond = null;
631 $this->stateMinute = null;
632 $this->stateHour = null;
633 $this->stateDay = null;
634 $this->stateWeek = null;
635 $this->stateMonth = null;
636 $this->stateYear = null;
637
638 // If we have a BYSETPOS, we need to generate the entire set before we
639 // can filter it and return results. Normally, we start generating at
640 // the start date, but we need to go back one interval to generate
641 // BYSETPOS events so we can make sure the entire set is generated.
642 if ($this->getBySetPosition()) {
643 $interval = $this->getInterval();
644 switch ($frequency) {
645 case self::FREQUENCY_YEARLY:
646 $this->cursorYear -= $interval;
647 break;
648 case self::FREQUENCY_MONTHLY:
649 $this->cursorMonth -= $interval;
650 $this->rewindMonth();
651 break;
652 case self::FREQUENCY_WEEKLY:
653 $this->cursorWeek -= $interval;
654 $this->rewindWeek();
655 break;
656 case self::FREQUENCY_DAILY:
657 $this->cursorDay -= $interval;
658 $this->rewindDay();
659 break;
660 case self::FREQUENCY_HOURLY:
661 $this->cursorHour -= $interval;
662 $this->rewindHour();
663 break;
664 case self::FREQUENCY_MINUTELY:
665 $this->cursorMinute -= $interval;
666 $this->rewindMinute();
667 break;
668 case self::FREQUENCY_SECONDLY:
669 default:
670 throw new Exception(
671 pht(
672 'RRULE specifies BYSETPOS with FREQ "%s", but this is invalid.',
673 $frequency));
674 }
675 }
676
677 // We can generate events from before the cursor when evaluating rules
678 // with BYSETPOS or FREQ=WEEKLY.
679 $this->minimumEpoch = $this->getStartDateTime()->getEpoch();
680
681 $cursor_state = array(
682 'year' => $this->cursorYear,
683 'month' => $this->cursorMonth,
684 'week' => $this->cursorWeek,
685 'day' => $this->cursorDay,
686 'hour' => $this->cursorHour,
687 );
688
689 $this->cursorDayState = $cursor_state;
690 $this->cursorWeekState = $cursor_state;
691 $this->cursorHourState = $cursor_state;
692
693 $by_hour = $this->getByHour();
694 $by_minute = $this->getByMinute();
695 $by_second = $this->getBySecond();
696
697 $scale = $this->getFrequencyScale();
698
699 // We return all-day events if the start date is an all-day event and we
700 // don't have more granular selectors or a more granular frequency.
701 $this->isAllDay = $date->getIsAllDay()
702 && !$by_hour
703 && !$by_minute
704 && !$by_second
705 && ($scale > self::SCALE_HOURLY);
706 }
707
708 public function getNextEvent($cursor) {
709 while (true) {
710 $event = $this->generateNextEvent();
711 if (!$event) {
712 break;
713 }
714
715 $epoch = $event->getEpoch();
716 if ($this->minimumEpoch) {
717 if ($epoch < $this->minimumEpoch) {
718 continue;
719 }
720 }
721
722 if ($epoch < $cursor) {
723 continue;
724 }
725
726 break;
727 }
728
729 return $event;
730 }
731
732 private function generateNextEvent() {
733 if ($this->activeSet) {
734 return array_pop($this->activeSet);
735 }
736
737 $this->baseYear = $this->cursorYear;
738
739 $by_setpos = $this->getBySetPosition();
740 if ($by_setpos) {
741 $old_state = $this->getSetPositionState();
742 }
743
744 while (!$this->activeSet) {
745 $this->activeSet = $this->nextSet;
746 $this->nextSet = array();
747
748 while (true) {
749 if ($this->isAllDay) {
750 $this->nextDay();
751 } else {
752 $this->nextSecond();
753 }
754
755 $result = id(new PhutilCalendarAbsoluteDateTime())
756 ->setTimezone($this->getStartDateTime()->getTimezone())
757 ->setViewerTimezone($this->getViewerTimezone())
758 ->setYear($this->stateYear)
759 ->setMonth($this->stateMonth)
760 ->setDay($this->stateDay);
761
762 if ($this->isAllDay) {
763 $result->setIsAllDay(true);
764 } else {
765 $result
766 ->setHour($this->stateHour)
767 ->setMinute($this->stateMinute)
768 ->setSecond($this->stateSecond);
769 }
770
771 // If we don't have BYSETPOS, we're all done. We put this into the
772 // set and will immediately return it.
773 if (!$by_setpos) {
774 $this->activeSet[] = $result;
775 break;
776 }
777
778 // Otherwise, check if we've completed a set. The set is complete if
779 // the state has moved past the span we were examining (for example,
780 // with a YEARLY event, if the state is now in the next year).
781 $new_state = $this->getSetPositionState();
782 if ($new_state == $old_state) {
783 $this->activeSet[] = $result;
784 continue;
785 }
786
787 $this->activeSet = $this->applySetPos($this->activeSet, $by_setpos);
788 $this->activeSet = array_reverse($this->activeSet);
789 $this->nextSet[] = $result;
790 $old_state = $new_state;
791 break;
792 }
793 }
794
795 return array_pop($this->activeSet);
796 }
797
798
799 protected function nextSecond() {
800 if ($this->setSeconds) {
801 $this->stateSecond = array_pop($this->setSeconds);
802 return;
803 }
804
805 $frequency = $this->getFrequency();
806 $interval = $this->getInterval();
807 $is_secondly = ($frequency == self::FREQUENCY_SECONDLY);
808 $by_second = $this->getBySecond();
809
810 while (!$this->setSeconds) {
811 $this->nextMinute();
812
813 if ($is_secondly || $by_second) {
814 $seconds = $this->newSecondsSet(
815 ($is_secondly ? $interval : 1),
816 $by_second);
817 } else {
818 $seconds = array(
819 $this->cursorSecond,
820 );
821 }
822
823 $this->setSeconds = array_reverse($seconds);
824 }
825
826 $this->stateSecond = array_pop($this->setSeconds);
827 }
828
829 protected function nextMinute() {
830 if ($this->setMinutes) {
831 $this->stateMinute = array_pop($this->setMinutes);
832 return;
833 }
834
835 $frequency = $this->getFrequency();
836 $interval = $this->getInterval();
837 $scale = $this->getFrequencyScale();
838 $is_minutely = ($frequency === self::FREQUENCY_MINUTELY);
839 $by_minute = $this->getByMinute();
840
841 while (!$this->setMinutes) {
842 $this->nextHour();
843
844 if ($is_minutely || $by_minute) {
845 $minutes = $this->newMinutesSet(
846 ($is_minutely ? $interval : 1),
847 $by_minute);
848 } else if ($scale < self::SCALE_MINUTELY) {
849 $minutes = $this->newMinutesSet(
850 1,
851 array());
852 } else {
853 $minutes = array(
854 $this->cursorMinute,
855 );
856 }
857
858 $this->setMinutes = array_reverse($minutes);
859 }
860
861 $this->stateMinute = array_pop($this->setMinutes);
862 }
863
864 protected function nextHour() {
865 if ($this->setHours) {
866 $this->stateHour = array_pop($this->setHours);
867 return;
868 }
869
870 $frequency = $this->getFrequency();
871 $interval = $this->getInterval();
872 $scale = $this->getFrequencyScale();
873 $is_hourly = ($frequency === self::FREQUENCY_HOURLY);
874 $by_hour = $this->getByHour();
875
876 while (!$this->setHours) {
877 $this->nextDay();
878
879 $is_dynamic = $is_hourly
880 || $by_hour
881 || ($scale < self::SCALE_HOURLY);
882
883 if ($is_dynamic) {
884 $hours = $this->newHoursSet(
885 ($is_hourly ? $interval : 1),
886 $by_hour);
887 } else {
888 $hours = array(
889 $this->cursorHour,
890 );
891 }
892
893 $this->setHours = array_reverse($hours);
894 }
895
896 $this->stateHour = array_pop($this->setHours);
897 }
898
899 protected function nextDay() {
900 if ($this->setDays) {
901 $info = array_pop($this->setDays);
902 $this->setDayState($info);
903 return;
904 }
905
906 $frequency = $this->getFrequency();
907 $interval = $this->getInterval();
908 $scale = $this->getFrequencyScale();
909 $is_daily = ($frequency === self::FREQUENCY_DAILY);
910 $is_weekly = ($frequency === self::FREQUENCY_WEEKLY);
911
912 $by_day = $this->getByDay();
913 $by_monthday = $this->getByMonthDay();
914 $by_yearday = $this->getByYearDay();
915 $by_weekno = $this->getByWeekNumber();
916 $by_month = $this->getByMonth();
917 $week_start = $this->getWeekStart();
918
919 while (!$this->setDays) {
920 if ($is_weekly) {
921 $this->nextWeek();
922 } else {
923 $this->nextMonth();
924 }
925
926 // NOTE: We normally handle BYMONTH when iterating months, but it acts
927 // like a filter if FREQ=WEEKLY.
928
929 $is_dynamic = $is_daily
930 || $is_weekly
931 || $by_day
932 || $by_monthday
933 || $by_yearday
934 || $by_weekno
935 || ($by_month && $is_weekly)
936 || ($scale < self::SCALE_DAILY);
937
938 if ($is_dynamic) {
939 $weeks = $this->newDaysSet(
940 ($is_daily ? $interval : 1),
941 $by_day,
942 $by_monthday,
943 $by_yearday,
944 $by_weekno,
945 $by_month,
946 $week_start);
947 } else {
948 // The cursor day may not actually exist in the current month, so
949 // make sure the day is valid before we generate a set which contains
950 // it.
951 $year_map = $this->getYearMap($this->stateYear, $week_start);
952 if ($this->cursorDay > $year_map['monthDays'][$this->stateMonth]) {
953 $weeks = array(
954 array(),
955 );
956 } else {
957 $key = $this->stateMonth.'M'.$this->cursorDay.'D';
958 $weeks = array(
959 array($year_map['info'][$key]),
960 );
961 }
962 }
963
964 // Unpack the weeks into days.
965 $days = array_mergev($weeks);
966
967 $this->setDays = array_reverse($days);
968 }
969
970 $info = array_pop($this->setDays);
971 $this->setDayState($info);
972 }
973
974 private function setDayState(array $info) {
975 $this->stateDay = $info['monthday'];
976 $this->stateWeek = $info['week'];
977 $this->stateMonth = $info['month'];
978 }
979
980 protected function nextMonth() {
981 if ($this->setMonths) {
982 $this->stateMonth = array_pop($this->setMonths);
983 return;
984 }
985
986 $frequency = $this->getFrequency();
987 $interval = $this->getInterval();
988 $scale = $this->getFrequencyScale();
989 $is_monthly = ($frequency === self::FREQUENCY_MONTHLY);
990
991 $by_month = $this->getByMonth();
992
993 // If we have a BYMONTHDAY, we consider that set of days in every month.
994 // For example, "FREQ=YEARLY;BYMONTHDAY=3" means "the third day of every
995 // month", so we need to expand the month set if the constraint is present.
996 $by_monthday = $this->getByMonthDay();
997
998 // Likewise, we need to generate all months if we have BYYEARDAY or
999 // BYWEEKNO or BYDAY.
1000 $by_yearday = $this->getByYearDay();
1001 $by_weekno = $this->getByWeekNumber();
1002 $by_day = $this->getByDay();
1003
1004 while (!$this->setMonths) {
1005 $this->nextYear();
1006
1007 $is_dynamic = $is_monthly
1008 || $by_month
1009 || $by_monthday
1010 || $by_yearday
1011 || $by_weekno
1012 || $by_day
1013 || ($scale < self::SCALE_MONTHLY);
1014
1015 if ($is_dynamic) {
1016 $months = $this->newMonthsSet(
1017 ($is_monthly ? $interval : 1),
1018 $by_month);
1019 } else {
1020 $months = array(
1021 $this->cursorMonth,
1022 );
1023 }
1024
1025 $this->setMonths = array_reverse($months);
1026 }
1027
1028 $this->stateMonth = array_pop($this->setMonths);
1029 }
1030
1031 protected function nextWeek() {
1032 if ($this->setWeeks) {
1033 $this->stateWeek = array_pop($this->setWeeks);
1034 return;
1035 }
1036
1037 $frequency = $this->getFrequency();
1038 $interval = $this->getInterval();
1039 $scale = $this->getFrequencyScale();
1040 $by_weekno = $this->getByWeekNumber();
1041
1042 while (!$this->setWeeks) {
1043 $this->nextYear();
1044
1045 $weeks = $this->newWeeksSet(
1046 $interval,
1047 $by_weekno);
1048
1049 $this->setWeeks = array_reverse($weeks);
1050 }
1051
1052 $this->stateWeek = array_pop($this->setWeeks);
1053 }
1054
1055 protected function nextYear() {
1056 $this->stateYear = $this->cursorYear;
1057
1058 $frequency = $this->getFrequency();
1059 $is_yearly = ($frequency === self::FREQUENCY_YEARLY);
1060
1061 if ($is_yearly) {
1062 $interval = $this->getInterval();
1063 } else {
1064 $interval = 1;
1065 }
1066
1067 $this->cursorYear = $this->cursorYear + $interval;
1068
1069 if ($this->cursorYear > ($this->baseYear + 100)) {
1070 throw new Exception(
1071 pht(
1072 'RRULE evaluation failed to generate more events in the next 100 '.
1073 'years. This RRULE is likely invalid or degenerate.'));
1074 }
1075
1076 }
1077
1078 private function newSecondsSet($interval, $set) {
1079 // TODO: This doesn't account for leap seconds. In theory, it probably
1080 // should, although this shouldn't impact any real events.
1081 $seconds_in_minute = 60;
1082
1083 if ($this->cursorSecond >= $seconds_in_minute) {
1084 $this->cursorSecond -= $seconds_in_minute;
1085 return array();
1086 }
1087
1088 list($cursor, $result) = $this->newIteratorSet(
1089 $this->cursorSecond,
1090 $interval,
1091 $set,
1092 $seconds_in_minute);
1093
1094 $this->cursorSecond = ($cursor - $seconds_in_minute);
1095
1096 return $result;
1097 }
1098
1099 private function newMinutesSet($interval, $set) {
1100 // NOTE: This value is legitimately a constant! Amazing!
1101 $minutes_in_hour = 60;
1102
1103 if ($this->cursorMinute >= $minutes_in_hour) {
1104 $this->cursorMinute -= $minutes_in_hour;
1105 return array();
1106 }
1107
1108 list($cursor, $result) = $this->newIteratorSet(
1109 $this->cursorMinute,
1110 $interval,
1111 $set,
1112 $minutes_in_hour);
1113
1114 $this->cursorMinute = ($cursor - $minutes_in_hour);
1115
1116 return $result;
1117 }
1118
1119 private function newHoursSet($interval, $set) {
1120 // TODO: This doesn't account for hours caused by daylight savings time.
1121 // It probably should, although this seems unlikely to impact any real
1122 // events.
1123 $hours_in_day = 24;
1124
1125 // If the hour cursor is behind the current time, we need to forward it in
1126 // INTERVAL increments so we end up with the right offset.
1127 list($skip, $this->cursorHourState) = $this->advanceCursorState(
1128 $this->cursorHourState,
1129 self::SCALE_HOURLY,
1130 $interval,
1131 $this->getWeekStart());
1132
1133 if ($skip) {
1134 return array();
1135 }
1136
1137 list($cursor, $result) = $this->newIteratorSet(
1138 $this->cursorHour,
1139 $interval,
1140 $set,
1141 $hours_in_day);
1142
1143 $this->cursorHour = ($cursor - $hours_in_day);
1144
1145 return $result;
1146 }
1147
1148 private function newWeeksSet($interval, $set) {
1149 $week_start = $this->getWeekStart();
1150
1151 list($skip, $this->cursorWeekState) = $this->advanceCursorState(
1152 $this->cursorWeekState,
1153 self::SCALE_WEEKLY,
1154 $interval,
1155 $week_start);
1156
1157 if ($skip) {
1158 return array();
1159 }
1160
1161 $year_map = $this->getYearMap($this->stateYear, $week_start);
1162
1163 $result = array();
1164 while (true) {
1165 if (!isset($year_map['weekMap'][$this->cursorWeek])) {
1166 break;
1167 }
1168 $result[] = $this->cursorWeek;
1169 $this->cursorWeek += $interval;
1170 }
1171
1172 $this->cursorWeek -= $year_map['weekCount'];
1173
1174 return $result;
1175 }
1176
1177 private function newDaysSet(
1178 $interval_day,
1179 $by_day,
1180 $by_monthday,
1181 $by_yearday,
1182 $by_weekno,
1183 $by_month,
1184 $week_start) {
1185
1186 $frequency = $this->getFrequency();
1187 $is_yearly = ($frequency == self::FREQUENCY_YEARLY);
1188 $is_monthly = ($frequency == self::FREQUENCY_MONTHLY);
1189 $is_weekly = ($frequency == self::FREQUENCY_WEEKLY);
1190
1191 $selection = array();
1192 if ($is_weekly) {
1193 $year_map = $this->getYearMap($this->stateYear, $week_start);
1194
1195 if (isset($year_map['weekMap'][$this->stateWeek])) {
1196 foreach ($year_map['weekMap'][$this->stateWeek] as $key) {
1197 $selection[] = $year_map['info'][$key];
1198 }
1199 }
1200 } else {
1201 // If the day cursor is behind the current year and month, we need to
1202 // forward it in INTERVAL increments so we end up with the right offset
1203 // in the current month.
1204 list($skip, $this->cursorDayState) = $this->advanceCursorState(
1205 $this->cursorDayState,
1206 self::SCALE_DAILY,
1207 $interval_day,
1208 $week_start);
1209
1210 if (!$skip) {
1211 $year_map = $this->getYearMap($this->stateYear, $week_start);
1212 while (true) {
1213 $month_idx = $this->stateMonth;
1214 $month_days = $year_map['monthDays'][$month_idx];
1215 if ($this->cursorDay > $month_days) {
1216 // NOTE: The year map is now out of date, but we're about to break
1217 // out of the loop anyway so it doesn't matter.
1218 break;
1219 }
1220
1221 $day_idx = $this->cursorDay;
1222
1223 $key = "{$month_idx}M{$day_idx}D";
1224 $selection[] = $year_map['info'][$key];
1225
1226 $this->cursorDay += $interval_day;
1227 }
1228 }
1229 }
1230
1231 // As a special case, BYDAY applies to relative month offsets if BYMONTH
1232 // is present in a YEARLY rule.
1233 if ($is_yearly) {
1234 if ($this->getByMonth()) {
1235 $is_yearly = false;
1236 $is_monthly = true;
1237 }
1238 }
1239
1240 // As a special case, BYDAY makes us examine all week days. This doesn't
1241 // check BYMONTHDAY or BYYEARDAY because they are not valid with WEEKLY.
1242 $filter_weekday = true;
1243 if ($is_weekly) {
1244 if ($by_day) {
1245 $filter_weekday = false;
1246 }
1247 }
1248
1249 $weeks = array();
1250 foreach ($selection as $key => $info) {
1251 if ($is_weekly) {
1252 if ($filter_weekday) {
1253 if ($info['weekday'] != $this->cursorWeekday) {
1254 continue;
1255 }
1256 }
1257 } else {
1258 if ($info['month'] != $this->stateMonth) {
1259 continue;
1260 }
1261 }
1262
1263 if ($by_day) {
1264 if (empty($by_day[$info['weekday']])) {
1265 if ($is_yearly) {
1266 if (empty($by_day[$info['weekday.yearly']]) &&
1267 empty($by_day[$info['-weekday.yearly']])) {
1268 continue;
1269 }
1270 } else if ($is_monthly) {
1271 if (empty($by_day[$info['weekday.monthly']]) &&
1272 empty($by_day[$info['-weekday.monthly']])) {
1273 continue;
1274 }
1275 } else {
1276 continue;
1277 }
1278 }
1279 }
1280
1281 if ($by_monthday) {
1282 if (empty($by_monthday[$info['monthday']]) &&
1283 empty($by_monthday[$info['-monthday']])) {
1284 continue;
1285 }
1286 }
1287
1288 if ($by_yearday) {
1289 if (empty($by_yearday[$info['yearday']]) &&
1290 empty($by_yearday[$info['-yearday']])) {
1291 continue;
1292 }
1293 }
1294
1295 if ($by_weekno) {
1296 if (empty($by_weekno[$info['week']]) &&
1297 empty($by_weekno[$info['-week']])) {
1298 continue;
1299 }
1300 }
1301
1302 if ($by_month) {
1303 if (empty($by_month[$info['month']])) {
1304 continue;
1305 }
1306 }
1307
1308 $weeks[$info['week']][] = $info;
1309 }
1310
1311 return array_values($weeks);
1312 }
1313
1314 private function newMonthsSet($interval, $set) {
1315 // NOTE: This value is also a real constant! Wow!
1316 $months_in_year = 12;
1317
1318 if ($this->cursorMonth > $months_in_year) {
1319 $this->cursorMonth -= $months_in_year;
1320 return array();
1321 }
1322
1323 list($cursor, $result) = $this->newIteratorSet(
1324 $this->cursorMonth,
1325 $interval,
1326 $set,
1327 $months_in_year + 1);
1328
1329 $this->cursorMonth = ($cursor - $months_in_year);
1330
1331 return $result;
1332 }
1333
1334 public static function getYearMap($year, $week_start) {
1335 static $maps = array();
1336
1337 $key = "{$year}/{$week_start}";
1338 if (isset($maps[$key])) {
1339 return $maps[$key];
1340 }
1341
1342 $map = self::newYearMap($year, $week_start);
1343 $maps[$key] = $map;
1344
1345 return $maps[$key];
1346 }
1347
1348 private static function newYearMap($year, $weekday_start) {
1349 $weekday_index = self::getWeekdayIndex($weekday_start);
1350
1351 $is_leap = (($year % 4 === 0) && ($year % 100 !== 0)) ||
1352 ($year % 400 === 0);
1353
1354 // There may be some clever way to figure out which day of the week a given
1355 // year starts on and avoid the cost of a DateTime construction, but I
1356 // wasn't able to turn it up and we only need to do this once per year.
1357 $datetime = new DateTime("{$year}-01-01", new DateTimeZone('UTC'));
1358 $weekday = (int)$datetime->format('w');
1359
1360 if ($is_leap) {
1361 $max_day = 366;
1362 } else {
1363 $max_day = 365;
1364 }
1365
1366 $month_days = array(
1367 1 => 31,
1368 2 => $is_leap ? 29 : 28,
1369 3 => 31,
1370 4 => 30,
1371 5 => 31,
1372 6 => 30,
1373 7 => 31,
1374 8 => 31,
1375 9 => 30,
1376 10 => 31,
1377 11 => 30,
1378 12 => 31,
1379 );
1380
1381 // Per the spec, the first week of the year must contain at least four
1382 // days. If the week starts on a Monday but the year starts on a Saturday,
1383 // the first couple of days don't count as a week. In this case, the first
1384 // week will begin on January 3.
1385 $first_week_size = 0;
1386 $first_weekday = $weekday;
1387 for ($year_day = 1; $year_day <= $max_day; $year_day++) {
1388 $first_weekday = ($first_weekday + 1) % 7;
1389 $first_week_size++;
1390 if ($first_weekday === $weekday_index) {
1391 break;
1392 }
1393 }
1394
1395 if ($first_week_size >= 4) {
1396 $week_number = 1;
1397 } else {
1398 $week_number = 0;
1399 }
1400
1401 $info_map = array();
1402
1403 $weekday_map = self::getWeekdayIndexMap();
1404 $weekday_map = array_flip($weekday_map);
1405
1406 $yearly_counts = array();
1407 $monthly_counts = array();
1408
1409 $month_number = 1;
1410 $month_day = 1;
1411 for ($year_day = 1; $year_day <= $max_day; $year_day++) {
1412 $key = "{$month_number}M{$month_day}D";
1413
1414 $short_day = $weekday_map[$weekday];
1415 if (empty($yearly_counts[$short_day])) {
1416 $yearly_counts[$short_day] = 0;
1417 }
1418 $yearly_counts[$short_day]++;
1419
1420 if (empty($monthly_counts[$month_number][$short_day])) {
1421 $monthly_counts[$month_number][$short_day] = 0;
1422 }
1423 $monthly_counts[$month_number][$short_day]++;
1424
1425 $info = array(
1426 'year' => $year,
1427 'key' => $key,
1428 'month' => $month_number,
1429 'monthday' => $month_day,
1430 '-monthday' => -$month_days[$month_number] + $month_day - 1,
1431 'yearday' => $year_day,
1432 '-yearday' => -$max_day + $year_day - 1,
1433 'week' => $week_number,
1434 'weekday' => $short_day,
1435 'weekday.yearly' => $yearly_counts[$short_day],
1436 'weekday.monthly' => $monthly_counts[$month_number][$short_day],
1437 );
1438
1439 $info_map[$key] = $info;
1440
1441 $weekday = ($weekday + 1) % 7;
1442 if ($weekday === $weekday_index) {
1443 $week_number++;
1444 }
1445
1446 $month_day = ($month_day + 1);
1447 if ($month_day > $month_days[$month_number]) {
1448 $month_day = 1;
1449 $month_number++;
1450 }
1451 }
1452
1453 // Check how long the final week is. If it doesn't have four days, this
1454 // is really the first week of the next year.
1455 $final_week = array();
1456 foreach ($info_map as $key => $info) {
1457 if ($info['week'] == $week_number) {
1458 $final_week[] = $key;
1459 }
1460 }
1461
1462 if (count($final_week) < 4) {
1463 $week_number = $week_number - 1;
1464 $next_year = self::getYearMap($year + 1, $weekday_start);
1465 $next_year_weeks = $next_year['weekCount'];
1466 } else {
1467 $next_year_weeks = null;
1468 }
1469
1470 if ($first_week_size < 4) {
1471 $last_year = self::getYearMap($year - 1, $weekday_start);
1472 $last_year_weeks = $last_year['weekCount'];
1473 } else {
1474 $last_year_weeks = null;
1475 }
1476
1477 // Now that we know how many weeks the year has, we can compute the
1478 // negative offsets.
1479 foreach ($info_map as $key => $info) {
1480 $week = $info['week'];
1481
1482 if ($week === 0) {
1483 // If this day is part of the first partial week of the year, give
1484 // it the week number of the last week of the prior year instead.
1485 $info['week'] = $last_year_weeks;
1486 $info['-week'] = -1;
1487 } else if ($week > $week_number) {
1488 // If this day is part of the last partial week of the year, give
1489 // it week numbers from the next year.
1490 $info['week'] = 1;
1491 $info['-week'] = -$next_year_weeks;
1492 } else {
1493 $info['-week'] = -$week_number + $week - 1;
1494 }
1495
1496 // Do all the arithmetic to figure out if this is the -19th Thursday
1497 // in the year and such.
1498 $month_number = $info['month'];
1499 $short_day = $info['weekday'];
1500 $monthly_count = $monthly_counts[$month_number][$short_day];
1501 $monthly_index = $info['weekday.monthly'];
1502 $info['-weekday.monthly'] = -$monthly_count + $monthly_index - 1;
1503 $info['-weekday.monthly'] .= $short_day;
1504 $info['weekday.monthly'] .= $short_day;
1505
1506 $yearly_count = $yearly_counts[$short_day];
1507 $yearly_index = $info['weekday.yearly'];
1508 $info['-weekday.yearly'] = -$yearly_count + $yearly_index - 1;
1509 $info['-weekday.yearly'] .= $short_day;
1510 $info['weekday.yearly'] .= $short_day;
1511
1512 $info_map[$key] = $info;
1513 }
1514
1515 $week_map = array();
1516 foreach ($info_map as $key => $info) {
1517 $week_map[$info['week']][] = $key;
1518 }
1519
1520 return array(
1521 'info' => $info_map,
1522 'weekCount' => $week_number,
1523 'dayCount' => $max_day,
1524 'monthDays' => $month_days,
1525 'weekMap' => $week_map,
1526 );
1527 }
1528
1529 private function newIteratorSet($cursor, $interval, $set, $limit) {
1530 if ($interval < 1) {
1531 throw new Exception(
1532 pht(
1533 'Invalid iteration interval ("%d"), must be at least 1.',
1534 $interval));
1535 }
1536
1537 $result = array();
1538 $seen = array();
1539
1540 $ii = $cursor;
1541 while (true) {
1542 if (!$set || isset($set[$ii])) {
1543 $result[] = $ii;
1544 }
1545
1546 $ii = ($ii + $interval);
1547
1548 if ($ii >= $limit) {
1549 break;
1550 }
1551 }
1552
1553 sort($result);
1554 $result = array_values($result);
1555
1556 return array($ii, $result);
1557 }
1558
1559 private function applySetPos(array $values, array $setpos) {
1560 $select = array();
1561
1562 $count = count($values);
1563 foreach ($setpos as $pos) {
1564 if ($pos > 0 && $pos <= $count) {
1565 $select[] = ($pos - 1);
1566 } else if ($pos < 0 && $pos >= -$count) {
1567 $select[] = ($count + $pos);
1568 }
1569 }
1570
1571 sort($select);
1572 $select = array_unique($select);
1573
1574 return array_select_keys($values, $select);
1575 }
1576
1577 private function assertByRange(
1578 $source,
1579 array $values,
1580 $min,
1581 $max,
1582 $allow_zero = true) {
1583
1584 foreach ($values as $value) {
1585 if (!is_int($value)) {
1586 throw new Exception(
1587 pht(
1588 'Value "%s" in RRULE "%s" parameter is invalid: values must be '.
1589 'integers.',
1590 $value,
1591 $source));
1592 }
1593
1594 if ($value < $min || $value > $max) {
1595 throw new Exception(
1596 pht(
1597 'Value "%s" in RRULE "%s" parameter is invalid: it must be '.
1598 'between %s and %s.',
1599 $value,
1600 $source,
1601 $min,
1602 $max));
1603 }
1604
1605 if (!$value && !$allow_zero) {
1606 throw new Exception(
1607 pht(
1608 'Value "%s" in RRULE "%s" parameter is invalid: it must not '.
1609 'be zero.',
1610 $value,
1611 $source));
1612 }
1613 }
1614 }
1615
1616 private function getSetPositionState() {
1617 $scale = $this->getFrequencyScale();
1618
1619 $parts = array();
1620 $parts[] = $this->stateYear;
1621
1622 if ($scale == self::SCALE_WEEKLY) {
1623 $parts[] = $this->stateWeek;
1624 } else {
1625 if ($scale < self::SCALE_YEARLY) {
1626 $parts[] = $this->stateMonth;
1627 }
1628 if ($scale < self::SCALE_MONTHLY) {
1629 $parts[] = $this->stateDay;
1630 }
1631 if ($scale < self::SCALE_DAILY) {
1632 $parts[] = $this->stateHour;
1633 }
1634 if ($scale < self::SCALE_HOURLY) {
1635 $parts[] = $this->stateMinute;
1636 }
1637 }
1638
1639 return implode('/', $parts);
1640 }
1641
1642 private function rewindMonth() {
1643 while ($this->cursorMonth < 1) {
1644 $this->cursorYear--;
1645 $this->cursorMonth += 12;
1646 }
1647 }
1648
1649 private function rewindWeek() {
1650 $week_start = $this->getWeekStart();
1651 while ($this->cursorWeek < 1) {
1652 $this->cursorYear--;
1653 $year_map = $this->getYearMap($this->cursorYear, $week_start);
1654 $this->cursorWeek += $year_map['weekCount'];
1655 }
1656 }
1657
1658 private function rewindDay() {
1659 $week_start = $this->getWeekStart();
1660 while ($this->cursorDay < 1) {
1661 $year_map = $this->getYearMap($this->cursorYear, $week_start);
1662 $this->cursorDay += $year_map['monthDays'][$this->cursorMonth];
1663 $this->cursorMonth--;
1664 $this->rewindMonth();
1665 }
1666 }
1667
1668 private function rewindHour() {
1669 while ($this->cursorHour < 0) {
1670 $this->cursorHour += 24;
1671 $this->cursorDay--;
1672 $this->rewindDay();
1673 }
1674 }
1675
1676 private function rewindMinute() {
1677 while ($this->cursorMinute < 0) {
1678 $this->cursorMinute += 60;
1679 $this->cursorHour--;
1680 $this->rewindHour();
1681 }
1682 }
1683
1684 private function advanceCursorState(
1685 array $cursor,
1686 $scale,
1687 $interval,
1688 $week_start) {
1689
1690 $state = array(
1691 'year' => $this->stateYear,
1692 'month' => $this->stateMonth,
1693 'week' => $this->stateWeek,
1694 'day' => $this->stateDay,
1695 'hour' => $this->stateHour,
1696 );
1697
1698 // In the common case when the interval is 1, we'll visit every possible
1699 // value so we don't need to do any math and can just jump to the first
1700 // hour, day, etc.
1701 if ($interval == 1) {
1702 if ($this->isCursorBehind($cursor, $state, $scale)) {
1703 switch ($scale) {
1704 case self::SCALE_DAILY:
1705 $this->cursorDay = 1;
1706 break;
1707 case self::SCALE_HOURLY:
1708 $this->cursorHour = 0;
1709 break;
1710 case self::SCALE_WEEKLY:
1711 $this->cursorWeek = 1;
1712 break;
1713 }
1714 }
1715
1716 return array(false, $state);
1717 }
1718
1719 $year_map = $this->getYearMap($cursor['year'], $week_start);
1720 while ($this->isCursorBehind($cursor, $state, $scale)) {
1721 switch ($scale) {
1722 case self::SCALE_DAILY:
1723 $cursor['day'] += $interval;
1724 break;
1725 case self::SCALE_HOURLY:
1726 $cursor['hour'] += $interval;
1727 break;
1728 case self::SCALE_WEEKLY:
1729 $cursor['week'] += $interval;
1730 break;
1731 }
1732
1733 if ($scale <= self::SCALE_HOURLY) {
1734 while ($cursor['hour'] >= 24) {
1735 $cursor['hour'] -= 24;
1736 $cursor['day']++;
1737 }
1738 }
1739
1740 if ($scale == self::SCALE_WEEKLY) {
1741 while ($cursor['week'] > $year_map['weekCount']) {
1742 $cursor['week'] -= $year_map['weekCount'];
1743 $cursor['year']++;
1744 $year_map = $this->getYearMap($cursor['year'], $week_start);
1745 }
1746 }
1747
1748 if ($scale <= self::SCALE_DAILY) {
1749 while ($cursor['day'] > $year_map['monthDays'][$cursor['month']]) {
1750 $cursor['day'] -= $year_map['monthDays'][$cursor['month']];
1751 $cursor['month']++;
1752 if ($cursor['month'] > 12) {
1753 $cursor['month'] -= 12;
1754 $cursor['year']++;
1755 $year_map = $this->getYearMap($cursor['year'], $week_start);
1756 }
1757 }
1758 }
1759 }
1760
1761 switch ($scale) {
1762 case self::SCALE_DAILY:
1763 $this->cursorDay = $cursor['day'];
1764 break;
1765 case self::SCALE_HOURLY:
1766 $this->cursorHour = $cursor['hour'];
1767 break;
1768 case self::SCALE_WEEKLY:
1769 $this->cursorWeek = $cursor['week'];
1770 break;
1771 }
1772
1773 $skip = $this->isCursorBehind($state, $cursor, $scale);
1774
1775 return array($skip, $cursor);
1776 }
1777
1778 private function isCursorBehind(array $cursor, array $state, $scale) {
1779 if ($cursor['year'] < $state['year']) {
1780 return true;
1781 } else if ($cursor['year'] > $state['year']) {
1782 return false;
1783 }
1784
1785 if ($scale == self::SCALE_WEEKLY) {
1786 return false;
1787 }
1788
1789 if ($cursor['month'] < $state['month']) {
1790 return true;
1791 } else if ($cursor['month'] > $state['month']) {
1792 return false;
1793 }
1794
1795 if ($scale >= self::SCALE_DAILY) {
1796 return false;
1797 }
1798
1799 if ($cursor['day'] < $state['day']) {
1800 return true;
1801 } else if ($cursor['day'] > $state['day']) {
1802 return false;
1803 }
1804
1805 if ($scale >= self::SCALE_HOURLY) {
1806 return false;
1807 }
1808
1809 if ($cursor['hour'] < $state['hour']) {
1810 return true;
1811 } else if ($cursor['hour'] > $state['hour']) {
1812 return false;
1813 }
1814
1815 return false;
1816 }
1817
1818
1819}