@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
at upstream/main 1819 lines 48 kB view raw
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}