* @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; public $name; private $cal; private $events = array(); private $imap_folder = 'INBOX/Calendar'; private $search_fields = array('title', 'description', 'location', 'attendees'); /** * Default constructor */ public function __construct($imap_folder, $calendar) { $this->cal = $calendar; if (strlen($imap_folder)) $this->imap_folder = $this->name = $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 if ($color = $this->storage->get_color()) { 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'; } /** * Compose an URL for CalDAV access to this calendar (if configured) */ public function get_caldav_url() { $url = null; if ($template = $this->cal->rc->config->get('calendar_caldav_url', null)) { return strtr($template, array( '%h' => $_SERVER['HTTP_HOST'], '%u' => urlencode($this->cal->rc->get_user_name()), '%i' => urlencode($this->storage->get_uid()), '%n' => urlencode($this->imap_folder), )); } return false; } /** * 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 ($master_id != $id && ($record = $this->storage->get_object($master_id))) $this->events[$master_id] = $this->_to_rcube_event($record); if (($master = $this->events[$master_id]) && $master['recurrence']) { $this->_get_recurring_events($record, $master['start'], null, $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 ($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($event[$col]) ? self::_complex2string($event[$col]) : $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']); $add = true; // skip the first instance of a recurring event if listed in exdate if ($virtual && (!empty($event['recurrence']['EXDATE']) || !empty($event['recurrence']['EXCEPTIONS']))) { $event_date = $event['start']->format('Ymd'); $exdates = (array)$event['recurrence']['EXDATE']; // add dates from exceptions to list if (is_array($event['recurrence']['EXCEPTIONS'])) { foreach ($event['recurrence']['EXCEPTIONS'] as $exception) { $exdates[] = clone $exception['start']; } } foreach ($exdates as $exdate) { if ($exdate->format('Ymd') == $event_date) { $add = false; break; } } } if ($add) $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) { rcube::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 = array($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, $exception_id = null) { $updated = false; $old = $this->storage->get_object($event['id']); if (!$old || PEAR::isError($old)) return false; $object = $this->_from_rcube_event($event, $old); $saved = $this->storage->save($object, 'event', $event['id']); if (!$saved) { rcube::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); // refresh local cache with recurring instances if ($exception_id) { $this->_get_recurring_events($object, $event['start'], $event['end'], $exception_id); } } 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) { rcube::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 { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error undeleting the event object $event[id] from the Kolab server"), true, false); } return false; } /** * Create instances of a recurring event * * @param array Hash array with event properties * @param object DateTime Start date of the recurrence window * @param object DateTime End date of the recurrence window * @param string ID of a specific recurring event instance * @return array List of recurring event instances */ public function _get_recurring_events($event, $start, $end = null, $event_id = null) { $object = $event['_formatobj']; if (!$object) { $rec = $this->storage->get_object($event['id']); $object = $rec['_formatobj']; } if (!is_object($object)) return array(); // determine a reasonable end date if none given if (!$end) { switch ($event['recurrence']['FREQ']) { case 'YEARLY': $intvl = 'P100Y'; break; case 'MONTHLY': $intvl = 'P20Y'; break; default: $intvl = 'P10Y'; break; } $end = clone $event['start']; $end->add(new DateInterval($intvl)); } // add recurrence exceptions to output $i = 0; $events = array(); $exdates = array(); $futuredata = array(); if (is_array($event['recurrence']['EXCEPTIONS'])) { // copy the recurrence rule from the master event (to be used in the UI) $recurrence_rule = $event['recurrence']; unset($recurrence_rule['EXCEPTIONS'], $recurrence_rule['EXDATE']); foreach ($event['recurrence']['EXCEPTIONS'] as $exception) { $rec_event = $this->_to_rcube_event($exception); $rec_event['id'] = $event['uid'] . '-' . ++$i; $rec_event['recurrence_id'] = $event['uid']; $rec_event['recurrence'] = $recurrence_rule; $rec_event['_instance'] = $i; $rec_event['isexception'] = 1; $events[] = $rec_event; // found the specifically requested instance, exiting... if ($rec_event['id'] == $event_id) { $this->events[$rec_event['id']] = $rec_event; return $events; } // remember this exception's date $exdate = $rec_event['start']->format('Y-m-d'); $exdates[$exdate] = $rec_event['id']; if ($rec_event['thisandfuture']) { $futuredata[$exdate] = $rec_event; } } } // use libkolab to compute recurring events if (class_exists('kolabcalendaring')) { $recurrence = new kolab_date_recurrence($object); } else { // fallback to local recurrence implementation require_once($this->cal->home . '/lib/calendar_recurrence.php'); $recurrence = new calendar_recurrence($this->cal, $event); } while ($next_event = $recurrence->next_instance()) { // skip if there's an exception at this date $datestr = $next_event['start']->format('Y-m-d'); if ($exdates[$datestr]) { // use this event data for future recurring instances if ($futuredata[$datestr]) $overlay_data = $futuredata[$datestr]; continue; } // add to output if in range $rec_id = $event['uid'] . '-' . ++$i; if (($next_event['start'] <= $end && $next_event['end'] >= $start) || ($event_id && $rec_id == $event_id)) { $rec_event = $this->_to_rcube_event($next_event); if ($overlay_data) // copy data from a 'this-and-future' exception $this->_merge_event_data($rec_event, $overlay_data); $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; // avoid endless recursion loops if ($i > 1000) break; } return $events; } /** * Merge certain properties from the overlay event to the base event object * * @param array The event object to be altered * @param array The overlay event object to be merged over $event */ private function _merge_event_data(&$event, $overlay) { static $forbidden = array('id','uid','created','changed','recurrence','organizer','attendees','sequence'); foreach ($overlay as $prop => $value) { // adjust time of the recurring event instance if ($prop == 'start' || $prop == 'end') { if (is_object($event[$prop]) && is_a($event[$prop], 'DateTime')) $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s'))); } else if ($prop[0] != '_' && !in_array($prop, $forbidden)) $event[$prop] = $value; } } /** * 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; } // Roundcube only supports one category assignment if (is_array($record['categories'])) $record['categories'] = $record['categories'][0]; // The web client only supports DISPLAY type of alarms if (!empty($record['alarms'])) $record['alarms'] = preg_replace('/:[A-Z]+$/', ':DISPLAY', $record['alarms']); // remove empty recurrence array if (empty($record['recurrence'])) unset($record['recurrence']); // remove internals unset($record['_mailbox'], $record['_msguid'], $record['_formatobj'], $record['_attachments'], $record['x-custom']); 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()) { // in kolab_storage attachments are indexed by content-id $event['_attachments'] = array(); if (is_array($event['attachments'])) { foreach ($event['attachments'] as $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']) { $event['_attachments'][$key] = false; } // replace existing entry else if ($key) { $event['_attachments'][$key] = $attachment; } // append as new attachment else { $event['_attachments'][] = $attachment; } } unset($event['attachments']); } // 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']; # remove EXDATE values if RDATE is given if (!empty($event['recurrence']['RDATE'])) { $event['recurrence']['EXDATE'] = array(); } // remove some internal properties which should not be saved unset($event['_savemode'], $event['_fromcalendar'], $event['_identity']); // 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; } /** * Convert a complex event attribute to a string value */ private static function _complex2string($prop) { static $ignorekeys = array('role','status','rsvp'); $out = ''; if (is_array($prop)) { foreach ($prop as $key => $val) { if (is_numeric($key)) { $out .= self::_complex2string($val); } else if (!in_array($key, $ignorekeys)) { $out .= $val . ' '; } } } else if (is_string($prop) || is_numeric($prop)) { $out .= $prop . ' '; } return rtrim($out); } }