1<?php
2
3namespace Sabre\VObject\Component;
4
5use DateTimeInterface;
6use DateTimeZone;
7use Sabre\VObject;
8use Sabre\VObject\Component;
9use Sabre\VObject\InvalidDataException;
10use Sabre\VObject\Property;
11use Sabre\VObject\Recur\EventIterator;
12use Sabre\VObject\Recur\NoInstancesException;
13
14/**
15 * The VCalendar component.
16 *
17 * This component adds functionality to a component, specific for a VCALENDAR.
18 *
19 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
20 * @author Evert Pot (http://evertpot.com/)
21 * @license http://sabre.io/license/ Modified BSD License
22 */
23class VCalendar extends VObject\Document {
24
25    /**
26     * The default name for this component.
27     *
28     * This should be 'VCALENDAR' or 'VCARD'.
29     *
30     * @var string
31     */
32    static $defaultName = 'VCALENDAR';
33
34    /**
35     * This is a list of components, and which classes they should map to.
36     *
37     * @var array
38     */
39    static $componentMap = [
40        'VCALENDAR'     => 'Sabre\\VObject\\Component\\VCalendar',
41        'VALARM'        => 'Sabre\\VObject\\Component\\VAlarm',
42        'VEVENT'        => 'Sabre\\VObject\\Component\\VEvent',
43        'VFREEBUSY'     => 'Sabre\\VObject\\Component\\VFreeBusy',
44        'VAVAILABILITY' => 'Sabre\\VObject\\Component\\VAvailability',
45        'AVAILABLE'     => 'Sabre\\VObject\\Component\\Available',
46        'VJOURNAL'      => 'Sabre\\VObject\\Component\\VJournal',
47        'VTIMEZONE'     => 'Sabre\\VObject\\Component\\VTimeZone',
48        'VTODO'         => 'Sabre\\VObject\\Component\\VTodo',
49    ];
50
51    /**
52     * List of value-types, and which classes they map to.
53     *
54     * @var array
55     */
56    static $valueMap = [
57        'BINARY'      => 'Sabre\\VObject\\Property\\Binary',
58        'BOOLEAN'     => 'Sabre\\VObject\\Property\\Boolean',
59        'CAL-ADDRESS' => 'Sabre\\VObject\\Property\\ICalendar\\CalAddress',
60        'DATE'        => 'Sabre\\VObject\\Property\\ICalendar\\Date',
61        'DATE-TIME'   => 'Sabre\\VObject\\Property\\ICalendar\\DateTime',
62        'DURATION'    => 'Sabre\\VObject\\Property\\ICalendar\\Duration',
63        'FLOAT'       => 'Sabre\\VObject\\Property\\FloatValue',
64        'INTEGER'     => 'Sabre\\VObject\\Property\\IntegerValue',
65        'PERIOD'      => 'Sabre\\VObject\\Property\\ICalendar\\Period',
66        'RECUR'       => 'Sabre\\VObject\\Property\\ICalendar\\Recur',
67        'TEXT'        => 'Sabre\\VObject\\Property\\Text',
68        'TIME'        => 'Sabre\\VObject\\Property\\Time',
69        'UNKNOWN'     => 'Sabre\\VObject\\Property\\Unknown', // jCard / jCal-only.
70        'URI'         => 'Sabre\\VObject\\Property\\Uri',
71        'UTC-OFFSET'  => 'Sabre\\VObject\\Property\\UtcOffset',
72    ];
73
74    /**
75     * List of properties, and which classes they map to.
76     *
77     * @var array
78     */
79    static $propertyMap = [
80        // Calendar properties
81        'CALSCALE' => 'Sabre\\VObject\\Property\\FlatText',
82        'METHOD'   => 'Sabre\\VObject\\Property\\FlatText',
83        'PRODID'   => 'Sabre\\VObject\\Property\\FlatText',
84        'VERSION'  => 'Sabre\\VObject\\Property\\FlatText',
85
86        // Component properties
87        'ATTACH'           => 'Sabre\\VObject\\Property\\Uri',
88        'CATEGORIES'       => 'Sabre\\VObject\\Property\\Text',
89        'CLASS'            => 'Sabre\\VObject\\Property\\FlatText',
90        'COMMENT'          => 'Sabre\\VObject\\Property\\FlatText',
91        'DESCRIPTION'      => 'Sabre\\VObject\\Property\\FlatText',
92        'GEO'              => 'Sabre\\VObject\\Property\\FloatValue',
93        'LOCATION'         => 'Sabre\\VObject\\Property\\FlatText',
94        'PERCENT-COMPLETE' => 'Sabre\\VObject\\Property\\IntegerValue',
95        'PRIORITY'         => 'Sabre\\VObject\\Property\\IntegerValue',
96        'RESOURCES'        => 'Sabre\\VObject\\Property\\Text',
97        'STATUS'           => 'Sabre\\VObject\\Property\\FlatText',
98        'SUMMARY'          => 'Sabre\\VObject\\Property\\FlatText',
99
100        // Date and Time Component Properties
101        'COMPLETED' => 'Sabre\\VObject\\Property\\ICalendar\\DateTime',
102        'DTEND'     => 'Sabre\\VObject\\Property\\ICalendar\\DateTime',
103        'DUE'       => 'Sabre\\VObject\\Property\\ICalendar\\DateTime',
104        'DTSTART'   => 'Sabre\\VObject\\Property\\ICalendar\\DateTime',
105        'DURATION'  => 'Sabre\\VObject\\Property\\ICalendar\\Duration',
106        'FREEBUSY'  => 'Sabre\\VObject\\Property\\ICalendar\\Period',
107        'TRANSP'    => 'Sabre\\VObject\\Property\\FlatText',
108
109        // Time Zone Component Properties
110        'TZID'         => 'Sabre\\VObject\\Property\\FlatText',
111        'TZNAME'       => 'Sabre\\VObject\\Property\\FlatText',
112        'TZOFFSETFROM' => 'Sabre\\VObject\\Property\\UtcOffset',
113        'TZOFFSETTO'   => 'Sabre\\VObject\\Property\\UtcOffset',
114        'TZURL'        => 'Sabre\\VObject\\Property\\Uri',
115
116        // Relationship Component Properties
117        'ATTENDEE'      => 'Sabre\\VObject\\Property\\ICalendar\\CalAddress',
118        'CONTACT'       => 'Sabre\\VObject\\Property\\FlatText',
119        'ORGANIZER'     => 'Sabre\\VObject\\Property\\ICalendar\\CalAddress',
120        'RECURRENCE-ID' => 'Sabre\\VObject\\Property\\ICalendar\\DateTime',
121        'RELATED-TO'    => 'Sabre\\VObject\\Property\\FlatText',
122        'URL'           => 'Sabre\\VObject\\Property\\Uri',
123        'UID'           => 'Sabre\\VObject\\Property\\FlatText',
124
125        // Recurrence Component Properties
126        'EXDATE' => 'Sabre\\VObject\\Property\\ICalendar\\DateTime',
127        'RDATE'  => 'Sabre\\VObject\\Property\\ICalendar\\DateTime',
128        'RRULE'  => 'Sabre\\VObject\\Property\\ICalendar\\Recur',
129        'EXRULE' => 'Sabre\\VObject\\Property\\ICalendar\\Recur', // Deprecated since rfc5545
130
131        // Alarm Component Properties
132        'ACTION'  => 'Sabre\\VObject\\Property\\FlatText',
133        'REPEAT'  => 'Sabre\\VObject\\Property\\IntegerValue',
134        'TRIGGER' => 'Sabre\\VObject\\Property\\ICalendar\\Duration',
135
136        // Change Management Component Properties
137        'CREATED'       => 'Sabre\\VObject\\Property\\ICalendar\\DateTime',
138        'DTSTAMP'       => 'Sabre\\VObject\\Property\\ICalendar\\DateTime',
139        'LAST-MODIFIED' => 'Sabre\\VObject\\Property\\ICalendar\\DateTime',
140        'SEQUENCE'      => 'Sabre\\VObject\\Property\\IntegerValue',
141
142        // Request Status
143        'REQUEST-STATUS' => 'Sabre\\VObject\\Property\\Text',
144
145        // Additions from draft-daboo-valarm-extensions-04
146        'ALARM-AGENT'   => 'Sabre\\VObject\\Property\\Text',
147        'ACKNOWLEDGED'  => 'Sabre\\VObject\\Property\\ICalendar\\DateTime',
148        'PROXIMITY'     => 'Sabre\\VObject\\Property\\Text',
149        'DEFAULT-ALARM' => 'Sabre\\VObject\\Property\\Boolean',
150
151        // Additions from draft-daboo-calendar-availability-05
152        'BUSYTYPE' => 'Sabre\\VObject\\Property\\Text',
153
154    ];
155
156    /**
157     * Returns the current document type.
158     *
159     * @return int
160     */
161    function getDocumentType() {
162
163        return self::ICALENDAR20;
164
165    }
166
167    /**
168     * Returns a list of all 'base components'. For instance, if an Event has
169     * a recurrence rule, and one instance is overridden, the overridden event
170     * will have the same UID, but will be excluded from this list.
171     *
172     * VTIMEZONE components will always be excluded.
173     *
174     * @param string $componentName filter by component name
175     *
176     * @return VObject\Component[]
177     */
178    function getBaseComponents($componentName = null) {
179
180        $isBaseComponent = function($component) {
181
182            if (!$component instanceof VObject\Component) {
183                return false;
184            }
185            if ($component->name === 'VTIMEZONE') {
186                return false;
187            }
188            if (isset($component->{'RECURRENCE-ID'})) {
189                return false;
190            }
191            return true;
192
193        };
194
195        if ($componentName) {
196            // Early exit
197            return array_filter(
198                $this->select($componentName),
199                $isBaseComponent
200            );
201        }
202
203        $components = [];
204        foreach ($this->children as $childGroup) {
205
206            foreach ($childGroup as $child) {
207
208                if (!$child instanceof Component) {
209                    // If one child is not a component, they all are so we skip
210                    // the entire group.
211                    continue 2;
212                }
213                if ($isBaseComponent($child)) {
214                    $components[] = $child;
215                }
216
217            }
218
219        }
220        return $components;
221
222    }
223
224    /**
225     * Returns the first component that is not a VTIMEZONE, and does not have
226     * an RECURRENCE-ID.
227     *
228     * If there is no such component, null will be returned.
229     *
230     * @param string $componentName filter by component name
231     *
232     * @return VObject\Component|null
233     */
234    function getBaseComponent($componentName = null) {
235
236        $isBaseComponent = function($component) {
237
238            if (!$component instanceof VObject\Component) {
239                return false;
240            }
241            if ($component->name === 'VTIMEZONE') {
242                return false;
243            }
244            if (isset($component->{'RECURRENCE-ID'})) {
245                return false;
246            }
247            return true;
248
249        };
250
251        if ($componentName) {
252            foreach ($this->select($componentName) as $child) {
253                if ($isBaseComponent($child)) {
254                    return $child;
255                }
256            }
257            return null;
258        }
259
260        // Searching all components
261        foreach ($this->children as $childGroup) {
262            foreach ($childGroup as $child) {
263                if ($isBaseComponent($child)) {
264                    return $child;
265                }
266            }
267
268        }
269        return null;
270
271    }
272
273    /**
274     * Expand all events in this VCalendar object and return a new VCalendar
275     * with the expanded events.
276     *
277     * If this calendar object, has events with recurrence rules, this method
278     * can be used to expand the event into multiple sub-events.
279     *
280     * Each event will be stripped from it's recurrence information, and only
281     * the instances of the event in the specified timerange will be left
282     * alone.
283     *
284     * In addition, this method will cause timezone information to be stripped,
285     * and normalized to UTC.
286     *
287     * @param DateTimeInterface $start
288     * @param DateTimeInterface $end
289     * @param DateTimeZone $timeZone reference timezone for floating dates and
290     *                     times.
291     * @return VCalendar
292     */
293    function expand(DateTimeInterface $start, DateTimeInterface $end, DateTimeZone $timeZone = null) {
294
295        $newChildren = [];
296        $recurringEvents = [];
297
298        if (!$timeZone) {
299            $timeZone = new DateTimeZone('UTC');
300        }
301
302        $stripTimezones = function(Component $component) use ($timeZone, &$stripTimezones) {
303
304            foreach ($component->children() as $componentChild) {
305                if ($componentChild instanceof Property\ICalendar\DateTime && $componentChild->hasTime()) {
306
307                    $dt = $componentChild->getDateTimes($timeZone);
308                    // We only need to update the first timezone, because
309                    // setDateTimes will match all other timezones to the
310                    // first.
311                    $dt[0] = $dt[0]->setTimeZone(new DateTimeZone('UTC'));
312                    $componentChild->setDateTimes($dt);
313                } elseif ($componentChild instanceof Component) {
314                    $stripTimezones($componentChild);
315                }
316
317            }
318            return $component;
319
320        };
321
322        foreach ($this->children() as $child) {
323
324            if ($child instanceof Property && $child->name !== 'PRODID') {
325                // We explictly want to ignore PRODID, because we want to
326                // overwrite it with our own.
327                $newChildren[] = clone $child;
328            } elseif ($child instanceof Component && $child->name !== 'VTIMEZONE') {
329
330                // We're also stripping all VTIMEZONE objects because we're
331                // converting everything to UTC.
332                if ($child->name === 'VEVENT' && (isset($child->{'RECURRENCE-ID'}) || isset($child->RRULE) || isset($child->RDATE))) {
333                    // Handle these a bit later.
334                    $uid = (string)$child->UID;
335                    if (!$uid) {
336                        throw new InvalidDataException('Every VEVENT object must have a UID property');
337                    }
338                    if (isset($recurringEvents[$uid])) {
339                        $recurringEvents[$uid][] = clone $child;
340                    } else {
341                        $recurringEvents[$uid] = [clone $child];
342                    }
343                } elseif ($child->name === 'VEVENT' && $child->isInTimeRange($start, $end)) {
344                    $newChildren[] = $stripTimezones(clone $child);
345                }
346
347            }
348
349        }
350
351        foreach ($recurringEvents as $events) {
352
353            try {
354                $it = new EventIterator($events, $timeZone);
355
356            } catch (NoInstancesException $e) {
357                // This event is recurring, but it doesn't have a single
358                // instance. We are skipping this event from the output
359                // entirely.
360                continue;
361            }
362            $it->fastForward($start);
363
364            while ($it->valid() && $it->getDTStart() < $end) {
365
366                if ($it->getDTEnd() > $start) {
367
368                    $newChildren[] = $stripTimezones($it->getEventObject());
369
370                }
371                $it->next();
372
373            }
374
375        }
376
377        return new self($newChildren);
378
379    }
380
381    /**
382     * This method should return a list of default property values.
383     *
384     * @return array
385     */
386    protected function getDefaults() {
387
388        return [
389            'VERSION'  => '2.0',
390            'PRODID'   => '-//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN',
391            'CALSCALE' => 'GREGORIAN',
392        ];
393
394    }
395
396    /**
397     * A simple list of validation rules.
398     *
399     * This is simply a list of properties, and how many times they either
400     * must or must not appear.
401     *
402     * Possible values per property:
403     *   * 0 - Must not appear.
404     *   * 1 - Must appear exactly once.
405     *   * + - Must appear at least once.
406     *   * * - Can appear any number of times.
407     *   * ? - May appear, but not more than once.
408     *
409     * @var array
410     */
411    function getValidationRules() {
412
413        return [
414            'PRODID'  => 1,
415            'VERSION' => 1,
416
417            'CALSCALE' => '?',
418            'METHOD'   => '?',
419        ];
420
421    }
422
423    /**
424     * Validates the node for correctness.
425     *
426     * The following options are supported:
427     *   Node::REPAIR - May attempt to automatically repair the problem.
428     *   Node::PROFILE_CARDDAV - Validate the vCard for CardDAV purposes.
429     *   Node::PROFILE_CALDAV - Validate the iCalendar for CalDAV purposes.
430     *
431     * This method returns an array with detected problems.
432     * Every element has the following properties:
433     *
434     *  * level - problem level.
435     *  * message - A human-readable string describing the issue.
436     *  * node - A reference to the problematic node.
437     *
438     * The level means:
439     *   1 - The issue was repaired (only happens if REPAIR was turned on).
440     *   2 - A warning.
441     *   3 - An error.
442     *
443     * @param int $options
444     *
445     * @return array
446     */
447    function validate($options = 0) {
448
449        $warnings = parent::validate($options);
450
451        if ($ver = $this->VERSION) {
452            if ((string)$ver !== '2.0') {
453                $warnings[] = [
454                    'level'   => 3,
455                    'message' => 'Only iCalendar version 2.0 as defined in rfc5545 is supported.',
456                    'node'    => $this,
457                ];
458            }
459
460        }
461
462        $uidList = [];
463        $componentsFound = 0;
464        $componentTypes = [];
465
466        foreach ($this->children() as $child) {
467            if ($child instanceof Component) {
468                $componentsFound++;
469
470                if (!in_array($child->name, ['VEVENT', 'VTODO', 'VJOURNAL'])) {
471                    continue;
472                }
473                $componentTypes[] = $child->name;
474
475                $uid = (string)$child->UID;
476                $isMaster = isset($child->{'RECURRENCE-ID'}) ? 0 : 1;
477                if (isset($uidList[$uid])) {
478                    $uidList[$uid]['count']++;
479                    if ($isMaster && $uidList[$uid]['hasMaster']) {
480                        $warnings[] = [
481                            'level'   => 3,
482                            'message' => 'More than one master object was found for the object with UID ' . $uid,
483                            'node'    => $this,
484                        ];
485                    }
486                    $uidList[$uid]['hasMaster'] += $isMaster;
487                } else {
488                    $uidList[$uid] = [
489                        'count'     => 1,
490                        'hasMaster' => $isMaster,
491                    ];
492                }
493
494            }
495        }
496
497        if ($componentsFound === 0) {
498            $warnings[] = [
499                'level'   => 3,
500                'message' => 'An iCalendar object must have at least 1 component.',
501                'node'    => $this,
502            ];
503        }
504
505        if ($options & self::PROFILE_CALDAV) {
506            if (count($uidList) > 1) {
507                $warnings[] = [
508                    'level'   => 3,
509                    'message' => 'A calendar object on a CalDAV server may only have components with the same UID.',
510                    'node'    => $this,
511                ];
512            }
513            if (count($componentTypes) === 0) {
514                $warnings[] = [
515                    'level'   => 3,
516                    'message' => 'A calendar object on a CalDAV server must have at least 1 component (VTODO, VEVENT, VJOURNAL).',
517                    'node'    => $this,
518                ];
519            }
520            if (count(array_unique($componentTypes)) > 1) {
521                $warnings[] = [
522                    'level'   => 3,
523                    'message' => 'A calendar object on a CalDAV server may only have 1 type of component (VEVENT, VTODO or VJOURNAL).',
524                    'node'    => $this,
525                ];
526            }
527
528            if (isset($this->METHOD)) {
529                $warnings[] = [
530                    'level'   => 3,
531                    'message' => 'A calendar object on a CalDAV server MUST NOT have a METHOD property.',
532                    'node'    => $this,
533                ];
534            }
535        }
536
537        return $warnings;
538
539    }
540
541    /**
542     * Returns all components with a specific UID value.
543     *
544     * @return array
545     */
546    function getByUID($uid) {
547
548        return array_filter($this->getComponents(), function($item) use ($uid) {
549
550            if (!$itemUid = $item->select('UID')) {
551                return false;
552            }
553            $itemUid = current($itemUid)->getValue();
554            return $uid === $itemUid;
555
556        });
557
558    }
559
560
561}
562