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