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