diff --git a/lib/kolab_sync_data.php b/lib/kolab_sync_data.php index 9f60d42..91a4362 100644 --- a/lib/kolab_sync_data.php +++ b/lib/kolab_sync_data.php @@ -1,1983 +1,1995 @@ | | | | 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 | +--------------------------------------------------------------------------+ */ /** * Base class for Syncroton data backends */ abstract class kolab_sync_data implements Syncroton_Data_IData { /** * ActiveSync protocol version * * @var int */ protected $asversion = 0; /** * information about the current device * * @var Syncroton_Model_IDevice */ protected $device; /** * timestamp to use for all sync requests * * @var DateTime */ protected $syncTimeStamp; /** * name of model to use * * @var string */ protected $modelName; /** * type of the default folder * * @var int */ protected $defaultFolderType; /** * default container for new entries * * @var string */ protected $defaultFolder; /** * type of user created folders * * @var int */ protected $folderType; /** * Internal cache for kolab_storage folder objects * * @var array */ protected $folders = array(); /** * Internal cache for IMAP folders list * * @var array */ protected $imap_folders = array(); /** * Logger instance. * * @var kolab_sync_logger */ protected $logger; /** * Timezone * * @var string */ protected $timezone; /** * List of device types with multiple folders support * * @var array */ protected $ext_devices = array( 'iphone', 'ipad', 'thundertine', 'windowsphone', 'wp', 'wp8', 'playbook', ); const RESULT_OBJECT = 0; const RESULT_UID = 1; const RESULT_COUNT = 2; /** * Recurrence types */ const RECUR_TYPE_DAILY = 0; // Recurs daily. const RECUR_TYPE_WEEKLY = 1; // Recurs weekly const RECUR_TYPE_MONTHLY = 2; // Recurs monthly const RECUR_TYPE_MONTHLY_DAYN = 3; // Recurs monthly on the nth day const RECUR_TYPE_YEARLY = 5; // Recurs yearly const RECUR_TYPE_YEARLY_DAYN = 6; // Recurs yearly on the nth day /** * Day of week constants */ const RECUR_DOW_SUNDAY = 1; const RECUR_DOW_MONDAY = 2; const RECUR_DOW_TUESDAY = 4; const RECUR_DOW_WEDNESDAY = 8; const RECUR_DOW_THURSDAY = 16; const RECUR_DOW_FRIDAY = 32; const RECUR_DOW_SATURDAY = 64; const RECUR_DOW_LAST = 127; // The last day of the month. Used as a special value in monthly or yearly recurrences. /** * Mapping of recurrence types * * @var array */ protected $recurTypeMap = array( self::RECUR_TYPE_DAILY => 'DAILY', self::RECUR_TYPE_WEEKLY => 'WEEKLY', self::RECUR_TYPE_MONTHLY => 'MONTHLY', self::RECUR_TYPE_MONTHLY_DAYN => 'MONTHLY', self::RECUR_TYPE_YEARLY => 'YEARLY', self::RECUR_TYPE_YEARLY_DAYN => 'YEARLY', ); /** * Mapping of weekdays * NOTE: ActiveSync uses a bitmask * * @var array */ protected $recurDayMap = array( 'SU' => self::RECUR_DOW_SUNDAY, 'MO' => self::RECUR_DOW_MONDAY, 'TU' => self::RECUR_DOW_TUESDAY, 'WE' => self::RECUR_DOW_WEDNESDAY, 'TH' => self::RECUR_DOW_THURSDAY, 'FR' => self::RECUR_DOW_FRIDAY, 'SA' => self::RECUR_DOW_SATURDAY, ); /** * the constructor * * @param Syncroton_Model_IDevice $device * @param DateTime $syncTimeStamp */ public function __construct(Syncroton_Model_IDevice $device, DateTime $syncTimeStamp) { $this->backend = kolab_sync_backend::get_instance(); $this->device = $device; $this->asversion = floatval($device->acsversion); $this->syncTimeStamp = $syncTimeStamp; $this->logger = Syncroton_Registry::get(Syncroton_Registry::LOGGERBACKEND); $this->defaultRootFolder = $this->defaultFolder . '::Syncroton'; // set internal timezone of kolab_format to user timezone try { $this->timezone = rcube::get_instance()->config->get('timezone', 'GMT'); kolab_format::$timezone = new DateTimeZone($this->timezone); } catch (Exception $e) { //rcube::raise_error($e, true); $this->timezone = 'GMT'; kolab_format::$timezone = new DateTimeZone('GMT'); } } /** * return list of supported folders for this backend * * @return array */ public function getAllFolders() { $list = array(); // device supports multiple folders ? if ($this->isMultiFolder()) { // get the folders the user has access to $list = $this->listFolders(); } else if ($default = $this->getDefaultFolder()) { $list = array($default['serverId'] => $default); } // getAllFolders() is called only in FolderSync // throw Syncroton_Exception_Status_FolderSync exception if (!is_array($list)) { throw new Syncroton_Exception_Status_FolderSync(Syncroton_Exception_Status_FolderSync::FOLDER_SERVER_ERROR); } foreach ($list as $idx => $folder) { $list[$idx] = new Syncroton_Model_Folder($folder); } return $list; } /** * Retrieve folders which were modified since last sync * * @param DateTime $startTimeStamp * @param DateTime $endTimeStamp * * @return array List of folders */ public function getChangedFolders(DateTime $startTimeStamp, DateTime $endTimeStamp) { return array(); } /** * Returns true if the device supports multiple folders or it was configured so */ protected function isMultiFolder() { $config = rcube::get_instance()->config; $blacklist = $config->get('activesync_multifolder_blacklist_' . $this->modelName); if (!is_array($blacklist)) { $blacklist = $config->get('activesync_multifolder_blacklist'); } if (is_array($blacklist)) { return !$this->deviceTypeFilter($blacklist); } return in_array_nocase($this->device->devicetype, $this->ext_devices); } /** * Returns default folder for current class type. */ protected function getDefaultFolder() { // Check if there's any folder configured for sync $folders = $this->listFolders(); if (empty($folders)) { return $folders; } foreach ($folders as $folder) { if ($folder['type'] == $this->defaultFolderType) { $default = $folder; break; } } // Return first on the list if there's no default if (empty($default)) { $key = array_shift(array_keys($folders)); $default = $folders[$key]; // make sure the type is default here $default['type'] = $this->defaultFolderType; } // Remember real folder ID and set ID/name to root folder $default['realid'] = $default['serverId']; $default['serverId'] = $this->defaultRootFolder; $default['displayName'] = $this->defaultFolder; return $default; } /** * Creates a folder */ public function createFolder(Syncroton_Model_IFolder $folder) { $parentid = $folder->parentId; $type = $folder->type; $display_name = $folder->displayName; if ($parentid) { $parent = $this->backend->folder_id2name($parentid, $this->device->deviceid); if ($parent === null) { throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::PARENT_NOT_FOUND); } } $name = rcube_charset::convert($display_name, kolab_sync::CHARSET, 'UTF7-IMAP'); if ($parent !== null) { $rcube = rcube::get_instance(); $storage = $rcube->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $name = $parent . $delim . $name; } // Create IMAP folder $result = $this->backend->folder_create($name, $type, $this->device->deviceid); if ($result) { $folder->serverId = $this->backend->folder_id($name); return $folder; } $errno = Syncroton_Exception_Status_FolderCreate::UNKNOWN_ERROR; // Special case when client tries to create a subfolder of INBOX // which is not possible on Cyrus-IMAP (T2223) if ($parent == 'INBOX' && stripos($this->backend->last_error(), 'invalid') !== false) { $errno = Syncroton_Exception_Status_FolderCreate::SPECIAL_FOLDER; } // Note: Looks like Outlook 2013 ignores any errors on FolderCreate command throw new Syncroton_Exception_Status_FolderCreate($errno); } /** * Updates a folder */ public function updateFolder(Syncroton_Model_IFolder $folder) { $parentid = $folder->parentId; $type = $folder->type; $display_name = $folder->displayName; $old_name = $this->backend->folder_id2name($folder->serverId, $this->device->deviceid); if ($parentid) { $parent = $this->backend->folder_id2name($parentid, $this->device->deviceid); } $name = rcube_charset::convert($display_name, kolab_sync::CHARSET, 'UTF7-IMAP'); if ($parent !== null) { $rcube = rcube::get_instance(); $storage = $rcube->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $name = $parent . $delim . $name; } // Rename/move IMAP folder if ($name == $old_name) { $result = true; // @TODO: folder type change? } else { $result = $this->backend->folder_rename($old_name, $name, $type); } if ($result) { return $folder; } // @TODO: throw exception } /** * Deletes a folder */ public function deleteFolder($folder) { if ($folder instanceof Syncroton_Model_IFolder) { $folder = $folder->serverId; } $name = $this->backend->folder_id2name($folder, $this->device->deviceid); // @TODO: throw exception return $this->backend->folder_delete($name, $this->device->deviceid); } /** * Empty folder (remove all entries and optionally subfolders) * * @param string $folderId Folder identifier * @param array $options Options */ public function emptyFolderContents($folderid, $options) { $folders = $this->extractFolders($folderid); foreach ($folders as $folderid) { $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); $folder = $this->getFolderObject($foldername); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status_ItemOperations(Syncroton_Exception_Status_ItemOperations::ITEM_SERVER_ERROR); } // Remove all entries $folder->delete_all(); // Remove subfolders if (!empty($options['deleteSubFolders'])) { $list = $this->listFolders($folderid); foreach ($list as $folderid => $folder) { $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); $folder = $this->getFolderObject($foldername); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status_ItemOperations(Syncroton_Exception_Status_ItemOperations::ITEM_SERVER_ERROR); } // Remove all entries $folder->delete_all(); } } } } /** * Moves object into another location (folder) * * @param string $srcFolderId Source folder identifier * @param string $serverId Object identifier * @param string $dstFolderId Destination folder identifier * * @throws Syncroton_Exception_Status * @return string New object identifier */ public function moveItem($srcFolderId, $serverId, $dstFolderId) { $item = $this->getObject($srcFolderId, $serverId, $folder); if (!$item || !$folder) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } $dstname = $this->backend->folder_id2name($dstFolderId, $this->device->deviceid); if ($dstname === null) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); } if (!$folder->move($serverId, $dstname)) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } return $item['uid']; } /** * Add entry * * @param string $folderId Folder identifier * @param Syncroton_Model_IEntry $entry Entry object * * @return string ID of the created entry */ public function createEntry($folderId, Syncroton_Model_IEntry $entry) { $entry = $this->toKolab($entry, $folderId); $entry = $this->createObject($folderId, $entry); if (empty($entry)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } return $entry['_serverId']; } /** * update existing entry * * @param string $folderId * @param string $serverId * @param SimpleXMLElement $entry * * @return string ID of the updated entry */ public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry) { $oldEntry = $this->getObject($folderId, $serverId); if (empty($oldEntry)) { throw new Syncroton_Exception_NotFound('id not found'); } $entry = $this->toKolab($entry, $folderId, $oldEntry); $entry = $this->updateObject($folderId, $serverId, $entry); if (empty($entry)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } return $entry['_serverId']; } /** * delete entry * * @param string $folderId * @param string $serverId * @param array $collectionData */ public function deleteEntry($folderId, $serverId, $collectionData) { $deleted = $this->deleteObject($folderId, $serverId); if (!$deleted) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } } public function getFileReference($fileReference) { // to be implemented by Email data class // @TODO: throw "unimplemented" exception here? } /** * Search for existing entries * * @param string $folderid Folder identifier * @param array $filter Search filter * @param int $result_type Type of the result (see RESULT_* constants) * * @return array|int Search result as count or array of uids/objects */ protected function searchEntries($folderid, $filter = array(), $result_type = self::RESULT_UID) { if ($folderid == $this->defaultRootFolder) { $folders = $this->listFolders(); if (!is_array($folders)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $folders = array_keys($folders); } else { $folders = array($folderid); } // there's a PHP Warning from kolab_storage if $filter isn't an array if (empty($filter)) { $filter = array(); } else { $changed_objects = $this->getChangesByRelations($folderid, $filter); } $result = $result_type == self::RESULT_COUNT ? 0 : array(); $found = 0; foreach ($folders as $folder_id) { $foldername = $this->backend->folder_id2name($folder_id, $this->device->deviceid); $folder = $this->getFolderObject($foldername); if (!$folder || !$folder->valid) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $found++; $error = false; switch ($result_type) { case self::RESULT_COUNT: $count = $folder->count($filter); if ($count === null || $count === false) { $error = true; } else { $result += (int) $count; } break; case self::RESULT_UID: $uids = $folder->get_uids($filter); if (!is_array($uids)) { $error = true; } else if (!empty($uids)) { $result = array_merge($result, $this->applyServerId($uids, $folder)); } break; } if ($error) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } // handle tag modifications if (!empty($changed_objects)) { // build new filter // search objects mathing current filter, // relations may contain members of many types, we need to // search them by UID in all requested folders to get // only these with requested type (and that really exist // in specified folders) $tag_filter = array(array('uid', '=', $changed_objects)); foreach ($filter as $f) { if ($f[0] != 'changed') { $tag_filter[] = $f; } } switch ($result_type) { case self::RESULT_COUNT: // Note: this way we're potentally counting the same objects twice // I'm not sure if this is a problem, we most likely do not // need a precise result here $count = $folder->count($tag_filter); if ($count !== null && $count !== false) { $result += (int) $count; } break; case self::RESULT_UID: $uids = $folder->get_uids($tag_filter); if (is_array($uids) && !empty($uids)) { $result = array_unique(array_merge($result, $this->applyServerId($uids, $folder))); } break; } } } if (!$found) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } return $result; } /** * Detect changes of relation (tag) objects data and assigned objects * Returns relation member identifiers */ protected function getChangesByRelations($folderid, $filter) { if (!$this->tag_categories) { return; } // get period filter, create new objects filter foreach ($filter as $f) { if ($f[0] == 'changed' && $f[1] == '>') { $since = $f[2]; } } // this is not search for changes, do nothing if (empty($since)) { return; } // get relations state from the last sync $last_state = (array) $this->backend->relations_state_get($this->device->id, $folderid, $since); // get current relations state $config = kolab_storage_config::get_instance(); $default = true; $filter = array( array('type', '=', 'relation'), array('category', '=', 'tag') ); $relations = $config->get_objects($filter, $default, 100); $result = array(); $changed = false; // compare states, get members of changed relations foreach ($relations as $relation) { $rel_id = $relation['uid']; if ($relation['changed']) { $relation['changed']->setTimezone(new DateTimeZone('UTC')); } // last state unknown... if (empty($last_state[$rel_id])) { // ...get all members if (!empty($relation['members'])) { $changed = true; $result = array_merge($result, $relation['members']); } } // last state known, changed tag name... else if ($last_state[$rel_id]['name'] != $relation['name']) { // ...get all (old and new) members $members_old = explode("\n", $last_state[$rel_id]['members']); $changed = true; $members = array_unique(array_merge($relation['members'], $members_old)); $result = array_merge($result, $members); } // last state known, any other change change... else if ($last_state[$rel_id]['changed'] < $relation['changed']->format('U')) { // ...find new and removed members $members_old = explode("\n", $last_state[$rel_id]['members']); $new = array_diff($relation['members'], $members_old); $removed = array_diff($members_old, $relation['members']); if (!empty($new) || !empty($removed)) { $changed = true; $result = array_merge($result, $new, $removed); } } unset($last_state[$rel_id]); } // get members of deleted relations if (!empty($last_state)) { $changed = true; foreach ($last_state as $relation) { $members = explode("\n", $relation['members']); $result = array_merge($result, $members); } } // save current state if ($changed) { $data = array(); foreach ($relations as $relation) { $data[$relation['uid']] = array( 'name' => $relation['name'], 'changed' => $relation['changed']->format('U'), 'members' => implode("\n", (array)$relation['members']), ); } $now = new DateTime('now', new DateTimeZone('UTC')); $this->backend->relations_state_set($this->device->id, $folderid, $now, $data); } // in mail mode return only message URIs if ($this->modelName == 'mail') { // lambda function to skip email members $filter_func = function($value) { return strpos($value, 'imap://') === 0; }; $result = array_filter(array_unique($result), $filter_func); } // otherwise return only object UIDs else { // lambda function to skip email members $filter_func = function($value) { return strpos($value, 'urn:uuid:') === 0; }; // lambda function to parse member URI $member_func = function($value) { if (strpos($value, 'urn:uuid:') === 0) { $value = substr($value, 9); } return $value; }; $result = array_map($member_func, array_filter(array_unique($result), $filter_func)); } return $result; } /** * 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) { // overwrite by child class according to specified type return array(); } /** * get all entries changed between two dates * * @param string $folderId * @param DateTime $start * @param DateTime $end * @param int $filterType * * @return array */ public function getChangedEntries($folderId, DateTime $start, DateTime $end = null, $filter_type = null) { $filter = $this->filter($filter_type); $filter[] = array('changed', '>', $start); if ($end) { $filter[] = array('changed', '<=', $end); } return $this->searchEntries($folderId, $filter, self::RESULT_UID); } /** * Get count of entries changed between two dates * * @param string $folderId * @param DateTime $start * @param DateTime $end * @param int $filterType * * @return int */ public function getChangedEntriesCount($folderId, DateTime $start, DateTime $end = null, $filter_type = null) { $filter = $this->filter($filter_type); $filter[] = array('changed', '>', $start); if ($end) { $filter[] = array('changed', '<=', $end); } return $this->searchEntries($folderId, $filter, self::RESULT_COUNT); } /** * get id's of all entries available on the server * * @param string $folderId * @param int $filterType * * @return array */ public function getServerEntries($folder_id, $filter_type) { $filter = $this->filter($filter_type); $result = $this->searchEntries($folder_id, $filter, self::RESULT_UID); return $result; } /** * get count of all entries available on the server * * @param string $folderId * @param int $filterType * * @return int */ public function getServerEntriesCount($folder_id, $filter_type) { $filter = $this->filter($filter_type); $result = $this->searchEntries($folder_id, $filter, self::RESULT_COUNT); return $result; } /** * Returns number of changed objects in the backend folder * * @param Syncroton_Backend_IContent $contentBackend * @param Syncroton_Model_IFolder $folder * @param Syncroton_Model_ISyncState $syncState * * @return int */ public function getCountOfChanges(Syncroton_Backend_IContent $contentBackend, Syncroton_Model_IFolder $folder, Syncroton_Model_ISyncState $syncState) { $allClientEntries = $contentBackend->getFolderState($this->device, $folder); $allServerEntries = $this->getServerEntries($folder->serverId, $folder->lastfiltertype); $changedEntries = $this->getChangedEntriesCount($folder->serverId, $syncState->lastsync, null, $folder->lastfiltertype); $addedEntries = array_diff($allServerEntries, $allClientEntries); $deletedEntries = array_diff($allClientEntries, $allServerEntries); return count($addedEntries) + count($deletedEntries) + $changedEntries; } /** * Returns true if any data got modified in the backend folder * * @param Syncroton_Backend_IContent $contentBackend * @param Syncroton_Model_IFolder $folder * @param Syncroton_Model_ISyncState $syncState * * @return bool */ public function hasChanges(Syncroton_Backend_IContent $contentBackend, Syncroton_Model_IFolder $folder, Syncroton_Model_ISyncState $syncState) { try { if ($this->getChangedEntriesCount($folder->serverId, $syncState->lastsync, null, $folder->lastfiltertype)) { return true; } $allClientEntries = $contentBackend->getFolderState($this->device, $folder); // @TODO: Consider looping over all folders here, not in getServerEntries() and // getChangedEntriesCount(). This way we could break the loop and not check all folders // or at least skip redundant cache sync of the same folder $allServerEntries = $this->getServerEntries($folder->serverId, $folder->lastfiltertype); $addedEntries = array_diff($allServerEntries, $allClientEntries); $deletedEntries = array_diff($allClientEntries, $allServerEntries); return count($addedEntries) > 0 || count($deletedEntries) > 0; } catch (Exception $e) { // return "no changes" if something failed return false; } } /** * Fetches the entry from the backend */ protected function getObject($folderid, $entryid, &$folder = null) { $folders = $this->extractFolders($folderid); foreach ($folders as $folderid) { $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); $folder = $this->getFolderObject($foldername); if ($folder && $folder->valid) { $crc = null; $uid = $entryid; // See self::serverId() for full explanation // Use (slower) UID prefix matching... if (preg_match('/^CRC([0-9A-Fa-f]{8})(.+)$/', $uid, $matches)) { $crc = $matches[1]; $uid = $matches[2]; if (strlen($entryid) >= 64) { foreach ($folder->select(array(array('uid', '~*', $uid))) as $object) { if (($object['uid'] == $uid || strpos($object['uid'], $uid) === 0) && $crc == $this->objectCRC($object['uid'], $folder) ) { $object['_folderid'] = $folderid; return $object; } } continue; } } // Or (faster) strict UID matching... if (($object = $folder->get_object($uid)) && ($crc === null || $crc == $this->objectCRC($object['uid'], $folder)) ) { $object['_folderid'] = $folderid; return $object; } } } } /** * Saves the entry on the backend */ protected function createObject($folderid, $data) { if ($folderid == $this->defaultRootFolder) { $default = $this->getDefaultFolder(); if (!is_array($default)) { return null; } $folderid = isset($default['realid']) ? $default['realid'] : $default['serverId']; } // convert categories into tags, save them after creating an object if ($this->tag_categories) { $tags = $data['categories']; unset($data['categories']); } $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); $folder = $this->getFolderObject($foldername); if ($folder && $folder->valid && $folder->save($data)) { if (!empty($tags)) { $this->setKolabTags($data['uid'], $tags); } $data['_serverId'] = $this->serverId($data['uid'], $folder); return $data; } } /** * Updates the entry on the backend */ protected function updateObject($folderid, $entryid, $data) { $object = $this->getObject($folderid, $entryid); if ($object) { $folder = $this->getFolderObject($object['_mailbox']); // convert categories into tags, save them after updating an object if ($this->tag_categories && array_key_exists('categories', $data)) { $tags = (array) $data['categories']; unset($data['categories']); } if ($folder && $folder->valid && $folder->save($data)) { if (isset($tags)) { $this->setKolabTags($data['uid'], $tags); } $data['_serverId'] = $this->serverId($object['uid'], $folder); return $data; } } } /** * Removes the entry from the backend */ protected function deleteObject($folderid, $entryid) { $object = $this->getObject($folderid, $entryid); if ($object) { $folder = $this->getFolderObject($object['_mailbox']); if ($folder && $folder->valid && $folder->delete($object['uid'])) { if ($this->tag_categories) { $this->setKolabTags($object['uid'], null); } return true; } return false; } // object doesn't exist, confirm deletion return true; } /** * Returns internal folder IDs * * @param string $folderid Folder identifier * * @return array List of folder identifiers */ protected function extractFolders($folderid) { if ($folderid instanceof Syncroton_Model_IFolder) { $folderid = $folderid->serverId; } if ($folderid == $this->defaultRootFolder) { $folders = $this->listFolders(); if (!is_array($folders)) { return null; } $folders = array_keys($folders); } else { $folders = array($folderid); } return $folders; } /** * List of all IMAP folders (or subtree) * * @param string $parentid Parent folder identifier * * @return array List of folder identifiers */ protected function listFolders($parentid = null) { if (empty($this->imap_folders)) { $this->imap_folders = $this->backend->folders_list( $this->device->deviceid, $this->modelName, $this->isMultiFolder()); } if ($parentid === null) { return $this->imap_folders; } $folders = array(); $parents = array($parentid); foreach ($this->imap_folders as $folder_id => $folder) { if ($folder['parentId'] && in_array($folder['parentId'], $parents)) { $folders[$folder_id] = $folder; $parents[] = $folder_id; } } return $folders; } /** * Returns Folder object (uses internal cache) * * @param string $name Folder name (UTF7-IMAP) * * @return kolab_storage_folder Folder object */ protected function getFolderObject($name) { if ($name === null || $name === '') { return null; } if (!isset($this->folders[$name])) { $this->folders[$name] = kolab_storage::get_folder($name, $this->modelName); } return $this->folders[$name]; } /** * Returns ActiveSync settings of specified folder * * @param string $name Folder name (UTF7-IMAP) * * @return array Folder settings */ protected function getFolderConfig($name) { $metadata = $this->backend->folder_meta(); if (!is_array($metadata)) { return array(); } $deviceid = $this->device->deviceid; $config = $metadata[$name]['FOLDER'][$deviceid]; return array( 'ALARMS' => $config['S'] == 2, ); } /** * Returns real folder name for specified folder ID */ protected function getFolderName($folderid) { if ($folderid == $this->defaultRootFolder) { $default = $this->getDefaultFolder(); if (!is_array($default)) { return null; } $folderid = isset($default['realid']) ? $default['realid'] : $default['serverId']; } return $this->backend->folder_id2name($folderid, $this->device->deviceid); } + /** + * Returns folder ID from Kolab folder object + */ + protected function getFolderId($folder) + { + if (!$this->isMultiFolder()) { + return $this->defaultRootFolder; + } + + return $this->backend->folder_id($folder->get_name(), $folder->get_type()); + } + /** * Convert contact from xml to kolab format * * @param Syncroton_Model_IEntry $data Contact data * @param string $folderId Folder identifier * @param array $entry Old Contact data for merge * * @return array */ abstract function toKolab(Syncroton_Model_IEntry $data, $folderId, $entry = null); /** * Extracts data from kolab data array */ protected function getKolabDataItem($data, $name) { $name_items = explode('.', $name); $count = count($name_items); // multi-level array (e.g. address, phone) if ($count == 3) { $name = $name_items[0]; $type = $name_items[1]; $key_name = $name_items[2]; if (!empty($data[$name]) && is_array($data[$name])) { foreach ($data[$name] as $element) { if ($element['type'] == $type) { return $element[$key_name]; } } } return null; } // custom properties if ($count == 2 && $name_items[0] == 'x-custom') { $value = null; foreach ((array) $data['x-custom'] as $val) { if (is_array($val) && $val[0] == $name_items[1]) { $value = $val[1]; break; } } return $value; } $name_items = explode(':', $name); $name = $name_items[0]; if (empty($data[$name])) { return null; } // simple array (e.g. email) if (count($name_items) == 2) { return $data[$name][$name_items[1]]; } return $data[$name]; } /** * Saves data in kolab data array */ protected function setKolabDataItem(&$data, $name, $value) { if (empty($value)) { return $this->unsetKolabDataItem($data, $name); } $name_items = explode('.', $name); $count = count($name_items); // multi-level array (e.g. address, phone) if ($count == 3) { $name = $name_items[0]; $type = $name_items[1]; $key_name = $name_items[2]; if (!isset($data[$name])) { $data[$name] = array(); } foreach ($data[$name] as $idx => $element) { if ($element['type'] == $type) { $found = $idx; break; } } if (!isset($found)) { $data[$name] = array_values($data[$name]); $found = count($data[$name]); $data[$name][$found] = array('type' => $type); } $data[$name][$found][$key_name] = $value; return; } // custom properties if ($count == 2 && $name_items[0] == 'x-custom') { foreach ((array) $data['x-custom'] as $idx => $val) { if (is_array($val) && $val[0] == $name_items[1]) { $data['x-custom'][$idx][1] = $value; return; } } $data['x-custom'][] = array($name_items[1], $value); return; } $name_items = explode(':', $name); $name = $name_items[0]; // simple array (e.g. email) if (count($name_items) == 2) { $data[$name][$name_items[1]] = $value; return; } $data[$name] = $value; } /** * Unsets data item in kolab data array */ protected function unsetKolabDataItem(&$data, $name) { $name_items = explode('.', $name); $count = count($name_items); // multi-level array (e.g. address, phone) if ($count == 3) { $name = $name_items[0]; $type = $name_items[1]; $key_name = $name_items[2]; if (!isset($data[$name])) { return; } foreach ($data[$name] as $idx => $element) { if ($element['type'] == $type) { $found = $idx; break; } } if (!isset($found)) { return; } unset($data[$name][$found][$key_name]); // if there's only one element and it's 'type', remove it if (count($data[$name][$found]) == 1 && isset($data[$name][$found]['type'])) { unset($data[$name][$found]['type']); } if (empty($data[$name][$found])) { unset($data[$name][$found]); } if (empty($data[$name])) { unset($data[$name]); } return; } // custom properties if ($count == 2 && $name_items[0] == 'x-custom') { foreach ((array) $data['x-custom'] as $idx => $val) { if (is_array($val) && $val[0] == $name_items[1]) { unset($data['x-custom'][$idx]); } } } $name_items = explode(':', $name); $name = $name_items[0]; // simple array (e.g. email) if (count($name_items) == 2) { unset($data[$name][$name_items[1]]); if (empty($data[$name])) { unset($data[$name]); } return; } unset($data[$name]); } /** * Setter for Body attribute according to client version * * @param string $value Body * @param array $param Body parameters * * @reurn Syncroton_Model_EmailBody Body element */ protected function setBody($value, $params = array()) { if (empty($value) && empty($params)) { return; } // Old protocol version doesn't support AirSyncBase:Body, it's eg. WindowsCE if ($this->asversion < 12) { return; } if (!empty($value)) { // cast to string to workaround issue described in Bug #1635 $params['data'] = (string) $value; } if (!isset($params['type'])) { $params['type'] = Syncroton_Model_EmailBody::TYPE_PLAINTEXT; } return new Syncroton_Model_EmailBody($params); } /** * Getter for Body attribute value according to client version * * @param mixed $body Body element * @param int $type Result data type (to which the body will be converted, if specified). * One or array of Syncroton_Model_EmailBody constants. * * @return string Body value */ protected function getBody($body, $type = null) { if ($body && $body->data) { $data = $body->data; } if (!$data || empty($type)) { return; } $type = (array) $type; // Convert to specified type if (!in_array($body->type, $type)) { $converter = new kolab_sync_body_converter($data, $body->type); $data = $converter->convert($type[0]); } return $data; } /** * Converts text (plain or html) into ActiveSync Body element. * Takes bodyPreferences into account and detects if the text is plain or html. */ protected function body_from_kolab($body, $collection) { if (empty($body)) { return; } $opts = $collection->options; $prefs = $opts['bodyPreferences']; $html_type = Syncroton_Command_Sync::BODY_TYPE_HTML; $type = Syncroton_Command_Sync::BODY_TYPE_PLAIN_TEXT; $params = array(); // HTML? check for opening and closing or tags $is_html = preg_match('/<(html|body)(\s+[a-z]|>)/', $body, $m) && strpos($body, '') > 0; // here we assume that all devices support plain text if ($is_html) { // device supports HTML... if (!empty($prefs[$html_type])) { $type = $html_type; } // ...else convert to plain text else { $txt = new rcube_html2text($body, false, true); $body = $txt->get_text(); } } // strip out any non utf-8 characters $body = rcube_charset::clean($body); $real_length = $body_length = strlen($body); // truncate the body if needed if (($truncateAt = $prefs[$type]['truncationSize']) && $body_length > $truncateAt) { $body = mb_strcut($body, 0, $truncateAt); $body_length = strlen($body); $params['truncated'] = 1; $params['estimatedDataSize'] = $real_length; } $params['type'] = $type; return $this->setBody($body, $params); } /** * Converts PHP DateTime, date (YYYY-MM-DD) or unixtimestamp into PHP DateTime in UTC * * @param DateTime|int|string $date Unix timestamp, date (YYYY-MM-DD) or PHP DateTime object * * @return DateTime Datetime object */ protected static function date_from_kolab($date) { if (!empty($date)) { if (is_numeric($date)) { $date = new DateTime('@' . $date); } else if (is_string($date)) { $date = new DateTime($date, new DateTimeZone('UTC')); } else if ($date instanceof DateTime) { $date = clone $date; $tz = $date->getTimezone(); $tz_name = $tz->getName(); // convert to UTC if needed if ($tz_name != 'UTC') { $utc = new DateTimeZone('UTC'); // safe dateonly object conversion to UTC // note: _dateonly flag is set by libkolab e.g. for birthdays if ($date->_dateonly) { // avoid time change $date = new DateTime($date->format('Y-m-d'), $utc); // set time to noon to avoid timezone troubles $date->setTime(12, 0, 0); } else { $date->setTimezone($utc); } } } else { return null; // invalid input } return $date; } } /** * Convert Kolab event/task recurrence into ActiveSync */ protected function recurrence_from_kolab($collection, $data, &$result, $type = 'Event') { if (empty($data['recurrence']) || !empty($data['recurrence_date'])) { return; } $recurrence = array(); $r = $data['recurrence']; // required fields switch($r['FREQ']) { case 'DAILY': $recurrence['type'] = self::RECUR_TYPE_DAILY; break; case 'WEEKLY': $recurrence['type'] = self::RECUR_TYPE_WEEKLY; $recurrence['dayOfWeek'] = $this->day2bitmask($r['BYDAY']); break; case 'MONTHLY': if (!empty($r['BYMONTHDAY'])) { // @TODO: ActiveSync doesn't support multi-valued month days, // should we replicate the recurrence element for each day of month? $month_day = array_shift(explode(',', $r['BYMONTHDAY'])); $recurrence['type'] = self::RECUR_TYPE_MONTHLY; $recurrence['dayOfMonth'] = $month_day; } else { $week = (int) substr($r['BYDAY'], 0, -2); $week = ($week == -1) ? 5 : $week; $day = substr($r['BYDAY'], -2); $recurrence['type'] = self::RECUR_TYPE_MONTHLY_DAYN; $recurrence['weekOfMonth'] = $week; $recurrence['dayOfWeek'] = $this->day2bitmask($day); } break; case 'YEARLY': // @TODO: ActiveSync doesn't support multi-valued months, // should we replicate the recurrence element for each month? $month = array_shift(explode(',', $r['BYMONTH'])); if (!empty($r['BYDAY'])) { $week = (int) substr($r['BYDAY'], 0, -2); $week = ($week == -1) ? 5 : $week; $day = substr($r['BYDAY'], -2); $recurrence['type'] = self::RECUR_TYPE_YEARLY_DAYN; $recurrence['weekOfMonth'] = $week; $recurrence['dayOfWeek'] = $this->day2bitmask($day); $recurrence['monthOfYear'] = $month; } else if (!empty($r['BYMONTHDAY'])) { // @TODO: ActiveSync doesn't support multi-valued month days, // should we replicate the recurrence element for each day of month? $month_day = array_shift(explode(',', $r['BYMONTHDAY'])); $recurrence['type'] = self::RECUR_TYPE_YEARLY; $recurrence['dayOfMonth'] = $month_day; $recurrence['monthOfYear'] = $month; } else { $recurrence['type'] = self::RECUR_TYPE_YEARLY; $recurrence['monthOfYear'] = $month; } break; default: return; } // Skip all empty values (T2519) if ($recurrence['type'] != self::RECUR_TYPE_DAILY) { $recurrence = array_filter($recurrence); } // required field $recurrence['interval'] = $r['INTERVAL'] ?: 1; if (!empty($r['UNTIL'])) { $recurrence['until'] = self::date_from_kolab($r['UNTIL']); } else if (!empty($r['COUNT'])) { $recurrence['occurrences'] = $r['COUNT']; } $class = 'Syncroton_Model_' . $type . 'Recurrence'; $result['recurrence'] = new $class($recurrence); // Tasks do not support exceptions if ($type == 'Event') { $result['exceptions'] = $this->exceptions_from_kolab($collection, $data); } } /** * Convert ActiveSync event/task recurrence into Kolab */ protected function recurrence_to_kolab($data, $folderid, $timezone = null) { if (!($data->recurrence instanceof Syncroton_Model_EventRecurrence) || !isset($data->recurrence->type)) { return null; } $recurrence = $data->recurrence; $type = $recurrence->type; switch ($type) { case self::RECUR_TYPE_DAILY: break; case self::RECUR_TYPE_WEEKLY: $rrule['BYDAY'] = $this->bitmask2day($recurrence->dayOfWeek); break; case self::RECUR_TYPE_MONTHLY: $rrule['BYMONTHDAY'] = $recurrence->dayOfMonth; break; case self::RECUR_TYPE_MONTHLY_DAYN: $week = $recurrence->weekOfMonth; $day = $recurrence->dayOfWeek; $byDay = $week == 5 ? -1 : $week; $byDay .= $this->bitmask2day($day); $rrule['BYDAY'] = $byDay; break; case self::RECUR_TYPE_YEARLY: $rrule['BYMONTH'] = $recurrence->monthOfYear; $rrule['BYMONTHDAY'] = $recurrence->dayOfMonth; break; case self::RECUR_TYPE_YEARLY_DAYN: $rrule['BYMONTH'] = $recurrence->monthOfYear; $week = $recurrence->weekOfMonth; $day = $recurrence->dayOfWeek; $byDay = $week == 5 ? -1 : $week; $byDay .= $this->bitmask2day($day); $rrule['BYDAY'] = $byDay; break; } $rrule['FREQ'] = $this->recurTypeMap[$type]; $rrule['INTERVAL'] = isset($recurrence->interval) ? $recurrence->interval : 1; if (isset($recurrence->until)) { if ($timezone) { $recurrence->until->setTimezone($timezone); } $rrule['UNTIL'] = $recurrence->until; } else if (!empty($recurrence->occurrences)) { $rrule['COUNT'] = $recurrence->occurrences; } // recurrence exceptions (not supported by Tasks) if ($data instanceof Syncroton_Model_Event) { $this->exceptions_to_kolab($data, $rrule, $folderid, $timezone); } return $rrule; } /** * Convert Kolab event recurrence exceptions into ActiveSync */ protected function exceptions_from_kolab($collection, $data) { if (empty($data['recurrence']['EXCEPTIONS']) && empty($data['recurrence']['EXDATE'])) { return null; } $ex_list = array(); // exceptions (modified occurences) foreach ((array)$data['recurrence']['EXCEPTIONS'] as $exception) { $exception['_mailbox'] = $data['_mailbox']; $ex = $this->getEntry($collection, $exception, true); $date = clone ($exception['recurrence_date'] ?: $ex['startTime']); $ex['exceptionStartTime'] = self::set_exception_time($date, $data['_start']); // remove fields not supported by Syncroton_Model_EventException unset($ex['uID']); // @TODO: 'thisandfuture=true' is not supported in Activesync // we'd need to slit the event into two separate events $ex_list[] = new Syncroton_Model_EventException($ex); } // exdate (deleted occurences) foreach ((array)$data['recurrence']['EXDATE'] as $exception) { if (!($exception instanceof DateTime)) { continue; } $ex = array( 'deleted' => 1, 'exceptionStartTime' => self::set_exception_time($exception, $data['_start']), ); $ex_list[] = new Syncroton_Model_EventException($ex); } return $ex_list; } /** * Convert ActiveSync event recurrence exceptions into Kolab */ protected function exceptions_to_kolab($data, &$rrule, $folderid, $timezone = null) { $rrule['EXDATE'] = array(); $rrule['EXCEPTIONS'] = array(); // handle exceptions from recurrence if (!empty($data->exceptions)) { foreach ($data->exceptions as $exception) { if ($exception->deleted) { $date = clone $exception->exceptionStartTime; if ($timezone) { $date->setTimezone($timezone); } $date->setTime(0, 0, 0); $rrule['EXDATE'][] = $date; } else { $ex = $this->toKolab($exception, $folderid, null, $timezone); if ($data->allDayEvent) { $ex['allday'] = 1; } $rrule['EXCEPTIONS'][] = $ex; } } } if (empty($rrule['EXDATE'])) { unset($rrule['EXDATE']); } if (empty($rrule['EXCEPTIONS'])) { unset($rrule['EXCEPTIONS']); } } /** * Sets ExceptionStartTime according to occurrence date and event start time */ protected static function set_exception_time($exception_date, $event_start) { if ($exception_date && $event_start) { $hour = $event_start->format('H'); $minute = $event_start->format('i'); $second = $event_start->format('s'); $exception_date->setTime($hour, $minute, $second); $exception_date->_dateonly = false; return self::date_from_kolab($exception_date); } } /** * Returns list of tag names assigned to kolab object */ protected function getKolabTags($uid, $categories = null) { $config = kolab_storage_config::get_instance(); $tags = $config->get_tags($uid); $tags = array_filter(array_map(function($v) { return $v['name']; }, $tags)); // merge result with old categories if (!empty($categories)) { $tags = array_unique(array_merge($tags, (array) $categories)); } return $tags; } /** * Set tags to kolab object */ protected function setKolabTags($uid, $tags) { $config = kolab_storage_config::get_instance(); $config->save_tags($uid, $tags); } /** * Converts string of days (TU,TH) to bitmask used by ActiveSync * * @param string $days * * @return int */ protected function day2bitmask($days) { $days = explode(',', $days); $result = 0; foreach ($days as $day) { $result = $result + $this->recurDayMap[$day]; } return $result; } /** * Convert bitmask used by ActiveSync to string of days (TU,TH) * * @param int $days * * @return string */ protected function bitmask2day($days) { $days_arr = array(); for ($bitmask = 1; $bitmask <= self::RECUR_DOW_SATURDAY; $bitmask = $bitmask << 1) { $dayMatch = $days & $bitmask; if ($dayMatch === $bitmask) { $days_arr[] = array_search($bitmask, $this->recurDayMap); } } $result = implode(',', $days_arr); return $result; } /** * Check if current device type string matches any of options */ protected function deviceTypeFilter($options) { foreach ($options as $option) { if ($option[0] == '/') { if (preg_match($option, $this->device->devicetype)) { return true; } } else if (stripos($this->device->devicetype, $option) !== false) { return true; } } return false; } /** * Returns all email addresses of the current user */ protected function user_emails() { $user_emails = kolab_sync::get_instance()->user->list_emails(); $user_emails = array_map(function($v) { return $v['email']; }, $user_emails); return $user_emails; } /** * Generate CRC-based ServerId from object UID */ protected function serverId($uid, $folder) { if ($this->modelName == 'mail') { return $uid; } // When ActiveSync communicates with the client, it refers to objects with a ServerId // We can't use object UID for ServerId because: // - ServerId is limited to 64 chars, // - there can be multiple calendars with a copy of the same event. // // The solution is to; Take the original UID, and regardless of its length, execute the following: // - Hash the UID concatenated with the Folder ID using CRC32b, // - Prefix the UID with 'CRC' and the hash string, // - Tryncate the result to 64 characters. // // Searching for the server-side copy of the object now follows the logic; // - If the ServerId is prefixed with 'CRC', strip off the first 11 characters // and we search for the UID using the remainder; // - if the UID is shorter than 53 characters, it'll be the complete UID, // - if the UID is longer than 53 characters, it'll be the truncated UID, // and we search for a wildcard match of * // When multiple copies of the same event are found, the same CRC32b hash can be used // on the events metadata (i.e. the copy's UID and Folder ID), and compared with the CRC from the ServerId. // ServerId is max. 64 characters, below we generate a string of max. 64 chars // Note: crc32b is always 8 characters return 'CRC' . $this->objectCRC($uid, $folder) . substr($uid, 0, 53); } /** * Calculate checksum on object UID and folder UID */ protected function objectCRC($uid, $folder) { if (!is_object($folder)) { $folder = $this->getFolderObject($folder); } $folder_uid = $folder->get_uid(); return strtoupper(hash('crc32b', $folder_uid . $uid)); // always 8 chars } /** * Apply serverId() on a set of uids */ protected function applyServerId($uids, $folder) { if (!empty($uids) && $this->modelName != 'mail') { $self = $this; $func = function($uid) use ($self, $folder) { return $self->serverId($uid, $folder); }; $uids = array_map($func, $uids); } return $uids; } } diff --git a/lib/kolab_sync_data_calendar.php b/lib/kolab_sync_data_calendar.php index 9bc88c3..bacaeb6 100644 --- a/lib/kolab_sync_data_calendar.php +++ b/lib/kolab_sync_data_calendar.php @@ -1,933 +1,957 @@ | | | | 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; /** * 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; } // 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'])) { 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; if ($this->asversion >= 12) { $type = isset($attendee['role']) ? $this->attendeeTypeMap[$attendee['role']] : null; $status = isset($attendee['status']) ? $this->attendeeStatusMap[$attendee['status']] : null; $att['attendeeType'] = $type ? $type : self::ATTENDEE_TYPE_REQUIRED; $att['attendeeStatus'] = $status ? $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); 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; // 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; // 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; } $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; } $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']) { $event['valarms'] = $this->to_kolab_alarm($data->reminder, $event); } $event['attendees'] = array(); $event['categories'] = array(); // Categories if (isset($data->categories)) { foreach ($data->categories as $category) { $event['categories'][] = $category; } } // Organizer if (!$is_exception) { $name = $data->organizerName; $email = $data->organizerEmail; if ($name || $email) { $event['attendees'][] = array( 'role' => 'ORGANIZER', 'name' => $name, 'email' => $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 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 ($is_outlook && !empty($entry) && $data->timezone == $dummy_tz && $data->responseRequested && !empty($data->attendees) ) { $event['attendees'] = $entry['attendees']; } else if (isset($data->attendees)) { $statusMap = array_flip($this->attendeeStatusMap); foreach ($data->attendees as $attendee) { $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'])) { // 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; } } } $event['attendees'][] = $_attendee; } } // 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 ($is_outlook && !empty($entry) && !$is_exception && !empty($data->attendees) && $data->timezone != $dummy_tz) { if ($last_update = $this->getKolabDataItem($event, 'x-custom.X-ACTIVESYNC-DTSTAMP')) { $last_update = new DateTime($last_update); } if ($data->dtStamp && $data->dtStamp != $last_update) { $event['sequence'] += 1; } } // 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, 'x-custom.X-ACTIVESYNC-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 data from the invitation $event = $this->get_event_from_invitation($request); // find the event in calendar $existing = $this->find_event_by_uid($event['uid']); /* switch ($status) { case 'ACCEPTED': $event['free_busy'] = 'busy'; break; case 'TENTATIVE': $event['free_busy'] = 'tentative'; break; case 'DECLINED': $event['free_busy'] = 'free'; break; } */ // Update/Save the event if (empty($existing)) { $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 { $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 */ protected function get_event_from_invitation(Syncroton_Model_MeetingResponse $request) { // Limitations: // 1. The meeting request may be in an iTip or the calendar event // For now we support iTips only here // 2. 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); if ($event = $mail_class->get_invitation_event($request->requestId)) { return $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); } $this->update_attendee_status($old, $status); 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; // TODO: Free/busy trigger? // Update the event return $this->save_event($old); } /** * 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 (empty($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); } 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; } } }