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