1<?php
2
3namespace Sabre\VObject;
4
5use Sabre\Xml;
6
7/**
8 * Component.
9 *
10 * A component represents a group of properties, such as VCALENDAR, VEVENT, or
11 * VCARD.
12 *
13 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
14 * @author Evert Pot (http://evertpot.com/)
15 * @license http://sabre.io/license/ Modified BSD License
16 */
17class Component extends Node
18{
19    /**
20     * Component name.
21     *
22     * This will contain a string such as VEVENT, VTODO, VCALENDAR, VCARD.
23     *
24     * @var string
25     */
26    public $name;
27
28    /**
29     * A list of properties and/or sub-components.
30     *
31     * @var array
32     */
33    protected $children = [];
34
35    /**
36     * Creates a new component.
37     *
38     * You can specify the children either in key=>value syntax, in which case
39     * properties will automatically be created, or you can just pass a list of
40     * Component and Property object.
41     *
42     * By default, a set of sensible values will be added to the component. For
43     * an iCalendar object, this may be something like CALSCALE:GREGORIAN. To
44     * ensure that this does not happen, set $defaults to false.
45     *
46     * @param Document $root
47     * @param string   $name     such as VCALENDAR, VEVENT
48     * @param array    $children
49     * @param bool     $defaults
50     */
51    public function __construct(Document $root, $name, array $children = [], $defaults = true)
52    {
53        $this->name = strtoupper($name);
54        $this->root = $root;
55
56        if ($defaults) {
57            // This is a terribly convoluted way to do this, but this ensures
58            // that the order of properties as they are specified in both
59            // defaults and the childrens list, are inserted in the object in a
60            // natural way.
61            $list = $this->getDefaults();
62            $nodes = [];
63            foreach ($children as $key => $value) {
64                if ($value instanceof Node) {
65                    if (isset($list[$value->name])) {
66                        unset($list[$value->name]);
67                    }
68                    $nodes[] = $value;
69                } else {
70                    $list[$key] = $value;
71                }
72            }
73            foreach ($list as $key => $value) {
74                $this->add($key, $value);
75            }
76            foreach ($nodes as $node) {
77                $this->add($node);
78            }
79        } else {
80            foreach ($children as $k => $child) {
81                if ($child instanceof Node) {
82                    // Component or Property
83                    $this->add($child);
84                } else {
85                    // Property key=>value
86                    $this->add($k, $child);
87                }
88            }
89        }
90    }
91
92    /**
93     * Adds a new property or component, and returns the new item.
94     *
95     * This method has 3 possible signatures:
96     *
97     * add(Component $comp) // Adds a new component
98     * add(Property $prop)  // Adds a new property
99     * add($name, $value, array $parameters = []) // Adds a new property
100     * add($name, array $children = []) // Adds a new component
101     * by name.
102     *
103     * @return Node
104     */
105    public function add()
106    {
107        $arguments = func_get_args();
108
109        if ($arguments[0] instanceof Node) {
110            if (isset($arguments[1])) {
111                throw new \InvalidArgumentException('The second argument must not be specified, when passing a VObject Node');
112            }
113            $arguments[0]->parent = $this;
114            $newNode = $arguments[0];
115        } elseif (is_string($arguments[0])) {
116            $newNode = call_user_func_array([$this->root, 'create'], $arguments);
117        } else {
118            throw new \InvalidArgumentException('The first argument must either be a \\Sabre\\VObject\\Node or a string');
119        }
120
121        $name = $newNode->name;
122        if (isset($this->children[$name])) {
123            $this->children[$name][] = $newNode;
124        } else {
125            $this->children[$name] = [$newNode];
126        }
127
128        return $newNode;
129    }
130
131    /**
132     * This method removes a component or property from this component.
133     *
134     * You can either specify the item by name (like DTSTART), in which case
135     * all properties/components with that name will be removed, or you can
136     * pass an instance of a property or component, in which case only that
137     * exact item will be removed.
138     *
139     * @param string|Property|Component $item
140     */
141    public function remove($item)
142    {
143        if (is_string($item)) {
144            // If there's no dot in the name, it's an exact property name and
145            // we can just wipe out all those properties.
146            //
147            if (false === strpos($item, '.')) {
148                unset($this->children[strtoupper($item)]);
149
150                return;
151            }
152            // If there was a dot, we need to ask select() to help us out and
153            // then we just call remove recursively.
154            foreach ($this->select($item) as $child) {
155                $this->remove($child);
156            }
157        } else {
158            foreach ($this->select($item->name) as $k => $child) {
159                if ($child === $item) {
160                    unset($this->children[$item->name][$k]);
161
162                    return;
163                }
164            }
165        }
166
167        throw new \InvalidArgumentException('The item you passed to remove() was not a child of this component');
168    }
169
170    /**
171     * Returns a flat list of all the properties and components in this
172     * component.
173     *
174     * @return array
175     */
176    public function children()
177    {
178        $result = [];
179        foreach ($this->children as $childGroup) {
180            $result = array_merge($result, $childGroup);
181        }
182
183        return $result;
184    }
185
186    /**
187     * This method only returns a list of sub-components. Properties are
188     * ignored.
189     *
190     * @return array
191     */
192    public function getComponents()
193    {
194        $result = [];
195
196        foreach ($this->children as $childGroup) {
197            foreach ($childGroup as $child) {
198                if ($child instanceof self) {
199                    $result[] = $child;
200                }
201            }
202        }
203
204        return $result;
205    }
206
207    /**
208     * Returns an array with elements that match the specified name.
209     *
210     * This function is also aware of MIME-Directory groups (as they appear in
211     * vcards). This means that if a property is grouped as "HOME.EMAIL", it
212     * will also be returned when searching for just "EMAIL". If you want to
213     * search for a property in a specific group, you can select on the entire
214     * string ("HOME.EMAIL"). If you want to search on a specific property that
215     * has not been assigned a group, specify ".EMAIL".
216     *
217     * @param string $name
218     *
219     * @return array
220     */
221    public function select($name)
222    {
223        $group = null;
224        $name = strtoupper($name);
225        if (false !== strpos($name, '.')) {
226            list($group, $name) = explode('.', $name, 2);
227        }
228        if ('' === $name) {
229            $name = null;
230        }
231
232        if (!is_null($name)) {
233            $result = isset($this->children[$name]) ? $this->children[$name] : [];
234
235            if (is_null($group)) {
236                return $result;
237            } else {
238                // If we have a group filter as well, we need to narrow it down
239                // more.
240                return array_filter(
241                    $result,
242                    function ($child) use ($group) {
243                        return $child instanceof Property && strtoupper($child->group) === $group;
244                    }
245                );
246            }
247        }
248
249        // If we got to this point, it means there was no 'name' specified for
250        // searching, implying that this is a group-only search.
251        $result = [];
252        foreach ($this->children as $childGroup) {
253            foreach ($childGroup as $child) {
254                if ($child instanceof Property && strtoupper($child->group) === $group) {
255                    $result[] = $child;
256                }
257            }
258        }
259
260        return $result;
261    }
262
263    /**
264     * Turns the object back into a serialized blob.
265     *
266     * @return string
267     */
268    public function serialize()
269    {
270        $str = 'BEGIN:'.$this->name."\r\n";
271
272        /**
273         * Gives a component a 'score' for sorting purposes.
274         *
275         * This is solely used by the childrenSort method.
276         *
277         * A higher score means the item will be lower in the list.
278         * To avoid score collisions, each "score category" has a reasonable
279         * space to accommodate elements. The $key is added to the $score to
280         * preserve the original relative order of elements.
281         *
282         * @param int   $key
283         * @param array $array
284         *
285         * @return int
286         */
287        $sortScore = function ($key, $array) {
288            if ($array[$key] instanceof Component) {
289                // We want to encode VTIMEZONE first, this is a personal
290                // preference.
291                if ('VTIMEZONE' === $array[$key]->name) {
292                    $score = 300000000;
293
294                    return $score + $key;
295                } else {
296                    $score = 400000000;
297
298                    return $score + $key;
299                }
300            } else {
301                // Properties get encoded first
302                // VCARD version 4.0 wants the VERSION property to appear first
303                if ($array[$key] instanceof Property) {
304                    if ('VERSION' === $array[$key]->name) {
305                        $score = 100000000;
306
307                        return $score + $key;
308                    } else {
309                        // All other properties
310                        $score = 200000000;
311
312                        return $score + $key;
313                    }
314                }
315            }
316        };
317
318        $children = $this->children();
319        $tmp = $children;
320        uksort(
321            $children,
322            function ($a, $b) use ($sortScore, $tmp) {
323                $sA = $sortScore($a, $tmp);
324                $sB = $sortScore($b, $tmp);
325
326                return $sA - $sB;
327            }
328        );
329
330        foreach ($children as $child) {
331            $str .= $child->serialize();
332        }
333        $str .= 'END:'.$this->name."\r\n";
334
335        return $str;
336    }
337
338    /**
339     * This method returns an array, with the representation as it should be
340     * encoded in JSON. This is used to create jCard or jCal documents.
341     *
342     * @return array
343     */
344    public function jsonSerialize()
345    {
346        $components = [];
347        $properties = [];
348
349        foreach ($this->children as $childGroup) {
350            foreach ($childGroup as $child) {
351                if ($child instanceof self) {
352                    $components[] = $child->jsonSerialize();
353                } else {
354                    $properties[] = $child->jsonSerialize();
355                }
356            }
357        }
358
359        return [
360            strtolower($this->name),
361            $properties,
362            $components,
363        ];
364    }
365
366    /**
367     * This method serializes the data into XML. This is used to create xCard or
368     * xCal documents.
369     *
370     * @param Xml\Writer $writer XML writer
371     */
372    public function xmlSerialize(Xml\Writer $writer)
373    {
374        $components = [];
375        $properties = [];
376
377        foreach ($this->children as $childGroup) {
378            foreach ($childGroup as $child) {
379                if ($child instanceof self) {
380                    $components[] = $child;
381                } else {
382                    $properties[] = $child;
383                }
384            }
385        }
386
387        $writer->startElement(strtolower($this->name));
388
389        if (!empty($properties)) {
390            $writer->startElement('properties');
391
392            foreach ($properties as $property) {
393                $property->xmlSerialize($writer);
394            }
395
396            $writer->endElement();
397        }
398
399        if (!empty($components)) {
400            $writer->startElement('components');
401
402            foreach ($components as $component) {
403                $component->xmlSerialize($writer);
404            }
405
406            $writer->endElement();
407        }
408
409        $writer->endElement();
410    }
411
412    /**
413     * This method should return a list of default property values.
414     *
415     * @return array
416     */
417    protected function getDefaults()
418    {
419        return [];
420    }
421
422    /* Magic property accessors {{{ */
423
424    /**
425     * Using 'get' you will either get a property or component.
426     *
427     * If there were no child-elements found with the specified name,
428     * null is returned.
429     *
430     * To use this, this may look something like this:
431     *
432     * $event = $calendar->VEVENT;
433     *
434     * @param string $name
435     *
436     * @return Property
437     */
438    public function __get($name)
439    {
440        if ('children' === $name) {
441            throw new \RuntimeException('Starting sabre/vobject 4.0 the children property is now protected. You should use the children() method instead');
442        }
443
444        $matches = $this->select($name);
445        if (0 === count($matches)) {
446            return;
447        } else {
448            $firstMatch = current($matches);
449            /* @var $firstMatch Property */
450            $firstMatch->setIterator(new ElementList(array_values($matches)));
451
452            return $firstMatch;
453        }
454    }
455
456    /**
457     * This method checks if a sub-element with the specified name exists.
458     *
459     * @param string $name
460     *
461     * @return bool
462     */
463    public function __isset($name)
464    {
465        $matches = $this->select($name);
466
467        return count($matches) > 0;
468    }
469
470    /**
471     * Using the setter method you can add properties or subcomponents.
472     *
473     * You can either pass a Component, Property
474     * object, or a string to automatically create a Property.
475     *
476     * If the item already exists, it will be removed. If you want to add
477     * a new item with the same name, always use the add() method.
478     *
479     * @param string $name
480     * @param mixed  $value
481     */
482    public function __set($name, $value)
483    {
484        $name = strtoupper($name);
485        $this->remove($name);
486        if ($value instanceof self || $value instanceof Property) {
487            $this->add($value);
488        } else {
489            $this->add($name, $value);
490        }
491    }
492
493    /**
494     * Removes all properties and components within this component with the
495     * specified name.
496     *
497     * @param string $name
498     */
499    public function __unset($name)
500    {
501        $this->remove($name);
502    }
503
504    /* }}} */
505
506    /**
507     * This method is automatically called when the object is cloned.
508     * Specifically, this will ensure all child elements are also cloned.
509     */
510    public function __clone()
511    {
512        foreach ($this->children as $childName => $childGroup) {
513            foreach ($childGroup as $key => $child) {
514                $clonedChild = clone $child;
515                $clonedChild->parent = $this;
516                $clonedChild->root = $this->root;
517                $this->children[$childName][$key] = $clonedChild;
518            }
519        }
520    }
521
522    /**
523     * A simple list of validation rules.
524     *
525     * This is simply a list of properties, and how many times they either
526     * must or must not appear.
527     *
528     * Possible values per property:
529     *   * 0 - Must not appear.
530     *   * 1 - Must appear exactly once.
531     *   * + - Must appear at least once.
532     *   * * - Can appear any number of times.
533     *   * ? - May appear, but not more than once.
534     *
535     * It is also possible to specify defaults and severity levels for
536     * violating the rule.
537     *
538     * See the VEVENT implementation for getValidationRules for a more complex
539     * example.
540     *
541     * @var array
542     */
543    public function getValidationRules()
544    {
545        return [];
546    }
547
548    /**
549     * Validates the node for correctness.
550     *
551     * The following options are supported:
552     *   Node::REPAIR - May attempt to automatically repair the problem.
553     *   Node::PROFILE_CARDDAV - Validate the vCard for CardDAV purposes.
554     *   Node::PROFILE_CALDAV - Validate the iCalendar for CalDAV purposes.
555     *
556     * This method returns an array with detected problems.
557     * Every element has the following properties:
558     *
559     *  * level - problem level.
560     *  * message - A human-readable string describing the issue.
561     *  * node - A reference to the problematic node.
562     *
563     * The level means:
564     *   1 - The issue was repaired (only happens if REPAIR was turned on).
565     *   2 - A warning.
566     *   3 - An error.
567     *
568     * @param int $options
569     *
570     * @return array
571     */
572    public function validate($options = 0)
573    {
574        $rules = $this->getValidationRules();
575        $defaults = $this->getDefaults();
576
577        $propertyCounters = [];
578
579        $messages = [];
580
581        foreach ($this->children() as $child) {
582            $name = strtoupper($child->name);
583            if (!isset($propertyCounters[$name])) {
584                $propertyCounters[$name] = 1;
585            } else {
586                ++$propertyCounters[$name];
587            }
588            $messages = array_merge($messages, $child->validate($options));
589        }
590
591        foreach ($rules as $propName => $rule) {
592            switch ($rule) {
593                case '0':
594                    if (isset($propertyCounters[$propName])) {
595                        $messages[] = [
596                            'level' => 3,
597                            'message' => $propName.' MUST NOT appear in a '.$this->name.' component',
598                            'node' => $this,
599                        ];
600                    }
601                    break;
602                case '1':
603                    if (!isset($propertyCounters[$propName]) || 1 !== $propertyCounters[$propName]) {
604                        $repaired = false;
605                        if ($options & self::REPAIR && isset($defaults[$propName])) {
606                            $this->add($propName, $defaults[$propName]);
607                            $repaired = true;
608                        }
609                        $messages[] = [
610                            'level' => $repaired ? 1 : 3,
611                            'message' => $propName.' MUST appear exactly once in a '.$this->name.' component',
612                            'node' => $this,
613                        ];
614                    }
615                    break;
616                case '+':
617                    if (!isset($propertyCounters[$propName]) || $propertyCounters[$propName] < 1) {
618                        $messages[] = [
619                            'level' => 3,
620                            'message' => $propName.' MUST appear at least once in a '.$this->name.' component',
621                            'node' => $this,
622                        ];
623                    }
624                    break;
625                case '*':
626                    break;
627                case '?':
628                    if (isset($propertyCounters[$propName]) && $propertyCounters[$propName] > 1) {
629                        $level = 3;
630
631                        // We try to repair the same property appearing multiple times with the exact same value
632                        // by removing the duplicates and keeping only one property
633                        if ($options & self::REPAIR) {
634                            $properties = array_unique($this->select($propName), SORT_REGULAR);
635
636                            if (1 === count($properties)) {
637                                $this->remove($propName);
638                                $this->add($properties[0]);
639
640                                $level = 1;
641                            }
642                        }
643
644                        $messages[] = [
645                            'level' => $level,
646                            'message' => $propName.' MUST NOT appear more than once in a '.$this->name.' component',
647                            'node' => $this,
648                        ];
649                    }
650                    break;
651            }
652        }
653
654        return $messages;
655    }
656
657    /**
658     * Call this method on a document if you're done using it.
659     *
660     * It's intended to remove all circular references, so PHP can easily clean
661     * it up.
662     */
663    public function destroy()
664    {
665        parent::destroy();
666        foreach ($this->children as $childGroup) {
667            foreach ($childGroup as $child) {
668                $child->destroy();
669            }
670        }
671        $this->children = [];
672    }
673}
674