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 @@
|
| |
| This program is free software: you can redistribute it and/or modify |
| it under the terms of the GNU Affero General Public License as published |
| by the Free Software Foundation, either version 3 of the License, or |
| (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU Affero General Public License for more details. |
| |
| You should have received a copy of the GNU Affero General Public License |
| along with this program. If not, see |
+--------------------------------------------------------------------------+
| Author: Aleksander Machniak |
+--------------------------------------------------------------------------+
*/
/**
* Base class for Syncroton data backends
*/
abstract class kolab_sync_data implements Syncroton_Data_IData
{
/**
* ActiveSync protocol version
*
* @var 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 or 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 *
// 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 @@
|
| |
| This program is free software: you can redistribute it and/or modify |
| it under the terms of the GNU Affero General Public License as published |
| by the Free Software Foundation, either version 3 of the License, or |
| (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU Affero General Public License for more details. |
| |
| You should have received a copy of the GNU Affero General Public License |
| along with this program. If not, see |
+--------------------------------------------------------------------------+
| Author: Aleksander Machniak |
+--------------------------------------------------------------------------+
*/
/**
* Calendar (Events) data class for Syncroton
*/
class kolab_sync_data_calendar extends kolab_sync_data implements Syncroton_Data_IDataCalendar
{
/**
* Mapping from ActiveSync Calendar namespace fields
*/
protected $mapping = [
'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 @@
|
| 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 |
+--------------------------------------------------------------------------+
| Author: Aleksander Machniak |
| Author: Jonas Fischer |
+--------------------------------------------------------------------------+
*/
/**
* 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];
}
}