Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117885658
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
128 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/lib/kolab_sync_data.php b/lib/kolab_sync_data.php
index 4354e0b..b18366e 100644
--- a/lib/kolab_sync_data.php
+++ b/lib/kolab_sync_data.php
@@ -1,1563 +1,1563 @@
<?php
/*
+--------------------------------------------------------------------------+
| Kolab Sync (ActiveSync for Kolab) |
| |
| Copyright (C) 2011-2012, Kolab Systems AG <contact@kolabsys.com> |
| |
| 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 <http://www.gnu.org/licenses/> |
+--------------------------------------------------------------------------+
| Author: Aleksander Machniak <machniak@kolabsys.com> |
+--------------------------------------------------------------------------+
*/
/**
* Base class for Syncroton data backends
*/
abstract class kolab_sync_data implements Syncroton_Data_IData
{
/**
* ActiveSync protocol version
*
* @var float
*/
protected $asversion = 0;
/**
* The storage backend
*
* @var kolab_sync_storage
*/
protected $backend;
/**
* 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;
/**
* default root folder
*
* @var string
*/
protected $defaultRootFolder;
/**
* type of user created folders
*
* @var int
*/
protected $folderType;
/**
* Internal cache for storage folders list
*
* @var array
*/
protected $folders = [];
/**
* 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 = [
'iphone',
'ipad',
'thundertine',
'windowsphone',
'wp',
'wp8',
'playbook',
];
protected $lastsync_folder = null;
protected $lastsync_time = null;
public const RESULT_OBJECT = 0;
public const RESULT_UID = 1;
public const RESULT_COUNT = 2;
/**
* Recurrence types
*/
public const RECUR_TYPE_DAILY = 0; // Recurs daily.
public const RECUR_TYPE_WEEKLY = 1; // Recurs weekly
public const RECUR_TYPE_MONTHLY = 2; // Recurs monthly
public const RECUR_TYPE_MONTHLY_DAYN = 3; // Recurs monthly on the nth day
public const RECUR_TYPE_YEARLY = 5; // Recurs yearly
public const RECUR_TYPE_YEARLY_DAYN = 6; // Recurs yearly on the nth day
/**
* Day of week constants
*/
public const RECUR_DOW_SUNDAY = 1;
public const RECUR_DOW_MONDAY = 2;
public const RECUR_DOW_TUESDAY = 4;
public const RECUR_DOW_WEDNESDAY = 8;
public const RECUR_DOW_THURSDAY = 16;
public const RECUR_DOW_FRIDAY = 32;
public const RECUR_DOW_SATURDAY = 64;
public 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 = [
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 = [
'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::storage();
$this->device = $device;
$this->asversion = floatval($device->acsversion);
$this->syncTimeStamp = $this->backend->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 = [];
// device supports multiple folders ?
if ($this->isMultiFolder()) {
// get the folders the user has access to
$list = $this->listFolders();
} elseif ($default = $this->getDefaultFolder()) {
$list = [$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)
{
// FIXME/TODO: Can we get mtime of a DAV folder?
// Without this, we have a problem if folder ID does not change on rename
return [];
}
/**
* 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)) {
$default = array_first($folders);
// 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)
{
$result = $this->backend->folder_create($folder->displayName, $folder->type, $this->device->deviceid, $folder->parentId);
if ($result) {
$folder->serverId = $result;
return $folder;
}
// Note: Looks like Outlook 2013 ignores any errors on FolderCreate command
throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::UNKNOWN_ERROR);
}
/**
* Updates a folder
*/
public function updateFolder(Syncroton_Model_IFolder $folder)
{
$result = $this->backend->folder_rename($folder->serverId, $this->device->deviceid, $folder->displayName, $folder->parentId);
if ($result) {
return $folder;
}
// @TODO: throw exception
}
/**
* Deletes a folder
*/
public function deleteFolder($folder)
{
if ($folder instanceof Syncroton_Model_IFolder) {
$folder = $folder->serverId;
}
// @TODO: throw exception
return $this->backend->folder_delete($folder, $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)
{
// ActiveSync spec.: Clients use EmptyFolderContents to empty the Deleted Items folder.
// The client can clear out all items in the Deleted Items folder when the user runs out of storage quota
// (indicated by the return of an MailboxQuotaExceeded (113) status code from the server.
// FIXME: Does that mean we don't need this to work on any other folder?
// TODO: Respond with MailboxQuotaExceeded status. Where exactly?
foreach ($this->extractFolders($folderid) as $folderid) {
if (!$this->backend->folder_empty($folderid, $this->device->deviceid, !empty($options['deleteSubFolders']))) {
throw new Syncroton_Exception_Status_ItemOperations(Syncroton_Exception_Status_ItemOperations::ITEM_SERVER_ERROR);
}
}
}
/**
* 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)
{
// TODO: Optimize, we just need to find the folder ID and UID, we do not need to "fetch" it.
$item = $this->getObject($srcFolderId, $serverId);
if (!$item) {
throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE);
}
$uid = $this->backend->moveItem($item['folderId'], $this->device->deviceid, $this->modelName, $item['uid'], $dstFolderId);
return $this->serverId($uid, $dstFolderId);
}
/**
* 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);
if ($folderId == $this->defaultRootFolder) {
$default = $this->getDefaultFolder();
if (!is_array($default)) {
throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR);
}
$folderId = $default['realid'] ?? $default['serverId'];
}
$uid = $this->backend->createItem($folderId, $this->device->deviceid, $this->modelName, $entry);
if (empty($uid)) {
throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR);
}
return $this->serverId($uid, $folderId);
}
/**
* update existing entry
*
* @param string $folderId
* @param string $serverId
* @param Syncroton_Model_IEntry $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('entry not found');
}
$entry = $this->toKolab($entry, $folderId, $oldEntry);
$uid = $this->backend->updateItem($oldEntry['folderId'], $this->device->deviceid, $this->modelName, $oldEntry['uid'], $entry);
if (empty($uid)) {
throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR);
}
return $this->serverId($uid, $oldEntry['folderId']);
}
/**
* Delete entry
*
* @param string $folderId
* @param string $serverId
* @param ?Syncroton_Model_SyncCollection $collectionData
*/
public function deleteEntry($folderId, $serverId, $collectionData = null)
{
// TODO: Optimize, we just need to find the folder ID and UID, we do not need to "fetch" it.
$object = $this->getObject($folderId, $serverId);
if ($object) {
$deleted = $this->backend->deleteItem($object['folderId'], $this->device->deviceid, $this->modelName, $object['uid']);
if (!$deleted) {
throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR);
}
}
}
/**
* Get attachment data from the server.
*
* @param string $fileReference
*
* @return Syncroton_Model_FileReference
*/
public function getFileReference($fileReference)
{
// to be implemented by Email data class
throw new Syncroton_Exception_NotFound('File references not supported');
}
/**
* 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 = [], $result_type = self::RESULT_UID, $extraData = null)
{
$result = $result_type == self::RESULT_COUNT ? 0 : [];
$ts = time();
$force = $this->lastsync_folder != $folderid || $this->lastsync_time <= $ts - Syncroton_Registry::getPingTimeout();
$found = false;
foreach ($this->extractFolders($folderid) as $fid) {
$search = $this->backend->searchEntries($fid, $this->device->deviceid, $this->device->id, $this->modelName, $filter, $result_type, $force, $extraData);
$found = true;
switch ($result_type) {
case self::RESULT_COUNT:
$result += $search;
break;
case self::RESULT_UID:
foreach ($search as $idx => $uid) {
$search[$idx] = $this->serverId($uid, $fid);
}
$result = array_unique(array_merge($result, $search));
break;
}
}
if (!$found) {
throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR);
}
$this->lastsync_folder = $folderid;
$this->lastsync_time = $ts;
return $result;
}
/**
* Returns filter query array according to specified ActiveSync FilterType
*
* @param int $filter_type Filter type
*
* @return array Filter query
*/
protected function filter($filter_type = 0)
{
// overwrite by child class according to specified type
return [];
}
/**
* get all entries changed between two dates
*
* @param string $folderId
* @param Syncroton_Model_ISyncState $syncState
* @param int $filter_type
*
* @return array
*/
public function getChangedEntries($folderId, Syncroton_Model_ISyncState $syncState, $filter_type = null)
{
$start = $syncState->lastsync;
$filter = $this->filter($filter_type);
$filter[] = ['changed', '>', $start];
return $this->searchEntries($folderId, $filter, self::RESULT_UID, $syncState->extraData);
}
/**
* Get count of entries changed between two dates
*
* @param string $folderId
* @param Syncroton_Model_ISyncState $syncState
* @param int $filter_type
*
* @return int
*/
private function getChangedEntriesCount($folderId, Syncroton_Model_ISyncState $syncState, $filter_type = null)
{
$start = $syncState->lastsync;
$filter = $this->filter($filter_type);
$filter[] = ['changed', '>', $start];
return $this->searchEntries($folderId, $filter, self::RESULT_COUNT, $syncState->extraData);
}
public function getExtraData(Syncroton_Model_IFolder $folder)
{
return $this->backend->getExtraData($folder->serverId, $this->device->deviceid);
}
/**
* get id's of all entries available on the server
*
* @param string $folder_id
* @param string $filter_type
*
* @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 $folder_id
* @param string $filter_type
*
* @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, $syncState->counter);
$allServerEntries = $this->getServerEntries($folder->serverId, $folder->lastfiltertype);
$changedEntries = $this->getChangedEntriesCount($folder->serverId, $syncState, $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, $folder->lastfiltertype)) {
return true;
}
$allClientEntries = $contentBackend->getFolderState($this->device, $folder, $syncState->counter);
// @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)
{
foreach ($this->extractFolders($folderid) as $fid) {
$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) {
$objects = $this->backend->getItemsByUidPrefix($fid, $this->device->deviceid, $this->modelName, $uid);
foreach ($objects as $object) {
if (($object['uid'] === $uid || strpos($object['uid'], $uid) === 0)
&& $crc == $this->objectCRC($object['uid'], $fid)
) {
$object['folderId'] = $fid;
return $object;
}
}
continue;
}
}
// Or (faster) strict UID matching...
$object = $this->backend->getItem($fid, $this->device->deviceid, $this->modelName, $uid);
- if (!empty($object) && ($crc === null || $crc == $this->objectCRC($object['uid'], $fid))) {
+ if (!empty($object) && ($crc === null || $crc == $this->objectCRC($uid, $fid))) {
$object['folderId'] = $fid;
return $object;
}
}
}
/**
* 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)) {
throw new Syncroton_Exception_NotFound('Folder not found');
}
$folders = array_keys($folders);
} else {
$folders = [$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->folders)) {
$this->folders = $this->backend->folders_list(
$this->device->deviceid,
$this->modelName,
$this->isMultiFolder()
);
}
if ($parentid === null || !is_array($this->folders)) {
return $this->folders;
}
$folders = [];
$parents = [$parentid];
foreach ($this->folders as $folder_id => $folder) {
if ($folder['parentId'] && in_array($folder['parentId'], $parents)) {
$folders[$folder_id] = $folder;
$parents[] = $folder_id;
}
}
return $folders;
}
/**
* Returns ActiveSync settings of specified folder
*
* @param string $folderid Folder identifier
*
* @return array Folder settings
*/
protected function getFolderConfig($folderid)
{
if ($folderid == $this->defaultRootFolder) {
$default = $this->getDefaultFolder();
if (!is_array($default)) {
return [];
}
$folderid = $default['realid'] ?? $default['serverId'];
}
return $this->backend->getFolderConfig($folderid, $this->device->deviceid, $this->modelName);
}
/**
* Convert contact from xml to kolab format
*
* @param mixed $data Contact data
* @param string $folderId Folder identifier
* @param array $entry Old Contact data for merge
*
* @return array
*/
abstract public function toKolab($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;
if (!empty($data['x-custom']) && is_array($data['x-custom'])) {
foreach ($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] = [];
}
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] = ['type' => $type];
}
$data[$name][$found][$key_name] = $value;
return;
}
// custom properties
if ($count == 2 && $name_items[0] == 'x-custom') {
$data['x-custom'] = isset($data['x-custom']) ? ((array) $data['x-custom']) : [];
foreach ($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'][] = [$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 $params Body parameters
*
* @reurn Syncroton_Model_EmailBody Body element
*/
protected function setBody($value, $params = [])
{
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|null Body value
*/
protected function getBody($body, $type = null)
{
$data = null;
if ($body && $body->data) {
$data = $body->data;
}
if (!$data || empty($type)) {
return null;
}
$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 = [];
// HTML? check for opening and closing <html> or <body> tags
$is_html = preg_match('/<(html|body)(\s+[a-z]|>)/', $body, $m) && strpos($body, '</' . $m[1] . '>') > 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 (isset($prefs[$type]['truncationSize']) && ($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|null Datetime object
*/
protected static function date_from_kolab($date)
{
if (!empty($date)) {
if (is_numeric($date)) {
$date = new DateTime('@' . $date);
} elseif (is_string($date)) {
$date = new DateTime($date, new DateTimeZone('UTC'));
} elseif ($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 (!empty($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;
}
return null;
}
/**
* 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']) || empty($data['recurrence']['FREQ'])) {
return;
}
$recurrence = [];
$r = $data['recurrence'];
// required fields
switch($r['FREQ']) {
case 'DAILY':
$recurrence['type'] = self::RECUR_TYPE_DAILY;
break;
case 'WEEKLY':
$day = $r['BYDAY'] ?? 0;
if (!$day && (!empty($data['_start']) || !empty($data['start']))) {
$days = ['', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA','SU'];
$start = $data['_start'] ?? $data['start'];
$day = $days[$start->format('N')];
}
$recurrence['type'] = self::RECUR_TYPE_WEEKLY;
$recurrence['dayOfWeek'] = $this->day2bitmask($day);
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, ] = explode(',', $r['BYMONTHDAY']);
$recurrence['type'] = self::RECUR_TYPE_MONTHLY;
$recurrence['dayOfMonth'] = $month_day;
} elseif (!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_MONTHLY_DAYN;
$recurrence['weekOfMonth'] = $week;
$recurrence['dayOfWeek'] = $this->day2bitmask($day);
} else {
return;
}
break;
case 'YEARLY':
// @TODO: ActiveSync doesn't support multi-valued months,
// should we replicate the recurrence element for each month?
[$month, ] = 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;
} elseif (!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, ] = 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;
}
// 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']);
} elseif (!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)
&& !($data->recurrence instanceof Syncroton_Model_TaskRecurrence)
) {
return;
}
if (!isset($data->recurrence->type)) {
return;
}
$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'] = $recurrence->interval ?? 1;
if (isset($recurrence->until)) {
if ($timezone) {
$recurrence->until->setTimezone($timezone);
}
$rrule['UNTIL'] = $recurrence->until;
} elseif (!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 = [];
// exceptions (modified occurences)
if (!empty($data['recurrence']['EXCEPTIONS'])) {
foreach ((array)$data['recurrence']['EXCEPTIONS'] as $exception) {
$exception['_mailbox'] = $data['_mailbox'];
$ex = $this->getEntry($collection, $exception, true); // @phpstan-ignore-line
$date = clone ($exception['recurrence_date'] ?: $ex['startTime']);
$ex['exceptionStartTime'] = self::set_exception_time($date, $data['_start'] ?? null);
// 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)
if (!empty($data['recurrence']['EXDATE'])) {
foreach ((array)$data['recurrence']['EXDATE'] as $exception) {
if (!($exception instanceof DateTime)) {
continue;
}
$ex = [
'deleted' => 1,
'exceptionStartTime' => self::set_exception_time($exception, $data['_start'] ?? null),
];
$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'] = [];
$rrule['EXCEPTIONS'] = [];
// handle exceptions from recurrence
if (!empty($data->exceptions)) {
foreach ($data->exceptions as $exception) {
$date = clone $exception->exceptionStartTime;
if ($timezone) {
$date->setTimezone($timezone);
}
if ($exception->deleted) {
$date->setTime(0, 0, 0);
$rrule['EXDATE'][] = $date;
} else {
$ex = $this->toKolab($exception, $folderid, null, $timezone); // @phpstan-ignore-line
$ex['recurrence_date'] = $date;
if (!empty($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);
}
}
/**
* 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) {
if ($day) {
$result = $result + ($this->recurDayMap[$day] ?? 0);
}
}
return $result;
}
/**
* Convert bitmask used by ActiveSync to string of days (TU,TH)
*
* @param int $days
*
* @return string
*/
protected function bitmask2day($days)
{
$days_arr = [];
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;
}
} elseif (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)
{
// 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 <uid>*
// 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->backend->getFolder($folder, $this->device->deviceid, $this->modelName);
}
$folder_uid = $folder->get_uid();
return strtoupper(hash('crc32b', $folder_uid . $uid)); // always 8 chars
}
}
diff --git a/lib/kolab_sync_data_calendar.php b/lib/kolab_sync_data_calendar.php
index 3a164b1..a2a6fd9 100644
--- a/lib/kolab_sync_data_calendar.php
+++ b/lib/kolab_sync_data_calendar.php
@@ -1,1346 +1,1346 @@
<?php
/*
+--------------------------------------------------------------------------+
| Kolab Sync (ActiveSync for Kolab) |
| |
| Copyright (C) 2011-2012, Kolab Systems AG <contact@kolabsys.com> |
| |
| 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 <http://www.gnu.org/licenses/> |
+--------------------------------------------------------------------------+
| Author: Aleksander Machniak <machniak@kolabsys.com> |
+--------------------------------------------------------------------------+
*/
/**
* 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 = [
'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
*/
public const ATTENDEE_STATUS_UNKNOWN = 0;
public const ATTENDEE_STATUS_TENTATIVE = 2;
public const ATTENDEE_STATUS_ACCEPTED = 3;
public const ATTENDEE_STATUS_DECLINED = 4;
public const ATTENDEE_STATUS_NOTRESPONDED = 5;
/**
* attendee types
*/
public const ATTENDEE_TYPE_REQUIRED = 1;
public const ATTENDEE_TYPE_OPTIONAL = 2;
public const ATTENDEE_TYPE_RESOURCE = 3;
/**
* busy status constants
*/
public const BUSY_STATUS_FREE = 0;
public const BUSY_STATUS_TENTATIVE = 1;
public const BUSY_STATUS_BUSY = 2;
public const BUSY_STATUS_OUTOFOFFICE = 3;
/**
* Sensitivity values
*/
public const SENSITIVITY_NORMAL = 0;
public const SENSITIVITY_PERSONAL = 1;
public const SENSITIVITY_PRIVATE = 2;
public const SENSITIVITY_CONFIDENTIAL = 3;
/**
* Internal iTip states
*/
public const ITIP_ACCEPTED = 'ACCEPTED';
public const ITIP_DECLINED = 'DECLINED';
public const ITIP_TENTATIVE = 'TENTATIVE';
public const ITIP_CANCELLED = 'CANCELLED';
public const KEY_DTSTAMP = 'x-custom.X-ACTIVESYNC-DTSTAMP';
public const KEY_REPLYTIME = 'x-custom.X-ACTIVESYNC-REPLYTIME';
/**
* Mapping of attendee status
*
* @var array
*/
protected $attendeeStatusMap = [
'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 = [
'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 = [
'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 = [
'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 bool $as_array Return entry as array
*
* @return array|Syncroton_Model_Event 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['folderId']);
+ $config = !empty($event['folderId']) ? $this->getFolderConfig($event['folderId']) : [];
$result = [];
$is_outlook = stripos($this->device->devicetype, 'outlook') !== false;
$is_android = stripos($this->device->devicetype, 'android') !== false;
// 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
$result['timezone'] = kolab_sync_timezone_converter::encodeTimezoneFromDate($event['start']);
// 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 (!empty($event['allday'])) {
// need this for self::date_from_kolab()
$date->_dateonly = false; // @phpstan-ignore-line
if ($name == 'start') {
$date->setTime(0, 0, 0);
} elseif ($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':
if (!empty($value)) {
$value = intval($this->sensitivityMap[$value]);
}
break;
case 'free_busy':
if (!empty($value)) {
$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 (!empty($config['ALARMS'])) {
$result['reminder'] = $this->from_kolab_alarm($event);
}
$result['categories'] = [];
$result['attendees'] = [];
// 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 (!empty($attendee['name'])) {
$result['organizerName'] = $attendee['name'];
}
if (!empty($attendee['email'])) {
$result['organizerEmail'] = $attendee['email'];
}
unset($event['attendees'][$idx]);
break;
}
}
}
$resp_type = self::ATTENDEE_STATUS_UNKNOWN;
$user_rsvp = false;
// Attendees
if (!empty($event['attendees'])) {
$user_emails = $this->user_emails();
foreach ($event['attendees'] as $idx => $attendee) {
if (empty($attendee['email'])) {
// In Activesync email is required
continue;
}
$email = $attendee['email'];
$att = [
'email' => $email,
'name' => !empty($attendee['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) {
if (isset($attendee['cutype']) && strtolower($attendee['cutype']) == 'resource') {
$att['attendeeType'] = self::ATTENDEE_TYPE_RESOURCE;
} else {
$att['attendeeType'] = $type ?: self::ATTENDEE_TYPE_REQUIRED;
}
$att['attendeeStatus'] = $status ?: self::ATTENDEE_STATUS_UNKNOWN;
}
if (in_array_nocase($email, $user_emails)) {
$user_rsvp = !empty($attendee['rsvp']);
$resp_type = $status ?: self::ATTENDEE_STATUS_UNKNOWN;
// Synchronize the attendee status to the event status to get the same behaviour as outlook.
if (($is_outlook || $is_android) && isset($attendee['status'])) {
if ($attendee['status'] == 'ACCEPTED') {
$result['busyStatus'] = self::BUSY_STATUS_BUSY;
}
if ($attendee['status'] == 'TENTATIVE') {
$result['busyStatus'] = self::BUSY_STATUS_TENTATIVE;
}
}
}
$result['attendees'][] = new Syncroton_Model_EventAttendee($att);
}
}
// Event meeting status
$this->meeting_status_from_kolab($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;
// Appointment Reply Time (without it Outlook displays e.g. "Accepted on None")
if ($resp_type != self::ATTENDEE_STATUS_UNKNOWN) {
if ($reply_time = $this->getKolabDataItem($event, self::KEY_REPLYTIME)) {
$result['appointmentReplyTime'] = new DateTime($reply_time, new DateTimeZone('UTC'));
} elseif (!empty($event['changed'])) {
$reply_time = clone $event['changed'];
$reply_time->setTimezone(new DateTimeZone('UTC'));
$result['appointmentReplyTime'] = $reply_time;
}
}
return $as_array ? $result : new Syncroton_Model_Event($result);
}
/**
* Convert an event from xml to libkolab array
*
* @param Syncroton_Model_Event|Syncroton_Model_EventException $data Event or event exception to convert
* @param string $folderid Folder identifier
* @param array $entry Existing entry
* @param DateTimeZone $timezone Timezone of the event
*
* @return array
*/
public function toKolab($data, $folderid, $entry = null, $timezone = null)
{
if (empty($entry) && !empty($data->uID)) {
// If we don't have an existing event (not a modification) we nevertheless check for conflicts.
// This is necessary so we don't overwrite the server-side copy in case the client did not have it available
// when generating an Add command.
try {
$entry = $this->getObject($folderid, $data->uID);
if ($entry) {
$this->logger->debug('Found and existing event for UID: ' . $data->uID);
}
} catch (Exception $e) {
// uID is not available on exceptions, so we guard for that and silently ignore.
}
}
$config = $this->getFolderConfig($entry ? $entry['folderId'] : $folderid);
$event = !empty($entry) ? $entry : [];
$is_exception = $data instanceof Syncroton_Model_EventException;
$dummy_tz = str_repeat('A', 230) . '==';
$is_outlook = stripos($this->device->devicetype, 'outlook') !== false;
$is_android = stripos($this->device->devicetype, 'android') !== false;
// check data validity (of a new event)
if (empty($event)) {
$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 = !empty($old_timezone) ? $old_timezone : kolab_format::$timezone;
try {
$timezone = $tzc->getTimezone($data->timezone, $expected->getName());
$timezone = new DateTimeZone($timezone);
} catch (Exception $e) {
$this->logger->warn('Failed to convert the timezone information. UID: ' . $event['uid'] . 'Timezone: ' . $data->timezone);
$timezone = null;
}
}
if (empty($timezone)) {
$timezone = !empty($old_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;
// Skip ghosted (unset) properties, (but make sure 'changed' timestamp is reset)
if ($value === null && $name != 'changed') {
continue;
}
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] ?? null;
break;
case 'free_busy':
// Outlook sets the busy state to the attendance state, and we don't want to change the event state based on that.
// Outlook doesn't have the concept of an event state, so we just ignore this.
if ($is_outlook || $is_android) {
continue 2;
}
$map = array_flip($this->busyStatusMap);
$value = $map[$value] ?? null;
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 (empty($event['allday']) && !empty($event['end']) && !empty($event['start'])) {
$interval = @date_diff($event['start'], $event['end']);
if ($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 (!empty($config['ALARMS'])) {
$event['valarms'] = $this->to_kolab_alarm($data->reminder, $event);
}
$attendees = [];
$categories = [];
// Categories
if (isset($data->categories)) {
foreach ($data->categories as $category) {
$categories[] = $category;
}
}
// Organizer
if (!$is_exception) {
// Organizer specified
if ($organizer_email = $data->organizerEmail) {
$attendees[] = [
'role' => 'ORGANIZER',
'name' => $data->organizerName,
'email' => $organizer_email,
];
} elseif (!empty($event['attendees'])) {
// Organizer not specified, use one from the original event if that's an update
foreach ($event['attendees'] as $idx => $attendee) {
if (!empty($attendee['email']) && !empty($attendee['role']) && $attendee['role'] == 'ORGANIZER') {
$organizer_email = $attendee['email'];
$attendees[] = [
'role' => 'ORGANIZER',
'name' => $attendee['name'] ?? '',
'email' => $organizer_email,
];
}
}
}
}
// Attendees
// Whenever Outlook sends dummy timezone it is an event where the user is an attendee.
// In these cases Attendees element is bogus: contains invalid status and does not
// contain all attendees. We have to ignore it.
if ($is_outlook && !$is_exception && $data->timezone === $dummy_tz) {
$this->logger->debug('Dummy outlook update detected, ignoring attendee changes.');
$attendees = $entry['attendees'];
} elseif (isset($data->attendees)) {
foreach ($data->attendees as $attendee) {
if (!empty($organizer_email) && $attendee->email && !strcasecmp($attendee->email, $organizer_email)) {
// skip the organizer
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 = [
'role' => $role,
'name' => $attendee->name != $attendee->email ? $attendee->name : '',
'email' => $attendee->email,
];
if (isset($attendee->attendeeType) && $attendee->attendeeType == self::ATTENDEE_TYPE_RESOURCE) {
$_attendee['cutype'] = 'RESOURCE';
}
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;
}
} elseif (!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;
}
}
// Outlook does not send the correct attendee status when changing between accepted and tentative, but it toggles the busyStatus.
if ($is_outlook || $is_android) {
$status = null;
if ($data->busyStatus == self::BUSY_STATUS_BUSY) {
$status = "ACCEPTED";
} elseif ($data->busyStatus == self::BUSY_STATUS_TENTATIVE) {
$status = "TENTATIVE";
}
if ($status) {
$this->logger->debug("Updating our attendee status based on the busy status to {$status}.");
$emails = $this->user_emails();
$this->find_and_update_attendee_status($attendees, $status, $emails);
}
}
if (!$is_exception) {
// Make sure the event has the organizer set
if (!$organizer_email && ($identity = kolab_sync::get_instance()->user->get_identity())) {
$attendees[] = [
'role' => 'ORGANIZER',
'name' => $identity['name'],
'email' => $identity['email'],
];
}
// recurrence (and exceptions)
$event['recurrence'] = $this->recurrence_to_kolab($data, $folderid, $timezone);
}
$event['attendees'] = $attendees;
$event['categories'] = $categories;
$event['exceptions'] = $event['recurrence']['EXCEPTIONS'] ?? [];
// 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
// @phpstan-ignore-next-line
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 (!empty($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 && !empty($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 = [
1 => 'ACCEPTED',
2 => 'TENTATIVE',
3 => 'DECLINED',
];
$status = $status_map[$request->userResponse] ?? null;
if (empty($status)) {
throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR);
}
// extract event from the invitation
[$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 response timestamp for further use
$reply_time = new DateTime('now', new DateTimeZone('UTC'));
$this->setKolabDataItem($event, self::KEY_REPLYTIME, $reply_time->format('Ymd\THis\Z'));
// Update/Save the event
if (empty($existing)) {
$folderId = $this->save_event($event, $status);
// Create SyncState for the new event, so it is not synced twice
if ($folderId) {
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([
'device_id' => $this->device->id,
'folder_id' => $syncFolder->id,
'contentid' => $this->serverId($event['uid'], $folderId),
'creation_time' => $syncState->lastsync,
'creation_synckey' => $syncState->counter,
]));
} catch (Exception $e) {
// ignore
}
}
} else {
$folderId = $this->update_event($event, $existing, $status, $request->instanceId);
}
if (!$folderId) {
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 $this->serverId($event['uid'], $folderId);
}
/**
* Process an event from an iTip message - update the event in the recipient's calendar
*
* @param array $event Event data from the iTip
*
* @return string|null Attendee status from the iTip (self::ITIP_* constant value)
*/
public function processItipReply($event)
{
// FIXME: This does not prevent from spoofing, i.e. an iTip message
// could be sent by anyone impersonating an organizer or attendee
// FIXME: This will not work with Kolab delegation, as we do look
// for the event instance in personal folders only (for now)
// We also do not use SENT-BY,DELEGATED-TO,DELEGATED-FROM here at all.
// FIXME: This is potential performance problem - we update an event
// whenever we sync an email message. User can have multiple AC clients
// or many iTip messages in INBOX. Should we remember which email was
// already processed?
// FIXME: Should we check SEQUENCE or something else to prevent
// overwriting the attendee status with outdated status (on REPLY)?
// Here we're handling CANCEL message, find the event (or occurrence) and remove it
if ($event['_method'] == 'CANCEL') {
// TODO: Performance: When we're going to delete the event we don't have to fetch it,
// we just need to find that it exists and in which folder.
if ($existing = $this->find_event_by_uid($event['uid'])) {
// Note: Normally we'd just set the event status to canceled, but
// ActiveSync clients do not understand that, we have to delete it
if (!empty($event['recurrence_date'])) {
// A single recurring event occurrence
$rec_day = $event['recurrence_date']->format('Ymd');
// Remove the matching RDATE entry
if (!empty($existing['recurrence']['RDATE'])) {
foreach ($existing['recurrence']['RDATE'] as $j => $rdate) {
if ($rdate->format('Ymd') == $rec_day) {
unset($existing['recurrence']['RDATE'][$j]);
break;
}
}
}
// Check EXDATE list, maybe already cancelled
if (!empty($existing['recurrence']['EXDATE'])) {
foreach ($existing['recurrence']['EXDATE'] as $j => $exdate) {
if ($exdate->format('Ymd') == $rec_day) {
return self::ITIP_CANCELLED; // skip update
}
}
} else {
$existing['recurrence']['EXDATE'] = [];
}
if (!isset($existing['exceptions'])) {
$existing['exceptions'] = [];
}
if (!empty($existing['exceptions'])) {
foreach ($existing['exceptions'] as $i => $exception) {
if (libcalendaring::is_recurrence_exception($event, $exception)) {
unset($existing['exceptions'][$i]);
}
}
}
// Add an exception to the master event
$existing['recurrence']['EXDATE'][] = $event['recurrence_date'];
// TODO: Handle errors
$this->save_event($existing, null);
} else {
$folder = $this->backend->getFolder($existing['folderId'], $this->device->deviceid, $this->modelName);
if ($folder && $folder->valid) {
// TODO: Handle errors
$folder->delete($event['uid']);
}
}
}
return self::ITIP_CANCELLED;
}
// Here we're handling REPLY message
if (empty($event['attendees']) || $event['_method'] != 'REPLY') {
return null;
}
$attendeeStatus = null;
$attendeeEmail = null;
// Get the attendee/status
foreach ($event['attendees'] as $attendee) {
if (empty($attendee['role']) || $attendee['role'] != 'ORGANIZER') {
if (!empty($attendee['email']) && !empty($attendee['status'])) {
// Per iTip spec. there should be only one (non-organizer) attendee here
// FIXME: Verify is it realy the case with e.g. Kolab webmail, If not, we should
// probably use the message sender from the From: header
$attendeeStatus = strtoupper($attendee['status']);
$attendeeEmail = $attendee['email'];
break;
}
}
}
// Find the event (or occurrence) and update it
if ($attendeeStatus && ($existing = $this->find_event_by_uid($event['uid']))) {
// TODO: We should probably check the SEQUENCE to not reset status to an outdated value
if (!empty($event['recurrence_date'])) {
// A single recurring event occurrence
// Find the exception entry, it should exist, if not ignore
if (!empty($existing['exceptions'])) {
foreach ($existing['exceptions'] as $i => $exception) {
if (!empty($exception['attendees']) && libcalendaring::is_recurrence_exception($event, $exception)) {
$attendees = &$existing['exceptions'][$i]['attendees'];
break;
}
}
}
} elseif (!empty($existing['attendees'])) {
$attendees = &$existing['attendees'];
}
if (isset($attendees)) {
$found = $this->find_and_update_attendee_status($attendees, $attendeeStatus, [$attendeeEmail], $changed);
if ($found && $changed) {
// TODO: error handling
$this->save_event($existing, null);
}
}
}
return $attendeeStatus;
}
/**
* 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 [$event, $existing];
}
// Event from calendar folder
if ($event = $this->getObject($request->collectionId, $request->requestId)) {
return [$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?
if ($folders = $this->listFolders()) {
foreach ($folders as $_folder) {
$folder = $this->backend->getFolder($_folder['serverId'], $this->device->deviceid, $this->modelName);
if ($folder
&& $folder->get_namespace() == 'personal'
&& ($result = $this->backend->getItem($_folder['serverId'], $this->device->deviceid, $this->modelName, $uid))
) {
$result['folderId'] = $_folder['serverId'];
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);
}
// A single recurring event occurrence
if (!empty($event['recurrence_date'])) {
$event['recurrence'] = [];
if ($status) {
$this->update_attendee_status($event, $status);
$status = null;
}
if (!isset($old['exceptions'])) {
$old['exceptions'] = [];
}
$existing = false;
foreach ($old['exceptions'] as $i => $exception) {
if (libcalendaring::is_recurrence_exception($event, $exception)) {
$old['exceptions'][$i] = $event;
$existing = true;
}
}
// TODO: In case organizer first cancelled an occurrence and then invited
// an attendee to the same date, and attendee accepts, we should remove EXDATE entry.
// FIXME: We have to check with ActiveSync clients whether it is better
// to have an exception with DECLINED attendee status, or an EXDATE entry
if (!$existing) {
$old['exceptions'][] = $event;
}
}
// A main event update
elseif (isset($event['sequence']) && $event['sequence'] > $old['sequence']) {
// FIXME: Can we be smarter here? Should we update everything? What about e.g. new attendees?
// And do we need to check the sequence?
$props = ['start', 'end', 'title', 'description', 'location', 'free_busy'];
foreach ($props as $prop) {
if (isset($event[$prop])) {
$old[$prop] = $event[$prop];
}
}
// Copy new custom properties
if (!empty($event['x-custom'])) {
foreach ($event['x-custom'] as $key => $val) {
$old['x-custom'][$key] = $val;
}
}
}
// Updating an existing event is most-likely a response
// to an iTip request with bumped SEQUENCE
$old['sequence'] = ($old['sequence'] ?? 0) + 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)
{
$first = null;
$default = null;
if (!isset($event['folderId'])) {
// Find the folder to which we'll save the event
if ($folders = $this->listFolders()) {
foreach ($folders as $_folder) {
$folder = $this->backend->getFolder($_folder['serverId'], $this->device->deviceid, $this->modelName);
if ($folder && $folder->get_namespace() == 'personal') {
if ($_folder['type'] == 8) {
$default = $_folder['serverId'];
break;
}
if (!$first) {
$first = $_folder['serverId'];
}
}
}
}
// 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?
$old_uid = isset($event['folderId']) ? $event['uid'] : null;
$folder_id = $event['folderId'] ?? ($default ?? $first);
$folder = $this->backend->getFolder($folder_id, $this->device->deviceid, $this->modelName);
if (!empty($folder) && $folder->valid && $folder->save($event, $this->modelName, $old_uid)) {
return $folder_id;
}
return false;
}
/**
* Update the attendee status of the user matching $emails
*/
protected function find_and_update_attendee_status(&$attendees, $status, $emails, &$changed = false)
{
$found = false;
foreach ((array) $attendees as $i => $attendee) {
if (!empty($attendee['email'])
&& (empty($attendee['role']) || $attendee['role'] != 'ORGANIZER')
&& in_array_nocase($attendee['email'], $emails)
) {
$changed = $changed || ($status != ($attendee['status'] ?? ''));
$attendees[$i]['status'] = $status;
$attendees[$i]['rsvp'] = false;
$this->logger->debug('Updating existing attendee: ' . $attendee['email'] . ' status: ' . $status);
$found = true;
}
}
return $found;
}
/**
* Update the attendee status of the user
*/
protected function update_attendee_status(&$event, $status)
{
$emails = $this->user_emails();
if (!$this->find_and_update_attendee_status($event['attendees'], $status, $emails)) {
$this->logger->debug('Adding new attendee ' . $emails[0] . ' status: ' . $status);
// Add the user to the attendees list
$event['attendees'][] = [
'role' => 'OPT-PARTICIPANT',
'name' => '',
'email' => $emails[0],
'status' => $status,
'rsvp' => false,
];
}
}
/**
* Returns filter query array according to specified ActiveSync FilterType
*
* @param int $filter_type Filter type
*
* @return array Filter query
*/
protected function filter($filter_type = 0)
{
$filter = [['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[] = ['dtend', '>', $dt];
}
return $filter;
}
/**
* Set MeetingStatus according to event data
*/
protected function meeting_status_from_kolab($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'], ['DISPLAY', 'AUDIO'])) {
$value = $alarm['trigger'];
break;
}
}
}
if (!empty($value) && $value instanceof DateTime) {
if (!empty($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);
}
}
} elseif (!empty($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 isset($event['valarms']) ? (array) $event['valarms'] : [];
}
$valarms = [];
$unsupported = [];
if (!empty($event['valarms'])) {
foreach ($event['valarms'] as $alarm) {
if (empty($current) && in_array($alarm['action'], ['DISPLAY', 'AUDIO'])) {
$current = $alarm;
} else {
$unsupported[] = $alarm;
}
}
}
$valarms[] = [
'action' => !empty($current['action']) ? $current['action'] : 'DISPLAY',
'description' => !empty($current['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_Event|Syncroton_Model_EventException &$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;
} elseif (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;
} elseif (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 (['allday', 'start', 'end', 'location', 'recurrence'] as $key) {
if (($event[$key] ?? null) != ($old[$key] ?? null)) {
// 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'])) {
+ if (($rec['FREQ'] ?? '') == 'WEEKLY' && empty($rec['BYDAY'])) {
$days = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
$day = $event['start']->format('w');
$rec['BYDAY'] = $days[$day];
}
- if (!$rec['INTERVAL']) {
+ if (empty($rec['INTERVAL'])) {
$rec['INTERVAL'] = 1;
}
ksort($rec);
return $rec;
}
}
diff --git a/lib/kolab_sync_timezone_converter.php b/lib/kolab_sync_timezone_converter.php
index f2743f3..01c2a11 100644
--- a/lib/kolab_sync_timezone_converter.php
+++ b/lib/kolab_sync_timezone_converter.php
@@ -1,656 +1,656 @@
<?php
/*
+--------------------------------------------------------------------------+
| Kolab Sync (ActiveSync for Kolab) |
| |
| Copyright (C) 2011-2017, Kolab Systems AG <contact@kolabsys.com> |
| Copyright (C) 2008-2012, Metaways Infosystems GmbH |
| |
| 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 <http://www.gnu.org/licenses/> |
+--------------------------------------------------------------------------+
| Author: Aleksander Machniak <machniak@kolabsys.com> |
| Author: Jonas Fischer <j.fischer@metaways.de> |
+--------------------------------------------------------------------------+
*/
/**
* Activesync timezone converter
*/
class kolab_sync_timezone_converter
{
/**
* holds the instance of the singleton
*
* @var ?kolab_sync_timezone_converter
*/
private static $_instance;
protected $_startDate = [];
/**
* If set then the timezone guessing results will be cached.
* This is strongly recommended for performance reasons.
*
* @var rcube_cache
*/
protected $cache = null;
/**
* array of offsets known by ActiceSync clients, but unknown by php
* @var array
*/
protected $_knownTimezones = [
'0AIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==' => [
'Pacific/Kwajalein' => 'MHT',
],
];
protected $_legacyTimezones = [
// This is an outdated timezone that outlook keeps sending because of an outdate timezone database on windows
'Lv///0kAcgBhAG4AIABTAHQAYQBuAGQAYQByAGQAIABUAGkAbQBlAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkABAADABcAOwA7AOcDAAAAAEkAcgBhAG4AIABEAGEAeQBsAGkAZwBoAHQAIABUAGkAbQBlAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAwAEAAAAAAAAAAAAxP///w==' => [
'Asia/Tehran' => '+0330',
],
];
/**
* don't use the constructor. Use the singleton.
*
* @param $_logger
*/
private function __construct()
{
}
/**
* don't clone. Use the singleton.
*/
private function __clone()
{
}
/**
* the singleton pattern
*
* @return kolab_sync_timezone_converter
*/
public static function getInstance()
{
if (self::$_instance === null) {
self::$_instance = new kolab_sync_timezone_converter();
}
return self::$_instance;
}
/**
* Returns a timezone with an offset matching the time difference
* of $dt from $referenceDt.
*
* If set and matching the offset, kolab_format::$timezone is preferred.
*
* @param DateTime $dt The date time value for which we
* calculate the offset.
* @param DateTime $referenceDt The reference value, for instance in UTC.
*
* @return DateTimeZone|null
*/
public function getOffsetTimezone($dt, $referenceDt)
{
$interval = $referenceDt->diff($dt);
$tz = new DateTimeZone($interval->format('%R%H%I')); //e.g. +0200
$utcOffset = $tz->getOffset($dt);
//Prefer the configured timezone if it matches the offset.
if (kolab_format::$timezone) {
if (kolab_format::$timezone->getOffset($dt) == $utcOffset) {
return kolab_format::$timezone;
}
}
//Look for any timezone with a matching offset.
foreach (DateTimeZone::listIdentifiers() as $timezoneIdentifier) {
$timezone = new DateTimeZone($timezoneIdentifier);
if ($timezone->getOffset($dt) == $utcOffset) {
return $timezone;
}
}
return null;
}
/**
* Returns a list of timezones that match to the {@param $_offsets}
*
* If {@see $_expectedTimezone} is set then the method will terminate as soon
* as the expected timezone has matched and the expected timezone will be the
* first entry to the returned array.
*
* @param string|array $_offsets
*
* @return array
*/
public function getListOfTimezones($_offsets)
{
if (is_string($_offsets) && isset($this->_knownTimezones[$_offsets])) {
$timezones = $this->_knownTimezones[$_offsets];
} elseif (is_string($_offsets) && isset($this->_legacyTimezones[$_offsets])) {
$timezones = $this->_legacyTimezones[$_offsets];
} else {
if (is_string($_offsets)) {
// unpack timezone info to array
$_offsets = $this->_unpackTimezoneInfo($_offsets);
}
if (!$this->_validateOffsets($_offsets)) {
return [];
}
$this->_setDefaultStartDateIfEmpty($_offsets);
$timezones = [];
foreach (DateTimeZone::listIdentifiers() as $timezoneIdentifier) {
$timezone = new DateTimeZone($timezoneIdentifier);
if (false !== ($matchingTransition = $this->_checkTimezone($timezone, $_offsets))) {
$timezones[$timezoneIdentifier] = $matchingTransition['abbr'];
}
}
}
return $timezones;
}
/**
* Returns PHP timezone that matches to the {@param $_offsets}
*
* If {@see $_expectedTimezone} is set then the method will return this timezone if it matches.
*
* @param string|array $_offsets Activesync timezone definition
* @param string $_expectedTimezone Expected timezone name
*
* @return string Expected timezone name
*/
public function getTimezone($_offsets, $_expectedTimezone = null)
{
$timezones = $this->getListOfTimezones($_offsets);
if ($_expectedTimezone && isset($timezones[$_expectedTimezone])) {
return $_expectedTimezone;
} else {
return key($timezones);
}
}
/**
* Return packed string for given {@param $_timezone}
*
* @param string $_timezone Timezone identifier
* @param string|int $_startDate Start date
*
* @return string Packed timezone offsets
*/
public function encodeTimezone($_timezone, $_startDate = null)
{
foreach ($this->_knownTimezones as $packedString => $knownTimezone) {
if (array_key_exists($_timezone, $knownTimezone)) {
return $packedString;
}
}
$offsets = $this->getOffsetsForTimezone($_timezone, $_startDate);
return $this->_packTimezoneInfo($offsets);
}
/**
* Returns an encoded timezone representation from $date
*
* @param DateTime $date The date with the timezone to encode
*
* @return string|null Timezone name
*/
public static function encodeTimezoneFromDate($date)
{
if ($date instanceof DateTime) {
$timezone = $date->getTimezone();
if (($tz_name = $timezone->getName()) != 'UTC') {
$tzc = self::getInstance();
if ($tz_name = $tzc->encodeTimezone($tz_name, $date->format('Y-m-d'))) {
return $tz_name;
}
}
}
return null;
}
/**
* Get offsets for given timezone
*
* @param string $_timezone Timezone identifier
* @param string|int $_startDate Start date
*
* @return array|null Timezone offsets
*/
public function getOffsetsForTimezone($_timezone, $_startDate = null)
{
$this->_setStartDate($_startDate);
$offsets = $this->_getOffsetsTemplate();
try {
$timezone = new DateTimeZone($_timezone);
} catch (Exception $e) {
return null;
}
[$standardTransition, $daylightTransition] = $this->_getTransitionsForTimezoneAndYear($timezone, $this->_startDate['year']);
if ($standardTransition) {
$offsets['bias'] = $standardTransition['offset'] / 60 * -1;
if ($daylightTransition) {
$offsets = $this->_generateOffsetsForTransition($offsets, $standardTransition, 'standard', $timezone);
$offsets = $this->_generateOffsetsForTransition($offsets, $daylightTransition, 'daylight', $timezone);
//@todo how do we get the standardBias (is usually 0)?
//$offsets['standardBias'] = ...
$offsets['daylightBias'] = ($daylightTransition['offset'] - $standardTransition['offset']) / 60 * -1;
$offsets['standardHour'] -= $offsets['daylightBias'] / 60;
$offsets['daylightHour'] += $offsets['daylightBias'] / 60;
}
}
return $offsets;
}
/**
* Get offsets for timezone transition
*
* @param array $_offsets Timezone offsets
* @param array $_transition Timezone transition information
* @param string $_type Transition type: 'standard' or 'daylight'
* @param DateTimeZone $_timezone Timezone of the transition
*
* @return array
*/
protected function _generateOffsetsForTransition(array $_offsets, array $_transition, $_type, $_timezone)
{
$transitionDate = new DateTime($_transition['time'], $_timezone);
if ($_transition['offset']) {
$transitionDate->modify($_transition['offset'] . ' seconds');
}
$_offsets[$_type . 'Month'] = (int) $transitionDate->format('n');
$_offsets[$_type . 'DayOfWeek'] = (int) $transitionDate->format('w');
$_offsets[$_type . 'Minute'] = (int) $transitionDate->format('i');
$_offsets[$_type . 'Hour'] = (int) $transitionDate->format('G');
for ($i = 5; $i > 0; $i--) {
if ($this->_isNthOcurrenceOfWeekdayInMonth($transitionDate, $i)) {
$_offsets[$_type . 'Week'] = $i;
break;
};
}
return $_offsets;
}
/**
* Test if the weekday of the given {@param $_timestamp} is the {@param $_occurence}th occurence of this weekday within its month.
*
* @param DateTime $_datetime
* @param int $_occurence [1 to 5, where 5 indicates the final occurrence during the month if that day of the week does not occur 5 times]
*
* @return bool
*/
protected function _isNthOcurrenceOfWeekdayInMonth($_datetime, $_occurence)
{
if ($_occurence <= 1) {
return true;
}
$orig = $_datetime->format('n');
if ($_occurence == 5) {
$modified = clone($_datetime);
$modified->modify('1 week');
$mod = $modified->format('n');
// modified date is a next month
return $mod > $orig || ($mod == 1 && $orig == 12);
}
$modified = clone($_datetime);
$modified->modify(sprintf('-%d weeks', $_occurence - 1));
$mod = $modified->format('n');
if ($mod != $orig) {
return false;
}
$modified = clone($_datetime);
$modified->modify(sprintf('-%d weeks', $_occurence));
$mod = $modified->format('n');
// modified month is earlier than original
return $mod < $orig || ($mod == 12 && $orig == 1);
}
/**
* Check if the given {@param $_standardTransition} and {@param $_daylightTransition}
* match to the object property {@see $_offsets}
*
* @param array $_standardTransition
* @param array $_daylightTransition
* @param array $_offsets
* @param DateTimeZone $tz
*
* @return bool
*/
protected function _checkTransition($_standardTransition, $_daylightTransition, $_offsets, $tz)
{
if (empty($_standardTransition) || empty($_offsets)) {
return false;
}
$standardOffset = ($_offsets['bias'] + $_offsets['standardBias']) * 60 * -1;
// check each condition in a single if statement and break the chain when one condition is not met - for performance reasons
if ($standardOffset == $_standardTransition['offset']) {
if (empty($_offsets['daylightMonth']) && (empty($_daylightTransition) || empty($_daylightTransition['isdst']))) {
// No DST
return true;
}
$daylightOffset = ($_offsets['bias'] + $_offsets['daylightBias']) * 60 * -1;
// the milestone is sending a positive value for daylightBias while it should send a negative value
$daylightOffsetMilestone = ($_offsets['bias'] + ($_offsets['daylightBias'] * -1)) * 60 * -1;
if (
!empty($_daylightTransition)
&& ($daylightOffset == $_daylightTransition['offset'] || $daylightOffsetMilestone == $_daylightTransition['offset'])
) {
// date-time input here contains UTC timezone specifier (+0000),
// we have to convert the date to the requested timezone afterwards.
$standardDate = new DateTime($_standardTransition['time']);
$daylightDate = new DateTime($_daylightTransition['time']);
$standardDate->setTimezone($tz);
$daylightDate->setTimezone($tz);
if ($standardDate->format('n') == $_offsets['standardMonth'] &&
$daylightDate->format('n') == $_offsets['daylightMonth'] &&
$standardDate->format('w') == $_offsets['standardDayOfWeek'] &&
$daylightDate->format('w') == $_offsets['daylightDayOfWeek']
) {
return $this->_isNthOcurrenceOfWeekdayInMonth($daylightDate, $_offsets['daylightWeek']) &&
$this->_isNthOcurrenceOfWeekdayInMonth($standardDate, $_offsets['standardWeek']);
}
}
}
return false;
}
/**
* decode timezone info from activesync
*
* @param string $_packedTimezoneInfo the packed timezone info
* @return array
*/
protected function _unpackTimezoneInfo($_packedTimezoneInfo)
{
$timezoneUnpackString = 'lbias/a64standardName/vstandardYear/vstandardMonth/vstandardDayOfWeek/vstandardWeek/vstandardHour/vstandardMinute/vstandardSecond/vstandardMilliseconds/lstandardBias'
. '/a64daylightName/vdaylightYear/vdaylightMonth/vdaylightDayOfWeek/vdaylightWeek/vdaylightHour/vdaylightMinute/vdaylightSecond/vdaylightMilliseconds/ldaylightBias';
$timezoneInfo = unpack($timezoneUnpackString, base64_decode($_packedTimezoneInfo));
if ($timezoneInfo['standardHour'] == 23 && $timezoneInfo['standardMilliseconds'] == 999
&& $timezoneInfo['standardMinute'] == 59 && $timezoneInfo['standardSecond'] == 59
) {
$timezoneInfo['standardHour'] = 24;
$timezoneInfo['standardMinute'] = 0;
$timezoneInfo['standardSecond'] = 0;
$timezoneInfo['standardMilliseconds'] = 0;
}
return $timezoneInfo;
}
/**
* Encode timezone info to activesync
*
* @param array $_timezoneInfo
*
* @return string|null
*/
protected function _packTimezoneInfo($_timezoneInfo)
{
if (!is_array($_timezoneInfo)) {
return null;
}
// According to e.g. https://docs.microsoft.com/en-us/windows/win32/api/minwinbase/ns-minwinbase-systemtime,
// 24 is not allowed in the Hour field, and consequently Outlook can't deal with it.
// This is the same workaround that Outlook applies.
if ($_timezoneInfo['standardHour'] == 24) {
$_timezoneInfo['standardHour'] = 23;
$_timezoneInfo['standardMinute'] = 59;
$_timezoneInfo['standardSecond'] = 59;
$_timezoneInfo['standardMilliseconds'] = 999;
}
$packed = pack(
"la64vvvvvvvvla64vvvvvvvvl",
$_timezoneInfo['bias'],
$_timezoneInfo['standardName'],
$_timezoneInfo['standardYear'],
$_timezoneInfo['standardMonth'],
$_timezoneInfo['standardDayOfWeek'],
$_timezoneInfo['standardWeek'],
$_timezoneInfo['standardHour'],
$_timezoneInfo['standardMinute'],
$_timezoneInfo['standardSecond'],
$_timezoneInfo['standardMilliseconds'],
$_timezoneInfo['standardBias'],
$_timezoneInfo['daylightName'],
$_timezoneInfo['daylightYear'],
$_timezoneInfo['daylightMonth'],
$_timezoneInfo['daylightDayOfWeek'],
$_timezoneInfo['daylightWeek'],
$_timezoneInfo['daylightHour'],
$_timezoneInfo['daylightMinute'],
$_timezoneInfo['daylightSecond'],
$_timezoneInfo['daylightMilliseconds'],
$_timezoneInfo['daylightBias']
);
return base64_encode($packed);
}
/**
* Returns complete offsets array with all fields empty
*
* Used e.g. when reverse-generating ActiveSync Timezone Offset Information
* based on a given Timezone, {@see getOffsetsForTimezone}
*
* @return array
*/
protected function _getOffsetsTemplate()
{
return [
'bias' => 0,
'standardName' => '',
'standardYear' => 0,
'standardMonth' => 0,
'standardDayOfWeek' => 0,
'standardWeek' => 0,
'standardHour' => 0,
'standardMinute' => 0,
'standardSecond' => 0,
'standardMilliseconds' => 0,
'standardBias' => 0,
'daylightName' => '',
'daylightYear' => 0,
'daylightMonth' => 0,
'daylightDayOfWeek' => 0,
'daylightWeek' => 0,
'daylightHour' => 0,
'daylightMinute' => 0,
'daylightSecond' => 0,
'daylightMilliseconds' => 0,
'daylightBias' => 0,
];
}
/**
* Validate and set offsets
*
* @param array $value
*
* @return bool Validation result
*/
protected function _validateOffsets($value)
{
// validate $value
if ((!empty($value['standardMonth']) || !empty($value['standardWeek']) || !empty($value['daylightMonth']) || !empty($value['daylightWeek'])) &&
(empty($value['standardMonth']) || empty($value['standardWeek']) || empty($value['daylightMonth']) || empty($value['daylightWeek']))
) {
// It is not possible not set standard offsets without setting daylight offsets and vice versa
return false;
}
return true;
}
/**
* Parse and set object property {@see $_startDate}
*
* @param mixed $_startDate
* @return void
*/
protected function _setStartDate($_startDate)
{
if (empty($_startDate)) {
$this->_setDefaultStartDateIfEmpty();
return;
}
$startDateParsed = [];
if (is_string($_startDate)) {
$startDateParsed['string'] = $_startDate;
$startDateParsed['ts'] = strtotime($_startDate);
} elseif (is_int($_startDate)) {
$startDateParsed['ts'] = $_startDate;
$startDateParsed['string'] = date('Y-m-d', $_startDate);
} else {
$this->_setDefaultStartDateIfEmpty();
return;
}
$startDateParsed['object'] = new DateTime($startDateParsed['string']);
$startDateParsed = array_merge($startDateParsed, getdate($startDateParsed['ts']));
$this->_startDate = $startDateParsed;
}
/**
* Set default value for object property {@see $_startdate} if it is not set yet.
* Tries to guess the correct startDate depending on object property {@see $_offsets} and
* falls back to current date.
*
* @param array $_offsets [offsets may be avaluated for a given start year]
* @return void
*/
protected function _setDefaultStartDateIfEmpty($_offsets = null)
{
if (!empty($this->_startDate)) {
return;
}
if (!empty($_offsets['standardYear'])) {
$this->_setStartDate($_offsets['standardYear'] . '-01-01');
} else {
$this->_setStartDate(time());
}
}
/**
* Check if the given {@param $_timezone} matches the {@see $_offsets}
* and also evaluate the daylight saving time transitions for this timezone if necessary.
*
* @param DateTimeZone $timezone
* @param array $offsets
*
* @return array|bool
*/
protected function _checkTimezone(DateTimeZone $timezone, $offsets)
{
[$standardTransition, $daylightTransition] = $this->_getTransitionsForTimezoneAndYear($timezone, $this->_startDate['year']);
if ($this->_checkTransition($standardTransition, $daylightTransition, $offsets, $timezone)) {
return $standardTransition;
}
return false;
}
/**
* Returns the standard and daylight transitions for the given {@param $_timezone}
* and {@param $_year}.
*
* @param DateTimeZone $_timezone
* @param int $_year
*
* @return array
*/
protected function _getTransitionsForTimezoneAndYear(DateTimeZone $_timezone, $_year)
{
$standardTransition = null;
$daylightTransition = null;
$start = mktime(0, 0, 0, 12, 1, $_year - 1);
$end = mktime(24, 0, 0, 12, 31, $_year);
$transitions = $_timezone->getTransitions($start, $end);
if ($transitions === false) {
- return [];
+ return [null, null];
}
foreach ($transitions as $index => $transition) {
if (date('Y', $transition['ts']) == $_year) {
if (isset($transitions[$index + 1]) && date('Y', $transitions[$index]['ts']) == date('Y', $transitions[$index + 1]['ts'])) {
$daylightTransition = $transition['isdst'] ? $transition : $transitions[$index + 1];
$standardTransition = $transition['isdst'] ? $transitions[$index + 1] : $transition;
} else {
$daylightTransition = $transition['isdst'] ? $transition : null;
$standardTransition = $transition['isdst'] ? null : $transition;
}
break;
} elseif ($index == count($transitions) - 1) {
$standardTransition = $transition;
}
}
return [$standardTransition, $daylightTransition];
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Mon, Apr 6, 2:09 AM (1 w, 2 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18831928
Default Alt Text
(128 KB)
Attached To
Mode
rS syncroton
Attached
Detach File
Event Timeline