3 namespace Sabre\VObject;
6 * This class is used to determine new for a recurring event, when the next
9 * This iterator may loop infinitely in the future, therefore it is important
10 * that if you use this class, you set hard limits for the amount of iterations
13 * Note that currently there is not full support for the entire iCalendar
14 * specification, as it's very complex and contains a lot of permutations
15 * that's not yet used very often in software.
17 * For the focus has been on features as they actually appear in Calendaring
18 * software, but this may well get expanded as needed / on demand
20 * The following RRULE properties are supported
35 * * BYMONTHDAY (only if BYMONTH is also set)
36 * * BYDAY (only if BYMONTH is also set)
38 * Anything beyond this is 'undefined', which means that it may get ignored, or
39 * you may get unexpected results. The effect is that in some applications the
40 * specified recurrence may look incorrect, or is missing.
42 * @copyright Copyright (C) 2007-2012 Rooftop Solutions. All rights reserved.
43 * @author Evert Pot (http://www.rooftopsolutions.nl/)
44 * @license http://code.google.com/p/sabredav/wiki/License Modified BSD License
46 class RecurrenceIterator implements \Iterator {
49 * The initial event date
56 * The end-date of the initial event
63 * The 'current' recurrence.
65 * This will be increased for every iteration.
73 * List of dates that are excluded from the rules.
75 * This list contains the items that have been overriden by the EXDATE
80 public $exceptionDates = array();
85 * @var Component\VEvent
90 * List of dates that are overridden by other events.
91 * Similar to $overriddenEvents, but this just contains the original dates.
95 public $overriddenDates = array();
98 * list of events that are 'overridden'.
100 * This is an array of Component\VEvent objects.
104 public $overriddenEvents = array();
108 * Frequency is one of: secondly, minutely, hourly, daily, weekly, monthly,
116 * The last instance of this recurrence, inclusively
123 * The number of recurrences, or 'null' if infinitely recurring.
132 * If for example frequency is set to daily, interval = 2 would mean every
137 public $interval = 1;
140 * Which seconds to recur.
142 * This is an array of integers (between 0 and 60)
149 * Which minutes to recur
151 * This is an array of integers (between 0 and 59)
158 * Which hours to recur
160 * This is an array of integers (between 0 and 23)
167 * Which weekdays to recur.
169 * This is an array of weekdays
171 * This may also be preceeded by a positive or negative integer. If present,
172 * this indicates the nth occurrence of a specific day within the monthly or
173 * yearly rrule. For instance, -2TU indicates the second-last tuesday of
174 * the month, or year.
181 * Which days of the month to recur
183 * This is an array of days of the months (1-31). The value can also be
184 * negative. -5 for instance means the 5th last day of the month.
191 * Which days of the year to recur.
193 * This is an array with days of the year (1 to 366). The values can also
194 * be negative. For instance, -1 will always represent the last day of the
195 * year. (December 31st).
202 * Which week numbers to recur.
204 * This is an array of integers from 1 to 53. The values can also be
205 * negative. -1 will always refer to the last week of the year.
212 * Which months to recur
214 * This is an array of integers from 1 to 12.
221 * Which items in an existing st to recur.
223 * These numbers work together with an existing by* rule. It specifies
224 * exactly which items of the existing by-rule to filter.
226 * Valid values are 1 to 366 and -1 to -366. As an example, this can be
227 * used to recur the last workday of the month.
229 * This would be done by setting frequency to 'monthly', byDay to
230 * 'MO,TU,WE,TH,FR' and bySetPos to -1.
241 public $weekStart = 'MO';
244 * The current item in the list
251 * Simple mapping from iCalendar day names to day numbers
255 private $dayMap = array(
266 * Mappings between the day number and english day name.
270 private $dayNames = array(
281 * If the current iteration of the event is an overriden event, this
282 * property will hold the VObject
286 private $currentOverriddenEvent;
289 * This property may contain the date of the next not-overridden event.
290 * This date is calculated sometimes a bit early, before overridden events
298 * Creates the iterator
300 * You should pass a VCALENDAR component, as well as the UID of the event
301 * we're going to traverse.
303 * @param Component $vcal
304 * @param string|null $uid
306 public function __construct(Component $vcal, $uid=null) {
309 if ($vcal->name === 'VCALENDAR') {
310 throw new \InvalidArgumentException('If you pass a VCALENDAR object, you must pass a uid argument as well');
312 $components = array($vcal);
313 $uid = (string)$vcal->uid;
315 $components = $vcal->select('VEVENT');
317 foreach($components as $component) {
318 if ((string)$component->uid == $uid) {
319 if (isset($component->{'RECURRENCE-ID'})) {
320 $this->overriddenEvents[$component->DTSTART->getDateTime()->getTimeStamp()] = $component;
321 $this->overriddenDates[] = $component->{'RECURRENCE-ID'}->getDateTime();
323 $this->baseEvent = $component;
327 if (!$this->baseEvent) {
328 throw new \InvalidArgumentException('Could not find a base event with uid: ' . $uid);
331 $this->startDate = clone $this->baseEvent->DTSTART->getDateTime();
333 $this->endDate = null;
334 if (isset($this->baseEvent->DTEND)) {
335 $this->endDate = clone $this->baseEvent->DTEND->getDateTime();
337 $this->endDate = clone $this->startDate;
338 if (isset($this->baseEvent->DURATION)) {
339 $this->endDate->add(DateTimeParser::parse($this->baseEvent->DURATION->value));
340 } elseif ($this->baseEvent->DTSTART->getDateType()===Property\DateTime::DATE) {
341 $this->endDate->modify('+1 day');
344 $this->currentDate = clone $this->startDate;
346 $rrule = (string)$this->baseEvent->RRULE;
348 $parts = explode(';', $rrule);
350 // If no rrule was specified, we create a default setting
352 $this->frequency = 'daily';
354 } else foreach($parts as $part) {
356 list($key, $value) = explode('=', $part, 2);
358 switch(strtoupper($key)) {
363 array('secondly','minutely','hourly','daily','weekly','monthly','yearly')
365 throw new \InvalidArgumentException('Unknown value for FREQ=' . strtoupper($value));
368 $this->frequency = strtolower($value);
372 $this->until = DateTimeParser::parse($value);
376 $this->count = (int)$value;
380 $this->interval = (int)$value;
384 $this->bySecond = explode(',', $value);
388 $this->byMinute = explode(',', $value);
392 $this->byHour = explode(',', $value);
396 $this->byDay = explode(',', strtoupper($value));
400 $this->byMonthDay = explode(',', $value);
404 $this->byYearDay = explode(',', $value);
408 $this->byWeekNo = explode(',', $value);
412 $this->byMonth = explode(',', $value);
416 $this->bySetPos = explode(',', $value);
420 $this->weekStart = strtoupper($value);
427 // Parsing exception dates
428 if (isset($this->baseEvent->EXDATE)) {
429 foreach($this->baseEvent->EXDATE as $exDate) {
431 foreach(explode(',', (string)$exDate) as $exceptionDate) {
433 $this->exceptionDates[] =
434 DateTimeParser::parse($exceptionDate, $this->startDate->getTimeZone());
445 * Returns the current item in the list
449 public function current() {
451 if (!$this->valid()) return null;
452 return clone $this->currentDate;
457 * This method returns the startdate for the current iteration of the
462 public function getDtStart() {
464 if (!$this->valid()) return null;
465 return clone $this->currentDate;
470 * This method returns the enddate for the current iteration of the
475 public function getDtEnd() {
477 if (!$this->valid()) return null;
478 $dtEnd = clone $this->currentDate;
479 $dtEnd->add( $this->startDate->diff( $this->endDate ) );
485 * Returns a VEVENT object with the updated start and end date.
487 * Any recurrence information is removed, and this function may return an
488 * 'overridden' event instead.
490 * This method always returns a cloned instance.
492 * @return Component\VEvent
494 public function getEventObject() {
496 if ($this->currentOverriddenEvent) {
497 return clone $this->currentOverriddenEvent;
499 $event = clone $this->baseEvent;
500 unset($event->RRULE);
501 unset($event->EXDATE);
502 unset($event->RDATE);
503 unset($event->EXRULE);
505 $event->DTSTART->setDateTime($this->getDTStart(), $event->DTSTART->getDateType());
506 if (isset($event->DTEND)) {
507 $event->DTEND->setDateTime($this->getDtEnd(), $event->DTSTART->getDateType());
509 if ($this->counter > 0) {
510 $event->{'RECURRENCE-ID'} = (string)$event->DTSTART;
518 * Returns the current item number
522 public function key() {
524 return $this->counter;
529 * Whether or not there is a 'next item'
533 public function valid() {
535 if (!is_null($this->count)) {
536 return $this->counter < $this->count;
538 if (!is_null($this->until)) {
539 return $this->currentDate <= $this->until;
546 * Resets the iterator
550 public function rewind() {
552 $this->currentDate = clone $this->startDate;
558 * This method allows you to quickly go to the next occurrence after the
561 * Note that this checks the current 'endDate', not the 'stardDate'. This
562 * means that if you forward to January 1st, the iterator will stop at the
563 * first event that ends *after* January 1st.
565 * @param DateTime $dt
568 public function fastForward(\DateTime $dt) {
570 while($this->valid() && $this->getDTEnd() <= $dt) {
577 * Returns true if this recurring event never ends.
581 public function isInfinite() {
583 return !$this->count && !$this->until;
588 * Goes on to the next iteration
592 public function next() {
595 if (!is_null($this->count) && $this->counter >= $this->count) {
596 $this->currentDate = null;
600 $previousStamp = $this->currentDate->getTimeStamp();
604 $this->currentOverriddenEvent = null;
606 // If we have a next date 'stored', we use that
607 if ($this->nextDate) {
608 $this->currentDate = $this->nextDate;
609 $currentStamp = $this->currentDate->getTimeStamp();
610 $this->nextDate = null;
613 // Otherwise, we calculate it
614 switch($this->frequency) {
625 $this->nextMonthly();
633 $currentStamp = $this->currentDate->getTimeStamp();
635 // Checking exception dates
636 foreach($this->exceptionDates as $exceptionDate) {
637 if ($this->currentDate == $exceptionDate) {
642 foreach($this->overriddenDates as $overriddenDate) {
643 if ($this->currentDate == $overriddenDate) {
650 // Checking overridden events
651 foreach($this->overriddenEvents as $index=>$event) {
652 if ($index > $previousStamp && $index <= $currentStamp) {
654 // We're moving the 'next date' aside, for later use.
655 $this->nextDate = clone $this->currentDate;
657 $this->currentDate = $event->DTSTART->getDateTime();
658 $this->currentOverriddenEvent = $event;
669 if (!is_null($this->until)) {
670 if($this->currentDate > $this->until) {
671 $this->currentDate = null;
680 * Does the processing for advancing the iterator for daily frequency.
684 protected function nextDaily() {
687 $this->currentDate->modify('+' . $this->interval . ' days');
691 $recurrenceDays = array();
692 foreach($this->byDay as $byDay) {
694 // The day may be preceeded with a positive (+n) or
695 // negative (-n) integer. However, this does not make
696 // sense in 'weekly' so we ignore it here.
697 $recurrenceDays[] = $this->dayMap[substr($byDay,-2)];
703 $this->currentDate->modify('+' . $this->interval . ' days');
705 // Current day of the week
706 $currentDay = $this->currentDate->format('w');
708 } while (!in_array($currentDay, $recurrenceDays));
713 * Does the processing for advancing the iterator for weekly frequency.
717 protected function nextWeekly() {
720 $this->currentDate->modify('+' . $this->interval . ' weeks');
724 $recurrenceDays = array();
725 foreach($this->byDay as $byDay) {
727 // The day may be preceeded with a positive (+n) or
728 // negative (-n) integer. However, this does not make
729 // sense in 'weekly' so we ignore it here.
730 $recurrenceDays[] = $this->dayMap[substr($byDay,-2)];
734 // Current day of the week
735 $currentDay = $this->currentDate->format('w');
737 // First day of the week:
738 $firstDay = $this->dayMap[$this->weekStart];
741 $this->currentDate->format('H'),
742 $this->currentDate->format('i'),
743 $this->currentDate->format('s')
746 // Increasing the 'current day' until we find our next
756 // We need to roll over to the next week
757 if ($currentDay === $firstDay) {
758 $this->currentDate->modify('+' . $this->interval . ' weeks');
760 // We need to go to the first day of this week, but only if we
761 // are not already on this first day of this week.
762 if($this->currentDate->format('w') != $firstDay) {
763 $this->currentDate->modify('last ' . $this->dayNames[$this->dayMap[$this->weekStart]]);
764 $this->currentDate->setTime($time[0],$time[1],$time[2]);
769 if (in_array($currentDay ,$recurrenceDays)) {
770 $this->currentDate->modify($this->dayNames[$currentDay]);
771 $this->currentDate->setTime($time[0],$time[1],$time[2]);
780 * Does the processing for advancing the iterator for monthly frequency.
784 protected function nextMonthly() {
786 $currentDayOfMonth = $this->currentDate->format('j');
787 if (!$this->byMonthDay && !$this->byDay) {
789 // If the current day is higher than the 28th, rollover can
790 // occur to the next month. We Must skip these invalid
792 if ($currentDayOfMonth < 29) {
793 $this->currentDate->modify('+' . $this->interval . ' months');
798 $tempDate = clone $this->currentDate;
799 $tempDate->modify('+ ' . ($this->interval*$increase) . ' months');
800 } while ($tempDate->format('j') != $currentDayOfMonth);
801 $this->currentDate = $tempDate;
808 $occurrences = $this->getMonthlyOccurrences();
810 foreach($occurrences as $occurrence) {
812 // The first occurrence thats higher than the current
813 // day of the month wins.
814 if ($occurrence > $currentDayOfMonth) {
820 // If we made it all the way here, it means there were no
821 // valid occurrences, and we need to advance to the next
823 $this->currentDate->modify('first day of this month');
824 $this->currentDate->modify('+ ' . $this->interval . ' months');
826 // This goes to 0 because we need to start counting at hte
828 $currentDayOfMonth = 0;
832 $this->currentDate->setDate($this->currentDate->format('Y'), $this->currentDate->format('n'), $occurrence);
837 * Does the processing for advancing the iterator for yearly frequency.
841 protected function nextYearly() {
843 $currentMonth = $this->currentDate->format('n');
844 $currentYear = $this->currentDate->format('Y');
845 $currentDayOfMonth = $this->currentDate->format('j');
847 // No sub-rules, so we just advance by year
848 if (!$this->byMonth) {
850 // Unless it was a leap day!
851 if ($currentMonth==2 && $currentDayOfMonth==29) {
856 // Here we increase the year count by the interval, until
857 // we hit a date that's also in a leap year.
859 // We could just find the next interval that's dividable by
860 // 4, but that would ignore the rule that there's no leap
861 // year every year that's dividable by a 100, but not by
862 // 400. (1800, 1900, 2100). So we just rely on the datetime
863 // functions instead.
864 $nextDate = clone $this->currentDate;
865 $nextDate->modify('+ ' . ($this->interval*$counter) . ' years');
866 } while ($nextDate->format('n')!=2);
867 $this->currentDate = $nextDate;
874 $this->currentDate->modify('+' . $this->interval . ' years');
879 $currentMonth = $this->currentDate->format('n');
880 $currentYear = $this->currentDate->format('Y');
881 $currentDayOfMonth = $this->currentDate->format('j');
883 $advancedToNewMonth = false;
885 // If we got a byDay or getMonthDay filter, we must first expand
887 if ($this->byDay || $this->byMonthDay) {
891 $occurrences = $this->getMonthlyOccurrences();
893 foreach($occurrences as $occurrence) {
895 // The first occurrence that's higher than the current
896 // day of the month wins.
897 // If we advanced to the next month or year, the first
898 // occurrence is always correct.
899 if ($occurrence > $currentDayOfMonth || $advancedToNewMonth) {
905 // If we made it here, it means we need to advance to
906 // the next month or year.
907 $currentDayOfMonth = 1;
908 $advancedToNewMonth = true;
912 if ($currentMonth>12) {
913 $currentYear+=$this->interval;
916 } while (!in_array($currentMonth, $this->byMonth));
918 $this->currentDate->setDate($currentYear, $currentMonth, $currentDayOfMonth);
922 // If we made it here, it means we got a valid occurrence
923 $this->currentDate->setDate($currentYear, $currentMonth, $occurrence);
928 // These are the 'byMonth' rules, if there are no byDay or
929 // byMonthDay sub-rules.
933 if ($currentMonth>12) {
934 $currentYear+=$this->interval;
937 } while (!in_array($currentMonth, $this->byMonth));
938 $this->currentDate->setDate($currentYear, $currentMonth, $currentDayOfMonth);
947 * Returns all the occurrences for a monthly frequency with a 'byDay' or
948 * 'byMonthDay' expansion for the current month.
950 * The returned list is an array of integers with the day of month (1-31).
954 protected function getMonthlyOccurrences() {
956 $startDate = clone $this->currentDate;
958 $byDayResults = array();
960 // Our strategy is to simply go through the byDays, advance the date to
961 // that point and add it to the results.
962 if ($this->byDay) foreach($this->byDay as $day) {
964 $dayName = $this->dayNames[$this->dayMap[substr($day,-2)]];
966 // Dayname will be something like 'wednesday'. Now we need to find
967 // all wednesdays in this month.
970 $checkDate = clone $startDate;
971 $checkDate->modify('first day of this month');
972 $checkDate->modify($dayName);
975 $dayHits[] = $checkDate->format('j');
976 $checkDate->modify('next ' . $dayName);
977 } while ($checkDate->format('n') === $startDate->format('n'));
979 // So now we have 'all wednesdays' for month. It is however
980 // possible that the user only really wanted the 1st, 2nd or last
982 if (strlen($day)>2) {
983 $offset = (int)substr($day,0,-2);
986 // It is possible that the day does not exist, such as a
987 // 5th or 6th wednesday of the month.
988 if (isset($dayHits[$offset-1])) {
989 $byDayResults[] = $dayHits[$offset-1];
993 // if it was negative we count from the end of the array
994 $byDayResults[] = $dayHits[count($dayHits) + $offset];
997 // There was no counter (first, second, last wednesdays), so we
998 // just need to add the all to the list).
999 $byDayResults = array_merge($byDayResults, $dayHits);
1005 $byMonthDayResults = array();
1006 if ($this->byMonthDay) foreach($this->byMonthDay as $monthDay) {
1008 // Removing values that are out of range for this month
1009 if ($monthDay > $startDate->format('t') ||
1010 $monthDay < 0-$startDate->format('t')) {
1014 $byMonthDayResults[] = $monthDay;
1017 $byMonthDayResults[] = $startDate->format('t') + 1 + $monthDay;
1021 // If there was just byDay or just byMonthDay, they just specify our
1022 // (almost) final list. If both were provided, then byDay limits the
1024 if ($this->byMonthDay && $this->byDay) {
1025 $result = array_intersect($byMonthDayResults, $byDayResults);
1026 } elseif ($this->byMonthDay) {
1027 $result = $byMonthDayResults;
1029 $result = $byDayResults;
1031 $result = array_unique($result);
1032 sort($result, SORT_NUMERIC);
1034 // The last thing that needs checking is the BYSETPOS. If it's set, it
1035 // means only certain items in the set survive the filter.
1036 if (!$this->bySetPos) {
1040 $filteredResult = array();
1041 foreach($this->bySetPos as $setPos) {
1044 $setPos = count($result)-($setPos+1);
1046 if (isset($result[$setPos-1])) {
1047 $filteredResult[] = $result[$setPos-1];
1051 sort($filteredResult, SORT_NUMERIC);
1052 return $filteredResult;