You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
715 lines
20 KiB
715 lines
20 KiB
<?php |
|
|
|
namespace Sabre\VObject; |
|
|
|
use Sabre\Xml; |
|
|
|
/** |
|
* Component. |
|
* |
|
* A component represents a group of properties, such as VCALENDAR, VEVENT, or |
|
* VCARD. |
|
* |
|
* @copyright Copyright (C) fruux GmbH (https://fruux.com/) |
|
* @author Evert Pot (http://evertpot.com/) |
|
* @license http://sabre.io/license/ Modified BSD License |
|
*/ |
|
class Component extends Node { |
|
|
|
/** |
|
* Component name. |
|
* |
|
* This will contain a string such as VEVENT, VTODO, VCALENDAR, VCARD. |
|
* |
|
* @var string |
|
*/ |
|
public $name; |
|
|
|
/** |
|
* A list of properties and/or sub-components. |
|
* |
|
* @var array |
|
*/ |
|
protected $children = []; |
|
|
|
/** |
|
* Creates a new component. |
|
* |
|
* You can specify the children either in key=>value syntax, in which case |
|
* properties will automatically be created, or you can just pass a list of |
|
* Component and Property object. |
|
* |
|
* By default, a set of sensible values will be added to the component. For |
|
* an iCalendar object, this may be something like CALSCALE:GREGORIAN. To |
|
* ensure that this does not happen, set $defaults to false. |
|
* |
|
* @param Document $root |
|
* @param string $name such as VCALENDAR, VEVENT. |
|
* @param array $children |
|
* @param bool $defaults |
|
* |
|
* @return void |
|
*/ |
|
function __construct(Document $root, $name, array $children = [], $defaults = true) { |
|
|
|
$this->name = strtoupper($name); |
|
$this->root = $root; |
|
|
|
if ($defaults) { |
|
// This is a terribly convoluted way to do this, but this ensures |
|
// that the order of properties as they are specified in both |
|
// defaults and the childrens list, are inserted in the object in a |
|
// natural way. |
|
$list = $this->getDefaults(); |
|
$nodes = []; |
|
foreach ($children as $key => $value) { |
|
if ($value instanceof Node) { |
|
if (isset($list[$value->name])) { |
|
unset($list[$value->name]); |
|
} |
|
$nodes[] = $value; |
|
} else { |
|
$list[$key] = $value; |
|
} |
|
} |
|
foreach ($list as $key => $value) { |
|
$this->add($key, $value); |
|
} |
|
foreach ($nodes as $node) { |
|
$this->add($node); |
|
} |
|
} else { |
|
foreach ($children as $k => $child) { |
|
if ($child instanceof Node) { |
|
// Component or Property |
|
$this->add($child); |
|
} else { |
|
|
|
// Property key=>value |
|
$this->add($k, $child); |
|
} |
|
} |
|
} |
|
|
|
} |
|
|
|
/** |
|
* Adds a new property or component, and returns the new item. |
|
* |
|
* This method has 3 possible signatures: |
|
* |
|
* add(Component $comp) // Adds a new component |
|
* add(Property $prop) // Adds a new property |
|
* add($name, $value, array $parameters = []) // Adds a new property |
|
* add($name, array $children = []) // Adds a new component |
|
* by name. |
|
* |
|
* @return Node |
|
*/ |
|
function add() { |
|
|
|
$arguments = func_get_args(); |
|
|
|
if ($arguments[0] instanceof Node) { |
|
if (isset($arguments[1])) { |
|
throw new \InvalidArgumentException('The second argument must not be specified, when passing a VObject Node'); |
|
} |
|
$arguments[0]->parent = $this; |
|
$newNode = $arguments[0]; |
|
|
|
} elseif (is_string($arguments[0])) { |
|
|
|
$newNode = call_user_func_array([$this->root, 'create'], $arguments); |
|
|
|
} else { |
|
|
|
throw new \InvalidArgumentException('The first argument must either be a \\Sabre\\VObject\\Node or a string'); |
|
|
|
} |
|
|
|
$name = $newNode->name; |
|
if (isset($this->children[$name])) { |
|
$this->children[$name][] = $newNode; |
|
} else { |
|
$this->children[$name] = [$newNode]; |
|
} |
|
return $newNode; |
|
|
|
} |
|
|
|
/** |
|
* This method removes a component or property from this component. |
|
* |
|
* You can either specify the item by name (like DTSTART), in which case |
|
* all properties/components with that name will be removed, or you can |
|
* pass an instance of a property or component, in which case only that |
|
* exact item will be removed. |
|
* |
|
* @param string|Property|Component $item |
|
* @return void |
|
*/ |
|
function remove($item) { |
|
|
|
if (is_string($item)) { |
|
// If there's no dot in the name, it's an exact property name and |
|
// we can just wipe out all those properties. |
|
// |
|
if (strpos($item, '.') === false) { |
|
unset($this->children[strtoupper($item)]); |
|
return; |
|
} |
|
// If there was a dot, we need to ask select() to help us out and |
|
// then we just call remove recursively. |
|
foreach ($this->select($item) as $child) { |
|
|
|
$this->remove($child); |
|
|
|
} |
|
} else { |
|
foreach ($this->select($item->name) as $k => $child) { |
|
if ($child === $item) { |
|
unset($this->children[$item->name][$k]); |
|
return; |
|
} |
|
} |
|
} |
|
|
|
throw new \InvalidArgumentException('The item you passed to remove() was not a child of this component'); |
|
|
|
} |
|
|
|
/** |
|
* Returns a flat list of all the properties and components in this |
|
* component. |
|
* |
|
* @return array |
|
*/ |
|
function children() { |
|
|
|
$result = []; |
|
foreach ($this->children as $childGroup) { |
|
$result = array_merge($result, $childGroup); |
|
} |
|
return $result; |
|
|
|
} |
|
|
|
/** |
|
* This method only returns a list of sub-components. Properties are |
|
* ignored. |
|
* |
|
* @return array |
|
*/ |
|
function getComponents() { |
|
|
|
$result = []; |
|
|
|
foreach ($this->children as $childGroup) { |
|
foreach ($childGroup as $child) { |
|
if ($child instanceof self) { |
|
$result[] = $child; |
|
} |
|
} |
|
} |
|
return $result; |
|
|
|
} |
|
|
|
/** |
|
* Returns an array with elements that match the specified name. |
|
* |
|
* This function is also aware of MIME-Directory groups (as they appear in |
|
* vcards). This means that if a property is grouped as "HOME.EMAIL", it |
|
* will also be returned when searching for just "EMAIL". If you want to |
|
* search for a property in a specific group, you can select on the entire |
|
* string ("HOME.EMAIL"). If you want to search on a specific property that |
|
* has not been assigned a group, specify ".EMAIL". |
|
* |
|
* @param string $name |
|
* @return array |
|
*/ |
|
function select($name) { |
|
|
|
$group = null; |
|
$name = strtoupper($name); |
|
if (strpos($name, '.') !== false) { |
|
list($group, $name) = explode('.', $name, 2); |
|
} |
|
if ($name === '') $name = null; |
|
|
|
if (!is_null($name)) { |
|
|
|
$result = isset($this->children[$name]) ? $this->children[$name] : []; |
|
|
|
if (is_null($group)) { |
|
return $result; |
|
} else { |
|
// If we have a group filter as well, we need to narrow it down |
|
// more. |
|
return array_filter( |
|
$result, |
|
function($child) use ($group) { |
|
|
|
return $child instanceof Property && strtoupper($child->group) === $group; |
|
|
|
} |
|
); |
|
} |
|
|
|
} |
|
|
|
// If we got to this point, it means there was no 'name' specified for |
|
// searching, implying that this is a group-only search. |
|
$result = []; |
|
foreach ($this->children as $childGroup) { |
|
|
|
foreach ($childGroup as $child) { |
|
|
|
if ($child instanceof Property && strtoupper($child->group) === $group) { |
|
$result[] = $child; |
|
} |
|
|
|
} |
|
|
|
} |
|
return $result; |
|
|
|
} |
|
|
|
/** |
|
* Turns the object back into a serialized blob. |
|
* |
|
* @return string |
|
*/ |
|
function serialize() { |
|
|
|
$str = "BEGIN:" . $this->name . "\r\n"; |
|
|
|
/** |
|
* Gives a component a 'score' for sorting purposes. |
|
* |
|
* This is solely used by the childrenSort method. |
|
* |
|
* A higher score means the item will be lower in the list. |
|
* To avoid score collisions, each "score category" has a reasonable |
|
* space to accomodate elements. The $key is added to the $score to |
|
* preserve the original relative order of elements. |
|
* |
|
* @param int $key |
|
* @param array $array |
|
* |
|
* @return int |
|
*/ |
|
$sortScore = function($key, $array) { |
|
|
|
if ($array[$key] instanceof Component) { |
|
|
|
// We want to encode VTIMEZONE first, this is a personal |
|
// preference. |
|
if ($array[$key]->name === 'VTIMEZONE') { |
|
$score = 300000000; |
|
return $score + $key; |
|
} else { |
|
$score = 400000000; |
|
return $score + $key; |
|
} |
|
} else { |
|
// Properties get encoded first |
|
// VCARD version 4.0 wants the VERSION property to appear first |
|
if ($array[$key] instanceof Property) { |
|
if ($array[$key]->name === 'VERSION') { |
|
$score = 100000000; |
|
return $score + $key; |
|
} else { |
|
// All other properties |
|
$score = 200000000; |
|
return $score + $key; |
|
} |
|
} |
|
} |
|
|
|
}; |
|
|
|
$children = $this->children(); |
|
$tmp = $children; |
|
uksort( |
|
$children, |
|
function($a, $b) use ($sortScore, $tmp) { |
|
|
|
$sA = $sortScore($a, $tmp); |
|
$sB = $sortScore($b, $tmp); |
|
|
|
return $sA - $sB; |
|
|
|
} |
|
); |
|
|
|
foreach ($children as $child) $str .= $child->serialize(); |
|
$str .= "END:" . $this->name . "\r\n"; |
|
|
|
return $str; |
|
|
|
} |
|
|
|
/** |
|
* This method returns an array, with the representation as it should be |
|
* encoded in JSON. This is used to create jCard or jCal documents. |
|
* |
|
* @return array |
|
*/ |
|
function jsonSerialize() { |
|
|
|
$components = []; |
|
$properties = []; |
|
|
|
foreach ($this->children as $childGroup) { |
|
foreach ($childGroup as $child) { |
|
if ($child instanceof self) { |
|
$components[] = $child->jsonSerialize(); |
|
} else { |
|
$properties[] = $child->jsonSerialize(); |
|
} |
|
} |
|
} |
|
|
|
return [ |
|
strtolower($this->name), |
|
$properties, |
|
$components |
|
]; |
|
|
|
} |
|
|
|
/** |
|
* This method serializes the data into XML. This is used to create xCard or |
|
* xCal documents. |
|
* |
|
* @param Xml\Writer $writer XML writer. |
|
* |
|
* @return void |
|
*/ |
|
function xmlSerialize(Xml\Writer $writer) { |
|
|
|
$components = []; |
|
$properties = []; |
|
|
|
foreach ($this->children as $childGroup) { |
|
foreach ($childGroup as $child) { |
|
if ($child instanceof self) { |
|
$components[] = $child; |
|
} else { |
|
$properties[] = $child; |
|
} |
|
} |
|
} |
|
|
|
$writer->startElement(strtolower($this->name)); |
|
|
|
if (!empty($properties)) { |
|
|
|
$writer->startElement('properties'); |
|
|
|
foreach ($properties as $property) { |
|
$property->xmlSerialize($writer); |
|
} |
|
|
|
$writer->endElement(); |
|
|
|
} |
|
|
|
if (!empty($components)) { |
|
|
|
$writer->startElement('components'); |
|
|
|
foreach ($components as $component) { |
|
$component->xmlSerialize($writer); |
|
} |
|
|
|
$writer->endElement(); |
|
} |
|
|
|
$writer->endElement(); |
|
|
|
} |
|
|
|
/** |
|
* This method should return a list of default property values. |
|
* |
|
* @return array |
|
*/ |
|
protected function getDefaults() { |
|
|
|
return []; |
|
|
|
} |
|
|
|
/* Magic property accessors {{{ */ |
|
|
|
/** |
|
* Using 'get' you will either get a property or component. |
|
* |
|
* If there were no child-elements found with the specified name, |
|
* null is returned. |
|
* |
|
* To use this, this may look something like this: |
|
* |
|
* $event = $calendar->VEVENT; |
|
* |
|
* @param string $name |
|
* |
|
* @return Property |
|
*/ |
|
function __get($name) { |
|
|
|
if ($name === 'children') { |
|
|
|
throw new \RuntimeException('Starting sabre/vobject 4.0 the children property is now protected. You should use the children() method instead'); |
|
|
|
} |
|
|
|
$matches = $this->select($name); |
|
if (count($matches) === 0) { |
|
return; |
|
} else { |
|
$firstMatch = current($matches); |
|
/** @var $firstMatch Property */ |
|
$firstMatch->setIterator(new ElementList(array_values($matches))); |
|
return $firstMatch; |
|
} |
|
|
|
} |
|
|
|
/** |
|
* This method checks if a sub-element with the specified name exists. |
|
* |
|
* @param string $name |
|
* |
|
* @return bool |
|
*/ |
|
function __isset($name) { |
|
|
|
$matches = $this->select($name); |
|
return count($matches) > 0; |
|
|
|
} |
|
|
|
/** |
|
* Using the setter method you can add properties or subcomponents. |
|
* |
|
* You can either pass a Component, Property |
|
* object, or a string to automatically create a Property. |
|
* |
|
* If the item already exists, it will be removed. If you want to add |
|
* a new item with the same name, always use the add() method. |
|
* |
|
* @param string $name |
|
* @param mixed $value |
|
* |
|
* @return void |
|
*/ |
|
function __set($name, $value) { |
|
|
|
$name = strtoupper($name); |
|
$this->remove($name); |
|
if ($value instanceof self || $value instanceof Property) { |
|
$this->add($value); |
|
} else { |
|
$this->add($name, $value); |
|
} |
|
} |
|
|
|
/** |
|
* Removes all properties and components within this component with the |
|
* specified name. |
|
* |
|
* @param string $name |
|
* |
|
* @return void |
|
*/ |
|
function __unset($name) { |
|
|
|
$this->remove($name); |
|
|
|
} |
|
|
|
/* }}} */ |
|
|
|
/** |
|
* This method is automatically called when the object is cloned. |
|
* Specifically, this will ensure all child elements are also cloned. |
|
* |
|
* @return void |
|
*/ |
|
function __clone() { |
|
|
|
foreach ($this->children as $childName => $childGroup) { |
|
foreach ($childGroup as $key => $child) { |
|
$clonedChild = clone $child; |
|
$clonedChild->parent = $this; |
|
$clonedChild->root = $this->root; |
|
$this->children[$childName][$key] = $clonedChild; |
|
} |
|
} |
|
|
|
} |
|
|
|
/** |
|
* A simple list of validation rules. |
|
* |
|
* This is simply a list of properties, and how many times they either |
|
* must or must not appear. |
|
* |
|
* Possible values per property: |
|
* * 0 - Must not appear. |
|
* * 1 - Must appear exactly once. |
|
* * + - Must appear at least once. |
|
* * * - Can appear any number of times. |
|
* * ? - May appear, but not more than once. |
|
* |
|
* It is also possible to specify defaults and severity levels for |
|
* violating the rule. |
|
* |
|
* See the VEVENT implementation for getValidationRules for a more complex |
|
* example. |
|
* |
|
* @var array |
|
*/ |
|
function getValidationRules() { |
|
|
|
return []; |
|
|
|
} |
|
|
|
/** |
|
* Validates the node for correctness. |
|
* |
|
* The following options are supported: |
|
* Node::REPAIR - May attempt to automatically repair the problem. |
|
* Node::PROFILE_CARDDAV - Validate the vCard for CardDAV purposes. |
|
* Node::PROFILE_CALDAV - Validate the iCalendar for CalDAV purposes. |
|
* |
|
* This method returns an array with detected problems. |
|
* Every element has the following properties: |
|
* |
|
* * level - problem level. |
|
* * message - A human-readable string describing the issue. |
|
* * node - A reference to the problematic node. |
|
* |
|
* The level means: |
|
* 1 - The issue was repaired (only happens if REPAIR was turned on). |
|
* 2 - A warning. |
|
* 3 - An error. |
|
* |
|
* @param int $options |
|
* |
|
* @return array |
|
*/ |
|
function validate($options = 0) { |
|
|
|
$rules = $this->getValidationRules(); |
|
$defaults = $this->getDefaults(); |
|
|
|
$propertyCounters = []; |
|
|
|
$messages = []; |
|
|
|
foreach ($this->children() as $child) { |
|
$name = strtoupper($child->name); |
|
if (!isset($propertyCounters[$name])) { |
|
$propertyCounters[$name] = 1; |
|
} else { |
|
$propertyCounters[$name]++; |
|
} |
|
$messages = array_merge($messages, $child->validate($options)); |
|
} |
|
|
|
foreach ($rules as $propName => $rule) { |
|
|
|
switch ($rule) { |
|
case '0' : |
|
if (isset($propertyCounters[$propName])) { |
|
$messages[] = [ |
|
'level' => 3, |
|
'message' => $propName . ' MUST NOT appear in a ' . $this->name . ' component', |
|
'node' => $this, |
|
]; |
|
} |
|
break; |
|
case '1' : |
|
if (!isset($propertyCounters[$propName]) || $propertyCounters[$propName] !== 1) { |
|
$repaired = false; |
|
if ($options & self::REPAIR && isset($defaults[$propName])) { |
|
$this->add($propName, $defaults[$propName]); |
|
$repaired = true; |
|
} |
|
$messages[] = [ |
|
'level' => $repaired ? 1 : 3, |
|
'message' => $propName . ' MUST appear exactly once in a ' . $this->name . ' component', |
|
'node' => $this, |
|
]; |
|
} |
|
break; |
|
case '+' : |
|
if (!isset($propertyCounters[$propName]) || $propertyCounters[$propName] < 1) { |
|
$messages[] = [ |
|
'level' => 3, |
|
'message' => $propName . ' MUST appear at least once in a ' . $this->name . ' component', |
|
'node' => $this, |
|
]; |
|
} |
|
break; |
|
case '*' : |
|
break; |
|
case '?' : |
|
if (isset($propertyCounters[$propName]) && $propertyCounters[$propName] > 1) { |
|
$level = 3; |
|
|
|
// We try to repair the same property appearing multiple times with the exact same value |
|
// by removing the duplicates and keeping only one property |
|
if ($options & self::REPAIR) { |
|
$properties = array_unique($this->select($propName), SORT_REGULAR); |
|
|
|
if (count($properties) === 1) { |
|
$this->remove($propName); |
|
$this->add($properties[0]); |
|
|
|
$level = 1; |
|
} |
|
} |
|
|
|
$messages[] = [ |
|
'level' => $level, |
|
'message' => $propName . ' MUST NOT appear more than once in a ' . $this->name . ' component', |
|
'node' => $this, |
|
]; |
|
} |
|
break; |
|
|
|
} |
|
|
|
} |
|
return $messages; |
|
|
|
} |
|
|
|
/** |
|
* Call this method on a document if you're done using it. |
|
* |
|
* It's intended to remove all circular references, so PHP can easily clean |
|
* it up. |
|
* |
|
* @return void |
|
*/ |
|
function destroy() { |
|
|
|
parent::destroy(); |
|
foreach ($this->children as $childGroup) { |
|
foreach ($childGroup as $child) { |
|
$child->destroy(); |
|
} |
|
} |
|
$this->children = []; |
|
|
|
} |
|
|
|
}
|
|
|