diff --git a/lib/kolab_sync_data_calendar.php b/lib/kolab_sync_data_calendar.php
index a594662..7f37c30 100644
--- a/lib/kolab_sync_data_calendar.php
+++ b/lib/kolab_sync_data_calendar.php
@@ -1,1123 +1,1150 @@
|
| |
| This program is free software: you can redistribute it and/or modify |
| it under the terms of the GNU Affero General Public License as published |
| by the Free Software Foundation, either version 3 of the License, or |
| (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU Affero General Public License for more details. |
| |
| You should have received a copy of the GNU Affero General Public License |
| along with this program. If not, see |
+--------------------------------------------------------------------------+
| Author: Aleksander Machniak |
+--------------------------------------------------------------------------+
*/
/**
* Calendar (Events) data class for Syncroton
*/
class kolab_sync_data_calendar extends kolab_sync_data implements Syncroton_Data_IDataCalendar
{
/**
* Mapping from ActiveSync Calendar namespace fields
*/
protected $mapping = array(
'allDayEvent' => 'allday',
'startTime' => 'start', // keep it before endTime here
//'attendees' => 'attendees',
'body' => 'description',
//'bodyTruncated' => 'bodytruncated',
'busyStatus' => 'free_busy',
//'categories' => 'categories',
'dtStamp' => 'changed',
'endTime' => 'end',
//'exceptions' => 'exceptions',
'location' => 'location',
//'meetingStatus' => 'meetingstatus',
//'organizerEmail' => 'organizeremail',
//'organizerName' => 'organizername',
//'recurrence' => 'recurrence',
//'reminder' => 'reminder',
//'responseRequested' => 'responserequested',
//'responseType' => 'responsetype',
'sensitivity' => 'sensitivity',
'subject' => 'title',
//'timezone' => 'timezone',
'uID' => 'uid',
);
/**
* Kolab object type
*
* @var string
*/
protected $modelName = 'event';
/**
* Type of the default folder
*
* @var int
*/
protected $defaultFolderType = Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR;
/**
* Default container for new entries
*
* @var string
*/
protected $defaultFolder = 'Calendar';
/**
* Type of user created folders
*
* @var int
*/
protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR_USER_CREATED;
/**
* attendee status
*/
const ATTENDEE_STATUS_UNKNOWN = 0;
const ATTENDEE_STATUS_TENTATIVE = 2;
const ATTENDEE_STATUS_ACCEPTED = 3;
const ATTENDEE_STATUS_DECLINED = 4;
const ATTENDEE_STATUS_NOTRESPONDED = 5;
/**
* attendee types
*/
const ATTENDEE_TYPE_REQUIRED = 1;
const ATTENDEE_TYPE_OPTIONAL = 2;
const ATTENDEE_TYPE_RESOURCE = 3;
/**
* busy status constants
*/
const BUSY_STATUS_FREE = 0;
const BUSY_STATUS_TENTATIVE = 1;
const BUSY_STATUS_BUSY = 2;
const BUSY_STATUS_OUTOFOFFICE = 3;
/**
* Sensitivity values
*/
const SENSITIVITY_NORMAL = 0;
const SENSITIVITY_PERSONAL = 1;
const SENSITIVITY_PRIVATE = 2;
const SENSITIVITY_CONFIDENTIAL = 3;
const KEY_DTSTAMP = 'x-custom.X-ACTIVESYNC-DTSTAMP';
const KEY_RESPONSE_DTSTAMP = 'x-custom.X-ACTIVESYNC-RESPONSE-DTSTAMP';
/**
* Mapping of attendee status
*
* @var array
*/
protected $attendeeStatusMap = array(
'UNKNOWN' => self::ATTENDEE_STATUS_UNKNOWN,
'TENTATIVE' => self::ATTENDEE_STATUS_TENTATIVE,
'ACCEPTED' => self::ATTENDEE_STATUS_ACCEPTED,
'DECLINED' => self::ATTENDEE_STATUS_DECLINED,
'DELEGATED' => self::ATTENDEE_STATUS_UNKNOWN,
'NEEDS-ACTION' => self::ATTENDEE_STATUS_NOTRESPONDED,
);
/**
* Mapping of attendee type
*
* NOTE: recurrences need extra handling!
* @var array
*/
protected $attendeeTypeMap = array(
'REQ-PARTICIPANT' => self::ATTENDEE_TYPE_REQUIRED,
'OPT-PARTICIPANT' => self::ATTENDEE_TYPE_OPTIONAL,
// 'NON-PARTICIPANT' => self::ATTENDEE_TYPE_RESOURCE,
// 'CHAIR' => self::ATTENDEE_TYPE_RESOURCE,
);
/**
* Mapping of busy status
*
* @var array
*/
protected $busyStatusMap = array(
'free' => self::BUSY_STATUS_FREE,
'tentative' => self::BUSY_STATUS_TENTATIVE,
'busy' => self::BUSY_STATUS_BUSY,
'outofoffice' => self::BUSY_STATUS_OUTOFOFFICE,
);
/**
* mapping of sensitivity
*
* @var array
*/
protected $sensitivityMap = array(
'public' => self::SENSITIVITY_PERSONAL,
'private' => self::SENSITIVITY_PRIVATE,
'confidential' => self::SENSITIVITY_CONFIDENTIAL,
);
/**
* Appends contact data to xml element
*
* @param Syncroton_Model_SyncCollection $collection Collection data
* @param string $serverId Local entry identifier
* @param boolean $as_array Return entry as array
*
* @return array|Syncroton_Model_Event|array Event object
*/
public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId, $as_array = false)
{
$event = is_array($serverId) ? $serverId : $this->getObject($collection->collectionId, $serverId);
$config = $this->getFolderConfig($event['_mailbox']);
$result = array();
// 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 ($event['allday']) {
// need this for self::date_from_kolab()
$date->_dateonly = false;
if ($name == 'start') {
$date->setTime(0, 0, 0);
}
else if ($name == 'end') {
$date->setTime(0, 0, 0);
$date->modify('+1 day');
}
}
// set this date for use in recurrence exceptions handling
if ($name == 'start') {
$event['_start'] = $date;
}
$value = self::date_from_kolab($date);
}
break;
case 'sensitivity':
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 ($config['ALARMS']) {
$result['reminder'] = $this->from_kolab_alarm($event);
}
$result['categories'] = array();
$result['attendees'] = array();
// Categories, Roundcube Calendar plugin supports only one category at a time
if (!empty($event['categories'])) {
$result['categories'] = (array) $event['categories'];
}
// Organizer
if (!empty($event['attendees'])) {
foreach ($event['attendees'] as $idx => $attendee) {
if ($attendee['role'] == 'ORGANIZER') {
if ($name = $attendee['name']) {
$result['organizerName'] = $name;
}
if ($email = $attendee['email']) {
$result['organizerEmail'] = $email;
}
unset($event['attendees'][$idx]);
break;
}
}
}
$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) {
$att = array();
if ($email = $attendee['email']) {
$att['email'] = $email;
}
else {
// In Activesync email is required
continue;
}
$att['name'] = $attendee['name'] ?: $email;
$type = isset($attendee['role']) ? $this->attendeeTypeMap[$attendee['role']] : null;
$status = isset($attendee['status']) ? $this->attendeeStatusMap[$attendee['status']] : null;
if ($this->asversion >= 12) {
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 ($email && in_array_nocase($email, $user_emails)) {
$user_rsvp = !empty($attendee['rsvp']);
$resp_type = $status ?: self::ATTENDEE_STATUS_UNKNOWN;
}
$result['attendees'][] = new Syncroton_Model_EventAttendee($att);
}
}
// Event meeting status
$this->meeting_status_from_kolab($event, $result);
// Recurrence (and exceptions)
$this->recurrence_from_kolab($collection, $event, $result);
// RSVP status
$result['responseRequested'] = $result['meetingStatus'] == 3 && $user_rsvp ? 1 : 0;
$result['responseType'] = $result['meetingStatus'] == 3 ? $resp_type : null;
return $as_array ? $result : new Syncroton_Model_Event($result);
}
/**
* convert contact from xml to libkolab array
*
* @param Syncroton_Model_IEntry $data Contact to convert
* @param string $folderid Folder identifier
* @param array $entry Existing entry
* @param DateTimeZone $timezone Timezone of the event
*
* @return array
*/
public function toKolab(Syncroton_Model_IEntry $data, $folderid, $entry = null, $timezone = null)
{
$foldername = isset($entry['_mailbox']) ? $entry['_mailbox'] : $this->getFolderName($folderid);
if (empty($entry)) {
// 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 {
$folder = $this->getFolderObject($foldername);
$entry = $folder->get_object($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.
}
}
$event = !empty($entry) ? $entry : array();
$config = $this->getFolderConfig($foldername);
$is_exception = $data instanceof Syncroton_Model_EventException;
$dummy_tz = str_repeat('A', 230) . '==';
$is_outlook = stripos($this->device->devicetype, 'outlook') !== false;
// check data validity
$this->check_event($data);
if (!empty($event['start']) && ($event['start'] instanceof DateTime)) {
$old_timezone = $event['start']->getTimezone();
}
// Timezone
if (!$timezone && isset($data->timezone) && $data->timezone != $dummy_tz) {
$tzc = kolab_sync_timezone_converter::getInstance();
$expected = !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;
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 = isset($map[$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) {
+ continue 2;
+ }
$map = array_flip($this->busyStatusMap);
$value = isset($map[$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 && $interval->format('%y%m%d%h%i%s') === '001000') {
$event['allday'] = 1;
$event['end'] = clone $event['start'];
}
}
// Reminder
// @TODO: should alarms be used when importing event from phone?
if ($config['ALARMS']) {
$event['valarms'] = $this->to_kolab_alarm($data->reminder, $event);
}
$attendees = array();
$categories = array();
// Categories
if (isset($data->categories)) {
foreach ($data->categories as $category) {
$categories[] = $category;
}
}
// Organizer
if (!$is_exception && ($organizer_email = $data->organizerEmail)) {
$attendees[] = array(
'role' => 'ORGANIZER',
'name' => $data->organizerName,
'email' => $organizer_email,
);
}
// Attendees
// Outlook 2013 sends a dummy update just after MeetingResponse has been processed,
// this update resets attendee status set in the MeetingResponse request.
// We ignore changes to attendees data on such updates
if ($is_outlook && $this->isDummyOutlookUpdate($data, $entry, $event)) {
$this->logger->debug('Dummy outlook update detected, ignoring attendee changes.');
$attendees = $entry['attendees'];
}
else if (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 = array(
'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;
}
}
else if (!empty($event['attendees']) && !empty($attendee->email)) {
// copy the old attendee status
foreach ($event['attendees'] as $old_attendee) {
if ($old_attendee['email'] == $_attendee['email'] && isset($old_attendee['status'])) {
$_attendee['status'] = $old_attendee['status'];
$_attendee['rsvp'] = $old_attendee['rsvp'];
break;
}
}
}
$attendees[] = $_attendee;
}
}
+ // Outlook does not send the correct attendee status when changing between accepted and tentative, but it toggles the busyStatus.
+ if ($is_outlook) {
+ $status = null;
+ if ($data->busyStatus == self::BUSY_STATUS_BUSY) {
+ $status = "ACCEPTED";
+ } else if ($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[] = array(
'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'] = isset($event['recurrence']['EXCEPTIONS']) ? $event['recurrence']['EXCEPTIONS'] : array();
// Bump SEQUENCE number on update (Outlook only).
// It's been confirmed that any change of the event that has attendees specified
// bumps SEQUENCE number of the event (we can see this in sent iTips).
// Unfortunately Outlook also sends an update when no SEQUENCE bump
// is needed, e.g. when updating attendee status.
// We try our best to bump the SEQUENCE only when expected
if (!empty($entry) && !$is_exception && !empty($data->attendees) && $data->timezone != $dummy_tz) {
if ($last_update = $this->getKolabDataItem($event, self::KEY_DTSTAMP)) {
$last_update = new DateTime($last_update);
}
if ($data->dtStamp && $data->dtStamp != $last_update) {
if ($this->has_significant_changes($event, $entry)) {
$event['sequence']++;
$this->logger->debug('Found significant changes in the updated event. Bumping SEQUENCE to ' . $event['sequence']);
}
}
}
// Because we use last event modification time above, we make sure
// the event modification time is not (re)set by the server,
// we use the original Outlook's timestamp.
if ($is_outlook && $data->dtStamp) {
$this->setKolabDataItem($event, self::KEY_DTSTAMP, $data->dtStamp->format(DateTime::ATOM));
}
// This prevents kolab_format code to bump the sequence when not needed
if (!isset($event['sequence'])) {
$event['sequence'] = 0;
}
return $event;
}
/**
* Set attendee status for meeting
*
* @param Syncroton_Model_MeetingResponse $request The meeting response
*
* @return string ID of new calendar entry
*/
public function setAttendeeStatus(Syncroton_Model_MeetingResponse $request)
{
$status_map = array(
1 => 'ACCEPTED',
2 => 'TENTATIVE',
3 => 'DECLINED',
);
if ($status = $status_map[$request->userResponse]) {
// extract event from the invitation
list($event, $existing) = $this->get_event_from_invitation($request);
/*
switch ($status) {
case 'ACCEPTED': $event['free_busy'] = 'busy'; break;
case 'TENTATIVE': $event['free_busy'] = 'tentative'; break;
case 'DECLINED': $event['free_busy'] = 'free'; break;
}
*/
// Store Outlook response timestamp for further use
if (stripos($this->device->devicetype, 'outlook') !== false) {
$dtstamp = new DateTime('now', new DateTimeZone('UTC'));
$dtstamp = $dtstamp->format(DateTime::ATOM);
}
// Update/Save the event
if (empty($existing)) {
if ($dtstamp) {
$this->setKolabDataItem($event, self::KEY_RESPONSE_DTSTAMP, $dtstamp);
}
$folder = $this->save_event($event, $status);
// Create SyncState for the new event, so it is not synced twice
if ($folder) {
$folderId = $this->getFolderId($folder);
try {
$syncBackend = Syncroton_Registry::getSyncStateBackend();
$folderBackend = Syncroton_Registry::getFolderBackend();
$contentBackend = Syncroton_Registry::getContentStateBackend();
$syncFolder = $folderBackend->getFolder($this->device->id, $folderId);
$syncState = $syncBackend->getSyncState($this->device->id, $syncFolder->id);
$contentBackend->create(new Syncroton_Model_Content(array(
'device_id' => $this->device->id,
'folder_id' => $syncFolder->id,
'contentid' => $this->serverId($event['uid'], $folder),
'creation_time' => $syncState->lastsync,
'creation_synckey' => $syncState->counter,
)));
}
catch (Exception $e) {
// ignore
}
}
}
else {
if ($dtstamp) {
$this->setKolabDataItem($existing, self::KEY_RESPONSE_DTSTAMP, $dtstamp);
}
$folder = $this->update_event($event, $existing, $status, $request->instanceId);
}
if (!$folder) {
throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR);
}
// TODO: ActiveSync version >= 16, send the iTip response.
if (isset($request->sendResponse)) {
// SendResponse can contain Body to use as email body (can be empty)
// TODO: Activesync >= 16.1 proposedStartTime and proposedEndTime.
}
}
// FIXME: We should not return an UID when status=DECLINED
// as it's expected by the specification. Server
// should delete an event in such a case, but we
// keep the event copy with appropriate attendee status instead.
return empty($status) ? null : $this->serverId($event['uid'], $folder);
}
/**
* Get an event from the invitation email or calendar folder
*/
protected function get_event_from_invitation(Syncroton_Model_MeetingResponse $request)
{
// Limitation: LongId might be used instead of RequestId, this is not supported
if ($request->requestId) {
$mail_class = new kolab_sync_data_email($this->device, $this->syncTimeStamp);
// Event from an invitation email
if ($event = $mail_class->get_invitation_event($request->requestId)) {
// find the event in calendar
$existing = $this->find_event_by_uid($event['uid']);
return array($event, $existing);
}
// Event from calendar folder
if ($event = $this->getObject($request->collectionId, $request->requestId, $folder)) {
return array($event, $event);
}
throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::INVALID_REQUEST);
}
throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR);
}
/**
* Find the Kolab event in any (of subscribed personal calendars) folder
*/
protected function find_event_by_uid($uid)
{
if (empty($uid)) {
return;
}
// TODO: should we check every existing event folder even if not subscribed for sync?
foreach ($this->listFolders() as $folder) {
$storage_folder = $this->getFolderObject($folder['imap_name']);
if ($storage_folder->get_namespace() == 'personal'
&& ($result = $storage_folder->get_object($uid))
) {
return $result;
}
}
}
/**
* Wrapper to update an event object
*/
protected function update_event($event, $old, $status, $instanceId = null)
{
// TODO: instanceId - DateTime - of the exception to be processed, if not set process all occurrences
if ($instanceId) {
throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::INVALID_REQUEST);
}
if ($event['free_busy']) {
$old['free_busy'] = $event['free_busy'];
}
// Updating an existing event is most-likely a response
// to an iTip request with bumped SEQUENCE
$old['sequence'] += 1;
// Update the event
return $this->save_event($old, $status);
}
/**
* Save the Kolab event (create if not exist)
* If an event does not exist it will be created in the default folder
*/
protected function save_event(&$event, $status = null)
{
// Find default folder to which we'll save the event
if (!isset($event['_mailbox'])) {
$folders = $this->listFolders();
$storage = rcube::get_instance()->get_storage();
// find the default
foreach ($folders as $folder) {
if ($folder['type'] == 8 && $storage->folder_namespace($folder['imap_name']) == 'personal') {
$event['_mailbox'] = $folder['imap_name'];
break;
}
}
// if there's no folder marked as default, use any
if (!isset($event['_mailbox']) && !empty($folders)) {
foreach ($folders as $folder) {
if ($storage->folder_namespace($folder['imap_name']) == 'personal') {
$event['_mailbox'] = $folder['imap_name'];
break;
}
}
}
// TODO: what if the user has no subscribed event folders for this device
// should we use any existing event folder even if not subscribed for sync?
}
if ($status) {
$this->update_attendee_status($event, $status);
}
// TODO: Free/busy trigger?
if (isset($event['_mailbox'])) {
$folder = $this->getFolderObject($event['_mailbox']);
if ($folder && $folder->valid && $folder->save($event)) {
return $folder;
}
}
return false;
}
- /**
- * Update the attendee status of the user
- */
- protected function update_attendee_status(&$event, $status)
+ protected function find_and_update_attendee_status(&$attendees, $status, $emails)
{
- $emails = $this->user_emails();
-
- foreach ((array) $event['attendees'] as $i => $attendee) {
+ $found = false;
+ foreach ((array) $attendees as $i => $attendee) {
if (!empty($attendee['email'])
&& (empty($attendee['role']) || $attendee['role'] != 'ORGANIZER')
&& in_array_nocase($attendee['email'], $emails)
) {
- $event['attendees'][$i]['status'] = $status;
- $event['attendees'][$i]['rsvp'] = false;
- $event_attendee = $attendee;
+ $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 (empty($event_attendee)) {
+ 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'][] = array(
'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
*
* @param array Filter query
*/
protected function filter($filter_type = 0)
{
$filter = array(array('type', '=', $this->modelName));
switch ($filter_type) {
case Syncroton_Command_Sync::FILTER_2_WEEKS_BACK:
$mod = '-2 weeks';
break;
case Syncroton_Command_Sync::FILTER_1_MONTH_BACK:
$mod = '-1 month';
break;
case Syncroton_Command_Sync::FILTER_3_MONTHS_BACK:
$mod = '-3 months';
break;
case Syncroton_Command_Sync::FILTER_6_MONTHS_BACK:
$mod = '-6 months';
break;
}
if (!empty($mod)) {
$dt = new DateTime('now', new DateTimeZone('UTC'));
$dt->modify($mod);
$filter[] = array('dtend', '>', $dt);
}
return $filter;
}
/**
* Set MeetingStatus according to event data
*/
protected function meeting_status_from_kolab($event, &$result)
{
// 0 - The event is an appointment, which has no attendees.
// 1 - The event is a meeting and the user is the meeting organizer.
// 3 - This event is a meeting, and the user is not the meeting organizer.
// 5 - The meeting has been canceled and the user was the meeting organizer.
// 7 - The meeting has been canceled. The user was not the meeting organizer.
$status = 0;
if (!empty($event['attendees'])) {
// Find out if the user is an organizer
// TODO: Delegation/aliases support
$user_emails = $this->user_emails();
$is_organizer = false;
if ($event['organizer'] && $event['organizer']['email']) {
$is_organizer = in_array_nocase($event['organizer']['email'], $user_emails);
}
if ($event['status'] == 'CANCELLED') {
$status = $is_organizer ? 5 : 7;
}
else {
$status = $is_organizer ? 1 : 3;
}
}
$result['meetingStatus'] = $status;
}
/**
* Converts libkolab alarms spec. into a number of minutes
*/
protected function from_kolab_alarm($event)
{
if (isset($event['valarms'])) {
foreach ($event['valarms'] as $alarm) {
if (in_array($alarm['action'], array('DISPLAY', 'AUDIO'))) {
$value = $alarm['trigger'];
break;
}
}
}
if (!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);
}
}
}
else if (!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'] : array();
}
$valarms = array();
$unsupported = array();
if (!empty($event['valarms'])) {
foreach ($event['valarms'] as $alarm) {
if (empty($current) && in_array($alarm['action'], array('DISPLAY', 'AUDIO'))) {
$current = $alarm;
}
else {
$unsupported[] = $alarm;
}
}
}
$valarms[] = array(
'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_IEntry &$entry Entry object
*
* @throws Syncroton_Exception_Status_Sync
*/
protected function check_event(Syncroton_Model_IEntry &$entry)
{
// https://msdn.microsoft.com/en-us/library/jj194434(v=exchg.80).aspx
$now = new DateTime('now');
$rounded = new DateTime('now');
$min = (int) $rounded->format('i');
$add = $min > 30 ? (60 - $min) : (30 - $min);
$rounded->add(new DateInterval('PT' . $add . 'M'));
if (empty($entry->startTime) && empty($entry->endTime)) {
// use current time rounded to 30 minutes
$end = clone $rounded;
$end->add(new DateInterval($entry->allDayEvent ? 'P1D' : 'PT30M'));
$entry->startTime = $rounded;
$entry->endTime = $end;
}
else if (empty($entry->startTime)) {
if ($entry->endTime < $now || $entry->endTime < $rounded) {
throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::INVALID_ITEM);
}
$entry->startTime = $rounded;
}
else if (empty($entry->endTime)) {
if ($entry->startTime < $now) {
throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::INVALID_ITEM);
}
$rounded->add(new DateInterval($entry->allDayEvent ? 'P1D' : 'PT30M'));
$entry->endTime = $rounded;
}
}
/**
* Check if the new event version has any significant changes
*/
protected function has_significant_changes($event, $old)
{
// Calendar namespace fields
foreach (array('allday', 'start', 'end', 'location', 'recurrence') as $key) {
if ((isset($event[$key]) ? $event[$key] : null) != (isset($old[$key]) ? $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'])) {
$days = array('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA');
$day = $event['start']->format('w');
$rec['BYDAY'] = $days[$day];
}
if (!$rec['INTERVAL']) {
$rec['INTERVAL'] = 1;
}
ksort($rec);
return $rec;
}
/**
* Check if the event update request is a fake (for Outlook)
*/
protected function isDummyOutlookUpdate($data, $entry, &$result)
{
$is_dummy = false;
// Outlook 2013 sends a dummy update just after MeetingResponse has been processed,
// this update resets attendee status set in the MeetingResponse request.
// We ignore attendees data in such updates, they should not happen according to
// https://msdn.microsoft.com/en-us/library/office/hh428685(v=exchg.140).aspx
// but they will contain some data as alarms and free/busy status so we don't
// ignore them completely
if (!empty($entry) && !empty($data->attendees) && stripos($this->device->devicetype, 'outlook') !== false) {
// Some of these requests use just dummy Timezone
$dummy_tz = str_repeat('A', 230) . '==';
if ($data->timezone == $dummy_tz) {
$is_dummy = true;
}
// But some of them do not, so we have check if that is a first
// update immediately (up to 5 seconds) after MeetingResponse request
if (!$is_dummy && ($dtstamp = $this->getKolabDataItem($entry, self::KEY_RESPONSE_DTSTAMP))) {
$dtstamp = new DateTime($dtstamp);
$now = new DateTime('now', new DateTimeZone('UTC'));
$is_dummy = $now->getTimestamp() - $dtstamp->getTimestamp() <= 5;
}
$this->unsetKolabDataItem($result, self::KEY_RESPONSE_DTSTAMP);
}
return $is_dummy;
}
}