diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php index e20133e1..a62eb549 100644 --- a/plugins/calendar/drivers/kolab/kolab_calendar.php +++ b/plugins/calendar/drivers/kolab/kolab_calendar.php @@ -1,517 +1,525 @@ * @author Aleksander Machniak * * 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_calendar { public $id; public $ready = false; public $readonly = true; public $attachments = true; public $alarms = false; public $categories = array(); public $storage; private $cal; private $events = array(); private $imap_folder = 'INBOX/Calendar'; private $search_fields = array('title', 'description', 'location', '_attendees'); private $sensitivity_map = array('public', 'private', 'confidential'); /** * Default constructor */ public function __construct($imap_folder, $calendar) { $this->cal = $calendar; if (strlen($imap_folder)) $this->imap_folder = $imap_folder; // ID is derrived from folder name $this->id = kolab_storage::folder_id($this->imap_folder); // fetch objects from the given IMAP folder $this->storage = kolab_storage::get_folder($this->imap_folder); $this->ready = $this->storage && !PEAR::isError($this->storage); // Set readonly and alarms flags according to folder permissions if ($this->ready) { if ($this->storage->get_namespace() == 'personal') { $this->readonly = false; $this->alarms = true; } else { $rights = $this->storage->get_myrights(); if ($rights && !PEAR::isError($rights)) { if (strpos($rights, 'i') !== false) $this->readonly = false; } } // user-specific alarms settings win $prefs = $this->cal->rc->config->get('kolab_calendars', array()); if (isset($prefs[$this->id]['showalarms'])) $this->alarms = $prefs[$this->id]['showalarms']; } } /** * Getter for a nice and human readable name for this calendar * See http://wiki.kolab.org/UI-Concepts/Folder-Listing for reference * * @return string Name of this calendar */ public function get_name() { $folder = kolab_storage::object_name($this->imap_folder, $this->namespace); return $folder; } /** * Getter for the IMAP folder name * * @return string Name of the IMAP folder */ public function get_realname() { return $this->imap_folder; } /** * Getter for the IMAP folder owner * * @return string Name of the folder owner */ public function get_owner() { return $this->storage->get_owner(); } /** * Getter for the name of the namespace to which the IMAP folder belongs * * @return string Name of the namespace (personal, other, shared) */ public function get_namespace() { return $this->storage->get_namespace(); } /** * Getter for the top-end calendar folder name (not the entire path) * * @return string Name of this calendar */ public function get_foldername() { $parts = explode('/', $this->imap_folder); return rcube_charset::convert(end($parts), 'UTF7-IMAP'); } /** * Return color to display this calendar */ public function get_color() { // color is defined in folder METADATA $metadata = $this->storage->get_metadata(array(kolab_storage::COLOR_KEY_PRIVATE, kolab_storage::COLOR_KEY_SHARED)); if (($color = $metadata[kolab_storage::COLOR_KEY_PRIVATE]) || ($color = $metadata[kolab_storage::COLOR_KEY_SHARED])) { return $color; } // calendar color is stored in user prefs (temporary solution) $prefs = $this->cal->rc->config->get('kolab_calendars', array()); if (!empty($prefs[$this->id]) && !empty($prefs[$this->id]['color'])) return $prefs[$this->id]['color']; return 'cc0000'; } /** * Return the corresponding kolab_storage_folder instance */ public function get_folder() { return $this->storage; } /** * Getter for a single event object */ public function get_event($id) { // directly access storage object if (!$this->events[$id] && ($record = $this->storage->get_object($id))) $this->events[$id] = $this->_to_rcube_event($record); // event not found, maybe a recurring instance is requested if (!$this->events[$id]) { $master_id = preg_replace('/-\d+$/', '', $id); if ($record = $this->storage->get_object($master_id)) $this->events[$master_id] = $this->_to_rcube_event($record); if (($master = $this->events[$master_id]) && $master['recurrence']) { $limit = clone $master['start']; $limit->add(new DateInterval('P10Y')); $this->_get_recurring_events($record, $master['start'], $limit, $id); } } return $this->events[$id]; } /** * @param integer Event's new start (unix timestamp) * @param integer Event's new end (unix timestamp) * @param string Search query (optional) * @param boolean Include virtual events (optional) * @param array Additional parameters to query storage * @return array A list of event records */ public function list_events($start, $end, $search = null, $virtual = 1, $query = array()) { // convert to DateTime for comparisons $start = new DateTime('@'.$start); $end = new DateTime('@'.$end); // query Kolab storage $query[] = array('dtstart', '<=', $end); $query[] = array('dtend', '>=', $start); if (!empty($search)) { $search = mb_strtolower($search); foreach (rcube_utils::normalize_string($search, true) as $word) { $query[] = array('words', 'LIKE', $word); } } $events = array(); foreach ((array)$this->storage->select($query) as $record) { $event = $this->_to_rcube_event($record); $this->events[$event['id']] = $event; // remember seen categories if ($event['categories']) $this->categories[$event['categories']]++; // filter events by search query if (!empty($search)) { $hit = false; foreach ($this->search_fields as $col) { $sval = is_array($col) ? $event[$col[0]][$col[1]] : $event[$col]; if (empty($sval)) continue; // do a simple substring matching (to be improved) $val = mb_strtolower($sval); if (strpos($val, $search) !== false) { $hit = true; break; } } if (!$hit) // skip this event if not match with search term continue; } // list events in requested time window if ($event['start'] <= $end && $event['end'] >= $start) { unset($event['_attendees']); $events[] = $event; } // resolve recurring events if ($record['recurrence'] && $virtual == 1) { $events = array_merge($events, $this->_get_recurring_events($record, $start, $end)); } } return $events; } /** * Create a new event record * * @see calendar_driver::new_event() * * @return mixed The created record ID on success, False on error */ public function insert_event($event) { if (!is_array($event)) return false; //generate new event from RC input $object = $this->_from_rcube_event($event); $saved = $this->storage->save($object, 'event'); if (!$saved) { raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving event object to Kolab server"), true, false); $saved = false; } else { $event['id'] = $event['uid']; $this->events[$event['uid']] = $this->_to_rcube_event($object); } return $saved; } /** * Update a specific event record * * @see calendar_driver::new_event() * @return boolean True on success, False on error */ public function update_event($event) { $updated = false; $old = $this->storage->get_object($event['id']); if (!$old || PEAR::isError($old)) return false; $old['recurrence'] = ''; # clear old field, could have been removed in new, too $object = $this->_from_rcube_event($event, $old); $saved = $this->storage->save($object, 'event', $event['id']); if (!$saved) { raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving event object to Kolab server"), true, false); } else { $updated = true; $this->events[$event['id']] = $this->_to_rcube_event($object); } return $updated; } /** * Delete an event record * * @see calendar_driver::remove_event() * @return boolean True on success, False on error */ public function delete_event($event, $force = true) { $deleted = $this->storage->delete($event['id'], $force); if (!$deleted) { raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error deleting event object from Kolab server"), true, false); } return $deleted; } /** * Restore deleted event record * * @see calendar_driver::undelete_event() * @return boolean True on success, False on error */ public function restore_event($event) { if ($this->storage->undelete($event['id'])) { return true; } else { raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error undeleting the event object $uid from the Kolab server"), true, false); } return false; } /** * Create instances of a recurring event */ public function _get_recurring_events($event, $start, $end, $event_id = null) { - $recurrence = new kolab_date_recurrence($event); + $object = $event['_formatobj']; + if (!$object) { + $rec = $this->storage->get_object($event['id']); + $object = $rec['_formatobj']; + } + if (!is_object($object)) + return array(); + + $recurrence = new kolab_date_recurrence($object); $i = 0; $events = array(); while ($next_event = $recurrence->next_instance()) { $rec_start = $next_event['start']->format('U'); $rec_end = $next_event['end']->format('U'); $rec_id = $event['uid'] . '-' . ++$i; // add to output if in range if (($next_event['start'] <= $end && $next_event['end'] >= $start) || ($event_id && $rec_id == $event_id)) { $rec_event = $this->_to_rcube_event($next_event); $rec_event['id'] = $rec_id; $rec_event['recurrence_id'] = $event['uid']; $rec_event['_instance'] = $i; unset($rec_event['_attendees']); $events[] = $rec_event; if ($rec_id == $event_id) { $this->events[$rec_id] = $rec_event; break; } } else if ($next_event['start'] > $end) // stop loop if out of range break; } return $events; } /** * Convert from Kolab_Format to internal representation */ private function _to_rcube_event($record) { $record['id'] = $record['uid']; $record['calendar'] = $this->id; /* // convert from DateTime to unix timestamp if (is_a($record['start'], 'DateTime')) $record['start'] = $record['start']->format('U'); if (is_a($record['end'], 'DateTime')) $record['end'] = $record['end']->format('U'); */ // all-day events go from 12:00 - 13:00 if ($record['end'] <= $record['start'] && $record['allday']) { $record['end'] = clone $record['start']; $record['end']->add(new DateInterval('PT1H')); } if (!empty($record['_attachments'])) { foreach ($record['_attachments'] as $key => $attachment) { if ($attachment !== false) { if (!$attachment['name']) $attachment['name'] = $key; unset($attachment['path'], $attachment['content']); $attachments[] = $attachment; } } $record['attachments'] = $attachments; } $sensitivity_map = array_flip($this->sensitivity_map); $record['sensitivity'] = intval($sensitivity_map[$record['sensitivity']]); // Roundcube only supports one category assignment if (is_array($record['categories'])) $record['categories'] = $record['categories'][0]; // remove internals unset($record['_mailbox'], $record['_msguid'], $record['_formatobj'], $record['_attachments']); return $record; } /** * Convert the given event record into a data structure that can be passed to Kolab_Storage backend for saving * (opposite of self::_to_rcube_event()) */ private function _from_rcube_event($event, $old = array()) { $object = &$event; // in kolab_storage attachments are indexed by content-id $object['_attachments'] = array(); if (is_array($event['attachments'])) { foreach ($event['attachments'] as $idx => $attachment) { $key = null; // Roundcube ID has nothing to do with the storage ID, remove it if ($attachment['content']) { unset($attachment['id']); } else { foreach ((array)$old['_attachments'] as $cid => $oldatt) { if ($attachment['id'] == $oldatt['id']) $key = $cid; } } // flagged for deletion => set to false if ($attachment['_deleted']) { $object['_attachments'][$key] = false; } // replace existing entry else if ($key) { $object['_attachments'][$key] = $attachment; } // append as new attachment else { $object['_attachments'][] = $attachment; } } unset($event['attachments']); } // translate sensitivity property $event['sensitivity'] = $this->sensitivity_map[$event['sensitivity']]; // set current user as ORGANIZER $identity = $this->cal->rc->user->get_identity(); if (empty($event['attendees']) && $identity['email']) $event['attendees'] = array(array('role' => 'ORGANIZER', 'name' => $identity['name'], 'email' => $identity['email'])); $event['_owner'] = $identity['email']; // copy meta data (starting with _) from old object foreach ((array)$old as $key => $val) { if (!isset($event[$key]) && $key[0] == '_') $event[$key] = $val; } return $event; } } diff --git a/plugins/libkolab/lib/kolab_date_recurrence.php b/plugins/libkolab/lib/kolab_date_recurrence.php index 427f62ac..2e883d08 100644 --- a/plugins/libkolab/lib/kolab_date_recurrence.php +++ b/plugins/libkolab/lib/kolab_date_recurrence.php @@ -1,171 +1,114 @@ * * 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_date_recurrence { - private $engine; - private $object; - private $next; - private $duration; - private $tz_offset = 0; - private $dst_start = 0; - private $allday = false; - private $hour = 0; + private /* EventCal */ $engine; + private /* kolab_format_xcal */ $object; + private /* DateTime */ $start; + private /* DateTime */ $next; + private /* DateInterval */ $duration; /** * Default constructor * * @param array The Kolab object to operate on */ function __construct($object) { + $data = $object->to_array(); + $this->object = $object; - $this->next = new Horde_Date($object['start'], kolab_format::$timezone->getName()); + $this->engine = $object->to_libcal(); + $this->start = $this->next = $data['start']; - if (is_object($object['start']) && is_object($object['end'])) - $this->duration = $object['start']->diff($object['end']); + if (is_object($data['start']) && is_object($data['end'])) + $this->duration = $data['start']->diff($data['end']); else - $this->duration = new DateInterval('PT' . ($object['end'] - $object['start']) . 'S'); - - // use (copied) Horde classes to compute recurring instances - // TODO: replace with something that has less than 6'000 lines of code - $this->engine = new Horde_Date_Recurrence($this->next); - $this->engine->fromRRule20($this->to_rrule($object['recurrence'])); // TODO: get that string directly from libkolabxml - - foreach ((array)$object['recurrence']['EXDATE'] as $exdate) - $this->engine->addException($exdate->format('Y'), $exdate->format('n'), $exdate->format('j')); - - $now = new DateTime('now', kolab_format::$timezone); - $this->tz_offset = $object['allday'] ? $now->getOffset() - date('Z') : 0; - $this->dst_start = $this->next->format('I'); - $this->allday = $object['allday']; - $this->hour = $this->next->hour; + $this->duration = new DateInterval('PT' . ($data['end'] - $data['start']) . 'S'); } /** * Get date/time of the next occurence of this event * * @param boolean Return a Unix timestamp instead of a DateTime object * @return mixed DateTime object/unix timestamp or False if recurrence ended */ public function next_start($timestamp = false) { $time = false; - if ($this->next && ($next = $this->engine->nextActiveRecurrence(array('year' => $this->next->year, 'month' => $this->next->month, 'mday' => $this->next->mday + 1, 'hour' => $this->next->hour, 'min' => $this->next->min, 'sec' => $this->next->sec)))) { - if ($this->allday) { - $next->hour = $this->hour; # fix time for all-day events - $next->min = 0; - } - if ($timestamp) { - # consider difference in daylight saving between base event and recurring instance - $dst_diff = ($this->dst_start - $next->format('I')) * 3600; - $time = $next->timestamp() - $this->tz_offset - $dst_diff; - } - else { - $time = $next->toDateTime(); + + if ($this->engine && $this->next) { + $cstart = kolab_format::get_datetime($this->next); + if ($next = kolab_format::php_datetime(new cDateTime($this->engine->getNextOccurence($cstart)))) { + $time = $timestamp ? $next->format('U') : $next; + $this->next = $next; } - $this->next = $next; } return $time; } /** * Get the next recurring instance of this event * * @return mixed Array with event properties or False if recurrence ended */ public function next_instance() { if ($next_start = $this->next_start()) { $next_end = clone $next_start; $next_end->add($this->duration); - $next = $this->object; + $next = $this->object->to_array(); $next['recurrence_id'] = $next_start->format('Y-m-d'); $next['start'] = $next_start; $next['end'] = $next_end; unset($next['_formatobj']); return $next; } return false; } /** * Get the end date of the occurence of this recurrence cycle * * @param string Date limit (where infinite recurrences should abort) * @return mixed Timestamp with end date of the last event or False if recurrence exceeds limit */ public function end($limit = 'now +1 year') { - if ($this->object['recurrence']['UNTIL']) - return $this->object['recurrence']['UNTIL']->format('U'); - - $limit_time = strtotime($limit); - while ($next_start = $this->next_start(true)) { - if ($next_start > $limit_time) - break; - } - - if ($this->next) { - $next_end = $this->next->toDateTime(); - $next_end->add($this->duration); - return $next_end->format('U'); + $limit_dt = new DateTime($limit); + $cstart = kolab_format::get_datetime($this->start); + if ($this->engine && ($cend = $this->engine->getOccurenceEndDate($cstart)) && ($end_dt = kolab_format::php_datetime(new cDateTime($cend))) && $end_dt < $limit_dt) { + return $end_dt->format('U'); } return false; } - - /** - * Convert the internal structured data into a vcalendar RRULE 2.0 string - */ - private function to_rrule($recurrence) - { - if (is_string($recurrence)) - return $recurrence; - - $rrule = ''; - foreach ((array)$recurrence as $k => $val) { - $k = strtoupper($k); - switch ($k) { - case 'UNTIL': - $val = $val->format('Ymd\THis'); - break; - case 'EXDATE': - foreach ((array)$val as $i => $ex) - $val[$i] = $ex->format('Ymd\THis'); - $val = join(',', (array)$val); - break; - } - $rrule .= $k . '=' . $val . ';'; - } - - return $rrule; - } - } diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php index 33ed5af3..4e790f2b 100644 --- a/plugins/libkolab/lib/kolab_format_event.php +++ b/plugins/libkolab/lib/kolab_format_event.php @@ -1,303 +1,313 @@ * * 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_xcal { protected $read_func = 'kolabformat::readEvent'; protected $write_func = 'kolabformat::writeEvent'; 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_monthmap = array('', 'january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'); /** * Default constructor */ function __construct($xmldata = null) { $this->obj = new Event; $this->xmldata = $xmldata; } + /** + * Clones into an instance of libcalendaring's extended EventCal class + * + * @return mixed EventCal object or false on failure + */ + public function to_libcal() + { + return class_exists('kolabcalendaring') ? new EventCal($this->obj) : false; + } + /** * Set event properties to the kolabformat object * * @param array Event data as hash array */ public function set(&$object) { $this->init(); // set common xcal properties parent::set($object); // do the hard work of setting object values $this->obj->setStart(self::get_datetime($object['start'], null, $object['allday'])); $this->obj->setEnd(self::get_datetime($object['end'], null, $object['allday'])); $this->obj->setTransparency($object['free_busy'] == 'free'); $status = kolabformat::StatusUndefined; if ($object['free_busy'] == 'tentative') $status = kolabformat::StatusTentative; if ($object['cancelled']) $status = kolabformat::StatusCancelled; $this->obj->setStatus($status); // save attachments $vattach = new vectorattachment; foreach ((array)$object['_attachments'] as $cid => $attr) { if (empty($attr)) continue; $attach = new Attachment; $attach->setLabel((string)$attr['name']); $attach->setUri('cid:' . $cid, $attr['mimetype']); $vattach->push($attach); } $this->obj->setAttachments($vattach); // cache this data $this->data = $object; unset($this->data['_formatobj']); } /** * */ public function is_valid() { return $this->data || (is_object($this->obj) && $this->obj->isValid() && $this->obj->uid()); } /** * Convert the Event object into a hash array data structure * * @return array Event data as hash array */ public function to_array() { // return cached result if (!empty($this->data)) return $this->data; $this->init(); // read common xcal props $object = parent::to_array(); // read object properties $object += array( 'end' => self::php_datetime($this->obj->end()), 'allday' => $this->obj->start()->isDateOnly(), 'free_busy' => $this->obj->transparency() ? 'free' : 'busy', // TODO: transparency is only boolean 'attendees' => array(), ); // organizer is part of the attendees list in Roundcube if ($object['organizer']) { $object['organizer']['role'] = 'ORGANIZER'; array_unshift($object['attendees'], $object['organizer']); } // status defines different event properties... $status = $this->obj->status(); if ($status == kolabformat::StatusTentative) $object['free_busy'] = 'tentative'; else if ($status == kolabformat::StatusCancelled) $objec['cancelled'] = true; // handle attachments $vattach = $this->obj->attachments(); for ($i=0; $i < $vattach->size(); $i++) { $attach = $vattach->get($i); // skip cid: attachments which are mime message parts handled by kolab_storage_folder if (substr($attach->uri(), 0, 4) != 'cid') { $name = $attach->label(); $data = $attach->data(); $object['_attachments'][$name] = array( 'name' => $name, 'mimetype' => $attach->mimetype(), 'size' => strlen($data), 'content' => $data, ); } } $this->data = $object; return $this->data; } /** * 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 alarm time into internal format if ($rec['alarm']) { $alarm_value = $rec['alarm']; $alarm_unit = 'M'; if ($rec['alarm'] % 1440 == 0) { $alarm_value /= 1440; $alarm_unit = 'D'; } else if ($rec['alarm'] % 60 == 0) { $alarm_value /= 60; $alarm_unit = 'H'; } $alarm_value *= -1; } // 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') $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 } } $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'], 'title' => $rec['summary'], 'location' => $rec['location'], 'description' => $rec['body'], 'start' => new DateTime('@'.$rec['start-date']), 'end' => new DateTime('@'.$rec['end-date']), 'allday' => $allday, 'recurrence' => $rrule, 'alarms' => $alarm_value . $alarm_unit, 'categories' => explode(',', $rec['categories']), 'attachments' => $attachments, '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 $this->data['start']->setTimezone(self::$timezone); $this->data['end']->setTimezone(self::$timezone); } } diff --git a/plugins/libkolab/lib/kolab_storage_cache.php b/plugins/libkolab/lib/kolab_storage_cache.php index c3e88da8..866a7950 100644 --- a/plugins/libkolab/lib/kolab_storage_cache.php +++ b/plugins/libkolab/lib/kolab_storage_cache.php @@ -1,728 +1,728 @@ * * 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_storage_cache { private $db; private $imap; private $folder; private $uid2msg; private $objects; private $index = array(); private $resource_uri; private $enabled = true; private $synched = false; private $synclock = false; private $ready = false; private $max_sql_packet = 1046576; // 1 MB - 2000 bytes private $binary_cols = array('photo','pgppublickey','pkcs7publickey'); /** * Default constructor */ public function __construct(kolab_storage_folder $storage_folder = null) { $rcmail = rcube::get_instance(); $this->db = $rcmail->get_dbh(); $this->imap = $rcmail->get_storage(); $this->enabled = $rcmail->config->get('kolab_cache', false); if ($this->enabled) { // remove sync-lock on script termination $rcmail->add_shutdown_function(array($this, '_sync_unlock')); // read max_allowed_packet from mysql config $this->max_sql_packet = min($this->db->get_variable('max_allowed_packet', 1048500), 4*1024*1024) - 2000; // mysql limit or max 4 MB } if ($storage_folder) $this->set_folder($storage_folder); } /** * Connect cache with a storage folder * * @param kolab_storage_folder The storage folder instance to connect with */ public function set_folder(kolab_storage_folder $storage_folder) { $this->folder = $storage_folder; if (empty($this->folder->name)) { $this->ready = false; return; } // compose fully qualified ressource uri for this instance $this->resource_uri = $this->folder->get_resource_uri(); $this->ready = $this->enabled; } /** * Synchronize local cache data with remote */ public function synchronize() { // only sync once per request cycle if ($this->synched) return; // increase time limit @set_time_limit(500); // lock synchronization for this folder or wait if locked $this->_sync_lock(); // synchronize IMAP mailbox cache $this->imap->folder_sync($this->folder->name); // compare IMAP index with object cache index $imap_index = $this->imap->index($this->folder->name); $this->index = $imap_index->get(); // determine objects to fetch or to invalidate if ($this->ready) { // read cache index $sql_result = $this->db->query( "SELECT msguid, uid FROM kolab_cache WHERE resource=? AND type<>?", $this->resource_uri, 'lock' ); $old_index = array(); while ($sql_arr = $this->db->fetch_assoc($sql_result)) { $old_index[] = $sql_arr['msguid']; $this->uid2msg[$sql_arr['uid']] = $sql_arr['msguid']; } // fetch new objects from imap foreach (array_diff($this->index, $old_index) as $msguid) { if ($object = $this->folder->read_object($msguid, '*')) { $this->_extended_insert($msguid, $object); } } $this->_extended_insert(0, null); // delete invalid entries from local DB $del_index = array_diff($old_index, $this->index); if (!empty($del_index)) { $quoted_ids = join(',', array_map(array($this->db, 'quote'), $del_index)); $this->db->query( "DELETE FROM kolab_cache WHERE resource=? AND msguid IN ($quoted_ids)", $this->resource_uri ); } } // remove lock $this->_sync_unlock(); $this->synched = time(); } /** * Read a single entry from cache or from IMAP directly * * @param string Related IMAP message UID * @param string Object type to read * @param string IMAP folder name the entry relates to * @param array Hash array with object properties or null if not found */ public function get($msguid, $type = null, $foldername = null) { // delegate to another cache instance if ($foldername && $foldername != $this->folder->name) { return kolab_storage::get_folder($foldername)->cache->get($msguid, $object); } // load object if not in memory if (!isset($this->objects[$msguid])) { if ($this->ready) { $sql_result = $this->db->query( "SELECT * FROM kolab_cache ". "WHERE resource=? AND type=? AND msguid=?", $this->resource_uri, $type ?: $this->folder->type, $msguid ); if ($sql_arr = $this->db->fetch_assoc($sql_result)) { $this->objects[$msguid] = $this->_unserialize($sql_arr); } } // fetch from IMAP if not present in cache if (empty($this->objects[$msguid])) { $result = $this->_fetch(array($msguid), $type, $foldername); $this->objects[$msguid] = $result[0]; } } return $this->objects[$msguid]; } /** * Insert/Update a cache entry * * @param string Related IMAP message UID * @param mixed Hash array with object properties to save or false to delete the cache entry * @param string IMAP folder name the entry relates to */ public function set($msguid, $object, $foldername = null) { if (!$msguid) { return; } // delegate to another cache instance if ($foldername && $foldername != $this->folder->name) { kolab_storage::get_folder($foldername)->cache->set($msguid, $object); return; } // remove old entry if ($this->ready) { $this->db->query("DELETE FROM kolab_cache WHERE resource=? AND msguid=? AND type<>?", $this->resource_uri, $msguid, 'lock'); } if ($object) { // insert new object data... $this->insert($msguid, $object); } else { // ...or set in-memory cache to false $this->objects[$msguid] = $object; } } /** * Insert a cache entry * * @param string Related IMAP message UID * @param mixed Hash array with object properties to save or false to delete the cache entry */ public function insert($msguid, $object) { // write to cache if ($this->ready) { $sql_data = $this->_serialize($object); $objtype = $object['_type'] ? $object['_type'] : $this->folder->type; $result = $this->db->query( "INSERT INTO kolab_cache ". " (resource, type, msguid, uid, created, changed, data, xml, dtstart, dtend, tags, words)". " VALUES (?, ?, ?, ?, " . $this->db->now() . ", ?, ?, ?, ?, ?, ?, ?)", $this->resource_uri, $objtype, $msguid, $object['uid'], $sql_data['changed'], $sql_data['data'], $sql_data['xml'], $sql_data['dtstart'], $sql_data['dtend'], $sql_data['tags'], $sql_data['words'] ); if (!$this->db->affected_rows($result)) { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "Failed to write to kolab cache" ), true); } } // keep a copy in memory for fast access $this->objects[$msguid] = $object; $this->uid2msg[$object['uid']] = $msguid; } /** * Move an existing cache entry to a new resource * * @param string Entry's IMAP message UID * @param string Entry's Object UID * @param string Target IMAP folder to move it to */ public function move($msguid, $objuid, $target_folder) { $target = kolab_storage::get_folder($target_folder); // resolve new message UID in target folder if ($new_msguid = $target->cache->uid2msguid($objuid)) { $this->db->query( "UPDATE kolab_cache SET resource=?, msguid=? ". "WHERE resource=? AND msguid=? AND type<>?", $target->get_resource_uri(), $new_msguid, $this->resource_uri, $msguid, 'lock' ); } else { // just clear cache entry $this->set($msguid, false); } unset($this->uid2msg[$uid]); } /** * Remove all objects from local cache */ public function purge($type = null) { $result = $this->db->query( "DELETE FROM kolab_cache WHERE resource=?". ($type ? ' AND type=?' : ''), $this->resource_uri, $type ); return $this->db->affected_rows($result); } /** * Select Kolab objects filtered by the given query * * @param array Pseudo-SQL query as list of filter parameter triplets * triplet: array('', '', '') * @param boolean Set true to only return UIDs instead of complete objects * @return array List of Kolab data objects (each represented as hash array) or UIDs */ public function select($query = array(), $uids = false) { $result = array(); // read from local cache DB (assume it to be synchronized) if ($this->ready) { $sql_result = $this->db->query( "SELECT " . ($uids ? 'msguid, uid' : '*') . " FROM kolab_cache ". "WHERE resource=? " . $this->_sql_where($query), $this->resource_uri ); while ($sql_arr = $this->db->fetch_assoc($sql_result)) { if ($uids) { $this->uid2msg[$sql_arr['uid']] = $sql_arr['msguid']; $result[] = $sql_arr['uid']; } else if ($object = $this->_unserialize($sql_arr)) { $result[] = $object; } } } else { // extract object type from query parameter $filter = $this->_query2assoc($query); // use 'list' for folder's default objects if ($filter['type'] == $this->type) { $index = $this->index; } else { // search by object type $search = 'UNDELETED HEADER X-Kolab-Type ' . kolab_format::KTYPE_PREFIX . $filter['type']; $index = $this->imap->search_once($this->folder->name, $search)->get(); } // fetch all messages in $index from IMAP $result = $uids ? $this->_fetch_uids($index, $filter['type']) : $this->_fetch($index, $filter['type']); // TODO: post-filter result according to query } return $result; } /** * Get number of objects mathing the given query * * @param array $query Pseudo-SQL query as list of filter parameter triplets * @return integer The number of objects of the given type */ public function count($query = array()) { $count = 0; // cache is in sync, we can count records in local DB if ($this->synched) { $sql_result = $this->db->query( "SELECT COUNT(*) AS numrows FROM kolab_cache ". "WHERE resource=? " . $this->_sql_where($query), $this->resource_uri ); $sql_arr = $this->db->fetch_assoc($sql_result); $count = intval($sql_arr['numrows']); } else { // search IMAP by object type $filter = $this->_query2assoc($query); $ctype = kolab_format::KTYPE_PREFIX . $filter['type']; $index = $this->imap->search_once($this->folder->name, 'UNDELETED HEADER X-Kolab-Type ' . $ctype); $count = $index->count(); } return $count; } /** * Helper method to compose a valid SQL query from pseudo filter triplets */ private function _sql_where($query) { $sql_where = ''; foreach ($query as $param) { if ($param[1] == '=' && is_array($param[2])) { $qvalue = '(' . join(',', array_map(array($this->db, 'quote'), $param[2])) . ')'; $param[1] = 'IN'; } else if ($param[1] == '~' || $param[1] == 'LIKE' || $param[1] == '!~' || $param[1] == '!LIKE') { $not = ($param[1] == '!~' || $param[1] == '!LIKE') ? 'NOT ' : ''; $param[1] = $not . 'LIKE'; $qvalue = $this->db->quote('%'.preg_replace('/(^\^|\$$)/', ' ', $param[2]).'%'); } else if ($param[0] == 'tags') { $param[1] = 'LIKE'; $qvalue = $this->db->quote('% '.$param[2].' %'); } else { $qvalue = $this->db->quote($param[2]); } $sql_where .= sprintf(' AND %s %s %s', $this->db->quote_identifier($param[0]), $param[1], $qvalue ); } return $sql_where; } /** * Helper method to convert the given pseudo-query triplets into * an associative filter array with 'equals' values only */ private function _query2assoc($query) { // extract object type from query parameter $filter = array(); foreach ($query as $param) { if ($param[1] == '=') $filter[$param[0]] = $param[2]; } return $filter; } /** * Fetch messages from IMAP * * @param array List of message UIDs to fetch * @param string Requested object type or * for all * @param string IMAP folder to read from * @return array List of parsed Kolab objects */ private function _fetch($index, $type = null, $folder = null) { $results = array(); foreach ((array)$index as $msguid) { if ($object = $this->folder->read_object($msguid, $type, $folder)) { $results[] = $object; $this->set($msguid, $object); } } return $results; } /** * Fetch object UIDs (aka message subjects) from IMAP * * @param array List of message UIDs to fetch * @param string Requested object type or * for all * @param string IMAP folder to read from * @return array List of parsed Kolab objects */ private function _fetch_uids($index, $type = null) { if (!$type) $type = $this->folder->type; $results = array(); foreach ((array)$this->imap->fetch_headers($this->folder->name, $index, false) as $msguid => $headers) { $object_type = kolab_format::mime2object_type($headers->others['x-kolab-type']); // check object type header and abort on mismatch if ($type != '*' && $object_type != $type) return false; $uid = $headers->subject; $this->uid2msg[$uid] = $msguid; $results[] = $uid; } return $results; } /** * Helper method to convert the given Kolab object into a dataset to be written to cache */ private function _serialize($object) { $bincols = array_flip($this->binary_cols); $sql_data = array('changed' => null, 'dtstart' => null, 'dtend' => null, 'xml' => '', 'tags' => '', 'words' => ''); $objtype = $object['_type'] ? $object['_type'] : $this->folder->type; // set type specific values if ($objtype == 'event') { // database runs in server's timezone so using date() is what we want $sql_data['dtstart'] = date('Y-m-d H:i:s', is_object($object['start']) ? $object['start']->format('U') : $object['start']); $sql_data['dtend'] = date('Y-m-d H:i:s', is_object($object['end']) ? $object['end']->format('U') : $object['end']); // extend date range for recurring events - if ($object['recurrence']) { - $recurrence = new kolab_date_recurrence($object); + if ($object['recurrence'] && $object['_formatobj']) { + $recurrence = new kolab_date_recurrence($object['_formatobj']); $sql_data['dtend'] = date('Y-m-d 23:59:59', $recurrence->end() ?: strtotime('now +1 year')); } } else if ($objtype == 'task') { if ($object['start']) $sql_data['dtstart'] = date('Y-m-d H:i:s', is_object($object['start']) ? $object['start']->format('U') : $object['start']); if ($object['due']) $sql_data['dtend'] = date('Y-m-d H:i:s', is_object($object['due']) ? $object['due']->format('U') : $object['due']); } if ($object['changed']) { $sql_data['changed'] = date('Y-m-d H:i:s', is_object($object['changed']) ? $object['changed']->format('U') : $object['changed']); } if ($object['_formatobj']) { $sql_data['xml'] = preg_replace('!()[\n\r\t\s]+!ms', '$1', (string)$object['_formatobj']->write()); $sql_data['tags'] = ' ' . join(' ', $object['_formatobj']->get_tags()) . ' '; // pad with spaces for strict/prefix search $sql_data['words'] = ' ' . join(' ', $object['_formatobj']->get_words()) . ' '; } // extract object data $data = array(); foreach ($object as $key => $val) { if ($val === "" || $val === null) { // skip empty properties continue; } if (isset($bincols[$key])) { $data[$key] = base64_encode($val); } else if ($key[0] != '_') { $data[$key] = $val; } else if ($key == '_attachments') { foreach ($val as $k => $att) { unset($att['content'], $att['path']); if ($att['id']) $data[$key][$k] = $att; } } } $sql_data['data'] = serialize($data); return $sql_data; } /** * Helper method to turn stored cache data into a valid storage object */ private function _unserialize($sql_arr) { $object = unserialize($sql_arr['data']); // decode binary properties foreach ($this->binary_cols as $key) { if (!empty($object[$key])) $object[$key] = base64_decode($object[$key]); } // add meta data $object['_type'] = $sql_arr['type']; $object['_msguid'] = $sql_arr['msguid']; $object['_mailbox'] = $this->folder->name; $object['_formatobj'] = kolab_format::factory($sql_arr['type'], $sql_arr['xml']); return $object; } /** * Write records into cache using extended inserts to reduce the number of queries to be executed * * @param int Message UID. Set 0 to commit buffered inserts * @param array Kolab object to cache */ private function _extended_insert($msguid, $object) { static $buffer = ''; $line = ''; if ($object) { $sql_data = $this->_serialize($object); $objtype = $object['_type'] ? $object['_type'] : $this->folder->type; $values = array( $this->db->quote($this->resource_uri), $this->db->quote($objtype), $this->db->quote($msguid), $this->db->quote($object['uid']), $this->db->now(), $this->db->quote($sql_data['changed']), $this->db->quote($sql_data['data']), $this->db->quote($sql_data['xml']), $this->db->quote($sql_data['dtstart']), $this->db->quote($sql_data['dtend']), $this->db->quote($sql_data['tags']), $this->db->quote($sql_data['words']), ); $line = '(' . join(',', $values) . ')'; } if ($buffer && (!$msguid || (strlen($buffer) + strlen($line) > $this->max_sql_packet))) { $result = $this->db->query( "INSERT INTO kolab_cache ". " (resource, type, msguid, uid, created, changed, data, xml, dtstart, dtend, tags, words)". " VALUES $buffer" ); if (!$this->db->affected_rows($result)) { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "Failed to write to kolab cache" ), true); } $buffer = ''; } $buffer .= ($buffer ? ',' : '') . $line; } /** * Check lock record for this folder and wait if locked or set lock */ private function _sync_lock() { if (!$this->ready) return; $sql_arr = $this->db->fetch_assoc($this->db->query( "SELECT msguid AS locked, ".$this->db->unixtimestamp('created')." AS created FROM kolab_cache ". "WHERE resource=? AND type=?", $this->resource_uri, 'lock' )); // abort if database is not set-up if ($this->db->is_error()) { $this->ready = false; return; } $this->synclock = true; // create lock record if not exists if (!$sql_arr) { $this->db->query( "INSERT INTO kolab_cache (resource, type, msguid, created, uid, data, xml)". " VALUES (?, ?, 1, ?, '', '', '')", $this->resource_uri, 'lock', date('Y-m-d H:i:s') ); } // wait if locked (expire locks after 10 minutes) else if (intval($sql_arr['locked']) > 0 && (time() - $sql_arr['created']) < 600) { usleep(500000); return $this->_sync_lock(); } // set lock else { $this->db->query( "UPDATE kolab_cache SET msguid=1, created=? ". "WHERE resource=? AND type=?", date('Y-m-d H:i:s'), $this->resource_uri, 'lock' ); } } /** * Remove lock for this folder */ public function _sync_unlock() { if (!$this->ready || !$this->synclock) return; $this->db->query( "UPDATE kolab_cache SET msguid=0 ". "WHERE resource=? AND type=?", $this->resource_uri, 'lock' ); $this->synclock = false; } /** * Resolve an object UID into an IMAP message UID * * @param string Kolab object UID * @param boolean Include deleted objects * @return int The resolved IMAP message UID */ public function uid2msguid($uid, $deleted = false) { if (!isset($this->uid2msg[$uid])) { // use IMAP SEARCH to get the right message $index = $this->imap->search_once($this->folder->name, ($deleted ? '' : 'UNDELETED ') . 'HEADER SUBJECT ' . $uid); $results = $index->get(); $this->uid2msg[$uid] = $results[0]; } return $this->uid2msg[$uid]; } }