4 * This class is used to determine new for a recurring event, when the next
7 * This iterator may loop infinitely in the future, therefore it is important
8 * that if you use this class, you set hard limits for the amount of iterations
11 * Note that currently there is not full support for the entire iCalendar
12 * specification, as it's very complex and contains a lot of permutations
13 * that's not yet used very often in software.
15 * For the focus has been on features as they actually appear in Calendaring
16 * software, but this may well get expanded as needed / on demand
18 * The following RRULE properties are supported
33 * * BYMONTHDAY (only if BYMONTH is also set)
34 * * BYDAY (only if BYMONTH is also set)
36 * Anything beyond this is 'undefined', which means that it may get ignored, or
37 * you may get unexpected results. The effect is that in some applications the
38 * 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 Sabre_VObject_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 Sabre_VObject_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 Sabre_VObject_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
284 * @var Sabre_VObject_Component
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 Sabre_VObject_Component $vcal
304 * @param string|null $uid
306 public function __construct(Sabre_VObject_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(Sabre_VObject_DateTimeParser::parse($this->baseEvent->DURATION->value));
342 $this->currentDate = clone $this->startDate;
344 $rrule = (string)$this->baseEvent->RRULE;
346 $parts = explode(';', $rrule);
348 // If no rrule was specified, we create a default setting
350 $this->frequency = 'daily';
352 } else foreach($parts as $part) {
354 list($key, $value) = explode('=', $part, 2);
356 switch(strtoupper($key)) {
361 array('secondly','minutely','hourly','daily','weekly','monthly','yearly')
363 throw new InvalidArgumentException('Unknown value for FREQ=' . strtoupper($value));
366 $this->frequency = strtolower($value);
370 $this->until = Sabre_VObject_DateTimeParser::parse($value);
374 $this->count = (int)$value;
378 $this->interval = (int)$value;
382 $this->bySecond = explode(',', $value);
386 $this->byMinute = explode(',', $value);
390 $this->byHour = explode(',', $value);
394 $this->byDay = explode(',', strtoupper($value));
398 $this->byMonthDay = explode(',', $value);
402 $this->byYearDay = explode(',', $value);
406 $this->byWeekNo = explode(',', $value);
410 $this->byMonth = explode(',', $value);
414 $this->bySetPos = explode(',', $value);
418 $this->weekStart = strtoupper($value);
425 // Parsing exception dates
426 if (isset($this->baseEvent->EXDATE)) {
427 foreach($this->baseEvent->EXDATE as $exDate) {
429 foreach(explode(',', (string)$exDate) as $exceptionDate) {
431 $this->exceptionDates[] =
432 Sabre_VObject_DateTimeParser::parse($exceptionDate, $this->startDate->getTimeZone());
443 * Returns the current item in the list
447 public function current() {
449 if (!$this->valid()) return null;
450 return clone $this->currentDate;
455 * This method returns the startdate for the current iteration of the
460 public function getDtStart() {
462 if (!$this->valid()) return null;
463 return clone $this->currentDate;
468 * This method returns the enddate for the current iteration of the
473 public function getDtEnd() {
475 if (!$this->valid()) return null;
476 $dtEnd = clone $this->currentDate;
477 $dtEnd->add( $this->startDate->diff( $this->endDate ) );
483 * Returns a VEVENT object with the updated start and end date.
485 * Any recurrence information is removed, and this function may return an
486 * 'overridden' event instead.
488 * This method always returns a cloned instance.
490 * @return Sabre_VObject_Component_VEvent
492 public function getEventObject() {
494 if ($this->currentOverriddenEvent) {
495 return clone $this->currentOverriddenEvent;
497 $event = clone $this->baseEvent;
498 unset($event->RRULE);
499 unset($event->EXDATE);
500 unset($event->RDATE);
501 unset($event->EXRULE);
503 $event->DTSTART->setDateTime($this->getDTStart(), $event->DTSTART->getDateType());
504 if (isset($event->DTEND)) {
505 $event->DTEND->setDateTime($this->getDtEnd(), $event->DTSTART->getDateType());
507 if ($this->counter > 0) {
508 $event->{'RECURRENCE-ID'} = (string)$event->DTSTART;
516 * Returns the current item number
520 public function key() {
522 return $this->counter;
527 * Whether or not there is a 'next item'
531 public function valid() {
533 if (!is_null($this->count)) {
534 return $this->counter < $this->count;
536 if (!is_null($this->until)) {
537 return $this->currentDate <= $this->until;
544 * Resets the iterator
548 public function rewind() {
550 $this->currentDate = clone $this->startDate;
556 * This method allows you to quickly go to the next occurrence after the
559 * Note that this checks the current 'endDate', not the 'stardDate'. This
560 * means that if you forward to January 1st, the iterator will stop at the
561 * first event that ends *after* January 1st.
563 * @param DateTime $dt
566 public function fastForward(DateTime $dt) {
568 while($this->valid() && $this->getDTEnd() < $dt) {
575 * Returns true if this recurring event never ends.
579 public function isInfinite() {
581 return !$this->count && !$this->until;
586 * Goes on to the next iteration
590 public function next() {
593 if (!is_null($this->count) && $this->counter >= $this->count) {
594 $this->currentDate = null;
598 $previousStamp = $this->currentDate->getTimeStamp();
602 $this->currentOverriddenEvent = null;
604 // If we have a next date 'stored', we use that
605 if ($this->nextDate) {
606 $this->currentDate = $this->nextDate;
607 $currentStamp = $this->currentDate->getTimeStamp();
608 $this->nextDate = null;
611 // Otherwise, we calculate it
612 switch($this->frequency) {
623 $this->nextMonthly();
631 $currentStamp = $this->currentDate->getTimeStamp();
633 // Checking exception dates
634 foreach($this->exceptionDates as $exceptionDate) {
635 if ($this->currentDate == $exceptionDate) {
640 foreach($this->overriddenDates as $overriddenDate) {
641 if ($this->currentDate == $overriddenDate) {
648 // Checking overridden events
649 foreach($this->overriddenEvents as $index=>$event) {
650 if ($index > $previousStamp && $index <= $currentStamp) {
652 // We're moving the 'next date' aside, for later use.
653 $this->nextDate = clone $this->currentDate;
655 $this->currentDate = $event->DTSTART->getDateTime();
656 $this->currentOverriddenEvent = $event;
667 if (!is_null($this->until)) {
668 if($this->currentDate > $this->until) {
669 $this->currentDate = null;
678 * Does the processing for advancing the iterator for daily frequency.
682 protected function nextDaily() {
685 $this->currentDate->modify('+' . $this->interval . ' days');
689 $recurrenceDays = array();
690 foreach($this->byDay as $byDay) {
692 // The day may be preceeded with a positive (+n) or
693 // negative (-n) integer. However, this does not make
694 // sense in 'weekly' so we ignore it here.
695 $recurrenceDays[] = $this->dayMap[substr($byDay,-2)];
701 $this->currentDate->modify('+' . $this->interval . ' days');
703 // Current day of the week
704 $currentDay = $this->currentDate->format('w');
706 } while (!in_array($currentDay, $recurrenceDays));
711 * Does the processing for advancing the iterator for weekly frequency.
715 protected function nextWeekly() {
718 $this->currentDate->modify('+' . $this->interval . ' weeks');
722 $recurrenceDays = array();
723 foreach($this->byDay as $byDay) {
725 // The day may be preceeded with a positive (+n) or
726 // negative (-n) integer. However, this does not make
727 // sense in 'weekly' so we ignore it here.
728 $recurrenceDays[] = $this->dayMap[substr($byDay,-2)];
732 // Current day of the week
733 $currentDay = $this->currentDate->format('w');
735 // First day of the week:
736 $firstDay = $this->dayMap[$this->weekStart];
739 $this->currentDate->format('H'),
740 $this->currentDate->format('i'),
741 $this->currentDate->format('s')
744 // Increasing the 'current day' until we find our next
754 // We need to roll over to the next week
755 if ($currentDay === $firstDay) {
756 $this->currentDate->modify('+' . $this->interval . ' weeks');
758 // We need to go to the first day of this week, but only if we
759 // are not already on this first day of this week.
760 if($this->currentDate->format('w') != $firstDay) {
761 $this->currentDate->modify('last ' . $this->dayNames[$this->dayMap[$this->weekStart]]);
762 $this->currentDate->setTime($time[0],$time[1],$time[2]);
767 if (in_array($currentDay ,$recurrenceDays)) {
768 $this->currentDate->modify($this->dayNames[$currentDay]);
769 $this->currentDate->setTime($time[0],$time[1],$time[2]);
778 * Does the processing for advancing the iterator for monthly frequency.
782 protected function nextMonthly() {
784 $currentDayOfMonth = $this->currentDate->format('j');
785 if (!$this->byMonthDay && !$this->byDay) {
787 // If the current day is higher than the 28th, rollover can
788 // occur to the next month. We Must skip these invalid
790 if ($currentDayOfMonth < 29) {
791 $this->currentDate->modify('+' . $this->interval . ' months');
796 $tempDate = clone $this->currentDate;
797 $tempDate->modify('+ ' . ($this->interval*$increase) . ' months');
798 } while ($tempDate->format('j') != $currentDayOfMonth);
799 $this->currentDate = $tempDate;
806 $occurrences = $this->getMonthlyOccurrences();
808 foreach($occurrences as $occurrence) {
810 // The first occurrence thats higher than the current
811 // day of the month wins.
812 if ($occurrence > $currentDayOfMonth) {
818 // If we made it all the way here, it means there were no
819 // valid occurrences, and we need to advance to the next
821 $this->currentDate->modify('first day of this month');
822 $this->currentDate->modify('+ ' . $this->interval . ' months');
824 // This goes to 0 because we need to start counting at hte
826 $currentDayOfMonth = 0;
830 $this->currentDate->setDate($this->currentDate->format('Y'), $this->currentDate->format('n'), $occurrence);
835 * Does the processing for advancing the iterator for yearly frequency.
839 protected function nextYearly() {
841 if (!$this->byMonth) {
842 $this->currentDate->modify('+' . $this->interval . ' years');
846 $currentMonth = $this->currentDate->format('n');
847 $currentYear = $this->currentDate->format('Y');
848 $currentDayOfMonth = $this->currentDate->format('j');
850 $advancedToNewMonth = false;
852 // If we got a byDay or getMonthDay filter, we must first expand
854 if ($this->byDay || $this->byMonthDay) {
858 $occurrences = $this->getMonthlyOccurrences();
860 foreach($occurrences as $occurrence) {
862 // The first occurrence that's higher than the current
863 // day of the month wins.
864 // If we advanced to the next month or year, the first
865 // occurrence is always correct.
866 if ($occurrence > $currentDayOfMonth || $advancedToNewMonth) {
872 // If we made it here, it means we need to advance to
873 // the next month or year.
874 $currentDayOfMonth = 1;
875 $advancedToNewMonth = true;
879 if ($currentMonth>12) {
880 $currentYear+=$this->interval;
883 } while (!in_array($currentMonth, $this->byMonth));
885 $this->currentDate->setDate($currentYear, $currentMonth, $currentDayOfMonth);
889 // If we made it here, it means we got a valid occurrence
890 $this->currentDate->setDate($currentYear, $currentMonth, $occurrence);
895 // no byDay or byMonthDay, so we can just loop through the
900 if ($currentMonth>12) {
901 $currentYear+=$this->interval;
904 } while (!in_array($currentMonth, $this->byMonth));
905 $this->currentDate->setDate($currentYear, $currentMonth, $currentDayOfMonth);
913 * Returns all the occurrences for a monthly frequency with a 'byDay' or
914 * 'byMonthDay' expansion for the current month.
916 * The returned list is an array of integers with the day of month (1-31).
920 protected function getMonthlyOccurrences() {
922 $startDate = clone $this->currentDate;
924 $byDayResults = array();
926 // Our strategy is to simply go through the byDays, advance the date to
927 // that point and add it to the results.
928 if ($this->byDay) foreach($this->byDay as $day) {
930 $dayName = $this->dayNames[$this->dayMap[substr($day,-2)]];
932 // Dayname will be something like 'wednesday'. Now we need to find
933 // all wednesdays in this month.
936 $checkDate = clone $startDate;
937 $checkDate->modify('first day of this month');
938 $checkDate->modify($dayName);
941 $dayHits[] = $checkDate->format('j');
942 $checkDate->modify('next ' . $dayName);
943 } while ($checkDate->format('n') === $startDate->format('n'));
945 // So now we have 'all wednesdays' for month. It is however
946 // possible that the user only really wanted the 1st, 2nd or last
948 if (strlen($day)>2) {
949 $offset = (int)substr($day,0,-2);
952 // It is possible that the day does not exist, such as a
953 // 5th or 6th wednesday of the month.
954 if (isset($dayHits[$offset-1])) {
955 $byDayResults[] = $dayHits[$offset-1];
959 // if it was negative we count from the end of the array
960 $byDayResults[] = $dayHits[count($dayHits) + $offset];
963 // There was no counter (first, second, last wednesdays), so we
964 // just need to add the all to the list).
965 $byDayResults = array_merge($byDayResults, $dayHits);
971 $byMonthDayResults = array();
972 if ($this->byMonthDay) foreach($this->byMonthDay as $monthDay) {
974 // Removing values that are out of range for this month
975 if ($monthDay > $startDate->format('t') ||
976 $monthDay < 0-$startDate->format('t')) {
980 $byMonthDayResults[] = $monthDay;
983 $byMonthDayResults[] = $startDate->format('t') + 1 + $monthDay;
987 // If there was just byDay or just byMonthDay, they just specify our
988 // (almost) final list. If both were provided, then byDay limits the
990 if ($this->byMonthDay && $this->byDay) {
991 $result = array_intersect($byMonthDayResults, $byDayResults);
992 } elseif ($this->byMonthDay) {
993 $result = $byMonthDayResults;
995 $result = $byDayResults;
997 $result = array_unique($result);
998 sort($result, SORT_NUMERIC);
1000 // The last thing that needs checking is the BYSETPOS. If it's set, it
1001 // means only certain items in the set survive the filter.
1002 if (!$this->bySetPos) {
1006 $filteredResult = array();
1007 foreach($this->bySetPos as $setPos) {
1010 $setPos = count($result)-($setPos+1);
1012 if (isset($result[$setPos-1])) {
1013 $filteredResult[] = $result[$setPos-1];
1017 sort($filteredResult, SORT_NUMERIC);
1018 return $filteredResult;