diff --git a/lib/kolab_sync_data_calendar.php b/lib/kolab_sync_data_calendar.php
index 8fab24d..44da67f 100644
--- a/lib/kolab_sync_data_calendar.php
+++ b/lib/kolab_sync_data_calendar.php
@@ -1,701 +1,719 @@
|
| |
| This program is free software: you can redistribute it and/or modify |
| it under the terms of the GNU Affero General Public License as published |
| by the Free Software Foundation, either version 3 of the License, or |
| (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU Affero General Public License for more details. |
| |
| You should have received a copy of the GNU Affero General Public License |
| along with this program. If not, see |
+--------------------------------------------------------------------------+
| Author: Aleksander Machniak |
+--------------------------------------------------------------------------+
*/
/**
* Calendar (Events) data class for Syncroton
*/
class kolab_sync_data_calendar extends kolab_sync_data implements Syncroton_Data_IDataCalendar
{
/**
* Mapping from ActiveSync Calendar namespace fields
*/
protected $mapping = array(
'allDayEvent' => 'allday',
'startTime' => 'start', // keep it before endTime here
//'attendees' => 'attendees',
'body' => 'description',
//'bodyTruncated' => 'bodytruncated',
'busyStatus' => 'free_busy',
//'categories' => 'categories',
'dtStamp' => 'changed',
'endTime' => 'end',
//'exceptions' => 'exceptions',
'location' => 'location',
//'meetingStatus' => 'meetingstatus',
//'organizerEmail' => 'organizeremail',
//'organizerName' => 'organizername',
//'recurrence' => 'recurrence',
//'reminder' => 'reminder',
//'responseRequested' => 'responserequested',
//'responseType' => 'responsetype',
'sensitivity' => 'sensitivity',
'subject' => 'title',
//'timezone' => 'timezone',
'uID' => 'uid',
);
/**
* Kolab object type
*
* @var string
*/
protected $modelName = 'event';
/**
* Type of the default folder
*
* @var int
*/
protected $defaultFolderType = Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR;
/**
* Default container for new entries
*
* @var string
*/
protected $defaultFolder = 'Calendar';
/**
* Type of user created folders
*
* @var int
*/
protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR_USER_CREATED;
/**
* attendee status
*/
const ATTENDEE_STATUS_UNKNOWN = 0;
const ATTENDEE_STATUS_TENTATIVE = 2;
const ATTENDEE_STATUS_ACCEPTED = 3;
const ATTENDEE_STATUS_DECLINED = 4;
const ATTENDEE_STATUS_NOTRESPONDED = 5;
/**
* attendee types
*/
const ATTENDEE_TYPE_REQUIRED = 1;
const ATTENDEE_TYPE_OPTIONAL = 2;
const ATTENDEE_TYPE_RESOURCE = 3;
/**
* busy status constants
*/
const BUSY_STATUS_FREE = 0;
const BUSY_STATUS_TENTATIVE = 1;
const BUSY_STATUS_BUSY = 2;
const BUSY_STATUS_OUTOFOFFICE = 3;
/**
* Sensitivity values
*/
const SENSITIVITY_NORMAL = 0;
const SENSITIVITY_PERSONAL = 1;
const SENSITIVITY_PRIVATE = 2;
const SENSITIVITY_CONFIDENTIAL = 3;
/**
* Mapping of attendee status
*
* @var array
*/
protected $attendeeStatusMap = array(
'UNKNOWN' => self::ATTENDEE_STATUS_UNKNOWN,
'TENTATIVE' => self::ATTENDEE_STATUS_TENTATIVE,
'ACCEPTED' => self::ATTENDEE_STATUS_ACCEPTED,
'DECLINED' => self::ATTENDEE_STATUS_DECLINED,
'DELEGATED' => self::ATTENDEE_STATUS_UNKNOWN,
'NEEDS-ACTION' => self::ATTENDEE_STATUS_UNKNOWN,
//self::ATTENDEE_STATUS_NOTRESPONDED,
);
/**
* Mapping of attendee type
*
* NOTE: recurrences need extra handling!
* @var array
*/
protected $attendeeTypeMap = array(
'REQ-PARTICIPANT' => self::ATTENDEE_TYPE_REQUIRED,
'OPT-PARTICIPANT' => self::ATTENDEE_TYPE_OPTIONAL,
// 'NON-PARTICIPANT' => self::ATTENDEE_TYPE_RESOURCE,
// 'CHAIR' => self::ATTENDEE_TYPE_RESOURCE,
);
/**
* Mapping of busy status
*
* @var array
*/
protected $busyStatusMap = array(
'free' => self::BUSY_STATUS_FREE,
'tentative' => self::BUSY_STATUS_TENTATIVE,
'busy' => self::BUSY_STATUS_BUSY,
'outofoffice' => self::BUSY_STATUS_OUTOFOFFICE,
);
/**
* mapping of sensitivity
*
* @var array
*/
protected $sensitivityMap = array(
'public' => self::SENSITIVITY_PERSONAL,
'private' => self::SENSITIVITY_PRIVATE,
'confidential' => self::SENSITIVITY_CONFIDENTIAL,
);
/**
* Appends contact data to xml element
*
* @param Syncroton_Model_SyncCollection $collection Collection data
* @param string $serverId Local entry identifier
* @param boolean $as_array Return entry as array
*
* @return array|Syncroton_Model_Event|array Event object
*/
public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId, $as_array = false)
{
$event = is_array($serverId) ? $serverId : $this->getObject($collection->collectionId, $serverId);
$config = $this->getFolderConfig($event['_mailbox']);
$result = array();
// Timezone
// Kolab Format 3.0 and xCal does support timezone per-date, but ActiveSync allows
// only one timezone per-event. We'll use timezone of the start date
if ($event['start'] instanceof DateTime) {
$timezone = $event['start']->getTimezone();
if ($timezone && ($tz_name = $timezone->getName()) != 'UTC') {
$tzc = kolab_sync_timezone_converter::getInstance();
if ($tz_name = $tzc->encodeTimezone($tz_name)) {
$result['timezone'] = $tz_name;
}
}
}
// Calendar namespace fields
foreach ($this->mapping as $key => $name) {
$value = $this->getKolabDataItem($event, $name);
switch ($name) {
case 'changed':
case 'end':
case 'start':
// For all-day events Kolab uses different times
// At least Android doesn't display such event as all-day event
if ($value && is_a($value, 'DateTime')) {
$date = clone $value;
if ($event['allday']) {
// need this for self::date_from_kolab()
$date->_dateonly = false;
if ($name == 'start') {
$date->setTime(0, 0, 0);
}
else if ($name == 'end') {
$date->setTime(0, 0, 0);
$date->modify('+1 day');
}
}
// set this date for use in recurrence exceptions handling
if ($name == 'start') {
$event['_start'] = $date;
}
$value = self::date_from_kolab($date);
}
break;
case 'sensitivity':
$value = intval($this->sensitivityMap[$value]);
break;
case 'free_busy':
$value = $this->busyStatusMap[$value];
break;
case 'description':
$value = $this->body_from_kolab($value, $collection);
break;
}
if (empty($value) || is_array($value)) {
continue;
}
$result[$key] = $value;
}
// Event reminder time
if ($config['ALARMS']) {
$result['reminder'] = $this->from_kolab_alarm($event);
}
$result['categories'] = array();
$result['attendees'] = array();
// Categories, Roundcube Calendar plugin supports only one category at a time
if (!empty($event['categories'])) {
$result['categories'] = (array) $event['categories'];
}
// Organizer
if (!empty($event['attendees'])) {
foreach ($event['attendees'] as $idx => $attendee) {
if ($attendee['role'] == 'ORGANIZER') {
if ($name = $attendee['name']) {
$result['organizerName'] = $name;
}
if ($email = $attendee['email']) {
$result['organizerEmail'] = $email;
}
unset($event['attendees'][$idx]);
break;
}
}
}
// Attendees
if (!empty($event['attendees'])) {
foreach ($event['attendees'] as $idx => $attendee) {
$att = array();
if ($name = $attendee['name']) {
$att['name'] = $name;
}
if ($email = $attendee['email']) {
$att['email'] = $email;
}
if ($this->asversion >= 12) {
$type = isset($attendee['role']) ? $this->attendeeTypeMap[$attendee['role']] : null;
$status = isset($attendee['status']) ? $this->attendeeStatusMap[$attendee['status']] : null;
$att['attendeeType'] = $type ? $type : self::ATTENDEE_TYPE_REQUIRED;
$att['attendeeStatus'] = $status ? $status : self::ATTENDEE_STATUS_UNKNOWN;
}
$result['attendees'][] = new Syncroton_Model_EventAttendee($att);
}
}
// Event meeting status
$this->meeting_status_from_kolab($collection, $event, $result);
// Recurrence (and exceptions)
$this->recurrence_from_kolab($collection, $event, $result);
return $as_array ? $result : new Syncroton_Model_Event($result);
}
/**
* convert contact from xml to libkolab array
*
* @param Syncroton_Model_IEntry $data Contact to convert
* @param string $folderid Folder identifier
* @param array $entry Existing entry
* @param DateTimeZone $timezone Timezone of the event
*
* @return array
*/
public function toKolab(Syncroton_Model_IEntry $data, $folderid, $entry = null, $timezone = null)
{
$event = !empty($entry) ? $entry : array();
$foldername = isset($event['_mailbox']) ? $event['_mailbox'] : $this->getFolderName($folderid);
$config = $this->getFolderConfig($foldername);
$is_exception = $data instanceof Syncroton_Model_EventException;
$last_update = $event['changed'];
$event['allday'] = 0;
// check data validity
$this->eventCheck($data);
// Timezone
if (!$timezone && isset($data->timezone)) {
$tzc = kolab_sync_timezone_converter::getInstance();
$expected = kolab_format::$timezone->getName();
if (!empty($event['start']) && ($event['start'] instanceof DateTime)) {
$expected = $event['start']->getTimezone()->getName();
}
$timezone = $tzc->getTimezone($data->timezone, $expected);
try {
$timezone = new DateTimeZone($timezone);
}
catch (Exception $e) {
$timezone = null;
}
}
if (empty($timezone)) {
$timezone = new DateTimeZone('UTC');
}
// Calendar namespace fields
foreach ($this->mapping as $key => $name) {
// skip UID field, unsupported in event exceptions
// we need to do this here, because the next line (data getter) will throw an exception
if ($is_exception && $key == 'uID') {
continue;
}
$value = $data->$key;
switch ($name) {
case 'changed':
$value = null;
break;
case 'end':
case 'start':
if ($timezone && $value) {
$value->setTimezone($timezone);
}
if ($value && $data->allDayEvent) {
$value->_dateonly = true;
// In ActiveSync all-day event ends on 00:00:00 next day
// In Kolab we just ignore the time spec.
if ($name == 'end') {
$diff = date_diff($event['start'], $value);
$value = clone $event['start'];
if ($diff->days > 1) {
$value->add(new DateInterval('P' . ($diff->days - 1) . 'D'));
}
}
}
break;
case 'sensitivity':
$map = array_flip($this->sensitivityMap);
$value = $map[$value];
break;
case 'free_busy':
$map = array_flip($this->busyStatusMap);
$value = $map[$value];
break;
case 'description':
$value = $this->getBody($value, Syncroton_Model_EmailBody::TYPE_PLAINTEXT);
// If description isn't specified keep old description
if ($value === null) {
continue 2;
}
break;
}
$this->setKolabDataItem($event, $name, $value);
}
// Try to fix allday events from Android
// It doesn't set all-day flag but the period is a whole day
if (!$event['allday'] && $event['end'] && $event['start']) {
$interval = @date_diff($event['start'], $event['end']);
if ($interval && $interval->format('%y%m%d%h%i%s') === '001000') {
$event['allday'] = 1;
$event['end'] = clone $event['start'];
}
}
// Reminder
// @TODO: should alarms be used when importing event from phone?
if ($config['ALARMS']) {
$event['valarms'] = $this->to_kolab_alarm($data->reminder, $event);
}
$event['attendees'] = array();
$event['categories'] = array();
// Categories
if (isset($data->categories)) {
foreach ($data->categories as $category) {
$event['categories'][] = $category;
}
}
// Organizer
if (!$is_exception) {
$name = $data->organizerName;
$email = $data->organizerEmail;
if ($name || $email) {
$event['attendees'][] = array(
'role' => 'ORGANIZER',
'name' => $name,
'email' => $email,
);
}
}
// Attendees
if (isset($data->attendees)) {
foreach ($data->attendees as $attendee) {
$role = false;
if (isset($attendee->attendeeType)) {
$role = array_search($attendee->attendeeType, $this->attendeeTypeMap);
}
if ($role === false) {
$role = array_search(self::ATTENDEE_TYPE_REQUIRED, $this->attendeeTypeMap);
}
- // AttendeeStatus send only on repsonse (?)
-
- $event['attendees'][] = array(
+ $_attendee = array(
'role' => $role,
'name' => $attendee->name,
'email' => $attendee->email,
);
+
+ if (isset($attendee->attendeeStatus)) {
+ $_attendee['status'] = $attendee->attendeeStatus ? array_search($attendee->attendeeStatus, $this->attendeeStatusMap) : null;
+ if (!$_attendee['status']) {
+ $_attendee['status'] = 'NEEDS-ACTION';
+ $_attendee['rsvp'] = true;
+ }
+ }
+ else if (!empty($event['attendees'])) {
+ // copy the old attendee status
+ foreach ($event['attendees'] as $old_attendee) {
+ if ($old_attendee['email'] == $_attendee['email'] && isset($old_attendee['status'])) {
+ $_attendee['status'] = $old_attendee['status'];
+ $_attendee['rsvp'] = $old_attendee['rsvp'];
+ break;
+ }
+ }
+ }
+
+ $event['attendees'][] = $_attendee;
}
}
// recurrence (and exceptions)
if (!$is_exception) {
$event['recurrence'] = $this->recurrence_to_kolab($data, $folderid, $timezone);
}
// Bump SEQUENCE number on update (Outlook only).
// It's been confirmed that any change of the event that has attendees specified
// bumps SEQUENCE number of the event (we can see this in sent iTips).
if (!empty($entry) && !$is_exception && !empty($data->attendees)
&& $data->dtStamp && $last_update && $data->dtStamp > $last_update
&& stripos($this->device->devicetype, 'outlook') !== false
) {
$event['sequence'] += 1;
}
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)
{
// @TODO: not implemented
throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR);
}
/**
* Returns filter query array according to specified ActiveSync FilterType
*
* @param int $filter_type Filter type
*
* @param array Filter query
*/
protected function filter($filter_type = 0)
{
$filter = array(array('type', '=', $this->modelName));
switch ($filter_type) {
case Syncroton_Command_Sync::FILTER_2_WEEKS_BACK:
$mod = '-2 weeks';
break;
case Syncroton_Command_Sync::FILTER_1_MONTH_BACK:
$mod = '-1 month';
break;
case Syncroton_Command_Sync::FILTER_3_MONTHS_BACK:
$mod = '-3 months';
break;
case Syncroton_Command_Sync::FILTER_6_MONTHS_BACK:
$mod = '-6 months';
break;
}
if (!empty($mod)) {
$dt = new DateTime('now', new DateTimeZone('UTC'));
$dt->modify($mod);
$filter[] = array('dtend', '>', $dt);
}
return $filter;
}
/**
* Set MeetingStatus according to event data
*/
protected function meeting_status_from_kolab($collection, $event, &$result)
{
// 0 - The event is an appointment, which has no attendees.
// 1 - The event is a meeting and the user is the meeting organizer.
// 3 - This event is a meeting, and the user is not the meeting organizer.
// 5 - The meeting has been canceled and the user was the meeting organizer.
// 7 - The meeting has been canceled. The user was not the meeting organizer.
$status = 0;
if (!empty($event['attendees'])) {
// Find out if the user is an organizer
// TODO: Delegation/aliases support
$user_emails = kolab_sync::get_instance()->user->list_emails();
$user_emails = array_map(function($v) { return $v['email']; }, $user_emails);
$is_organizer = true;
foreach ($event['attendees'] as $attendee) {
if (in_array_nocase($attendee['email'], $user_emails)) {
$is_organizer = false;
break;
}
}
if ($event['status'] == 'CANCELLED') {
$status = !empty($is_organizer) ? 5 : 7;
}
else {
$status = !empty($is_organizer) ? 1 : 3;
}
}
$result['meetingStatus'] = $status;
}
/**
* Converts libkolab alarms spec. into a number of minutes
*/
protected function from_kolab_alarm($event)
{
if (isset($event['valarms'])) {
foreach ($event['valarms'] as $alarm) {
if (in_array($alarm['action'], array('DISPLAY', 'AUDIO'))) {
$value = $alarm['trigger'];
break;
}
}
}
if ($value && $value instanceof DateTime) {
if ($event['start'] && ($interval = $event['start']->diff($value))) {
if ($interval->invert && !$interval->m && !$interval->y) {
return intval(round($interval->s/60) + $interval->i + $interval->h * 60 + $interval->d * 60 * 24);
}
}
}
else if ($value && preg_match('/^([-+]*)[PT]*([0-9]+)([WDHMS])$/', $value, $matches)) {
$value = intval($matches[2]);
if ($value && $matches[1] != '-') {
return null;
}
switch ($matches[3]) {
case 'S': $value = intval(round($value/60)); break;
case 'H': $value *= 60; break;
case 'D': $value *= 24 * 60; break;
case 'W': $value *= 7 * 24 * 60; break;
}
return $value;
}
}
/**
* Converts ActiveSync reminder into libkolab alarms spec.
*/
protected function to_kolab_alarm($value, $event)
{
if ($value === null || $value === '') {
return (array) $event['valarms'];
}
$valarms = array();
$unsupported = array();
if (!empty($event['valarms'])) {
foreach ($event['valarms'] as $alarm) {
if (!$current && in_array($alarm['action'], array('DISPLAY', 'AUDIO'))) {
$current = $alarm;
}
else {
$unsupported[] = $alarm;
}
}
}
$valarms[] = array(
'action' => $current['action'] ?: 'DISPLAY',
'description' => $current['description'] ?: '',
'trigger' => sprintf('-PT%dM', $value),
);
if (!empty($unsupported)) {
$valarms = array_merge($valarms, $unsupported);
}
return $valarms;
}
/**
* Sanity checks on event input
*
* @param Syncroton_Model_IEntry &$entry Entry object
*
* @throws Syncroton_Exception_Status_Sync
*/
protected function eventCheck(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;
}
}
}