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