diff --git a/lib/ext/Syncroton/Model/AXMLEntry.php b/lib/ext/Syncroton/Model/AXMLEntry.php index 8a02707..4b60e26 100644 --- a/lib/ext/Syncroton/Model/AXMLEntry.php +++ b/lib/ext/Syncroton/Model/AXMLEntry.php @@ -1,328 +1,342 @@ */ /** * abstract class to handle ActiveSync entry * * @package Syncroton * @subpackage Model */ - abstract class Syncroton_Model_AXMLEntry extends Syncroton_Model_AEntry implements Syncroton_Model_IXMLEntry { protected $_xmlBaseElement; - + protected $_properties = array(); - + protected $_dateTimeFormat = "Y-m-d\TH:i:s.000\Z"; - + /** * (non-PHPdoc) * @see Syncroton_Model_IEntry::__construct() */ public function __construct($properties = null) { if ($properties instanceof SimpleXMLElement) { $this->setFromSimpleXMLElement($properties); } elseif (is_array($properties)) { $this->setFromArray($properties); } - + $this->_isDirty = false; } - + /** * (non-PHPdoc) * @see Syncroton_Model_IEntry::appendXML() */ public function appendXML(DOMElement $domParrent, Syncroton_Model_IDevice $device) { $this->_addXMLNamespaces($domParrent); - - foreach($this->_elements as $elementName => $value) { + + foreach ($this->_elements as $elementName => $value) { // skip empty values - if($value === null || $value === '' || (is_array($value) && empty($value))) { + if ($value === null || $value === '' || (is_array($value) && empty($value))) { continue; } - - list ($nameSpace, $elementProperties) = $this->_getElementProperties($elementName); - - if ($nameSpace == 'Internal') { - continue; - } - - $elementVersion = isset($elementProperties['supportedSince']) ? $elementProperties['supportedSince'] : '12.0'; - - if (version_compare($device->acsversion, $elementVersion, '<')) { + + list ($nameSpace, $elementProperties) = $this->_getElementProperties($elementName, $device->acsversion); + + if ($nameSpace == 'Internal') { continue; } - + $nameSpace = 'uri:' . $nameSpace; - + if (isset($elementProperties['childElement'])) { $element = $domParrent->ownerDocument->createElementNS($nameSpace, ucfirst($elementName)); foreach($value as $subValue) { $subElement = $domParrent->ownerDocument->createElementNS($nameSpace, ucfirst($elementProperties['childElement'])); $this->_appendXMLElement($device, $subElement, $elementProperties, $subValue); $element->appendChild($subElement); } $domParrent->appendChild($element); } else if ($elementProperties['type'] == 'container' && !empty($elementProperties['multiple'])) { foreach ($value as $element) { $container = $domParrent->ownerDocument->createElementNS($nameSpace, ucfirst($elementName)); $element->appendXML($container, $device); $domParrent->appendChild($container); } } else if ($elementProperties['type'] == 'none') { if ($value) { $element = $domParrent->ownerDocument->createElementNS($nameSpace, ucfirst($elementName)); $domParrent->appendChild($element); } } else { $element = $domParrent->ownerDocument->createElementNS($nameSpace, ucfirst($elementName)); $this->_appendXMLElement($device, $element, $elementProperties, $value); $domParrent->appendChild($element); } } } - + /** * (non-PHPdoc) * @see Syncroton_Model_IEntry::getProperties() */ public function getProperties($selectedNamespace = null) { $properties = array(); - - foreach($this->_properties as $namespace => $namespaceProperties) { + + foreach ($this->_properties as $namespace => $namespaceProperties) { if ($selectedNamespace !== null && $namespace != $selectedNamespace) { continue; } - $properties = array_merge($properties, array_keys($namespaceProperties)); + $properties = array_merge($properties, array_keys($namespaceProperties)); } - - return $properties; - + + return array_unique($properties); } - - /** - * set properties from SimpleXMLElement object - * - * @param SimpleXMLElement $xmlCollection - * @throws InvalidArgumentException - */ - public function setFromSimpleXMLElement(SimpleXMLElement $properties) - { + + /** + * set properties from SimpleXMLElement object + * + * @param SimpleXMLElement $xmlCollection + * @throws InvalidArgumentException + */ + public function setFromSimpleXMLElement(SimpleXMLElement $properties) + { if (!in_array($properties->getName(), (array) $this->_xmlBaseElement)) { - throw new InvalidArgumentException('Unexpected element name: ' . $properties->getName()); - } - + throw new InvalidArgumentException('Unexpected element name: ' . $properties->getName()); + } + foreach (array_keys($this->_properties) as $namespace) { if ($namespace == 'Internal') { continue; } - - $this->_parseNamespace($namespace, $properties); - } - - return; + + $this->_parseNamespace($namespace, $properties); + } } - + /** * add needed xml namespaces to DomDocument * * @param unknown_type $domParrent */ - protected function _addXMLNamespaces(DOMElement $domParrent) + protected function _addXMLNamespaces(DOMElement $domParrent) { - foreach($this->_properties as $namespace => $namespaceProperties) { + foreach (array_keys($this->_properties) as $namespace) { // don't add default namespace again - if($domParrent->ownerDocument->documentElement->namespaceURI != 'uri:'.$namespace) { + if ($domParrent->ownerDocument->documentElement->namespaceURI != 'uri:'.$namespace) { $domParrent->ownerDocument->documentElement->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:'.$namespace, 'uri:'.$namespace); - } + } } - } - + } + protected function _appendXMLElement(Syncroton_Model_IDevice $device, DOMElement $element, $elementProperties, $value) { - if ($value instanceof Syncroton_Model_IEntry) { - $value->appendXML($element, $device); - } else { - if ($value instanceof DateTime) { + if ($value instanceof Syncroton_Model_IEntry && $elementProperties['type'] === 'container') { + $value->appendXML($element, $device); + } else { + if ($value instanceof DateTime) { $value = $value->format($this->_dateTimeFormat); - - } elseif (isset($elementProperties['encoding']) && $elementProperties['encoding'] == 'base64') { - if (is_resource($value)) { + } elseif (isset($elementProperties['encoding']) && $elementProperties['encoding'] == 'base64') { + if (is_resource($value)) { rewind($value); - $value = stream_get_contents($value); - } - $value = base64_encode($value); - } - + $value = stream_get_contents($value); + } + $value = base64_encode($value); + } + if ($elementProperties['type'] == 'byteArray') { $element->setAttributeNS('uri:Syncroton', 'Syncroton:encoding', 'opaque'); // encode to base64; the wbxml encoder will base64_decode it again // this way we can also transport data, which would break the xmlparser otherwise $element->appendChild($element->ownerDocument->createCDATASection(base64_encode($value))); + } else if ($elementProperties['type'] == 'double') { + $element->appendChild($element->ownerDocument->createTextNode((string) floatval($value))); } else { + $value = (string) $value; // strip off any non printable control characters if (!ctype_print($value)) { $value = $this->_removeControlChars($value); } - + $element->appendChild($element->ownerDocument->createTextNode($this->_enforceUTF8($value))); - } - } + } + } } - + /** * remove control chars from a string which are not allowed in XML values * * @param string $dirty An input string * @return string Cleaned up string */ protected function _removeControlChars($dirty) { // Replace non-character UTF-8 sequences that cause XML Parser to fail // https://git.kolab.org/T1311 $dirty = str_replace(array("\xEF\xBF\xBE", "\xEF\xBF\xBF"), '', $dirty); // Replace ASCII control-characters return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $dirty); } - + /** * enforce >valid< utf-8 encoding - * + * * @param string $dirty the string with maybe invalid utf-8 data * @return string string with valid utf-8 */ protected function _enforceUTF8($dirty) { if (function_exists('iconv')) { if (($clean = @iconv('UTF-8', 'UTF-8//IGNORE', $dirty)) !== false) { return $clean; } } - + if (function_exists('mb_convert_encoding')) { if (($clean = mb_convert_encoding($dirty, 'UTF-8', 'UTF-8')) !== false) { return $clean; } } - + return $dirty; } - + /** - * - * @param unknown_type $element + * + * @param unknown_type $element Element name + * @param string $version Protocol version * @throws InvalidArgumentException - * @return multitype:unknown + * @return array */ - protected function _getElementProperties($element) + protected function _getElementProperties($element, $version = null) { - foreach($this->_properties as $namespace => $namespaceProperties) { - if (array_key_exists($element, $namespaceProperties)) { - return array($namespace, $namespaceProperties[$element]); - } - } - - throw new InvalidArgumentException("$element is no valid property of " . get_class($this)); + + foreach ($this->_properties as $namespace => $namespaceProperties) { + if (array_key_exists($element, $namespaceProperties)) { + $elementProperties = $namespaceProperties[$element]; + + if ($version) { + $supportedSince = isset($elementProperties['supportedSince']) ? $elementProperties['supportedSince'] : '12.0'; + $supportedUntil = isset($elementProperties['supportedUntil']) ? $elementProperties['supportedUntil'] : '9999'; + + if (version_compare($version, $supportedSince, '<') || version_compare($version, $supportedUntil, '>')) { + continue; + } + } + + return array($namespace, $elementProperties); + } + } + + throw new InvalidArgumentException("$element is no valid property of " . get_class($this)); } - + protected function _parseNamespace($nameSpace, SimpleXMLElement $properties) { - // fetch data from Contacts namespace - $children = $properties->children("uri:$nameSpace"); - + // fetch data from the specified namespace + $children = $properties->children("uri:$nameSpace"); + foreach ($children as $elementName => $xmlElement) { $elementName = lcfirst($elementName); - + if (!isset($this->_properties[$nameSpace][$elementName])) { continue; } - + list (, $elementProperties) = $this->_getElementProperties($elementName); - + switch ($elementProperties['type']) { case 'container': if (!empty($elementProperties['multiple'])) { $property = (array) $this->$elementName; - + if (isset($elementProperties['class'])) { $property[] = new $elementProperties['class']($xmlElement); } else { $property[] = (string) $xmlElement; } } else if (isset($elementProperties['childElement'])) { $property = array(); - $childElement = ucfirst($elementProperties['childElement']); - + foreach ($xmlElement->$childElement as $subXmlElement) { if (isset($elementProperties['class'])) { $property[] = new $elementProperties['class']($subXmlElement); } else { $property[] = (string) $subXmlElement; } } } else { - $subClassName = isset($elementProperties['class']) ? $elementProperties['class'] : get_class($this) . ucfirst($elementName); - - $property = new $subClassName($xmlElement); + $subClassName = isset($elementProperties['class']) ? $elementProperties['class'] : get_class($this) . ucfirst($elementName); + + $property = new $subClassName($xmlElement); } - + break; - + case 'datetime': $property = new DateTime((string) $xmlElement, new DateTimeZone('UTC')); break; case 'number': $property = (int) $xmlElement; break; + case 'double': + $property = (float) $xmlElement; + break; + default: $property = (string) $xmlElement; break; } if (isset($elementProperties['encoding']) && $elementProperties['encoding'] == 'base64') { $property = base64_decode($property); } - + $this->$elementName = $property; - } + } + } + + public function &__get($name) + { + $this->_getElementProperties($name); + + return $this->_elements[$name]; } - - public function &__get($name) - { - $this->_getElementProperties($name); - - return $this->_elements[$name]; - } - - public function __set($name, $value) - { - list ($nameSpace, $properties) = $this->_getElementProperties($name); - - if ($properties['type'] == 'datetime' && !$value instanceof DateTime) { - throw new InvalidArgumentException("value for $name must be an instance of DateTime"); - } - - if (!array_key_exists($name, $this->_elements) || $this->_elements[$name] != $value) { + + public function __set($name, $value) + { + list ($nameSpace, $properties) = $this->_getElementProperties($name); + + if ($properties['type'] == 'datetime' && !$value instanceof DateTime) { + throw new InvalidArgumentException("value for $name must be an instance of DateTime"); + } + + if (!array_key_exists($name, $this->_elements) || $this->_elements[$name] != $value) { + // Always use location object + if ($name === 'location' && !($value instanceof Syncroton_Model_Location)) { + $value = new Syncroton_Model_Location($value); + } + $this->_elements[$name] = $value; - + $this->_isDirty = true; - } - } -} \ No newline at end of file + } + } +} diff --git a/lib/ext/Syncroton/Model/Email.php b/lib/ext/Syncroton/Model/Email.php index cd448c8..76de3f7 100644 --- a/lib/ext/Syncroton/Model/Email.php +++ b/lib/ext/Syncroton/Model/Email.php @@ -1,90 +1,129 @@ */ /** * class to handle ActiveSync email * * @package Syncroton * @subpackage Model - * @property array attachments - * @property string contentType - * @property array flag - * @property Syncroton_Model_EmailBody body - * @property array cc - * @property array to - * @property int lastVerbExecuted - * @property DateTime lastVerbExecutionTime - * @property int read + * + * @property string $accountId + * @property Syncroton_Model_EmailAttachment[] $attachments + * @property Syncroton_Model_EmailBody $body + * @property int $busyStatus + * @property array $categories + * @property string $cc + * @property DateTime $completeTime + * @property string $contentClass + * @property string $contentType + * @property string $conversationId + * @property string $conversationIndex + * @property DateTime $dateReceived + * @property bool $disallowNewTimeProposal + * @property string $displayTo + * @property DateTime $dtStamp + * @property DateTime $endTime + * @property Syncroton_Model_EmailFlag $flag + * @property string $from + * @property string $globalObjId + * @property int $importance + * @property int $instanceType + * @property string $internetCPID + * @property int $lastVerbExecuted + * @property DateTime $lastVerbExecutionTime + * @property Syncroton_Model_Location $location + * @property int $meetingMessageType + * @property Syncroton_Model_EmailMeetingRequest $meetingRequest + * @property string $messageClass + * @property int $nativeBodyType + * @property string $organizer + * @property int $read + * @property int $receivedAsBcc + * @property Syncroton_Model_EventRecurrence[] $recurrences + * @property int $reminder + * @property string $replyTo + * @property int $responseRequested + * @property string $sender + * @property int $sensitivity + * @property DateTime $startTime + * @property int $status + * @property string $subject + * @property string $threadTopic + * @property string $timeZone + * @property string $to + * @property string $umCallerID + * @property string $umUserNotes */ class Syncroton_Model_Email extends Syncroton_Model_AXMLEntry { const LASTVERB_UNKNOWN = 0; const LASTVERB_REPLYTOSENDER = 1; const LASTVERB_REPLYTOALL = 2; const LASTVERB_FORWARD = 3; - + protected $_xmlBaseElement = 'ApplicationData'; - + protected $_properties = array( 'AirSyncBase' => array( 'attachments' => array('type' => 'container', 'childElement' => 'attachment', 'class' => 'Syncroton_Model_EmailAttachment'), 'contentType' => array('type' => 'string'), 'body' => array('type' => 'container', 'class' => 'Syncroton_Model_EmailBody'), + 'location' => array('type' => 'container', 'class' => 'Syncroton_Model_Location', 'supportedSince' => '16.0'), 'nativeBodyType' => array('type' => 'number'), ), 'Email' => array( 'busyStatus' => array('type' => 'number'), 'categories' => array('type' => 'container', 'childElement' => 'category', 'supportedSince' => '14.0'), 'cc' => array('type' => 'string'), 'completeTime' => array('type' => 'datetime'), 'contentClass' => array('type' => 'string'), 'dateReceived' => array('type' => 'datetime'), 'disallowNewTimeProposal' => array('type' => 'number'), 'displayTo' => array('type' => 'string'), 'dTStamp' => array('type' => 'datetime'), 'endTime' => array('type' => 'datetime'), 'flag' => array('type' => 'container', 'class' => 'Syncroton_Model_EmailFlag'), 'from' => array('type' => 'string'), 'globalObjId' => array('type' => 'string'), 'importance' => array('type' => 'number'), 'instanceType' => array('type' => 'number'), 'internetCPID' => array('type' => 'string'), - 'location' => array('type' => 'string'), + 'location' => array('type' => 'string', 'supportedUntil' => '16.0'), 'meetingRequest' => array('type' => 'container', 'class' => 'Syncroton_Model_EmailMeetingRequest'), 'messageClass' => array('type' => 'string'), 'organizer' => array('type' => 'string'), 'read' => array('type' => 'number'), 'recurrences' => array('type' => 'container'), 'reminder' => array('type' => 'number'), 'replyTo' => array('type' => 'string'), 'responseRequested' => array('type' => 'number'), 'sensitivity' => array('type' => 'number'), 'startTime' => array('type' => 'datetime'), 'status' => array('type' => 'number'), 'subject' => array('type' => 'string'), 'threadTopic' => array('type' => 'string'), 'timeZone' => array('type' => 'timezone'), 'to' => array('type' => 'string'), ), 'Email2' => array( 'accountId' => array('type' => 'string', 'supportedSince' => '14.1'), 'conversationId' => array('type' => 'byteArray', 'supportedSince' => '14.0'), 'conversationIndex' => array('type' => 'byteArray', 'supportedSince' => '14.0'), 'lastVerbExecuted' => array('type' => 'number', 'supportedSince' => '14.0'), 'lastVerbExecutionTime' => array('type' => 'datetime', 'supportedSince' => '14.0'), 'meetingMessageType' => array('type' => 'number', 'supportedSince' => '14.1'), 'receivedAsBcc' => array('type' => 'number', 'supportedSince' => '14.0'), 'sender' => array('type' => 'string', 'supportedSince' => '14.0'), 'umCallerID' => array('type' => 'string', 'supportedSince' => '14.0'), 'umUserNotes' => array('type' => 'string', 'supportedSince' => '14.0'), ), ); } diff --git a/lib/ext/Syncroton/Model/EmailMeetingRequest.php b/lib/ext/Syncroton/Model/EmailMeetingRequest.php index 2786be4..7123382 100644 --- a/lib/ext/Syncroton/Model/EmailMeetingRequest.php +++ b/lib/ext/Syncroton/Model/EmailMeetingRequest.php @@ -1,98 +1,102 @@ */ /** * class to handle Email:MeetingRequest * * @package Syncroton * @subpackage Model - * @property bool AllDayEvent - * @property int BusyStatus - * @property int DisallowNewTimeProposal - * @property DateTime DtStamp - * @property DateTime EndTime - * @property string GlobalObjId - * @property int InstanceType - * @property int MeetingMessageType - * @property string Organizer - * @property string RecurrenceId - * @property array Recurrences - * @property int Reminder - * @property int ResponseRequested - * @property int Sensitivity - * @property DateTime StartTime - * @property string Timezone + * @property bool $allDayEvent + * @property int $busyStatus + * @property int $disallowNewTimeProposal + * @property DateTime $dtStamp + * @property DateTime $endTime + * @property string $globalObjId + * @property int $instanceType + * @property Syncroton_Model_Location $location + * @property int $meetingMessageType + * @property string $organizer + * @property string $recurrenceId + * @property array $recurrences + * @property int $reminder + * @property int $responseRequested + * @property int $sensitivity + * @property DateTime $startTime + * @property string $timezone */ class Syncroton_Model_EmailMeetingRequest extends Syncroton_Model_AXMLEntry { /** * busy status constants */ const BUSY_STATUS_FREE = 0; const BUSY_STATUS_TENATTIVE = 1; const BUSY_STATUS_BUSY = 2; const BUSY_STATUS_OUT = 3; /** * sensitivity constants */ const SENSITIVITY_NORMAL = 0; const SENSITIVITY_PERSONAL = 1; const SENSITIVITY_PRIVATE = 2; const SENSITIVITY_CONFIDENTIAL = 3; /** * instanceType constants */ const TYPE_NORMAL = 0; const TYPE_RECURRING_MASTER = 1; const TYPE_RECURRING_SINGLE = 2; const TYPE_RECURRING_EXCEPTION = 3; /** * messageType constants */ const MESSAGE_TYPE_NORMAL = 0; const MESSAGE_TYPE_REQUEST = 1; const MESSAGE_TYPE_FULL_UPDATE = 2; const MESSAGE_TYPE_INFO_UPDATE = 3; const MESSAGE_TYPE_OUTDATED = 4; const MESSAGE_TYPE_COPY = 5; const MESSAGE_TYPE_DELEGATED = 6; protected $_dateTimeFormat = "Ymd\THis\Z"; protected $_xmlBaseElement = 'MeetingRequest'; protected $_properties = array( + 'AirSyncBase' => array( + 'location' => array('type' => 'container', 'class' => 'Syncroton_Model_Location', 'supportedSince' => '16.0'), + ), 'Email' => array( 'allDayEvent' => array('type' => 'number'), 'busyStatus' => array('type' => 'number'), 'disallowNewTimeProposal' => array('type' => 'number'), 'dtStamp' => array('type' => 'datetime'), 'endTime' => array('type' => 'datetime'), 'globalObjId' => array('type' => 'string'), 'instanceType' => array('type' => 'datetime'), - 'location' => array('type' => 'string'), + 'location' => array('type' => 'string', 'supportedUntil' => '16.0'), 'organizer' => array('type' => 'string'), //e-mail address 'recurrenceId' => array('type' => 'datetime'), 'recurrences' => array('type' => 'container'), 'reminder' => array('type' => 'number'), 'responseRequested' => array('type' => 'number'), 'sensitivity' => array('type' => 'number'), 'startTime' => array('type' => 'datetime'), 'timeZone' => array('type' => 'timezone'), ), 'Email2' => array( 'meetingMessageType' => array('type' => 'number'), ), ); } diff --git a/lib/ext/Syncroton/Model/Event.php b/lib/ext/Syncroton/Model/Event.php index 679a2a1..2289f79 100644 --- a/lib/ext/Syncroton/Model/Event.php +++ b/lib/ext/Syncroton/Model/Event.php @@ -1,125 +1,147 @@ */ /** - * class to handle ActiveSync event + * Class to handle ActiveSync event * * @package Syncroton * @subpackage Model - * @property string class - * @property string collectionId - * @property bool deletesAsMoves - * @property bool getChanges - * @property string syncKey - * @property int windowSize + * + * @property Syncroton_Model_EmailBody $body + * @property bool $allDayEvent + * @property DateTime $appointmentReplyTime + * @property Syncroton_Model_EventAttendee[] $attendees + * @property int $busyStatus + * @property array $categories + * @property string $clientUid + * @property bool $disallowNewTimeProposal + * @property DateTime $dtStamp + * @property DateTime $endTime + * @property Syncroton_Model_EventException[] $exceptions + * @property Syncroton_Model_Location $location + * @property int $meetingStatus + * @property string $onlineMeetingConfLink + * @property string $onlineMeetingExternalLink + * @property string $organizerEmail + * @property string $organizerName + * @property Syncroton_Model_EventRecurrence $recurrence + * @property int $reminder + * @property int $responseRequested + * @property int $responseType + * @property int $sensitivity + * @property DateTime $startTime + * @property string $subject + * @property string $timezone + * @property string $uID */ class Syncroton_Model_Event extends Syncroton_Model_AXMLEntry { /** * busy status constants */ const BUSY_STATUS_FREE = 0; const BUSY_STATUS_TENATTIVE = 1; const BUSY_STATUS_BUSY = 2; protected $_dateTimeFormat = "Ymd\THis\Z"; protected $_xmlBaseElement = 'ApplicationData'; protected $_properties = array( 'AirSyncBase' => array( - 'body' => array('type' => 'container', 'class' => 'Syncroton_Model_EmailBody') + 'body' => array('type' => 'container', 'class' => 'Syncroton_Model_EmailBody'), + 'location' => array('type' => 'container', 'class' => 'Syncroton_Model_Location', 'supportedSince' => '16.0'), ), 'Calendar' => array( 'allDayEvent' => array('type' => 'number'), 'appointmentReplyTime' => array('type' => 'datetime'), 'attendees' => array('type' => 'container', 'childElement' => 'attendee', 'class' => 'Syncroton_Model_EventAttendee'), 'busyStatus' => array('type' => 'number'), 'categories' => array('type' => 'container', 'childElement' => 'category'), 'clientUid' => array('type' => 'string', 'supportedSince' => '16.0'), 'disallowNewTimeProposal' => array('type' => 'number'), 'dtStamp' => array('type' => 'datetime'), 'endTime' => array('type' => 'datetime'), 'exceptions' => array('type' => 'container', 'childElement' => 'exception', 'class' => 'Syncroton_Model_EventException'), - 'location' => array('type' => 'string'), + 'location' => array('type' => 'string', 'supportedUntil' => '16.0'), 'meetingStatus' => array('type' => 'number'), 'onlineMeetingConfLink' => array('type' => 'string'), 'onlineMeetingExternalLink' => array('type' => 'string'), 'organizerEmail' => array('type' => 'string'), 'organizerName' => array('type' => 'string'), - 'recurrence' => array('type' => 'container'), + 'recurrence' => array('type' => 'container', 'class' => 'Syncroton_Model_EventRecurrence'), 'reminder' => array('type' => 'number'), 'responseRequested' => array('type' => 'number'), 'responseType' => array('type' => 'number'), 'sensitivity' => array('type' => 'number'), 'startTime' => array('type' => 'datetime'), 'subject' => array('type' => 'string'), 'timezone' => array('type' => 'timezone'), 'uID' => array('type' => 'string'), ) ); /** * (non-PHPdoc) * @see Syncroton_Model_IEntry::appendXML() * @todo handle Attendees element */ public function appendXML(DOMElement $domParrent, Syncroton_Model_IDevice $device) { parent::appendXML($domParrent, $device); $exceptionElements = $domParrent->getElementsByTagName('Exception'); $parentFields = array('AllDayEvent'/*, 'Attendees'*/, 'Body', 'BusyStatus'/*, 'Categories'*/, 'DtStamp', 'EndTime', 'Location', 'MeetingStatus', 'Reminder', 'ResponseType', 'Sensitivity', 'StartTime', 'Subject'); if ($exceptionElements->length > 0) { $mainEventElement = $exceptionElements->item(0)->parentNode->parentNode; foreach ($mainEventElement->childNodes as $childNode) { if (in_array($childNode->localName, $parentFields)) { foreach ($exceptionElements as $exception) { $elementsToLeftOut = $exception->getElementsByTagName($childNode->localName); foreach ($elementsToLeftOut as $elementToLeftOut) { if ($elementToLeftOut->nodeValue == $childNode->nodeValue) { $exception->removeChild($elementToLeftOut); } } } } } } } /** * some elements of an exception can be left out, if they have the same value * like the main event * * this function copies these elements to the exception for backends which need * this elements in the exceptions too. Tine 2.0 needs this for example. */ public function copyFieldsFromParent() { if (isset($this->_elements['exceptions']) && is_array($this->_elements['exceptions'])) { foreach ($this->_elements['exceptions'] as $exception) { // no need to update deleted exceptions if ($exception->deleted == 1) { continue; } $parentFields = array('allDayEvent', 'attendees', 'body', 'busyStatus', 'categories', 'dtStamp', 'endTime', 'location', 'meetingStatus', 'reminder', 'responseType', 'sensitivity', 'startTime', 'subject'); foreach ($parentFields as $field) { if (!isset($exception->$field) && isset($this->_elements[$field])) { $exception->$field = $this->_elements[$field]; } } } } } } diff --git a/lib/ext/Syncroton/Model/EventException.php b/lib/ext/Syncroton/Model/EventException.php index 06aece9..3b94250 100644 --- a/lib/ext/Syncroton/Model/EventException.php +++ b/lib/ext/Syncroton/Model/EventException.php @@ -1,54 +1,65 @@ */ /** - * class to handle ActiveSync event + * Class to handle ActiveSync event * * @package Syncroton * @subpackage Model - * @property string class - * @property string collectionId - * @property bool deletesAsMoves - * @property bool getChanges - * @property string syncKey - * @property int windowSize + * + * @property Syncroton_Model_EmailBody $body + * @property bool $allDayEvent + * @property DateTime $appointmentReplyTime + * @property Syncroton_Model_EventAttendee[] $attendees + * @property int $busyStatus + * @property array $categories + * @property bool $deleted + * @property DateTime $dtStamp + * @property DateTime $endTime + * @property DateTime exceptionStartTime + * @property Syncroton_Model_Location $location + * @property int $meetingStatus + * @property int $reminder + * @property int $responseType + * @property int $sensitivity + * @property DateTime $startTime + * @property string $subject */ - class Syncroton_Model_EventException extends Syncroton_Model_AXMLEntry -{ +{ protected $_xmlBaseElement = 'Exception'; - protected $_dateTimeFormat = "Ymd\THis\Z"; - protected $_properties = array( 'AirSyncBase' => array( - 'body' => array('type' => 'container', 'class' => 'Syncroton_Model_EmailBody') + 'body' => array('type' => 'container', 'class' => 'Syncroton_Model_EmailBody'), + 'location' => array('type' => 'container', 'class' => 'Syncroton_Model_Location', 'supportedSince' => '16.0'), ), 'Calendar' => array( - 'allDayEvent' => array('type' => 'number'), - 'appointmentReplyTime' => array('type' => 'datetime'), - 'attendees' => array('type' => 'container', 'childElement' => 'attendee', 'class' => 'Syncroton_Model_EventAttendee'), - 'busyStatus' => array('type' => 'number'), - 'categories' => array('type' => 'container', 'childElement' => 'category'), - 'deleted' => array('type' => 'number'), + 'allDayEvent' => array('type' => 'number'), + 'appointmentReplyTime' => array('type' => 'datetime'), + 'attendees' => array('type' => 'container', 'childElement' => 'attendee', 'class' => 'Syncroton_Model_EventAttendee'), + 'busyStatus' => array('type' => 'number'), + 'categories' => array('type' => 'container', 'childElement' => 'category'), + 'deleted' => array('type' => 'number'), 'dtStamp' => array('type' => 'datetime'), - 'endTime' => array('type' => 'datetime'), - 'exceptionStartTime' => array('type' => 'datetime'), - 'location' => array('type' => 'string'), - 'meetingStatus' => array('type' => 'number'), - 'reminder' => array('type' => 'number'), - 'responseType' => array('type' => 'number'), + 'endTime' => array('type' => 'datetime'), + 'exceptionStartTime' => array('type' => 'datetime'), + 'location' => array('type' => 'string', 'supportedUntil' => '16.0'), + 'meetingStatus' => array('type' => 'number'), + 'reminder' => array('type' => 'number'), + 'responseType' => array('type' => 'number'), 'sensitivity' => array('type' => 'number'), 'startTime' => array('type' => 'datetime'), - 'subject' => array('type' => 'string'), + 'subject' => array('type' => 'string'), ) - ); -} \ No newline at end of file + ); +} diff --git a/lib/ext/Syncroton/Model/Location.php b/lib/ext/Syncroton/Model/Location.php new file mode 100644 index 0000000..91c5035 --- /dev/null +++ b/lib/ext/Syncroton/Model/Location.php @@ -0,0 +1,95 @@ + + */ + +/** + * Class to handle ActiveSync AirSyncBase::Location element + * + * @package Syncroton + * @subpackage Model + * + * @property float $accuracy Accuracy of the values of the Latitude element + * @property float $altitude Altitude of the event's location + * @property float $altitudeAccuracy Accuracy of the value of the Altitude element + * @property string $annotation Note about the event's location + * @property string $city City of the event's location + * @property string $country Country of the event's location + * @property string $displayName Display name of the event's location + * @property float $latitude Latitude of the event's location + * @property string $locationUri URI for the event's location + * @property float $longitude Longitude of the event's location + * @property string $postalCode Postal code for the address of the event's location + * @property string $state State or province of the event's location + * @property string $street Street address of the event's location + */ +class Syncroton_Model_Location extends Syncroton_Model_AXMLEntry +{ + protected $_xmlBaseElement = 'Location'; + + protected $_properties = array( + 'AirSyncBase' => array( + 'accuracy' => array('type' => 'double', 'supportedSince' => '16.0'), + 'altitude' => array('type' => 'double', 'supportedSince' => '16.0'), + 'altitudeAccuracy' => array('type' => 'double', 'supportedSince' => '16.0'), + 'annotation' => array('type' => 'string', 'supportedSince' => '16.0'), + 'city' => array('type' => 'string', 'supportedSince' => '16.0'), + 'country' => array('type' => 'string', 'supportedSince' => '16.0'), + 'displayName' => array('type' => 'string', 'supportedSince' => '16.0'), + 'latitude' => array('type' => 'double', 'supportedSince' => '16.0'), + 'locationUri' => array('type' => 'string', 'supportedSince' => '16.0'), + 'longitude' => array('type' => 'double', 'supportedSince' => '16.0'), + 'postalCode' => array('type' => 'string', 'supportedSince' => '16.0'), + 'state' => array('type' => 'string', 'supportedSince' => '16.0'), + 'street' => array('type' => 'string', 'supportedSince' => '16.0'), + ) + ); + + /** + * (non-PHPdoc) + * @see Syncroton_Model_IEntry::__construct() + */ + public function __construct($properties = null) + { + // Support ActiveSync < 16 location as a string + if (($properties instanceof SimpleXMLElement && $properties->count() == 0) + || (is_string($properties) && strlen($properties) > 0) + ) { + $properties = (string) $properties; + + // Note: iOS 12.4 does not use LocationUri, URLs are stored in DisplayName + $this->_elements['displayName'] = $properties; + + $properties = null; + } + + parent::__construct($properties); + } + + /** + * To string converter. + * + * It will be used with ActiveSync < 16.0, where Location is a property of type string + * + * @return string String representation of the object + */ + public function __toString() + { + if (!empty($this->_elements['displayName'])) { + return (string) $this->_elements['displayName']; + } + + if (!empty($this->_elements['locationUri'])) { + return (string) $this->_elements['locationUri']; + } + + return ''; + } +} diff --git a/lib/kolab_sync_data_calendar.php b/lib/kolab_sync_data_calendar.php index abf2de6..b558b3a 100644 --- a/lib/kolab_sync_data_calendar.php +++ b/lib/kolab_sync_data_calendar.php @@ -1,1095 +1,1115 @@ | | | | This program is free software: you can redistribute it and/or modify | | it under the terms of the GNU Affero General Public License as published | | by the Free Software Foundation, either version 3 of the License, or | | (at your option) any later version. | | | | This program is distributed in the hope that it will be useful, | | but WITHOUT ANY WARRANTY; without even the implied warranty of | | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | | GNU Affero General Public License for more details. | | | | You should have received a copy of the GNU Affero General Public License | | along with this program. If not, see | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Calendar (Events) data class for Syncroton */ class kolab_sync_data_calendar extends kolab_sync_data implements Syncroton_Data_IDataCalendar { /** * Mapping from ActiveSync Calendar namespace fields */ protected $mapping = array( 'allDayEvent' => 'allday', 'startTime' => 'start', // keep it before endTime here //'attendees' => 'attendees', 'body' => 'description', //'bodyTruncated' => 'bodytruncated', 'busyStatus' => 'free_busy', //'categories' => 'categories', 'dtStamp' => 'changed', 'endTime' => 'end', //'exceptions' => 'exceptions', 'location' => 'location', //'meetingStatus' => 'meetingstatus', //'organizerEmail' => 'organizeremail', //'organizerName' => 'organizername', //'recurrence' => 'recurrence', //'reminder' => 'reminder', //'responseRequested' => 'responserequested', //'responseType' => 'responsetype', 'sensitivity' => 'sensitivity', 'subject' => 'title', //'timezone' => 'timezone', 'uID' => 'uid', ); /** * Kolab object type * * @var string */ protected $modelName = 'event'; /** * Type of the default folder * * @var int */ protected $defaultFolderType = Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR; /** * Default container for new entries * * @var string */ protected $defaultFolder = 'Calendar'; /** * Type of user created folders * * @var int */ protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR_USER_CREATED; /** * attendee status */ const ATTENDEE_STATUS_UNKNOWN = 0; const ATTENDEE_STATUS_TENTATIVE = 2; const ATTENDEE_STATUS_ACCEPTED = 3; const ATTENDEE_STATUS_DECLINED = 4; const ATTENDEE_STATUS_NOTRESPONDED = 5; /** * attendee types */ const ATTENDEE_TYPE_REQUIRED = 1; const ATTENDEE_TYPE_OPTIONAL = 2; const ATTENDEE_TYPE_RESOURCE = 3; /** * busy status constants */ const BUSY_STATUS_FREE = 0; const BUSY_STATUS_TENTATIVE = 1; const BUSY_STATUS_BUSY = 2; const BUSY_STATUS_OUTOFOFFICE = 3; /** * Sensitivity values */ const SENSITIVITY_NORMAL = 0; const SENSITIVITY_PERSONAL = 1; const SENSITIVITY_PRIVATE = 2; const SENSITIVITY_CONFIDENTIAL = 3; const KEY_DTSTAMP = 'x-custom.X-ACTIVESYNC-DTSTAMP'; const KEY_RESPONSE_DTSTAMP = 'x-custom.X-ACTIVESYNC-RESPONSE-DTSTAMP'; /** * Mapping of attendee status * * @var array */ protected $attendeeStatusMap = array( 'UNKNOWN' => self::ATTENDEE_STATUS_UNKNOWN, 'TENTATIVE' => self::ATTENDEE_STATUS_TENTATIVE, 'ACCEPTED' => self::ATTENDEE_STATUS_ACCEPTED, 'DECLINED' => self::ATTENDEE_STATUS_DECLINED, 'DELEGATED' => self::ATTENDEE_STATUS_UNKNOWN, 'NEEDS-ACTION' => self::ATTENDEE_STATUS_NOTRESPONDED, ); /** * Mapping of attendee type * * NOTE: recurrences need extra handling! * @var array */ protected $attendeeTypeMap = array( 'REQ-PARTICIPANT' => self::ATTENDEE_TYPE_REQUIRED, 'OPT-PARTICIPANT' => self::ATTENDEE_TYPE_OPTIONAL, // 'NON-PARTICIPANT' => self::ATTENDEE_TYPE_RESOURCE, // 'CHAIR' => self::ATTENDEE_TYPE_RESOURCE, ); /** * Mapping of busy status * * @var array */ protected $busyStatusMap = array( 'free' => self::BUSY_STATUS_FREE, 'tentative' => self::BUSY_STATUS_TENTATIVE, 'busy' => self::BUSY_STATUS_BUSY, 'outofoffice' => self::BUSY_STATUS_OUTOFOFFICE, ); /** * mapping of sensitivity * * @var array */ protected $sensitivityMap = array( 'public' => self::SENSITIVITY_PERSONAL, 'private' => self::SENSITIVITY_PRIVATE, 'confidential' => self::SENSITIVITY_CONFIDENTIAL, ); /** * Appends contact data to xml element * * @param Syncroton_Model_SyncCollection $collection Collection data * @param string $serverId Local entry identifier * @param boolean $as_array Return entry as array * * @return array|Syncroton_Model_Event|array Event object */ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId, $as_array = false) { $event = is_array($serverId) ? $serverId : $this->getObject($collection->collectionId, $serverId); $config = $this->getFolderConfig($event['_mailbox']); $result = array(); // Timezone // Kolab Format 3.0 and xCal does support timezone per-date, but ActiveSync allows // only one timezone per-event. We'll use timezone of the start date if ($event['start'] instanceof DateTime) { $timezone = $event['start']->getTimezone(); if ($timezone && ($tz_name = $timezone->getName()) != 'UTC') { $tzc = kolab_sync_timezone_converter::getInstance(); if ($tz_name = $tzc->encodeTimezone($tz_name)) { $result['timezone'] = $tz_name; } } } // Calendar namespace fields foreach ($this->mapping as $key => $name) { $value = $this->getKolabDataItem($event, $name); switch ($name) { case 'changed': case 'end': case 'start': // For all-day events Kolab uses different times // At least Android doesn't display such event as all-day event if ($value && is_a($value, 'DateTime')) { $date = clone $value; if ($event['allday']) { // need this for self::date_from_kolab() $date->_dateonly = false; if ($name == 'start') { $date->setTime(0, 0, 0); } else if ($name == 'end') { $date->setTime(0, 0, 0); $date->modify('+1 day'); } } // set this date for use in recurrence exceptions handling if ($name == 'start') { $event['_start'] = $date; } $value = self::date_from_kolab($date); } break; case 'sensitivity': $value = intval($this->sensitivityMap[$value]); break; case 'free_busy': $value = $this->busyStatusMap[$value]; break; case 'description': $value = $this->body_from_kolab($value, $collection); break; + + case 'location': + $value = new Syncroton_Model_Location($value); + break; } // Ignore empty values (but not integer 0) if ((empty($value) || is_array($value)) && $value !== 0) { continue; } $result[$key] = $value; } // Event reminder time if ($config['ALARMS']) { $result['reminder'] = $this->from_kolab_alarm($event); } $result['categories'] = array(); $result['attendees'] = array(); // Categories, Roundcube Calendar plugin supports only one category at a time if (!empty($event['categories'])) { $result['categories'] = (array) $event['categories']; } // Organizer if (!empty($event['attendees'])) { foreach ($event['attendees'] as $idx => $attendee) { if ($attendee['role'] == 'ORGANIZER') { if ($name = $attendee['name']) { $result['organizerName'] = $name; } if ($email = $attendee['email']) { $result['organizerEmail'] = $email; } unset($event['attendees'][$idx]); break; } } } // Attendees if (!empty($event['attendees'])) { $user_emails = $this->user_emails(); $user_rsvp = false; foreach ($event['attendees'] as $idx => $attendee) { $att = array(); if ($email = $attendee['email']) { $att['email'] = $email; } else { // In Activesync email is required continue; } $att['name'] = $attendee['name'] ?: $email; $type = isset($attendee['role']) ? $this->attendeeTypeMap[$attendee['role']] : null; $status = isset($attendee['status']) ? $this->attendeeStatusMap[$attendee['status']] : null; if ($this->asversion >= 12) { $att['attendeeType'] = $type ?: self::ATTENDEE_TYPE_REQUIRED; $att['attendeeStatus'] = $status ?: self::ATTENDEE_STATUS_UNKNOWN; } if ($email && in_array_nocase($email, $user_emails)) { $user_rsvp = !empty($attendee['rsvp']); $resp_type = $status ?: self::ATTENDEE_STATUS_UNKNOWN; } $result['attendees'][] = new Syncroton_Model_EventAttendee($att); } } // Event meeting status $this->meeting_status_from_kolab($collection, $event, $result); // Recurrence (and exceptions) $this->recurrence_from_kolab($collection, $event, $result); // RSVP status $result['responseRequested'] = $result['meetingStatus'] == 3 && $user_rsvp ? 1 : 0; $result['responseType'] = $result['meetingStatus'] == 3 ? $resp_type : null; return $as_array ? $result : new Syncroton_Model_Event($result); } /** * convert contact from xml to libkolab array * * @param Syncroton_Model_IEntry $data Contact to convert * @param string $folderid Folder identifier * @param array $entry Existing entry * @param DateTimeZone $timezone Timezone of the event * * @return array */ public function toKolab(Syncroton_Model_IEntry $data, $folderid, $entry = null, $timezone = null) { $event = !empty($entry) ? $entry : array(); $foldername = isset($event['_mailbox']) ? $event['_mailbox'] : $this->getFolderName($folderid); $config = $this->getFolderConfig($foldername); $is_exception = $data instanceof Syncroton_Model_EventException; $dummy_tz = str_repeat('A', 230) . '=='; $is_outlook = stripos($this->device->devicetype, 'outlook') !== false; + $v16_update = $this->asversion >= 16 && !empty($event); // check data validity $this->check_event($data); if (!empty($event['start']) && ($event['start'] instanceof DateTime)) { $old_timezone = $event['start']->getTimezone(); } // Timezone if (!$timezone && isset($data->timezone) && $data->timezone != $dummy_tz) { $tzc = kolab_sync_timezone_converter::getInstance(); $expected = $old_timezone ?: kolab_format::$timezone; try { $timezone = $tzc->getTimezone($data->timezone, $expected->getName()); $timezone = new DateTimeZone($timezone); } catch (Exception $e) { $timezone = null; } } if (empty($timezone)) { $timezone = $old_timezone ?: new DateTimeZone('UTC'); } - $event['allday'] = 0; + $event['allday'] = $v16_update && !isset($data->allDayEvent) ? $event['allday'] : 0; // Calendar namespace fields foreach ($this->mapping as $key => $name) { // skip UID field, unsupported in event exceptions // we need to do this here, because the next line (data getter) will throw an exception if ($is_exception && $key == 'uID') { continue; } + // In ActiveSync >= v16 all properties are ghosted + if ($v16_update && !isset($data->$key)) { + continue; + } + $value = $data->$key; switch ($name) { case 'changed': $value = null; break; case 'end': case 'start': if ($timezone && $value) { $value->setTimezone($timezone); } if ($value && $data->allDayEvent) { $value->_dateonly = true; // In ActiveSync all-day event ends on 00:00:00 next day // In Kolab we just ignore the time spec. if ($name == 'end') { $diff = date_diff($event['start'], $value); $value = clone $event['start']; if ($diff->days > 1) { $value->add(new DateInterval('P' . ($diff->days - 1) . 'D')); } } } break; case 'sensitivity': $map = array_flip($this->sensitivityMap); $value = $map[$value]; break; case 'free_busy': $map = array_flip($this->busyStatusMap); $value = $map[$value]; break; case 'description': $value = $this->getBody($value, Syncroton_Model_EmailBody::TYPE_PLAINTEXT); // If description isn't specified keep old description if ($value === null) { continue 2; } break; + + case 'location': + $value = (string) $value; + break; } $this->setKolabDataItem($event, $name, $value); } // Try to fix allday events from Android // It doesn't set all-day flag but the period is a whole day if (!$event['allday'] && $event['end'] && $event['start']) { $interval = @date_diff($event['start'], $event['end']); if ($interval && $interval->format('%y%m%d%h%i%s') === '001000') { $event['allday'] = 1; $event['end'] = clone $event['start']; } } // Reminder // @TODO: should alarms be used when importing event from phone? - if ($config['ALARMS']) { + if ($config['ALARMS'] && (!$v16_update || isset($data->reminder))) { $event['valarms'] = $this->to_kolab_alarm($data->reminder, $event); } $attendees = array(); $categories = array(); // Categories if (isset($data->categories)) { foreach ($data->categories as $category) { $categories[] = $category; } } + if (!$v16_update || isset($data->categories)) { + $event['categories'] = $categories; + } + // Organizer if (!$is_exception && ($organizer_email = $data->organizerEmail)) { $attendees[] = array( 'role' => 'ORGANIZER', 'name' => $data->organizerName, 'email' => $organizer_email, ); } // Attendees // Outlook 2013 sends a dummy update just after MeetingResponse has been processed, // this update resets attendee status set in the MeetingResponse request. // We ignore changes to attendees data on such updates if ($is_outlook && $this->isDummyOutlookUpdate($data, $entry, $event)) { $attendees = $entry['attendees']; } else if (isset($data->attendees)) { $statusMap = array_flip($this->attendeeStatusMap); foreach ($data->attendees as $attendee) { if ($attendee->email && $attendee->email == $organizer_email) { continue; } $role = false; if (isset($attendee->attendeeType)) { $role = array_search($attendee->attendeeType, $this->attendeeTypeMap); } if ($role === false) { $role = array_search(self::ATTENDEE_TYPE_REQUIRED, $this->attendeeTypeMap); } $_attendee = array( 'role' => $role, 'name' => $attendee->name != $attendee->email ? $attendee->name : '', 'email' => $attendee->email, ); if (isset($attendee->attendeeStatus)) { $_attendee['status'] = $attendee->attendeeStatus ? array_search($attendee->attendeeStatus, $this->attendeeStatusMap) : null; if (!$_attendee['status']) { $_attendee['status'] = 'NEEDS-ACTION'; $_attendee['rsvp'] = true; } } else if (!empty($event['attendees']) && !empty($attendee->email)) { // copy the old attendee status foreach ($event['attendees'] as $old_attendee) { if ($old_attendee['email'] == $_attendee['email'] && isset($old_attendee['status'])) { $_attendee['status'] = $old_attendee['status']; $_attendee['rsvp'] = $old_attendee['rsvp']; break; } } } $attendees[] = $_attendee; } } // Make sure the event has the organizer set if (!$organizer_email && ($identity = kolab_sync::get_instance()->user->get_identity())) { $attendees[] = array( 'role' => 'ORGANIZER', 'name' => $identity['name'], 'email' => $identity['email'], ); } - $event['attendees'] = $attendees; - $event['categories'] = $categories; + // Note: In ActiveSync >= v16 all properties are ghosted + if (!$v16_update || isset($data->attendees) || isset($data->organizerEmail)) { + $event['attendees'] = $attendees; + } // recurrence (and exceptions) if (!$is_exception) { $event['recurrence'] = $this->recurrence_to_kolab($data, $folderid, $timezone); } // Bump SEQUENCE number on update (Outlook only). // It's been confirmed that any change of the event that has attendees specified // bumps SEQUENCE number of the event (we can see this in sent iTips). // Unfortunately Outlook also sends an update when no SEQUENCE bump // is needed, e.g. when updating attendee status. // We try our best to bump the SEQUENCE only when expected if (!empty($entry) && !$is_exception && !empty($data->attendees) && $data->timezone != $dummy_tz) { if ($last_update = $this->getKolabDataItem($event, self::KEY_DTSTAMP)) { $last_update = new DateTime($last_update); } if ($data->dtStamp && $data->dtStamp != $last_update) { if ($this->has_significant_changes($event, $entry)) { $event['sequence']++; $this->logger->debug('Found significant changes in the updated event. Bumping SEQUENCE to ' . $event['sequence']); } } } // Because we use last event modification time above, we make sure // the event modification time is not (re)set by the server, // we use the original Outlook's timestamp. if ($is_outlook && $data->dtStamp) { $this->setKolabDataItem($event, self::KEY_DTSTAMP, $data->dtStamp->format(DateTime::ATOM)); } // This prevents kolab_format code to bump the sequence when not needed if (!isset($event['sequence'])) { $event['sequence'] = 0; } return $event; } /** * Set attendee status for meeting * * @param Syncroton_Model_MeetingResponse $request The meeting response * * @return string ID of new calendar entry */ public function setAttendeeStatus(Syncroton_Model_MeetingResponse $request) { $status_map = array( 1 => 'ACCEPTED', 2 => 'TENTATIVE', 3 => 'DECLINED', ); if ($status = $status_map[$request->userResponse]) { // extract event from the invitation list($event, $existing) = $this->get_event_from_invitation($request); /* switch ($status) { case 'ACCEPTED': $event['free_busy'] = 'busy'; break; case 'TENTATIVE': $event['free_busy'] = 'tentative'; break; case 'DECLINED': $event['free_busy'] = 'free'; break; } */ // Store Outlook response timestamp for further use if (stripos($this->device->devicetype, 'outlook') !== false) { $dtstamp = new DateTime('now', new DateTimeZone('UTC')); $dtstamp = $dtstamp->format(DateTime::ATOM); } // Update/Save the event if (empty($existing)) { if ($dtstamp) { $this->setKolabDataItem($event, self::KEY_RESPONSE_DTSTAMP, $dtstamp); } $folder = $this->save_event($event, $status); // Create SyncState for the new event, so it is not synced twice if ($folder) { $folderId = $this->getFolderId($folder); try { $syncBackend = Syncroton_Registry::getSyncStateBackend(); $folderBackend = Syncroton_Registry::getFolderBackend(); $contentBackend = Syncroton_Registry::getContentStateBackend(); $syncFolder = $folderBackend->getFolder($this->device->id, $folderId); $syncState = $syncBackend->getSyncState($this->device->id, $syncFolder->id); $contentBackend->create(new Syncroton_Model_Content(array( 'device_id' => $this->device->id, 'folder_id' => $syncFolder->id, 'contentid' => $this->serverId($event['uid'], $folder), 'creation_time' => $syncState->lastsync, 'creation_synckey' => $syncState->counter, ))); } catch (Exception $e) { // ignore } } } else { if ($dtstamp) { $this->setKolabDataItem($existing, self::KEY_RESPONSE_DTSTAMP, $dtstamp); } $folder = $this->update_event($event, $existing, $status, $request->instanceId); } if (!$folder) { throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR); } // TODO: ActiveSync version >= 16, send the iTip response. if (isset($request->sendResponse)) { // SendResponse can contain Body to use as email body (can be empty) // TODO: Activesync >= 16.1 proposedStartTime and proposedEndTime. } } // FIXME: We should not return an UID when status=DECLINED // as it's expected by the specification. Server // should delete an event in such a case, but we // keep the event copy with appropriate attendee status instead. return empty($status) ? null : $this->serverId($event['uid'], $folder); } /** * Get an event from the invitation email or calendar folder */ protected function get_event_from_invitation(Syncroton_Model_MeetingResponse $request) { // Limitation: LongId might be used instead of RequestId, this is not supported if ($request->requestId) { $mail_class = new kolab_sync_data_email($this->device, $this->syncTimeStamp); // Event from an invitation email if ($event = $mail_class->get_invitation_event($request->requestId)) { // find the event in calendar $existing = $this->find_event_by_uid($event['uid']); return array($event, $existing); } // Event from calendar folder if ($event = $this->getObject($request->collectionId, $request->requestId, $folder)) { return array($event, $event); } throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::INVALID_REQUEST); } throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR); } /** * Find the Kolab event in any (of subscribed personal calendars) folder */ protected function find_event_by_uid($uid) { if (empty($uid)) { return; } // TODO: should we check every existing event folder even if not subscribed for sync? foreach ($this->listFolders() as $folder) { $storage_folder = $this->getFolderObject($folder['imap_name']); if ($storage_folder->get_namespace() == 'personal' && ($result = $storage_folder->get_object($uid)) ) { return $result; } } } /** * Wrapper to update an event object */ protected function update_event($event, $old, $status, $instanceId = null) { // TODO: instanceId - DateTime - of the exception to be processed, if not set process all occurrences if ($instanceId) { throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::INVALID_REQUEST); } if ($event['free_busy']) { $old['free_busy'] = $event['free_busy']; } // Updating an existing event is most-likely a response // to an iTip request with bumped SEQUENCE $old['sequence'] += 1; // Update the event return $this->save_event($old, $status); } /** * Save the Kolab event (create if not exist) * If an event does not exist it will be created in the default folder */ protected function save_event(&$event, $status = null) { // Find default folder to which we'll save the event if (!isset($event['_mailbox'])) { $folders = $this->listFolders(); $storage = rcube::get_instance()->get_storage(); // find the default foreach ($folders as $folder) { if ($folder['type'] == 8 && $storage->folder_namespace($folder['imap_name']) == 'personal') { $event['_mailbox'] = $folder['imap_name']; break; } } // if there's no folder marked as default, use any if (!isset($event['_mailbox']) && !empty($folders)) { foreach ($folders as $folder) { if ($storage->folder_namespace($folder['imap_name']) == 'personal') { $event['_mailbox'] = $folder['imap_name']; break; } } } // TODO: what if the user has no subscribed event folders for this device // should we use any existing event folder even if not subscribed for sync? } if ($status) { $this->update_attendee_status($event, $status); } // TODO: Free/busy trigger? if (isset($event['_mailbox'])) { $folder = $this->getFolderObject($event['_mailbox']); if ($folder && $folder->valid && $folder->save($event)) { return $folder; } } return false; } /** * Update the attendee status of the user */ protected function update_attendee_status(&$event, $status) { $organizer = null; $emails = $this->user_emails(); foreach ((array) $event['attendees'] as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER') { $organizer = $attendee; } else if ($attendee['email'] && in_array_nocase($attendee['email'], $emails)) { $event['attendees'][$i]['status'] = $status; $event['attendees'][$i]['rsvp'] = false; $event_attendee = $attendee; } } if (!$event_attendee) { $this->logger->warn('MeetingResponse on an event where the user is not an attendee. UID: ' . $event['uid']); throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR); } } /** * Returns filter query array according to specified ActiveSync FilterType * * @param int $filter_type Filter type * * @param array Filter query */ protected function filter($filter_type = 0) { $filter = array(array('type', '=', $this->modelName)); switch ($filter_type) { case Syncroton_Command_Sync::FILTER_2_WEEKS_BACK: $mod = '-2 weeks'; break; case Syncroton_Command_Sync::FILTER_1_MONTH_BACK: $mod = '-1 month'; break; case Syncroton_Command_Sync::FILTER_3_MONTHS_BACK: $mod = '-3 months'; break; case Syncroton_Command_Sync::FILTER_6_MONTHS_BACK: $mod = '-6 months'; break; } if (!empty($mod)) { $dt = new DateTime('now', new DateTimeZone('UTC')); $dt->modify($mod); $filter[] = array('dtend', '>', $dt); } return $filter; } /** * Set MeetingStatus according to event data */ protected function meeting_status_from_kolab($collection, $event, &$result) { // 0 - The event is an appointment, which has no attendees. // 1 - The event is a meeting and the user is the meeting organizer. // 3 - This event is a meeting, and the user is not the meeting organizer. // 5 - The meeting has been canceled and the user was the meeting organizer. // 7 - The meeting has been canceled. The user was not the meeting organizer. $status = 0; if (!empty($event['attendees'])) { // Find out if the user is an organizer // TODO: Delegation/aliases support $user_emails = $this->user_emails(); $is_organizer = false; if ($event['organizer'] && $event['organizer']['email']) { $is_organizer = in_array_nocase($event['organizer']['email'], $user_emails); } if ($event['status'] == 'CANCELLED') { $status = $is_organizer ? 5 : 7; } else { $status = $is_organizer ? 1 : 3; } } $result['meetingStatus'] = $status; } /** * Converts libkolab alarms spec. into a number of minutes */ protected function from_kolab_alarm($event) { if (isset($event['valarms'])) { foreach ($event['valarms'] as $alarm) { if (in_array($alarm['action'], array('DISPLAY', 'AUDIO'))) { $value = $alarm['trigger']; break; } } } if ($value && $value instanceof DateTime) { if ($event['start'] && ($interval = $event['start']->diff($value))) { if ($interval->invert && !$interval->m && !$interval->y) { return intval(round($interval->s/60) + $interval->i + $interval->h * 60 + $interval->d * 60 * 24); } } } else if ($value && preg_match('/^([-+]*)[PT]*([0-9]+)([WDHMS])$/', $value, $matches)) { $value = intval($matches[2]); if ($value && $matches[1] != '-') { return null; } switch ($matches[3]) { case 'S': $value = intval(round($value/60)); break; case 'H': $value *= 60; break; case 'D': $value *= 24 * 60; break; case 'W': $value *= 7 * 24 * 60; break; } return $value; } } /** * Converts ActiveSync reminder into libkolab alarms spec. */ protected function to_kolab_alarm($value, $event) { if ($value === null || $value === '') { return (array) $event['valarms']; } $valarms = array(); $unsupported = array(); if (!empty($event['valarms'])) { foreach ($event['valarms'] as $alarm) { if (!$current && in_array($alarm['action'], array('DISPLAY', 'AUDIO'))) { $current = $alarm; } else { $unsupported[] = $alarm; } } } $valarms[] = array( 'action' => $current['action'] ?: 'DISPLAY', 'description' => $current['description'] ?: '', 'trigger' => sprintf('-PT%dM', $value), ); if (!empty($unsupported)) { $valarms = array_merge($valarms, $unsupported); } return $valarms; } /** * Sanity checks on event input * * @param Syncroton_Model_IEntry &$entry Entry object * * @throws Syncroton_Exception_Status_Sync */ protected function check_event(Syncroton_Model_IEntry &$entry) { // https://msdn.microsoft.com/en-us/library/jj194434(v=exchg.80).aspx $now = new DateTime('now'); $rounded = new DateTime('now'); $min = (int) $rounded->format('i'); $add = $min > 30 ? (60 - $min) : (30 - $min); $rounded->add(new DateInterval('PT' . $add . 'M')); if (empty($entry->startTime) && empty($entry->endTime)) { // use current time rounded to 30 minutes $end = clone $rounded; $end->add(new DateInterval($entry->allDayEvent ? 'P1D' : 'PT30M')); $entry->startTime = $rounded; $entry->endTime = $end; } else if (empty($entry->startTime)) { if ($entry->endTime < $now || $entry->endTime < $rounded) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::INVALID_ITEM); } $entry->startTime = $rounded; } else if (empty($entry->endTime)) { if ($entry->startTime < $now) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::INVALID_ITEM); } $rounded->add(new DateInterval($entry->allDayEvent ? 'P1D' : 'PT30M')); $entry->endTime = $rounded; } } /** * Check if the new event version has any significant changes */ protected function has_significant_changes($event, $old) { // Calendar namespace fields foreach (array('allday', 'start', 'end', 'location', 'recurrence') as $key) { if ($event[$key] != $old[$key]) { // Comparing recurrence is tricky as there can be differences in default // value handling. Let's try to handle most common cases if ($key == 'recurrence' && $this->fixed_recurrence($event) == $this->fixed_recurrence($old)) { continue; } return true; } } if (count($event['attendees']) != count($old['attendees'])) { return true; } foreach ($event['attendees'] as $idx => $attendee) { $old_attendee = $old['attendees'][$idx]; if ($old_attendee['email'] != $attendee['email'] || ($attendee['role'] != 'ORGANIZER' && $attendee['status'] != $old_attendee['status'] && $attendee['status'] == 'NEEDS-ACTION') ) { return true; } } return false; } /** * Unify recurrence spec. for comparison */ protected function fixed_recurrence($event) { $rec = (array) $event['recurrence']; // Add BYDAY if not exists if ($rec['FREQ'] == 'WEEKLY' && empty($rec['BYDAY'])) { $days = array('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'); $day = $event['start']->format('w'); $rec['BYDAY'] = $days[$day]; } if (!$rec['INTERVAL']) { $rec['INTERVAL'] = 1; } ksort($rec); return $rec; } /** * Check if the event update request is a fake (for Outlook) */ protected function isDummyOutlookUpdate($data, $entry, &$result) { $is_dummy = false; // Outlook 2013 sends a dummy update just after MeetingResponse has been processed, // this update resets attendee status set in the MeetingResponse request. // We ignore attendees data in such updates, they should not happen according to // https://msdn.microsoft.com/en-us/library/office/hh428685(v=exchg.140).aspx // but they will contain some data as alarms and free/busy status so we don't // ignore them completely if (!empty($entry) && !empty($data->attendees) && stripos($this->device->devicetype, 'outlook') !== false) { // Some of these requests use just dummy Timezone $dummy_tz = str_repeat('A', 230) . '=='; if ($data->timezone == $dummy_tz) { $is_dummy = true; } // But some of them do not, so we have check if that is a first // update immediately (up to 5 seconds) after MeetingResponse request if (!$is_dummy && ($dtstamp = $this->getKolabDataItem($entry, self::KEY_RESPONSE_DTSTAMP))) { $dtstamp = new DateTime($dtstamp); $now = new DateTime('now', new DateTimeZone('UTC')); $is_dummy = $now->getTimestamp() - $dtstamp->getTimestamp() <= 5; } $this->unsetKolabDataItem($result, self::KEY_RESPONSE_DTSTAMP); } return $is_dummy; } }