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