1<?php 2 3namespace Sabre\VObject\Recur; 4 5use DateTimeImmutable; 6use DateTimeInterface; 7use DateTimeZone; 8use InvalidArgumentException; 9use Sabre\VObject\Component; 10use Sabre\VObject\Component\VEvent; 11use Sabre\VObject\Settings; 12 13/** 14 * This class is used to determine new for a recurring event, when the next 15 * events occur. 16 * 17 * This iterator may loop infinitely in the future, therefore it is important 18 * that if you use this class, you set hard limits for the amount of iterations 19 * you want to handle. 20 * 21 * Note that currently there is not full support for the entire iCalendar 22 * specification, as it's very complex and contains a lot of permutations 23 * that's not yet used very often in software. 24 * 25 * For the focus has been on features as they actually appear in Calendaring 26 * software, but this may well get expanded as needed / on demand 27 * 28 * The following RRULE properties are supported 29 * * UNTIL 30 * * INTERVAL 31 * * COUNT 32 * * FREQ=DAILY 33 * * BYDAY 34 * * BYHOUR 35 * * BYMONTH 36 * * FREQ=WEEKLY 37 * * BYDAY 38 * * BYHOUR 39 * * WKST 40 * * FREQ=MONTHLY 41 * * BYMONTHDAY 42 * * BYDAY 43 * * BYSETPOS 44 * * FREQ=YEARLY 45 * * BYMONTH 46 * * BYYEARDAY 47 * * BYWEEKNO 48 * * BYMONTHDAY (only if BYMONTH is also set) 49 * * BYDAY (only if BYMONTH is also set) 50 * 51 * Anything beyond this is 'undefined', which means that it may get ignored, or 52 * you may get unexpected results. The effect is that in some applications the 53 * specified recurrence may look incorrect, or is missing. 54 * 55 * The recurrence iterator also does not yet support THISANDFUTURE. 56 * 57 * @copyright Copyright (C) fruux GmbH (https://fruux.com/) 58 * @author Evert Pot (http://evertpot.com/) 59 * @license http://sabre.io/license/ Modified BSD License 60 */ 61class EventIterator implements \Iterator 62{ 63 /** 64 * Reference timeZone for floating dates and times. 65 * 66 * @var DateTimeZone 67 */ 68 protected $timeZone; 69 70 /** 71 * True if we're iterating an all-day event. 72 * 73 * @var bool 74 */ 75 protected $allDay = false; 76 77 /** 78 * Creates the iterator. 79 * 80 * There's three ways to set up the iterator. 81 * 82 * 1. You can pass a VCALENDAR component and a UID. 83 * 2. You can pass an array of VEVENTs (all UIDS should match). 84 * 3. You can pass a single VEVENT component. 85 * 86 * Only the second method is recomended. The other 1 and 3 will be removed 87 * at some point in the future. 88 * 89 * The $uid parameter is only required for the first method. 90 * 91 * @param Component|array $input 92 * @param string|null $uid 93 * @param DateTimeZone $timeZone reference timezone for floating dates and 94 * times 95 */ 96 public function __construct($input, $uid = null, DateTimeZone $timeZone = null) 97 { 98 if (is_null($timeZone)) { 99 $timeZone = new DateTimeZone('UTC'); 100 } 101 $this->timeZone = $timeZone; 102 103 if (is_array($input)) { 104 $events = $input; 105 } elseif ($input instanceof VEvent) { 106 // Single instance mode. 107 $events = [$input]; 108 } else { 109 // Calendar + UID mode. 110 $uid = (string) $uid; 111 if (!$uid) { 112 throw new InvalidArgumentException('The UID argument is required when a VCALENDAR is passed to this constructor'); 113 } 114 if (!isset($input->VEVENT)) { 115 throw new InvalidArgumentException('No events found in this calendar'); 116 } 117 $events = $input->getByUID($uid); 118 } 119 120 foreach ($events as $vevent) { 121 if (!isset($vevent->{'RECURRENCE-ID'})) { 122 $this->masterEvent = $vevent; 123 } else { 124 $this->exceptions[ 125 $vevent->{'RECURRENCE-ID'}->getDateTime($this->timeZone)->getTimeStamp() 126 ] = true; 127 $this->overriddenEvents[] = $vevent; 128 } 129 } 130 131 if (!$this->masterEvent) { 132 // No base event was found. CalDAV does allow cases where only 133 // overridden instances are stored. 134 // 135 // In this particular case, we're just going to grab the first 136 // event and use that instead. This may not always give the 137 // desired result. 138 if (!count($this->overriddenEvents)) { 139 throw new InvalidArgumentException('This VCALENDAR did not have an event with UID: '.$uid); 140 } 141 $this->masterEvent = array_shift($this->overriddenEvents); 142 } 143 144 $this->startDate = $this->masterEvent->DTSTART->getDateTime($this->timeZone); 145 $this->allDay = !$this->masterEvent->DTSTART->hasTime(); 146 147 if (isset($this->masterEvent->EXDATE)) { 148 foreach ($this->masterEvent->EXDATE as $exDate) { 149 foreach ($exDate->getDateTimes($this->timeZone) as $dt) { 150 $this->exceptions[$dt->getTimeStamp()] = true; 151 } 152 } 153 } 154 155 if (isset($this->masterEvent->DTEND)) { 156 $this->eventDuration = 157 $this->masterEvent->DTEND->getDateTime($this->timeZone)->getTimeStamp() - 158 $this->startDate->getTimeStamp(); 159 } elseif (isset($this->masterEvent->DURATION)) { 160 $duration = $this->masterEvent->DURATION->getDateInterval(); 161 $end = clone $this->startDate; 162 $end = $end->add($duration); 163 $this->eventDuration = $end->getTimeStamp() - $this->startDate->getTimeStamp(); 164 } elseif ($this->allDay) { 165 $this->eventDuration = 3600 * 24; 166 } else { 167 $this->eventDuration = 0; 168 } 169 170 if (isset($this->masterEvent->RDATE)) { 171 $this->recurIterator = new RDateIterator( 172 $this->masterEvent->RDATE->getParts(), 173 $this->startDate 174 ); 175 } elseif (isset($this->masterEvent->RRULE)) { 176 $this->recurIterator = new RRuleIterator( 177 $this->masterEvent->RRULE->getParts(), 178 $this->startDate 179 ); 180 } else { 181 $this->recurIterator = new RRuleIterator( 182 [ 183 'FREQ' => 'DAILY', 184 'COUNT' => 1, 185 ], 186 $this->startDate 187 ); 188 } 189 190 $this->rewind(); 191 if (!$this->valid()) { 192 throw new NoInstancesException('This recurrence rule does not generate any valid instances'); 193 } 194 } 195 196 /** 197 * Returns the date for the current position of the iterator. 198 * 199 * @return DateTimeImmutable 200 */ 201 public function current() 202 { 203 if ($this->currentDate) { 204 return clone $this->currentDate; 205 } 206 } 207 208 /** 209 * This method returns the start date for the current iteration of the 210 * event. 211 * 212 * @return DateTimeImmutable 213 */ 214 public function getDtStart() 215 { 216 if ($this->currentDate) { 217 return clone $this->currentDate; 218 } 219 } 220 221 /** 222 * This method returns the end date for the current iteration of the 223 * event. 224 * 225 * @return DateTimeImmutable 226 */ 227 public function getDtEnd() 228 { 229 if (!$this->valid()) { 230 return; 231 } 232 $end = clone $this->currentDate; 233 234 return $end->modify('+'.$this->eventDuration.' seconds'); 235 } 236 237 /** 238 * Returns a VEVENT for the current iterations of the event. 239 * 240 * This VEVENT will have a recurrence id, and its DTSTART and DTEND 241 * altered. 242 * 243 * @return VEvent 244 */ 245 public function getEventObject() 246 { 247 if ($this->currentOverriddenEvent) { 248 return $this->currentOverriddenEvent; 249 } 250 251 $event = clone $this->masterEvent; 252 253 // Ignoring the following block, because PHPUnit's code coverage 254 // ignores most of these lines, and this messes with our stats. 255 // 256 // @codeCoverageIgnoreStart 257 unset( 258 $event->RRULE, 259 $event->EXDATE, 260 $event->RDATE, 261 $event->EXRULE, 262 $event->{'RECURRENCE-ID'} 263 ); 264 // @codeCoverageIgnoreEnd 265 266 $event->DTSTART->setDateTime($this->getDtStart(), $event->DTSTART->isFloating()); 267 if (isset($event->DTEND)) { 268 $event->DTEND->setDateTime($this->getDtEnd(), $event->DTEND->isFloating()); 269 } 270 $recurid = clone $event->DTSTART; 271 $recurid->name = 'RECURRENCE-ID'; 272 $event->add($recurid); 273 274 return $event; 275 } 276 277 /** 278 * Returns the current position of the iterator. 279 * 280 * This is for us simply a 0-based index. 281 * 282 * @return int 283 */ 284 public function key() 285 { 286 // The counter is always 1 ahead. 287 return $this->counter - 1; 288 } 289 290 /** 291 * This is called after next, to see if the iterator is still at a valid 292 * position, or if it's at the end. 293 * 294 * @return bool 295 */ 296 public function valid() 297 { 298 if ($this->counter > Settings::$maxRecurrences && -1 !== Settings::$maxRecurrences) { 299 throw new MaxInstancesExceededException('Recurring events are only allowed to generate '.Settings::$maxRecurrences); 300 } 301 302 return (bool) $this->currentDate; 303 } 304 305 /** 306 * Sets the iterator back to the starting point. 307 */ 308 public function rewind() 309 { 310 $this->recurIterator->rewind(); 311 // re-creating overridden event index. 312 $index = []; 313 foreach ($this->overriddenEvents as $key => $event) { 314 $stamp = $event->DTSTART->getDateTime($this->timeZone)->getTimeStamp(); 315 $index[$stamp][] = $key; 316 } 317 krsort($index); 318 $this->counter = 0; 319 $this->overriddenEventsIndex = $index; 320 $this->currentOverriddenEvent = null; 321 322 $this->nextDate = null; 323 $this->currentDate = clone $this->startDate; 324 325 $this->next(); 326 } 327 328 /** 329 * Advances the iterator with one step. 330 */ 331 public function next() 332 { 333 $this->currentOverriddenEvent = null; 334 ++$this->counter; 335 if ($this->nextDate) { 336 // We had a stored value. 337 $nextDate = $this->nextDate; 338 $this->nextDate = null; 339 } else { 340 // We need to ask rruleparser for the next date. 341 // We need to do this until we find a date that's not in the 342 // exception list. 343 do { 344 if (!$this->recurIterator->valid()) { 345 $nextDate = null; 346 break; 347 } 348 $nextDate = $this->recurIterator->current(); 349 $this->recurIterator->next(); 350 } while (isset($this->exceptions[$nextDate->getTimeStamp()])); 351 } 352 353 // $nextDate now contains what rrule thinks is the next one, but an 354 // overridden event may cut ahead. 355 if ($this->overriddenEventsIndex) { 356 $offsets = end($this->overriddenEventsIndex); 357 $timestamp = key($this->overriddenEventsIndex); 358 $offset = end($offsets); 359 if (!$nextDate || $timestamp < $nextDate->getTimeStamp()) { 360 // Overridden event comes first. 361 $this->currentOverriddenEvent = $this->overriddenEvents[$offset]; 362 363 // Putting the rrule next date aside. 364 $this->nextDate = $nextDate; 365 $this->currentDate = $this->currentOverriddenEvent->DTSTART->getDateTime($this->timeZone); 366 367 // Ensuring that this item will only be used once. 368 array_pop($this->overriddenEventsIndex[$timestamp]); 369 if (!$this->overriddenEventsIndex[$timestamp]) { 370 array_pop($this->overriddenEventsIndex); 371 } 372 373 // Exit point! 374 return; 375 } 376 } 377 378 $this->currentDate = $nextDate; 379 } 380 381 /** 382 * Quickly jump to a date in the future. 383 * 384 * @param DateTimeInterface $dateTime 385 */ 386 public function fastForward(DateTimeInterface $dateTime) 387 { 388 while ($this->valid() && $this->getDtEnd() <= $dateTime) { 389 $this->next(); 390 } 391 } 392 393 /** 394 * Returns true if this recurring event never ends. 395 * 396 * @return bool 397 */ 398 public function isInfinite() 399 { 400 return $this->recurIterator->isInfinite(); 401 } 402 403 /** 404 * RRULE parser. 405 * 406 * @var RRuleIterator 407 */ 408 protected $recurIterator; 409 410 /** 411 * The duration, in seconds, of the master event. 412 * 413 * We use this to calculate the DTEND for subsequent events. 414 */ 415 protected $eventDuration; 416 417 /** 418 * A reference to the main (master) event. 419 * 420 * @var VEVENT 421 */ 422 protected $masterEvent; 423 424 /** 425 * List of overridden events. 426 * 427 * @var array 428 */ 429 protected $overriddenEvents = []; 430 431 /** 432 * Overridden event index. 433 * 434 * Key is timestamp, value is the list of indexes of the item in the $overriddenEvent 435 * property. 436 * 437 * @var array 438 */ 439 protected $overriddenEventsIndex; 440 441 /** 442 * A list of recurrence-id's that are either part of EXDATE, or are 443 * overridden. 444 * 445 * @var array 446 */ 447 protected $exceptions = []; 448 449 /** 450 * Internal event counter. 451 * 452 * @var int 453 */ 454 protected $counter; 455 456 /** 457 * The very start of the iteration process. 458 * 459 * @var DateTimeImmutable 460 */ 461 protected $startDate; 462 463 /** 464 * Where we are currently in the iteration process. 465 * 466 * @var DateTimeImmutable 467 */ 468 protected $currentDate; 469 470 /** 471 * The next date from the rrule parser. 472 * 473 * Sometimes we need to temporary store the next date, because an 474 * overridden event came before. 475 * 476 * @var DateTimeImmutable 477 */ 478 protected $nextDate; 479 480 /** 481 * The event that overwrites the current iteration. 482 * 483 * @var VEVENT 484 */ 485 protected $currentOverriddenEvent; 486} 487