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