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