diff --git a/lib/kolab_sync_data_calendar.php b/lib/kolab_sync_data_calendar.php index abf2de6..3d3409a 100644 --- a/lib/kolab_sync_data_calendar.php +++ b/lib/kolab_sync_data_calendar.php @@ -1,1095 +1,1095 @@ | | | | 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(); // Timezone // Kolab Format 3.0 and xCal does support timezone per-date, but ActiveSync allows // only one timezone per-event. We'll use timezone of the start date if ($event['start'] instanceof DateTime) { $timezone = $event['start']->getTimezone(); if ($timezone && ($tz_name = $timezone->getName()) != 'UTC') { $tzc = kolab_sync_timezone_converter::getInstance(); if ($tz_name = $tzc->encodeTimezone($tz_name)) { $result['timezone'] = $tz_name; } } } // Calendar namespace fields foreach ($this->mapping as $key => $name) { $value = $this->getKolabDataItem($event, $name); switch ($name) { case 'changed': case 'end': case 'start': // For all-day events Kolab uses different times // At least Android doesn't display such event as all-day event if ($value && is_a($value, 'DateTime')) { $date = clone $value; if ($event['allday']) { // need this for self::date_from_kolab() $date->_dateonly = false; if ($name == 'start') { $date->setTime(0, 0, 0); } else if ($name == 'end') { $date->setTime(0, 0, 0); $date->modify('+1 day'); } } // set this date for use in recurrence exceptions handling if ($name == 'start') { $event['_start'] = $date; } $value = self::date_from_kolab($date); } break; case 'sensitivity': $value = intval($this->sensitivityMap[$value]); break; case 'free_busy': $value = $this->busyStatusMap[$value]; break; case 'description': $value = $this->body_from_kolab($value, $collection); break; } // Ignore empty values (but not integer 0) if ((empty($value) || is_array($value)) && $value !== 0) { continue; } $result[$key] = $value; } // Event reminder time if ($config['ALARMS']) { $result['reminder'] = $this->from_kolab_alarm($event); } $result['categories'] = array(); $result['attendees'] = array(); // Categories, Roundcube Calendar plugin supports only one category at a time if (!empty($event['categories'])) { $result['categories'] = (array) $event['categories']; } // Organizer if (!empty($event['attendees'])) { foreach ($event['attendees'] as $idx => $attendee) { if ($attendee['role'] == 'ORGANIZER') { if ($name = $attendee['name']) { $result['organizerName'] = $name; } if ($email = $attendee['email']) { $result['organizerEmail'] = $email; } unset($event['attendees'][$idx]); break; } } } // Attendees if (!empty($event['attendees'])) { $user_emails = $this->user_emails(); $user_rsvp = false; 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) { $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($collection, $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) { $event = !empty($entry) ? $entry : array(); $foldername = isset($event['_mailbox']) ? $event['_mailbox'] : $this->getFolderName($folderid); $config = $this->getFolderConfig($foldername); $is_exception = $data instanceof Syncroton_Model_EventException; $dummy_tz = str_repeat('A', 230) . '=='; $is_outlook = stripos($this->device->devicetype, 'outlook') !== false; // check data validity $this->check_event($data); if (!empty($event['start']) && ($event['start'] instanceof DateTime)) { $old_timezone = $event['start']->getTimezone(); } // Timezone if (!$timezone && isset($data->timezone) && $data->timezone != $dummy_tz) { $tzc = kolab_sync_timezone_converter::getInstance(); $expected = $old_timezone ?: kolab_format::$timezone; try { $timezone = $tzc->getTimezone($data->timezone, $expected->getName()); $timezone = new DateTimeZone($timezone); } catch (Exception $e) { $timezone = null; } } if (empty($timezone)) { $timezone = $old_timezone ?: new DateTimeZone('UTC'); } $event['allday'] = 0; // Calendar namespace fields foreach ($this->mapping as $key => $name) { // skip UID field, unsupported in event exceptions // we need to do this here, because the next line (data getter) will throw an exception if ($is_exception && $key == 'uID') { continue; } $value = $data->$key; switch ($name) { case 'changed': $value = null; break; case 'end': case 'start': if ($timezone && $value) { $value->setTimezone($timezone); } if ($value && $data->allDayEvent) { $value->_dateonly = true; // In ActiveSync all-day event ends on 00:00:00 next day // In Kolab we just ignore the time spec. if ($name == 'end') { $diff = date_diff($event['start'], $value); $value = clone $event['start']; if ($diff->days > 1) { $value->add(new DateInterval('P' . ($diff->days - 1) . 'D')); } } } break; case 'sensitivity': $map = array_flip($this->sensitivityMap); $value = $map[$value]; break; case 'free_busy': $map = array_flip($this->busyStatusMap); $value = $map[$value]; break; case 'description': $value = $this->getBody($value, Syncroton_Model_EmailBody::TYPE_PLAINTEXT); // If description isn't specified keep old description if ($value === null) { continue 2; } break; } $this->setKolabDataItem($event, $name, $value); } // Try to fix allday events from Android // It doesn't set all-day flag but the period is a whole day if (!$event['allday'] && $event['end'] && $event['start']) { $interval = @date_diff($event['start'], $event['end']); if ($interval && $interval->format('%y%m%d%h%i%s') === '001000') { $event['allday'] = 1; $event['end'] = clone $event['start']; } } // Reminder // @TODO: should alarms be used when importing event from phone? if ($config['ALARMS']) { $event['valarms'] = $this->to_kolab_alarm($data->reminder, $event); } $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)) { $attendees = $entry['attendees']; } else if (isset($data->attendees)) { $statusMap = array_flip($this->attendeeStatusMap); foreach ($data->attendees as $attendee) { if ($attendee->email && $attendee->email == $organizer_email) { 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->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; } } // 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'], ); } $event['attendees'] = $attendees; $event['categories'] = $categories; // recurrence (and exceptions) if (!$is_exception) { $event['recurrence'] = $this->recurrence_to_kolab($data, $folderid, $timezone); } // Bump SEQUENCE number on update (Outlook only). // It's been confirmed that any change of the event that has attendees specified // bumps SEQUENCE number of the event (we can see this in sent iTips). // Unfortunately Outlook also sends an update when no SEQUENCE bump // is needed, e.g. when updating attendee status. // We try our best to bump the SEQUENCE only when expected if (!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) { $organizer = null; $emails = $this->user_emails(); foreach ((array) $event['attendees'] as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER') { $organizer = $attendee; } else if ($attendee['email'] && in_array_nocase($attendee['email'], $emails)) { $event['attendees'][$i]['status'] = $status; $event['attendees'][$i]['rsvp'] = false; $event_attendee = $attendee; } } if (!$event_attendee) { $this->logger->warn('MeetingResponse on an event where the user is not an attendee. UID: ' . $event['uid']); throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR); } } /** * Returns filter query array according to specified ActiveSync FilterType * * @param int $filter_type Filter type * * @param array Filter query */ protected function filter($filter_type = 0) { $filter = array(array('type', '=', $this->modelName)); switch ($filter_type) { case Syncroton_Command_Sync::FILTER_2_WEEKS_BACK: $mod = '-2 weeks'; break; case Syncroton_Command_Sync::FILTER_1_MONTH_BACK: $mod = '-1 month'; break; case Syncroton_Command_Sync::FILTER_3_MONTHS_BACK: $mod = '-3 months'; break; case Syncroton_Command_Sync::FILTER_6_MONTHS_BACK: $mod = '-6 months'; break; } if (!empty($mod)) { $dt = new DateTime('now', new DateTimeZone('UTC')); $dt->modify($mod); $filter[] = array('dtend', '>', $dt); } return $filter; } /** * Set MeetingStatus according to event data */ protected function meeting_status_from_kolab($collection, $event, &$result) { // 0 - The event is an appointment, which has no attendees. // 1 - The event is a meeting and the user is the meeting organizer. // 3 - This event is a meeting, and the user is not the meeting organizer. // 5 - The meeting has been canceled and the user was the meeting organizer. // 7 - The meeting has been canceled. The user was not the meeting organizer. $status = 0; if (!empty($event['attendees'])) { // Find out if the user is an organizer // TODO: Delegation/aliases support $user_emails = $this->user_emails(); $is_organizer = false; if ($event['organizer'] && $event['organizer']['email']) { $is_organizer = in_array_nocase($event['organizer']['email'], $user_emails); } if ($event['status'] == 'CANCELLED') { $status = $is_organizer ? 5 : 7; } else { $status = $is_organizer ? 1 : 3; } } $result['meetingStatus'] = $status; } /** * Converts libkolab alarms spec. into a number of minutes */ protected function from_kolab_alarm($event) { if (isset($event['valarms'])) { foreach ($event['valarms'] as $alarm) { if (in_array($alarm['action'], array('DISPLAY', 'AUDIO'))) { $value = $alarm['trigger']; break; } } } - if ($value && $value instanceof DateTime) { - if ($event['start'] && ($interval = $event['start']->diff($value))) { + if (!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 ($value && preg_match('/^([-+]*)[PT]*([0-9]+)([WDHMS])$/', $value, $matches)) { + 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 (array) $event['valarms']; + return isset($event['valarms']) ? (array) $event['valarms'] : array(); } $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'] ?: '', + '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 ($event[$key] != $old[$key]) { // 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; } } diff --git a/lib/kolab_sync_message.php b/lib/kolab_sync_message.php index 448d31c..5068738 100644 --- a/lib/kolab_sync_message.php +++ b/lib/kolab_sync_message.php @@ -1,494 +1,494 @@ | | | | 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 | +--------------------------------------------------------------------------+ */ class kolab_sync_message { protected $headers = array(); protected $body; protected $ctype; protected $ctype_params = array(); /** * Constructor * * @param string|resource $source MIME message source */ function __construct($source) { $this->parse_mime($source); } /** * Returns message headers * * @return array Message headers */ public function headers() { return $this->headers; } public function source() { $headers = array(); // Build the message back foreach ($this->headers as $header => $header_value) { $headers[$header] = $header . ': ' . $header_value; } return trim(implode("\r\n", $headers)) . "\r\n\r\n" . ltrim($this->body); // @TODO: work with file streams } /** * Appends text at the end of the message body * * @todo: HTML support * * @param string $text Text to append * @param string $charset Text charset */ public function append($text, $charset = null) { if ($this->ctype == 'text/plain') { // decode body $body = $this->decode($this->body, $this->headers['Content-Transfer-Encoding']); $body = rcube_charset::convert($body, $this->ctype_params['charset'], $charset); // append text $body .= $text; // encode and save $body = rcube_charset::convert($body, $charset, $this->ctype_params['charset']); $this->body = $this->encode($body, $this->headers['Content-Transfer-Encoding']); } } /** * Adds attachment to the message * * @param string $body Attachment body (not encoded) * @param string $params Attachment parameters (Mail_mimePart format) */ public function add_attachment($body, $params = array()) { // convert the message into multipart/mixed if ($this->ctype != 'multipart/mixed') { $boundary = '_' . md5(rand() . microtime()); $this->body = "--$boundary\r\n" ."Content-Type: " . $this->headers['Content-Type']."\r\n" ."Content-Transfer-Encoding: " . $this->headers['Content-Transfer-Encoding']."\r\n" ."\r\n" . trim($this->body) . "\r\n" ."--$boundary\r\n"; $this->ctype = 'multipart/mixed'; $this->ctype_params = array('boundary' => $boundary); unset($this->headers['Content-Transfer-Encoding']); $this->save_content_type($this->ctype, $this->ctype_params); } // make sure MIME-Version header is set, it's required by some servers if (empty($this->headers['MIME-Version'])) { $this->headers['MIME-Version'] = '1.0'; } $boundary = $this->ctype_params['boundary']; $part = new Mail_mimePart($body, $params); $body = $part->encode(); foreach ($body['headers'] as $name => $value) { $body['headers'][$name] = $name . ': ' . $value; } $this->body = rtrim($this->body); $this->body = preg_replace('/--$/', '', $this->body); // add the attachment to the end of the message $this->body .= "\r\n" .implode("\r\n", $body['headers']) . "\r\n\r\n" .$body['body'] . "\r\n--$boundary--\r\n"; } /** * Sets the value of specified message header * * @param string $name Header name * @param string $value Header value */ public function set_header($name, $value) { $name = self::normalize_header_name($name); if ($name != 'Content-Type') { $this->headers[$name] = $value; } } /** * Send the given message using the configured method. * * @param array $smtp_error SMTP error array (reference) * @param array $smtp_opts SMTP options (e.g. DSN request) * * @return boolean Send status. */ public function send(&$smtp_error = null, $smtp_opts = null) { $rcube = rcube::get_instance(); $headers = $this->headers; $mailto = $headers['To']; $headers['User-Agent'] .= $rcube->app_name . ' ' . kolab_sync::VERSION; if ($agent = $rcube->config->get('useragent')) { $headers['User-Agent'] .= '/' . $agent; } if (empty($headers['From'])) { $headers['From'] = $this->get_identity(); } // make sure there's sender name in From: else if ($rcube->config->get('activesync_fix_from') && preg_match('/^?$/', trim($headers['From']), $m) ) { $identities = kolab_sync::get_instance()->user->list_identities(); $email = $m[1]; foreach ((array) $identities as $ident) { if ($ident['email'] == $email) { if ($ident['name']) { $headers['From'] = format_email_recipient($email, $ident['name']); } break; } } } if (empty($headers['Message-ID'])) { $headers['Message-ID'] = $rcube->gen_message_id(); } // remove empty headers $headers = array_filter($headers); $smtp_headers = $headers; // generate list of recipients $recipients = array(); if (!empty($headers['To'])) $recipients[] = $headers['To']; if (!empty($headers['Cc'])) $recipients[] = $headers['Cc']; if (!empty($headers['Bcc'])) $recipients[] = $headers['Bcc']; if (empty($headers['To']) && empty($headers['Cc'])) { $headers['To'] = 'undisclosed-recipients:;'; } // remove Bcc header unset($smtp_headers['Bcc']); // send message if (!is_object($rcube->smtp)) { $rcube->smtp_init(true); } $sent = $rcube->smtp->send_mail($headers['From'], $recipients, $smtp_headers, $this->body, $smtp_opts); $smtp_response = $rcube->smtp->get_response(); $smtp_error = $rcube->smtp->get_error(); // log error if (!$sent) { rcube::raise_error(array('code' => 800, 'type' => 'smtp', 'line' => __LINE__, 'file' => __FILE__, 'message' => "SMTP error: ".join("\n", $smtp_response)), true, false); } if ($sent) { $rcube->plugins->exec_hook('message_sent', array('headers' => $headers, 'body' => $this->body)); // remove MDN headers after sending unset($headers['Return-Receipt-To'], $headers['Disposition-Notification-To']); if ($rcube->config->get('smtp_log')) { // get all recipient addresses $mailto = rcube_mime::decode_address_list(implode(',', $recipients), null, false, null, true); rcube::write_log('sendmail', sprintf("User %s [%s]; Message %s for %s; %s", $rcube->get_user_name(), rcube_utils::remote_addr(), $headers['Message-ID'], implode(', ', $mailto), !empty($smtp_response) ? implode('; ', $smtp_response) : '') ); } } $this->headers = $headers; return $sent; } /** * Parses the message source and fixes 8bit data for ActiveSync. * This way any not UTF8 characters will be encoded before * sending to the device. * * @param string $message Message source * * @return string Fixed message source */ public static function recode_message($message) { // @TODO: work with stream, to workaround memory issues with big messages if (is_resource($message)) { $message = stream_get_contents($message); } list($headers, $message) = preg_split('/\r?\n\r?\n/', $message, 2, PREG_SPLIT_NO_EMPTY); $hdrs = self::parse_headers($headers); // multipart message if (preg_match('/boundary="?([a-z0-9-\'\(\)+_\,\.\/:=\? ]+)"?/i', $hdrs['Content-Type'], $matches)) { $boundary = '--' . $matches[1]; $message = explode($boundary, $message); for ($x=1, $parts = count($message) - 1; $x<$parts; $x++) { $message[$x] = "\r\n" . self::recode_message(ltrim($message[$x])); } return $headers . "\r\n\r\n" . implode($boundary , $message); } // single part - $enc = strtolower($hdrs['Content-Transfer-Encoding']); + $enc = !empty($hdrs['Content-Transfer-Encoding']) ? strtolower($hdrs['Content-Transfer-Encoding']) : null; // do nothing if already encoded if ($enc != 'quoted-printable' && $enc != 'base64') { // recode body if any non-printable-ascii characters found if (preg_match('/[^\x20-\x7E\x0A\x0D\x09]/', $message)) { $hdrs['Content-Transfer-Encoding'] = 'base64'; foreach ($hdrs as $header => $header_value) { $hdrs[$header] = $header . ': ' . $header_value; } $headers = trim(implode("\r\n", $hdrs)); $message = rtrim(chunk_split(base64_encode(rtrim($message)), 76, "\r\n")) . "\r\n"; } } return $headers . "\r\n\r\n" . $message; } /** * Creates a fake plain text message source with predefined headers and body * * @param string $headers Message headers * @param string $body Plain text body * * @return string Message source */ public static function fake_message($headers, $body = '') { $hdrs = self::parse_headers($headers); $result = ''; $hdrs['Content-Type'] = 'text/plain; charset=UTF-8'; $hdrs['Content-Transfer-Encoding'] = 'quoted-printable'; foreach ($hdrs as $header => $header_value) { $result .= $header . ': ' . $header_value . "\r\n"; } return $result . "\r\n" . self::encode($body, 'quoted-printable'); } /** * MIME message parser * * @param string|resource $message MIME message source * @param bool $decode_body Enables body decoding * * @return array Message headers array and message body */ protected function parse_mime($message) { // @TODO: work with stream, to workaround memory issues with big messages if (is_resource($message)) { $message = stream_get_contents($message); } list($headers, $message) = preg_split('/\r?\n\r?\n/', $message, 2, PREG_SPLIT_NO_EMPTY); $headers = self::parse_headers($headers); // parse Content-Type header $ctype_parts = preg_split('/[; ]+/', $headers['Content-Type']); $this->ctype = strtolower(array_shift($ctype_parts)); foreach ($ctype_parts as $part) { if (preg_match('/^([a-z-_]+)\s*=\s*(.+)$/i', trim($part), $m)) { $this->ctype_params[strtolower($m[1])] = trim($m[2], '"'); } } if (!empty($headers['Content-Transfer-Encoding'])) { $headers['Content-Transfer-Encoding'] = strtolower($headers['Content-Transfer-Encoding']); } $this->headers = $headers; $this->body = $message; } /** * Parse message source with headers */ protected static function parse_headers($headers) { // Parse headers $headers = str_replace("\r\n", "\n", $headers); $headers = explode("\n", trim($headers)); $ln = 0; $lines = array(); foreach ($headers as $line) { if (ord($line[0]) <= 32) { $lines[$ln] .= (empty($lines[$ln]) ? '' : "\r\n") . $line; } else { $lines[++$ln] = trim($line); } } // Unify char-case of header names $headers = array(); foreach ($lines as $line) { list($field, $string) = explode(':', $line, 2); if ($field = self::normalize_header_name($field)) { $headers[$field] = trim($string); } } return $headers; } /** * Normalize (fix) header names */ protected static function normalize_header_name($name) { $headers_map = array( 'subject' => 'Subject', 'from' => 'From', 'to' => 'To', 'cc' => 'Cc', 'bcc' => 'Bcc', 'message-id' => 'Message-ID', 'references' => 'References', 'content-type' => 'Content-Type', 'content-transfer-encoding' => 'Content-Transfer-Encoding', ); $name_lc = strtolower($name); return isset($headers_map[$name_lc]) ? $headers_map[$name_lc] : $name; } /** * Encodes message/part body * * @param string $body Message/part body * @param string $encoding Content encoding * * @return string Encoded body */ protected function encode($body, $encoding) { switch ($encoding) { case 'base64': $body = base64_encode($body); $body = chunk_split($body, 76, "\r\n"); break; case 'quoted-printable': $body = quoted_printable_encode($body); break; } return $body; } /** * Decodes message/part body * * @param string $body Message/part body * @param string $encoding Content encoding * * @return string Decoded body */ protected function decode($body, $encoding) { $body = str_replace("\r\n", "\n", $body); switch ($encoding) { case 'base64': $body = base64_decode($body); break; case 'quoted-printable': $body = quoted_printable_decode($body); break; } return $body; } /** * Returns email address string from default identity of the current user */ protected function get_identity() { $user = kolab_sync::get_instance()->user; if ($identity = $user->get_identity()) { return format_email_recipient(format_email($identity['email']), $identity['name']); } } protected function save_content_type($ctype, $params = array()) { $this->ctype = $ctype; $this->ctype_params = $params; $this->headers['Content-Type'] = $ctype; if (!empty($params)) { foreach ($params as $name => $value) { $this->headers['Content-Type'] .= sprintf('; %s="%s"', $name, $value); } } } } diff --git a/lib/kolab_sync_timezone_converter.php b/lib/kolab_sync_timezone_converter.php index fe2e9d7..800be59 100644 --- a/lib/kolab_sync_timezone_converter.php +++ b/lib/kolab_sync_timezone_converter.php @@ -1,647 +1,650 @@ | | 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_onverter */ private static $_instance = NULL; protected $_startDate = array(); /** * 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 = array( '0AIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==' => array( 'Pacific/Kwajalein' => 'MHT' ) ); /** * 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]; } else { if (is_string($_offsets)) { // unpack timezone info to array $_offsets = $this->_unpackTimezoneInfo($_offsets); } if (!$this->_validateOffsets($_offsets)) { return array(); } $this->_setDefaultStartDateIfEmpty($_offsets); $cacheId = $this->_getCacheId('timezones', $_offsets); $timezones = $this->_loadFromCache($cacheId); if (!is_array($timezones)) { $timezones = array(); foreach (DateTimeZone::listIdentifiers() as $timezoneIdentifier) { $timezone = new DateTimeZone($timezoneIdentifier); if (false !== ($matchingTransition = $this->_checkTimezone($timezone, $_offsets))) { $timezones[$timezoneIdentifier] = $matchingTransition['abbr']; } } $this->_saveInCache($timezones, $cacheId); } } 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 $_expectedTomezone 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); } /** * Get offsets for given timezone * * @param string $_timezone Timezone identifier * @param string|int $_startDate Start date * * @return array Timezone offsets */ public function getOffsetsForTimezone($_timezone, $_startDate = null) { $this->_setStartDate($_startDate); $cacheId = $this->_getCacheId('offsets', array($_timezone)); if (false === ($offsets = $this->_loadFromCache($cacheId))) { $offsets = $this->_getOffsetsTemplate(); try { $timezone = new DateTimeZone($_timezone); } catch (Exception $e) { return null; } list($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; } } $this->_saveInCache($offsets, $cacheId); } 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 . 'Day'] = $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 * * @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 ($daylightOffset == $_daylightTransition['offset'] || $daylightOffsetMilestone == $_daylightTransition['offset']) { + if ( + !empty($_daylightTransition) + && ($daylightOffset == $_daylightTransition['offset'] || $daylightOffsetMilestone == $_daylightTransition['offset']) + ) { $standardDate = new DateTime($_standardTransition['time'], $tz); $daylightDate = new DateTime($_daylightTransition['time'], $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['daylightDay']) && $this->_isNthOcurrenceOfWeekdayInMonth($standardDate, $_offsets['standardDay']); } } } 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/vstandardDay/vstandardHour/vstandardMinute/vstandardSecond/vstandardMilliseconds/lstandardBias/a64daylightName/vdaylightYear/vdaylightMonth/vdaylightDayOfWeek/vdaylightDay/vdaylightHour/vdaylightMinute/vdaylightSecond/vdaylightMilliseconds/ldaylightBias'; $timezoneInfo = unpack($timezoneUnpackString, base64_decode($_packedTimezoneInfo)); return $timezoneInfo; } /** * encode timezone info to activesync * * @param array $_timezoneInfo * @return string */ protected function _packTimezoneInfo($_timezoneInfo) { if (!is_array($_timezoneInfo)) { return null; } $packed = pack( "la64vvvvvvvvla64vvvvvvvvl", $_timezoneInfo['bias'], $_timezoneInfo['standardName'], $_timezoneInfo['standardYear'], $_timezoneInfo['standardMonth'], $_timezoneInfo['standardDayOfWeek'], $_timezoneInfo['standardDay'], $_timezoneInfo['standardHour'], $_timezoneInfo['standardMinute'], $_timezoneInfo['standardSecond'], $_timezoneInfo['standardMilliseconds'], $_timezoneInfo['standardBias'], $_timezoneInfo['daylightName'], $_timezoneInfo['daylightYear'], $_timezoneInfo['daylightMonth'], $_timezoneInfo['daylightDayOfWeek'], $_timezoneInfo['daylightDay'], $_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 unknown_type */ protected function _getOffsetsTemplate() { return array( 'bias' => 0, 'standardName' => '', 'standardYear' => 0, 'standardMonth' => 0, 'standardDayOfWeek' => 0, 'standardDay' => 0, 'standardHour' => 0, 'standardMinute' => 0, 'standardSecond' => 0, 'standardMilliseconds' => 0, 'standardBias' => 0, 'daylightName' => '', 'daylightYear' => 0, 'daylightMonth' => 0, 'daylightDayOfWeek' => 0, 'daylightDay' => 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['standardDay']) || !empty($value['daylightMonth']) || !empty($value['daylightDay'])) && (empty($value['standardMonth']) || empty($value['standardDay']) || empty($value['daylightMonth']) || empty($value['daylightDay'])) ) { // 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 string|int $_startDate * @return void */ protected function _setStartDate($_startDate) { if (empty($_startDate)) { $this->_setDefaultStartDateIfEmpty(); return; } $startDateParsed = array(); if (is_string($_startDate)) { $startDateParsed['string'] = $_startDate; $startDateParsed['ts'] = strtotime($_startDate); } else if (is_int($_startDate)) { $startDateParsed['ts'] = $_startDate; $startDateParsed['string'] = strftime('%F', $_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) { list($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 array(); } foreach ($transitions as $index => $transition) { if (strftime('%Y', $transition['ts']) == $_year) { if (isset($transitions[$index+1]) && strftime('%Y', $transitions[$index]['ts']) == strftime('%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; } else if ($index == count($transitions) -1) { $standardTransition = $transition; } } return array($standardTransition, $daylightTransition); } protected function _getCacheId($_prefix, $_offsets) { return $_prefix . md5(serialize($_offsets)); } protected function _loadFromCache($key) { - if ($cache = $this->getCache) { + if ($cache = $this->getCache()) { return $cache->get($key); } return false; } protected function _saveInCache($value, $key) { - if ($cache = $this->getCache) { + if ($cache = $this->getCache()) { $cache->set($key, $value); } } /** * Getter for the cache engine object */ protected function getCache() { if ($this->cache === null) { $rcube = rcube::get_instance(); $cache = $rcube->get_cache_shared('activesync'); $this->cache = $cache ? $cache : false; } return $this->cache; } }