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