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    public 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    public 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    public 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    public 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     * Returns the current document type.
157     *
158     * @return int
159     */
160    public function getDocumentType()
161    {
162        return self::ICALENDAR20;
163    }
164
165    /**
166     * Returns a list of all 'base components'. For instance, if an Event has
167     * a recurrence rule, and one instance is overridden, the overridden event
168     * will have the same UID, but will be excluded from this list.
169     *
170     * VTIMEZONE components will always be excluded.
171     *
172     * @param string $componentName filter by component name
173     *
174     * @return VObject\Component[]
175     */
176    public function getBaseComponents($componentName = null)
177    {
178        $isBaseComponent = function ($component) {
179            if (!$component instanceof VObject\Component) {
180                return false;
181            }
182            if ('VTIMEZONE' === $component->name) {
183                return false;
184            }
185            if (isset($component->{'RECURRENCE-ID'})) {
186                return false;
187            }
188
189            return true;
190        };
191
192        if ($componentName) {
193            // Early exit
194            return array_filter(
195                $this->select($componentName),
196                $isBaseComponent
197            );
198        }
199
200        $components = [];
201        foreach ($this->children as $childGroup) {
202            foreach ($childGroup as $child) {
203                if (!$child instanceof Component) {
204                    // If one child is not a component, they all are so we skip
205                    // the entire group.
206                    continue 2;
207                }
208                if ($isBaseComponent($child)) {
209                    $components[] = $child;
210                }
211            }
212        }
213
214        return $components;
215    }
216
217    /**
218     * Returns the first component that is not a VTIMEZONE, and does not have
219     * an RECURRENCE-ID.
220     *
221     * If there is no such component, null will be returned.
222     *
223     * @param string $componentName filter by component name
224     *
225     * @return VObject\Component|null
226     */
227    public function getBaseComponent($componentName = null)
228    {
229        $isBaseComponent = function ($component) {
230            if (!$component instanceof VObject\Component) {
231                return false;
232            }
233            if ('VTIMEZONE' === $component->name) {
234                return false;
235            }
236            if (isset($component->{'RECURRENCE-ID'})) {
237                return false;
238            }
239
240            return true;
241        };
242
243        if ($componentName) {
244            foreach ($this->select($componentName) as $child) {
245                if ($isBaseComponent($child)) {
246                    return $child;
247                }
248            }
249
250            return null;
251        }
252
253        // Searching all components
254        foreach ($this->children as $childGroup) {
255            foreach ($childGroup as $child) {
256                if ($isBaseComponent($child)) {
257                    return $child;
258                }
259            }
260        }
261
262        return null;
263    }
264
265    /**
266     * Expand all events in this VCalendar object and return a new VCalendar
267     * with the expanded events.
268     *
269     * If this calendar object, has events with recurrence rules, this method
270     * can be used to expand the event into multiple sub-events.
271     *
272     * Each event will be stripped from its recurrence information, and only
273     * the instances of the event in the specified timerange will be left
274     * alone.
275     *
276     * In addition, this method will cause timezone information to be stripped,
277     * and normalized to UTC.
278     *
279     * @param DateTimeInterface $start
280     * @param DateTimeInterface $end
281     * @param DateTimeZone      $timeZone reference timezone for floating dates and
282     *                                    times
283     *
284     * @return VCalendar
285     */
286    public function expand(DateTimeInterface $start, DateTimeInterface $end, DateTimeZone $timeZone = null)
287    {
288        $newChildren = [];
289        $recurringEvents = [];
290
291        if (!$timeZone) {
292            $timeZone = new DateTimeZone('UTC');
293        }
294
295        $stripTimezones = function (Component $component) use ($timeZone, &$stripTimezones) {
296            foreach ($component->children() as $componentChild) {
297                if ($componentChild instanceof Property\ICalendar\DateTime && $componentChild->hasTime()) {
298                    $dt = $componentChild->getDateTimes($timeZone);
299                    // We only need to update the first timezone, because
300                    // setDateTimes will match all other timezones to the
301                    // first.
302                    $dt[0] = $dt[0]->setTimeZone(new DateTimeZone('UTC'));
303                    $componentChild->setDateTimes($dt);
304                } elseif ($componentChild instanceof Component) {
305                    $stripTimezones($componentChild);
306                }
307            }
308
309            return $component;
310        };
311
312        foreach ($this->children() as $child) {
313            if ($child instanceof Property && 'PRODID' !== $child->name) {
314                // We explictly want to ignore PRODID, because we want to
315                // overwrite it with our own.
316                $newChildren[] = clone $child;
317            } elseif ($child instanceof Component && 'VTIMEZONE' !== $child->name) {
318                // We're also stripping all VTIMEZONE objects because we're
319                // converting everything to UTC.
320                if ('VEVENT' === $child->name && (isset($child->{'RECURRENCE-ID'}) || isset($child->RRULE) || isset($child->RDATE))) {
321                    // Handle these a bit later.
322                    $uid = (string) $child->UID;
323                    if (!$uid) {
324                        throw new InvalidDataException('Every VEVENT object must have a UID property');
325                    }
326                    if (isset($recurringEvents[$uid])) {
327                        $recurringEvents[$uid][] = clone $child;
328                    } else {
329                        $recurringEvents[$uid] = [clone $child];
330                    }
331                } elseif ('VEVENT' === $child->name && $child->isInTimeRange($start, $end)) {
332                    $newChildren[] = $stripTimezones(clone $child);
333                }
334            }
335        }
336
337        foreach ($recurringEvents as $events) {
338            try {
339                $it = new EventIterator($events, null, $timeZone);
340            } catch (NoInstancesException $e) {
341                // This event is recurring, but it doesn't have a single
342                // instance. We are skipping this event from the output
343                // entirely.
344                continue;
345            }
346            $it->fastForward($start);
347
348            while ($it->valid() && $it->getDTStart() < $end) {
349                if ($it->getDTEnd() > $start) {
350                    $newChildren[] = $stripTimezones($it->getEventObject());
351                }
352                $it->next();
353            }
354        }
355
356        return new self($newChildren);
357    }
358
359    /**
360     * This method should return a list of default property values.
361     *
362     * @return array
363     */
364    protected function getDefaults()
365    {
366        return [
367            'VERSION' => '2.0',
368            'PRODID' => '-//Sabre//Sabre VObject '.VObject\Version::VERSION.'//EN',
369            'CALSCALE' => 'GREGORIAN',
370        ];
371    }
372
373    /**
374     * A simple list of validation rules.
375     *
376     * This is simply a list of properties, and how many times they either
377     * must or must not appear.
378     *
379     * Possible values per property:
380     *   * 0 - Must not appear.
381     *   * 1 - Must appear exactly once.
382     *   * + - Must appear at least once.
383     *   * * - Can appear any number of times.
384     *   * ? - May appear, but not more than once.
385     *
386     * @var array
387     */
388    public function getValidationRules()
389    {
390        return [
391            'PRODID' => 1,
392            'VERSION' => 1,
393
394            'CALSCALE' => '?',
395            'METHOD' => '?',
396        ];
397    }
398
399    /**
400     * Validates the node for correctness.
401     *
402     * The following options are supported:
403     *   Node::REPAIR - May attempt to automatically repair the problem.
404     *   Node::PROFILE_CARDDAV - Validate the vCard for CardDAV purposes.
405     *   Node::PROFILE_CALDAV - Validate the iCalendar for CalDAV purposes.
406     *
407     * This method returns an array with detected problems.
408     * Every element has the following properties:
409     *
410     *  * level - problem level.
411     *  * message - A human-readable string describing the issue.
412     *  * node - A reference to the problematic node.
413     *
414     * The level means:
415     *   1 - The issue was repaired (only happens if REPAIR was turned on).
416     *   2 - A warning.
417     *   3 - An error.
418     *
419     * @param int $options
420     *
421     * @return array
422     */
423    public function validate($options = 0)
424    {
425        $warnings = parent::validate($options);
426
427        if ($ver = $this->VERSION) {
428            if ('2.0' !== (string) $ver) {
429                $warnings[] = [
430                    'level' => 3,
431                    'message' => 'Only iCalendar version 2.0 as defined in rfc5545 is supported.',
432                    'node' => $this,
433                ];
434            }
435        }
436
437        $uidList = [];
438        $componentsFound = 0;
439        $componentTypes = [];
440
441        foreach ($this->children() as $child) {
442            if ($child instanceof Component) {
443                ++$componentsFound;
444
445                if (!in_array($child->name, ['VEVENT', 'VTODO', 'VJOURNAL'])) {
446                    continue;
447                }
448                $componentTypes[] = $child->name;
449
450                $uid = (string) $child->UID;
451                $isMaster = isset($child->{'RECURRENCE-ID'}) ? 0 : 1;
452                if (isset($uidList[$uid])) {
453                    ++$uidList[$uid]['count'];
454                    if ($isMaster && $uidList[$uid]['hasMaster']) {
455                        $warnings[] = [
456                            'level' => 3,
457                            'message' => 'More than one master object was found for the object with UID '.$uid,
458                            'node' => $this,
459                        ];
460                    }
461                    $uidList[$uid]['hasMaster'] += $isMaster;
462                } else {
463                    $uidList[$uid] = [
464                        'count' => 1,
465                        'hasMaster' => $isMaster,
466                    ];
467                }
468            }
469        }
470
471        if (0 === $componentsFound) {
472            $warnings[] = [
473                'level' => 3,
474                'message' => 'An iCalendar object must have at least 1 component.',
475                'node' => $this,
476            ];
477        }
478
479        if ($options & self::PROFILE_CALDAV) {
480            if (count($uidList) > 1) {
481                $warnings[] = [
482                    'level' => 3,
483                    'message' => 'A calendar object on a CalDAV server may only have components with the same UID.',
484                    'node' => $this,
485                ];
486            }
487            if (0 === count($componentTypes)) {
488                $warnings[] = [
489                    'level' => 3,
490                    'message' => 'A calendar object on a CalDAV server must have at least 1 component (VTODO, VEVENT, VJOURNAL).',
491                    'node' => $this,
492                ];
493            }
494            if (count(array_unique($componentTypes)) > 1) {
495                $warnings[] = [
496                    'level' => 3,
497                    'message' => 'A calendar object on a CalDAV server may only have 1 type of component (VEVENT, VTODO or VJOURNAL).',
498                    'node' => $this,
499                ];
500            }
501
502            if (isset($this->METHOD)) {
503                $warnings[] = [
504                    'level' => 3,
505                    'message' => 'A calendar object on a CalDAV server MUST NOT have a METHOD property.',
506                    'node' => $this,
507                ];
508            }
509        }
510
511        return $warnings;
512    }
513
514    /**
515     * Returns all components with a specific UID value.
516     *
517     * @return array
518     */
519    public function getByUID($uid)
520    {
521        return array_filter($this->getComponents(), function ($item) use ($uid) {
522            if (!$itemUid = $item->select('UID')) {
523                return false;
524            }
525            $itemUid = current($itemUid)->getValue();
526
527            return $uid === $itemUid;
528        });
529    }
530}
531