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