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