1<?php
2
3namespace Sabre\VObject;
4
5use DateTimeImmutable;
6use DateTimeInterface;
7use DateTimeZone;
8use Sabre\VObject\Component\VCalendar;
9use Sabre\VObject\Recur\EventIterator;
10use Sabre\VObject\Recur\NoInstancesException;
11
12/**
13 * This class helps with generating FREEBUSY reports based on existing sets of
14 * objects.
15 *
16 * It only looks at VEVENT and VFREEBUSY objects from the sourcedata, and
17 * generates a single VFREEBUSY object.
18 *
19 * VFREEBUSY components are described in RFC5545, The rules for what should
20 * go in a single freebusy report is taken from RFC4791, section 7.10.
21 *
22 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
23 * @author Evert Pot (http://evertpot.com/)
24 * @license http://sabre.io/license/ Modified BSD License
25 */
26class FreeBusyGenerator
27{
28    /**
29     * Input objects.
30     *
31     * @var array
32     */
33    protected $objects = [];
34
35    /**
36     * Start of range.
37     *
38     * @var DateTimeInterface|null
39     */
40    protected $start;
41
42    /**
43     * End of range.
44     *
45     * @var DateTimeInterface|null
46     */
47    protected $end;
48
49    /**
50     * VCALENDAR object.
51     *
52     * @var Document
53     */
54    protected $baseObject;
55
56    /**
57     * Reference timezone.
58     *
59     * When we are calculating busy times, and we come across so-called
60     * floating times (times without a timezone), we use the reference timezone
61     * instead.
62     *
63     * This is also used for all-day events.
64     *
65     * This defaults to UTC.
66     *
67     * @var DateTimeZone
68     */
69    protected $timeZone;
70
71    /**
72     * A VAVAILABILITY document.
73     *
74     * If this is set, its information will be included when calculating
75     * freebusy time.
76     *
77     * @var Document
78     */
79    protected $vavailability;
80
81    /**
82     * Creates the generator.
83     *
84     * Check the setTimeRange and setObjects methods for details about the
85     * arguments.
86     *
87     * @param DateTimeInterface $start
88     * @param DateTimeInterface $end
89     * @param mixed             $objects
90     * @param DateTimeZone      $timeZone
91     */
92    public function __construct(DateTimeInterface $start = null, DateTimeInterface $end = null, $objects = null, DateTimeZone $timeZone = null)
93    {
94        $this->setTimeRange($start, $end);
95
96        if ($objects) {
97            $this->setObjects($objects);
98        }
99        if (is_null($timeZone)) {
100            $timeZone = new DateTimeZone('UTC');
101        }
102        $this->setTimeZone($timeZone);
103    }
104
105    /**
106     * Sets the VCALENDAR object.
107     *
108     * If this is set, it will not be generated for you. You are responsible
109     * for setting things like the METHOD, CALSCALE, VERSION, etc..
110     *
111     * The VFREEBUSY object will be automatically added though.
112     */
113    public function setBaseObject(Document $vcalendar)
114    {
115        $this->baseObject = $vcalendar;
116    }
117
118    /**
119     * Sets a VAVAILABILITY document.
120     */
121    public function setVAvailability(Document $vcalendar)
122    {
123        $this->vavailability = $vcalendar;
124    }
125
126    /**
127     * Sets the input objects.
128     *
129     * You must either specify a vcalendar object as a string, or as the parse
130     * Component.
131     * It's also possible to specify multiple objects as an array.
132     *
133     * @param mixed $objects
134     */
135    public function setObjects($objects)
136    {
137        if (!is_array($objects)) {
138            $objects = [$objects];
139        }
140
141        $this->objects = [];
142        foreach ($objects as $object) {
143            if (is_string($object) || is_resource($object)) {
144                $this->objects[] = Reader::read($object);
145            } elseif ($object instanceof Component) {
146                $this->objects[] = $object;
147            } else {
148                throw new \InvalidArgumentException('You can only pass strings or \\Sabre\\VObject\\Component arguments to setObjects');
149            }
150        }
151    }
152
153    /**
154     * Sets the time range.
155     *
156     * Any freebusy object falling outside of this time range will be ignored.
157     *
158     * @param DateTimeInterface $start
159     * @param DateTimeInterface $end
160     */
161    public function setTimeRange(DateTimeInterface $start = null, DateTimeInterface $end = null)
162    {
163        if (!$start) {
164            $start = new DateTimeImmutable(Settings::$minDate);
165        }
166        if (!$end) {
167            $end = new DateTimeImmutable(Settings::$maxDate);
168        }
169        $this->start = $start;
170        $this->end = $end;
171    }
172
173    /**
174     * Sets the reference timezone for floating times.
175     */
176    public function setTimeZone(DateTimeZone $timeZone)
177    {
178        $this->timeZone = $timeZone;
179    }
180
181    /**
182     * Parses the input data and returns a correct VFREEBUSY object, wrapped in
183     * a VCALENDAR.
184     *
185     * @return Component
186     */
187    public function getResult()
188    {
189        $fbData = new FreeBusyData(
190            $this->start->getTimeStamp(),
191            $this->end->getTimeStamp()
192        );
193        if ($this->vavailability) {
194            $this->calculateAvailability($fbData, $this->vavailability);
195        }
196
197        $this->calculateBusy($fbData, $this->objects);
198
199        return $this->generateFreeBusyCalendar($fbData);
200    }
201
202    /**
203     * This method takes a VAVAILABILITY component and figures out all the
204     * available times.
205     */
206    protected function calculateAvailability(FreeBusyData $fbData, VCalendar $vavailability)
207    {
208        $vavailComps = iterator_to_array($vavailability->VAVAILABILITY);
209        usort(
210            $vavailComps,
211            function ($a, $b) {
212                // We need to order the components by priority. Priority 1
213                // comes first, up until priority 9. Priority 0 comes after
214                // priority 9. No priority implies priority 0.
215                //
216                // Yes, I'm serious.
217                $priorityA = isset($a->PRIORITY) ? (int) $a->PRIORITY->getValue() : 0;
218                $priorityB = isset($b->PRIORITY) ? (int) $b->PRIORITY->getValue() : 0;
219
220                if (0 === $priorityA) {
221                    $priorityA = 10;
222                }
223                if (0 === $priorityB) {
224                    $priorityB = 10;
225                }
226
227                return $priorityA - $priorityB;
228            }
229        );
230
231        // Now we go over all the VAVAILABILITY components and figure if
232        // there's any we don't need to consider.
233        //
234        // This is can be because of one of two reasons: either the
235        // VAVAILABILITY component falls outside the time we are interested in,
236        // or a different VAVAILABILITY component with a higher priority has
237        // already completely covered the time-range.
238        $old = $vavailComps;
239        $new = [];
240
241        foreach ($old as $vavail) {
242            list($compStart, $compEnd) = $vavail->getEffectiveStartEnd();
243
244            // We don't care about datetimes that are earlier or later than the
245            // start and end of the freebusy report, so this gets normalized
246            // first.
247            if (is_null($compStart) || $compStart < $this->start) {
248                $compStart = $this->start;
249            }
250            if (is_null($compEnd) || $compEnd > $this->end) {
251                $compEnd = $this->end;
252            }
253
254            // If the item fell out of the timerange, we can just skip it.
255            if ($compStart > $this->end || $compEnd < $this->start) {
256                continue;
257            }
258
259            // Going through our existing list of components to see if there's
260            // a higher priority component that already fully covers this one.
261            foreach ($new as $higherVavail) {
262                list($higherStart, $higherEnd) = $higherVavail->getEffectiveStartEnd();
263                if (
264                    (is_null($higherStart) || $higherStart < $compStart) &&
265                    (is_null($higherEnd) || $higherEnd > $compEnd)
266                ) {
267                    // Component is fully covered by a higher priority
268                    // component. We can skip this component.
269                    continue 2;
270                }
271            }
272
273            // We're keeping it!
274            $new[] = $vavail;
275        }
276
277        // Lastly, we need to traverse the remaining components and fill in the
278        // freebusydata slots.
279        //
280        // We traverse the components in reverse, because we want the higher
281        // priority components to override the lower ones.
282        foreach (array_reverse($new) as $vavail) {
283            $busyType = isset($vavail->BUSYTYPE) ? strtoupper($vavail->BUSYTYPE) : 'BUSY-UNAVAILABLE';
284            list($vavailStart, $vavailEnd) = $vavail->getEffectiveStartEnd();
285
286            // Making the component size no larger than the requested free-busy
287            // report range.
288            if (!$vavailStart || $vavailStart < $this->start) {
289                $vavailStart = $this->start;
290            }
291            if (!$vavailEnd || $vavailEnd > $this->end) {
292                $vavailEnd = $this->end;
293            }
294
295            // Marking the entire time range of the VAVAILABILITY component as
296            // busy.
297            $fbData->add(
298                $vavailStart->getTimeStamp(),
299                $vavailEnd->getTimeStamp(),
300                $busyType
301            );
302
303            // Looping over the AVAILABLE components.
304            if (isset($vavail->AVAILABLE)) {
305                foreach ($vavail->AVAILABLE as $available) {
306                    list($availStart, $availEnd) = $available->getEffectiveStartEnd();
307                    $fbData->add(
308                    $availStart->getTimeStamp(),
309                    $availEnd->getTimeStamp(),
310                    'FREE'
311                );
312
313                    if ($available->RRULE) {
314                        // Our favourite thing: recurrence!!
315
316                        $rruleIterator = new Recur\RRuleIterator(
317                        $available->RRULE->getValue(),
318                        $availStart
319                    );
320                        $rruleIterator->fastForward($vavailStart);
321
322                        $startEndDiff = $availStart->diff($availEnd);
323
324                        while ($rruleIterator->valid()) {
325                            $recurStart = $rruleIterator->current();
326                            $recurEnd = $recurStart->add($startEndDiff);
327
328                            if ($recurStart > $vavailEnd) {
329                                // We're beyond the legal timerange.
330                                break;
331                            }
332
333                            if ($recurEnd > $vavailEnd) {
334                                // Truncating the end if it exceeds the
335                                // VAVAILABILITY end.
336                                $recurEnd = $vavailEnd;
337                            }
338
339                            $fbData->add(
340                            $recurStart->getTimeStamp(),
341                            $recurEnd->getTimeStamp(),
342                            'FREE'
343                        );
344
345                            $rruleIterator->next();
346                        }
347                    }
348                }
349            }
350        }
351    }
352
353    /**
354     * This method takes an array of iCalendar objects and applies its busy
355     * times on fbData.
356     *
357     * @param VCalendar[] $objects
358     */
359    protected function calculateBusy(FreeBusyData $fbData, array $objects)
360    {
361        foreach ($objects as $key => $object) {
362            foreach ($object->getBaseComponents() as $component) {
363                switch ($component->name) {
364                    case 'VEVENT':
365                        $FBTYPE = 'BUSY';
366                        if (isset($component->TRANSP) && ('TRANSPARENT' === strtoupper($component->TRANSP))) {
367                            break;
368                        }
369                        if (isset($component->STATUS)) {
370                            $status = strtoupper($component->STATUS);
371                            if ('CANCELLED' === $status) {
372                                break;
373                            }
374                            if ('TENTATIVE' === $status) {
375                                $FBTYPE = 'BUSY-TENTATIVE';
376                            }
377                        }
378
379                        $times = [];
380
381                        if ($component->RRULE) {
382                            try {
383                                $iterator = new EventIterator($object, (string) $component->UID, $this->timeZone);
384                            } catch (NoInstancesException $e) {
385                                // This event is recurring, but it doesn't have a single
386                                // instance. We are skipping this event from the output
387                                // entirely.
388                                unset($this->objects[$key]);
389                                break;
390                            }
391
392                            if ($this->start) {
393                                $iterator->fastForward($this->start);
394                            }
395
396                            $maxRecurrences = Settings::$maxRecurrences;
397
398                            while ($iterator->valid() && --$maxRecurrences) {
399                                $startTime = $iterator->getDTStart();
400                                if ($this->end && $startTime > $this->end) {
401                                    break;
402                                }
403                                $times[] = [
404                                    $iterator->getDTStart(),
405                                    $iterator->getDTEnd(),
406                                ];
407
408                                $iterator->next();
409                            }
410                        } else {
411                            $startTime = $component->DTSTART->getDateTime($this->timeZone);
412                            if ($this->end && $startTime > $this->end) {
413                                break;
414                            }
415                            $endTime = null;
416                            if (isset($component->DTEND)) {
417                                $endTime = $component->DTEND->getDateTime($this->timeZone);
418                            } elseif (isset($component->DURATION)) {
419                                $duration = DateTimeParser::parseDuration((string) $component->DURATION);
420                                $endTime = clone $startTime;
421                                $endTime = $endTime->add($duration);
422                            } elseif (!$component->DTSTART->hasTime()) {
423                                $endTime = clone $startTime;
424                                $endTime = $endTime->modify('+1 day');
425                            } else {
426                                // The event had no duration (0 seconds)
427                                break;
428                            }
429
430                            $times[] = [$startTime, $endTime];
431                        }
432
433                        foreach ($times as $time) {
434                            if ($this->end && $time[0] > $this->end) {
435                                break;
436                            }
437                            if ($this->start && $time[1] < $this->start) {
438                                break;
439                            }
440
441                            $fbData->add(
442                                $time[0]->getTimeStamp(),
443                                $time[1]->getTimeStamp(),
444                                $FBTYPE
445                            );
446                        }
447                        break;
448
449                    case 'VFREEBUSY':
450                        foreach ($component->FREEBUSY as $freebusy) {
451                            $fbType = isset($freebusy['FBTYPE']) ? strtoupper($freebusy['FBTYPE']) : 'BUSY';
452
453                            // Skipping intervals marked as 'free'
454                            if ('FREE' === $fbType) {
455                                continue;
456                            }
457
458                            $values = explode(',', $freebusy);
459                            foreach ($values as $value) {
460                                list($startTime, $endTime) = explode('/', $value);
461                                $startTime = DateTimeParser::parseDateTime($startTime);
462
463                                if ('P' === substr($endTime, 0, 1) || '-P' === substr($endTime, 0, 2)) {
464                                    $duration = DateTimeParser::parseDuration($endTime);
465                                    $endTime = clone $startTime;
466                                    $endTime = $endTime->add($duration);
467                                } else {
468                                    $endTime = DateTimeParser::parseDateTime($endTime);
469                                }
470
471                                if ($this->start && $this->start > $endTime) {
472                                    continue;
473                                }
474                                if ($this->end && $this->end < $startTime) {
475                                    continue;
476                                }
477                                $fbData->add(
478                                    $startTime->getTimeStamp(),
479                                    $endTime->getTimeStamp(),
480                                    $fbType
481                                );
482                            }
483                        }
484                        break;
485                }
486            }
487        }
488    }
489
490    /**
491     * This method takes a FreeBusyData object and generates the VCALENDAR
492     * object associated with it.
493     *
494     * @return VCalendar
495     */
496    protected function generateFreeBusyCalendar(FreeBusyData $fbData)
497    {
498        if ($this->baseObject) {
499            $calendar = $this->baseObject;
500        } else {
501            $calendar = new VCalendar();
502        }
503
504        $vfreebusy = $calendar->createComponent('VFREEBUSY');
505        $calendar->add($vfreebusy);
506
507        if ($this->start) {
508            $dtstart = $calendar->createProperty('DTSTART');
509            $dtstart->setDateTime($this->start);
510            $vfreebusy->add($dtstart);
511        }
512        if ($this->end) {
513            $dtend = $calendar->createProperty('DTEND');
514            $dtend->setDateTime($this->end);
515            $vfreebusy->add($dtend);
516        }
517
518        $tz = new \DateTimeZone('UTC');
519        $dtstamp = $calendar->createProperty('DTSTAMP');
520        $dtstamp->setDateTime(new DateTimeImmutable('now', $tz));
521        $vfreebusy->add($dtstamp);
522
523        foreach ($fbData->getData() as $busyTime) {
524            $busyType = strtoupper($busyTime['type']);
525
526            // Ignoring all the FREE parts, because those are already assumed.
527            if ('FREE' === $busyType) {
528                continue;
529            }
530
531            $busyTime[0] = new \DateTimeImmutable('@'.$busyTime['start'], $tz);
532            $busyTime[1] = new \DateTimeImmutable('@'.$busyTime['end'], $tz);
533
534            $prop = $calendar->createProperty(
535                'FREEBUSY',
536                $busyTime[0]->format('Ymd\\THis\\Z').'/'.$busyTime[1]->format('Ymd\\THis\\Z')
537            );
538
539            // Only setting FBTYPE if it's not BUSY, because BUSY is the
540            // default anyway.
541            if ('BUSY' !== $busyType) {
542                $prop['FBTYPE'] = $busyType;
543            }
544            $vfreebusy->add($prop);
545        }
546
547        return $calendar;
548    }
549}
550