diff --git a/plugins/libkolab/lib/kolab_format.php b/plugins/libkolab/lib/kolab_format.php index 76db15e5..c57c4638 100644 --- a/plugins/libkolab/lib/kolab_format.php +++ b/plugins/libkolab/lib/kolab_format.php @@ -1,338 +1,396 @@ * * Copyright (C) 2012, Kolab Systems AG * * 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 . */ abstract class kolab_format { public static $timezone; public /*abstract*/ $CTYPE; protected /*abstract*/ $xmltype; protected /*abstract*/ $subtype; protected $handler; protected $data; protected $xmldata; protected $kolab_object; protected $loaded = false; protected $version = 2.0; const KTYPE_PREFIX = 'application/x-vnd.kolab.'; const PRODUCT_ID = 'Roundcube-libkolab-horde-0.9'; /** * Factory method to instantiate a kolab_format object of the given type and version * * @param string Object type to instantiate * @param float Format version * @param string Cached xml data to initialize with * @return object kolab_format */ public static function factory($type, $version = 2.0, $xmldata = null) { if (!isset(self::$timezone)) self::$timezone = new DateTimeZone('UTC'); if (!self::supports($version)) return PEAR::raiseError("No support for Kolab format version " . $version); list($xmltype, $subtype) = explode('.', $type); $type = preg_replace('/configuration\.[a-z.]+$/', 'configuration', $type); $suffix = preg_replace('/[^a-z]+/', '', $xmltype); $classname = 'kolab_format_' . $suffix; if (class_exists($classname)) return new $classname($xmldata, $subtype); return PEAR::raiseError("Failed to load Kolab Format wrapper for type " . $type); } /** * Determine support for the given format version * * @param float Format version to check * @return boolean True if supported, False otherwise */ public static function supports($version) { if ($version == 2.0) return class_exists('Horde_Kolab_Format_Xml'); return false; } + /** + * Convert the given date/time value into a structure for Horde_Kolab_Format_Xml_Type_DateTime + * + * @param mixed Date/Time value either as unix timestamp, date string or PHP DateTime object + * @param DateTimeZone The timezone the date/time is in. Use global default if Null, local time if False + * @param boolean True of the given date has no time component + * @return array Hash array with date + */ + public static function horde_datetime($datetime, $tz = null, $dateonly = false) + { + // use timezone information from datetime of global setting + if (!$tz && $tz !== false) { + if ($datetime instanceof DateTime) + $tz = $datetime->getTimezone(); + if (!$tz) + $tz = self::$timezone; + } + $result = null; + + // got a unix timestamp (in UTC) + if (is_numeric($datetime)) { + $datetime = new DateTime('@'.$datetime, new DateTimeZone('UTC')); + if ($tz) $datetime->setTimezone($tz); + } + else if (is_string($datetime) && strlen($datetime)) { + try { $datetime = new DateTime($datetime, $tz ?: null); } + catch (Exception $e) { } + } + + if ($datetime instanceof DateTime) { + $result = array('date' => $datetime, 'date-only' => $dateonly || $datetime->_dateonly); + } + + return $result; + } + + /** + * Convert the given Horde_Kolab_Format_Xml_Type_DateTime structure into a simple PHP DateTime object + * + * @param arrry Hash array with datetime properties + * @return object DateTime PHP datetime instance + */ + public static function php_datetime($data) + { + if (is_array($data)) { + $d = $data['date']; + if (is_a($d, 'DateTime')) { + if ($data['date-only']) + $d->_dateonly = $data['date-only']; + return $d; + } + } + else if (is_object($data) && is_a($data, 'DateTime')) + return $data; + + return null; + } + /** * Parse the X-Kolab-Type header from MIME messages and return the object type in short form * * @param string X-Kolab-Type header value * @return string Kolab object type (contact,event,task,note,etc.) */ public static function mime2object_type($x_kolab_type) { return preg_replace('/dictionary.[a-z.]+$/', 'dictionary', substr($x_kolab_type, strlen(self::KTYPE_PREFIX))); } /** * Convert alarm time into internal ical-based format * * @param int Alarm value as saved in Kolab 2 format * @return string iCal-style alarm value for internal use */ public static function from_kolab2_alarm($alarm_value) { if (!$alarm_value) return null; $alarm_unit = 'M'; - if ($rec['alarm'] % 1440 == 0) { + if ($alarm_value % 1440 == 0) { $alarm_value /= 1440; $alarm_unit = 'D'; } - else if ($rec['alarm'] % 60 == 0) { + else if ($alarm_value % 60 == 0) { $alarm_value /= 60; $alarm_unit = 'H'; } $alarm_value *= -1; return $alarm_value . $alarm_unit; } /** * Utility function to convert from Roundcube's internal alarms format * to an alarm offset in minutes used by the Kolab 2 format. * * @param string iCal-style alarm string * @return int Alarm offset in minutes */ public static function to_kolab2_alarm($alarms) { $ret = null; if (!$alarms) return $ret; $alarmbase = explode(":", $alarms); $avalue = intval(preg_replace('/[^0-9]/', '', $alarmbase[0])); if (preg_match("/H/",$alarmbase[0])) { $ret = $avalue*60; } else if (preg_match("/D/",$alarmbase[0])) { $ret = $avalue*24*60; } else { $ret = $avalue; } return $ret; } /** * Default constructor of all kolab_format_* objects */ public function __construct($xmldata = null, $subtype = null) { $this->subtype = $subtype; try { $factory = new Horde_Kolab_Format_Factory(); $handler = $factory->create('Xml', $this->xmltype, array('subtype' => $this->subtype)); if (is_object($handler) && !is_a($handler, 'PEAR_Error')) { $this->handler = $handler; $this->xmldata = $xmldata; } } catch (Exception $e) { rcube::raise_error($e, true); } } /** * Check for format errors after calling kolabformat::write*() * * @return boolean True if there were errors, False if OK */ protected function format_errors($p) { $ret = false; if (is_object($p) && is_a($p, 'PEAR_Error')) { rcube::raise_error(array( 'code' => 660, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Horde_Kolab_Format error: " . $p->getMessage(), ), true); $ret = true; } return $ret; } /** * Generate a unique identifier for a Kolab object */ protected function generate_uid() { $rc = rcube::get_instance(); return strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($rc->user ? $rc->user->get_username() : rand()), 0, 16)); } /** * Initialize libkolabxml object with cached xml data */ protected function init() { if (!$this->loaded) { if ($this->xmldata) { $this->load($this->xmldata); $this->xmldata = null; } $this->loaded = true; } } /** * Direct getter for object properties */ public function __get($var) { return $this->data[$var]; } /** * Load Kolab object data from the given XML block * * @param string XML data * @return boolean True on success, False on failure */ public function load($xml) { $this->loaded = false; // XML-to-array try { $object = $this->handler->load($xml, array('relaxed' => true)); $this->kolab_object = $object; $this->fromkolab2($object); $this->loaded = true; } catch (Exception $e) { rcube::raise_error($e, true); console($xml); } } /** * Write object data to XML format * * @param float Format version to write * @return string XML data */ public function write($version = null) { $this->init(); if ($version && !self::supports($version)) return false; // generate UID if not set if (!$this->kolab_object['uid']) { $this->kolab_object['uid'] = $this->generate_uid(); } try { $xml = $this->handler->save($this->kolab_object); if (strlen($xml)) { $this->xmldata = $xml; $this->data['uid'] = $this->kolab_object['uid']; } } catch (Exception $e) { rcube::raise_error($e, true); $this->xmldata = null; } return $this->xmldata; } /** * Set properties to the kolabformat object * * @param array Object data as hash array */ abstract public function set(&$object); /** * */ abstract public function is_valid(); /** * Getter for the parsed object data * * @return array Kolab object data as hash array */ public function to_array() { // load from XML if not done yet if (!empty($this->data)) $this->init(); return $this->data; } /** * Load object data from Kolab2 format * * @param array Hash array with object properties (produced by Horde Kolab_Format classes) */ abstract public function fromkolab2($object); /** * Callback for kolab_storage_cache to get object specific tags to cache * * @return array List of tags to save in cache */ public function get_tags() { return array(); } /** * Callback for kolab_storage_cache to get words to index for fulltext search * * @return array List of words to save in cache */ public function get_words() { return array(); } } diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php index 7aedeccb..88f2c80d 100644 --- a/plugins/libkolab/lib/kolab_format_event.php +++ b/plugins/libkolab/lib/kolab_format_event.php @@ -1,367 +1,330 @@ * * Copyright (C) 2012, Kolab Systems AG * * 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 . */ class kolab_format_event extends kolab_format { public $CTYPE = 'application/x-vnd.kolab.event'; protected $xmltype = 'event'; public static $fulltext_cols = array('title', 'description', 'location', 'attendees:name', 'attendees:email', 'categories'); // old Kolab 2 format field map private $kolab2_fieldmap = array( // kolab => roundcube 'summary' => 'title', 'location' => 'location', 'body' => 'description', 'categories' => 'categories', 'sensitivity' => 'sensitivity', 'show-time-as' => 'free_busy', 'priority' => 'priority', ); private $kolab2_rolemap = array( 'required' => 'REQ-PARTICIPANT', 'optional' => 'OPT-PARTICIPANT', 'resource' => 'CHAIR', ); private $kolab2_statusmap = array( 'none' => 'NEEDS-ACTION', 'tentative' => 'TENTATIVE', 'accepted' => 'CONFIRMED', 'accepted' => 'ACCEPTED', 'declined' => 'DECLINED', ); private $kolab2_weekdaymap = array('MO'=>'monday', 'TU'=>'tuesday', 'WE'=>'wednesday', 'TH'=>'thursday', 'FR'=>'friday', 'SA'=>'saturday', 'SU'=>'sunday'); private $kolab2_monthmap = array('', 'january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'); /** * Clones into an instance of libcalendaring's extended EventCal class * * @return mixed EventCal object or false on failure */ public function to_libcal() { return false; } /** * Set event properties to the kolabformat object * * @param array Event data as hash array */ public function set(&$object) { $this->init(); if ($object['uid']) $this->kolab_object['uid'] = $object['uid']; - $this->kolab_object['last-modification-date'] = time(); + $this->kolab_object['last-modification-date'] = new DateTime(); // map basic fields rcube => $kolab foreach ($this->kolab2_fieldmap as $kolab => $rcube) { $this->kolab_object[$kolab] = $object[$rcube]; } - // all-day event - if (intval($object['allday'])) { - // shift times from user's timezone to server's timezone - // because Horde_Kolab_Format_Date::encodeDate() uses strftime() which operates in server tz - $server_tz = new DateTimeZone(date_default_timezone_get()); - $start = clone $object['start']; - $end = clone $object['end']; + // make sure categories is an array + if (!is_array($this->kolab_object['categories'])) + $this->kolab_object['categories'] = array_filter((array)$this->kolab_object['categories']); - $start->setTimezone($server_tz); - $end->setTimezone($server_tz); - $start->setTime(0,0,0); - $end->setTime(0,0,0); - - // create timestamps at exactly 00:00. This is also needed for proper re-interpretation in _to_rcube_event() after updating an event - $this->kolab_object['start-date'] = mktime(0,0,0, $start->format('n'), $start->format('j'), $start->format('Y')); - $this->kolab_object['end-date'] = mktime(0,0,0, $end->format('n'), $end->format('j'), $end->format('Y')) + 86400; - - // sanity check: end date is same or smaller than start - if (date('Y-m-d', $this->kolab_object['end-date']) <= date('Y-m-d', $this->kolab_object['start-date'])) - $this->kolab_object['end-date'] = mktime(13,0,0, $start->format('n'), $start->format('j'), $start->format('Y')) + 86400; - - $this->kolab_object['_is_all_day'] = 1; - } - else { - $this->kolab_object['start-date'] = $object['start']->format('U'); - $this->kolab_object['end-date'] = $object['end']->format('U'); - } + // convert dates into a structure Horde understands + $is_allday = intval($object['allday']); + $this->kolab_object['start-date'] = self::horde_datetime($object['start'], null, $is_allday); + $this->kolab_object['end-date'] = self::horde_datetime($object['end'], null, $is_allday); // handle alarms $this->kolab_object['alarm'] = self::to_kolab2_alarm($object['alarms']); // recurr object/array if (count($object['recurrence']) > 1) { $ra = $object['recurrence']; // frequency and interval $this->kolab_object['recurrence'] = array( 'cycle' => strtolower($ra['FREQ']), 'interval' => intval($ra['INTERVAL']), ); // range Type if ($ra['UNTIL']) { $this->kolab_object['recurrence']['range-type'] = 'date'; - $this->kolab_object['recurrence']['range'] = $ra['UNTIL']->format('U'); + $this->kolab_object['recurrence']['range'] = $ra['UNTIL']; } if ($ra['COUNT']) { $this->kolab_object['recurrence']['range-type'] = 'number'; - $this->kolab_object['recurrence']['range'] = $ra['COUNT']; + $this->kolab_object['recurrence']['range'] = intval($ra['COUNT']); } // WEEKLY if ($ra['FREQ'] == 'WEEKLY') { if ($ra['BYDAY']) { foreach (explode(',', $ra['BYDAY']) as $day) $this->kolab_object['recurrence']['day'][] = $this->kolab2_weekdaymap[$day]; } else { // use weekday of start date if empty $this->kolab_object['recurrence']['day'][] = strtolower($object['start']->format('l')); } } - // MONTHLY (temporary hack to follow Horde logic) + // MONTHLY (hack to follow Horde logic) if ($ra['FREQ'] == 'MONTHLY') { if ($ra['BYDAY'] && preg_match('/(-?[1-4])([A-Z]+)/', $ra['BYDAY'], $m)) { $this->kolab_object['recurrence']['daynumber'] = $m[1]; $this->kolab_object['recurrence']['day'] = array($this->kolab2_weekdaymap[$m[2]]); $this->kolab_object['recurrence']['cycle'] = 'monthly'; $this->kolab_object['recurrence']['type'] = 'weekday'; } else { $this->kolab_object['recurrence']['daynumber'] = preg_match('/^\d+$/', $ra['BYMONTHDAY']) ? $ra['BYMONTHDAY'] : $object['start']->format('j'); $this->kolab_object['recurrence']['cycle'] = 'monthly'; $this->kolab_object['recurrence']['type'] = 'daynumber'; } } // YEARLY if ($ra['FREQ'] == 'YEARLY') { if (!$ra['BYMONTH']) $ra['BYMONTH'] = $object['start']->format('n'); $this->kolab_object['recurrence']['cycle'] = 'yearly'; $this->kolab_object['recurrence']['month'] = $this->month_map[intval($ra['BYMONTH'])]; if ($ra['BYDAY'] && preg_match('/(-?[1-4])([A-Z]+)/', $ra['BYDAY'], $m)) { $this->kolab_object['recurrence']['type'] = 'weekday'; $this->kolab_object['recurrence']['daynumber'] = $m[1]; $this->kolab_object['recurrence']['day'] = array($this->kolab2_weekdaymap[$m[2]]); } else { $this->kolab_object['recurrence']['type'] = 'monthday'; $this->kolab_object['recurrence']['daynumber'] = $object['start']->format('j'); } } // exclusions foreach ((array)$ra['EXDATE'] as $excl) { $this->kolab_object['recurrence']['exclusion'][] = $excl->format('Y-m-d'); } } else if (isset($object['recurrence'])) unset($this->kolab_object['recurrence']); // process event attendees $status_map = array_flip($this->kolab2_statusmap); $role_map = array_flip($this->kolab2_rolemap); $this->kolab_object['attendee'] = array(); foreach ((array)$object['attendees'] as $attendee) { $role = $attendee['role']; if ($role == 'ORGANIZER') { $this->kolab_object['organizer'] = array( 'display-name' => $attendee['name'], 'smtp-address' => $attendee['email'], ); } else { $this->kolab_object['attendee'][] = array( 'display-name' => $attendee['name'], 'smtp-address' => $attendee['email'], 'status' => $status_map[$attendee['status']], 'role' => $role_map[$role], 'request-response' => $attendee['rsvp'], ); } } // clear old cid: list attachments $links = array(); foreach ((array)$this->kolab_object['link-attachment'] as $i => $url) { if (strpos($url, 'cid:') !== 0) $links[] = $url; } foreach ((array)$object['_attachments'] as $key => $attachment) { if ($attachment) $links[] = 'cid:' . $key; } $this->kolab_object['link-attachment'] = $links; // cache this data $this->data = $object; unset($this->data['_formatobj']); } /** * */ public function is_valid() { return !empty($this->data['uid']) && $this->data['start'] && $this->data['end']; } /** * Callback for kolab_storage_cache to get object specific tags to cache * * @return array List of tags to save in cache */ public function get_tags() { $tags = array(); foreach ((array)$this->data['categories'] as $cat) { $tags[] = rcube_utils::normalize_string($cat); } if (!empty($this->data['alarms'])) { $tags[] = 'x-has-alarms'; } return $tags; } /** * Load data from old Kolab2 format */ public function fromkolab2($rec) { - if (PEAR::isError($rec)) - return; - - $start_time = date('H:i:s', $rec['start-date']); - $allday = $rec['_is_all_day'] || ($start_time == '00:00:00' && $start_time == date('H:i:s', $rec['end-date'])); - - // in Roundcube all-day events go from 12:00 to 13:00 - if ($allday) { - $now = new DateTime('now', self::$timezone); - $gmt_offset = $now->getOffset(); - - $rec['start-date'] += 12 * 3600; - $rec['end-date'] -= 11 * 3600; - $rec['end-date'] -= $gmt_offset - date('Z', $rec['end-date']); // shift times from server's timezone to user's timezone - $rec['start-date'] -= $gmt_offset - date('Z', $rec['start-date']); // because generated with mktime() in Horde_Kolab_Format_Date::decodeDate() - // sanity check - if ($rec['end-date'] <= $rec['start-date']) - $rec['end-date'] += 86400; - } - // convert recurrence rules into internal pseudo-vcalendar format if ($recurrence = $rec['recurrence']) { $rrule = array( 'FREQ' => strtoupper($recurrence['cycle']), 'INTERVAL' => intval($recurrence['interval']), ); - if ($recurrence['range-type'] == 'number') + if (is_object($recurrence['range']) && is_a($recurrence['range'], 'DateTime')) + $rrule['UNTIL'] = $recurrence['range']; + if (!empty($recurrence['range']) && is_numeric($recurrence['range'])) $rrule['COUNT'] = intval($recurrence['range']); - else if ($recurrence['range-type'] == 'date') - $rrule['UNTIL'] = date_create('@'.$recurrence['range']); if ($recurrence['day']) { $byday = array(); $prefix = ($rrule['FREQ'] == 'MONTHLY' || $rrule['FREQ'] == 'YEARLY') ? intval($recurrence['daynumber'] ? $recurrence['daynumber'] : 1) : ''; foreach ($recurrence['day'] as $day) $byday[] = $prefix . substr(strtoupper($day), 0, 2); $rrule['BYDAY'] = join(',', $byday); } if ($recurrence['daynumber']) { if ($recurrence['type'] == 'monthday' || $recurrence['type'] == 'daynumber') $rrule['BYMONTHDAY'] = $recurrence['daynumber']; else if ($recurrence['type'] == 'yearday') $rrule['BYYEARDAY'] = $recurrence['daynumber']; } if ($recurrence['month']) { $monthmap = array_flip($this->kolab2_monthmap); $rrule['BYMONTH'] = strtolower($monthmap[$recurrence['month']]); } if ($recurrence['exclusion']) { foreach ((array)$recurrence['exclusion'] as $excl) - $rrule['EXDATE'][] = date_create($excl . date(' H:i:s', $rec['start-date'])); // use time of event start + $rrule['EXDATE'][] = is_a($excl, 'DateTime') ? $excl : date_create($excl . date(' H:i:s', $rec['start-date'])); // use time of event start } } $attendees = array(); if ($rec['organizer']) { $attendees[] = array( 'role' => 'ORGANIZER', 'name' => $rec['organizer']['display-name'], 'email' => $rec['organizer']['smtp-address'], 'status' => 'ACCEPTED', ); $_attendees .= $rec['organizer']['display-name'] . ' ' . $rec['organizer']['smtp-address'] . ' '; } foreach ((array)$rec['attendee'] as $attendee) { $attendees[] = array( 'role' => $this->kolab2_rolemap[$attendee['role']], 'name' => $attendee['display-name'], 'email' => $attendee['smtp-address'], 'status' => $this->kolab2_statusmap[$attendee['status']], 'rsvp' => $attendee['request-response'], ); $_attendees .= $rec['organizer']['display-name'] . ' ' . $rec['organizer']['smtp-address'] . ' '; } $this->data = array( 'uid' => $rec['uid'], + 'changed' => self::php_datetime($rec['last-modification-date']), 'title' => $rec['summary'], 'location' => $rec['location'], 'description' => $rec['body'], - 'start' => new DateTime('@'.$rec['start-date']), - 'end' => new DateTime('@'.$rec['end-date']), - 'allday' => $allday, + 'start' => self::php_datetime($rec['start-date']), + 'end' => self::php_datetime($rec['end-date']), 'recurrence' => $rrule, 'alarms' => self::from_kolab2_alarm($rec['alarm']), 'categories' => $rec['categories'], 'attendees' => $attendees, 'free_busy' => $rec['show-time-as'], 'priority' => $rec['priority'], 'sensitivity' => $rec['sensitivity'], 'changed' => $rec['last-modification-date'], ); - // assign current timezone to event start/end which are in UTC - $this->data['start']->setTimezone(self::$timezone); - $this->data['end']->setTimezone(self::$timezone); + $this->data['allday'] = $this->data['start']->_dateonly; + + // assign current timezone to event start/end which are most likely in UTC + // $this->data['start']->setTimezone(self::$timezone); + // $this->data['end']->setTimezone(self::$timezone); } }