diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index 5cfb8114..9ae08c22 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -1,2503 +1,2504 @@ * @author Aleksander Machniak * * Copyright (C) 2012-2015, 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_driver extends calendar_driver { const INVITATIONS_CALENDAR_PENDING = '--invitation--pending'; const INVITATIONS_CALENDAR_DECLINED = '--invitation--declined'; // features this backend supports public $alarms = true; public $attendees = true; public $freebusy = true; public $attachments = true; public $undelete = true; public $alarm_types = array('DISPLAY','AUDIO'); public $categoriesimmutable = true; private $rc; private $cal; private $calendars; private $has_writeable = false; private $freebusy_trigger = false; private $bonnie_api = false; /** * Default constructor */ public function __construct($cal) { $cal->require_plugin('libkolab'); // load helper classes *after* libkolab has been loaded (#3248) require_once(dirname(__FILE__) . '/kolab_calendar.php'); require_once(dirname(__FILE__) . '/kolab_user_calendar.php'); require_once(dirname(__FILE__) . '/kolab_invitation_calendar.php'); $this->cal = $cal; $this->rc = $cal->rc; $this->_read_calendars(); $this->cal->register_action('push-freebusy', array($this, 'push_freebusy')); $this->cal->register_action('calendar-acl', array($this, 'calendar_acl')); $this->freebusy_trigger = $this->rc->config->get('calendar_freebusy_trigger', false); if (kolab_storage::$version == '2.0') { $this->alarm_types = array('DISPLAY'); $this->alarm_absolute = false; } // get configuration for the Bonnie API $this->bonnie_api = libkolab::get_bonnie_api(); // calendar uses fully encoded identifiers kolab_storage::$encode_ids = true; } /** * Read available calendars from server */ private function _read_calendars() { // already read sources if (isset($this->calendars)) return $this->calendars; // get all folders that have "event" type, sorted by namespace/name $folders = kolab_storage::sort_folders(kolab_storage::get_folders('event') + kolab_storage::get_user_folders('event', true)); $this->calendars = array(); foreach ($folders as $folder) { if ($folder instanceof kolab_storage_folder_user) { $calendar = new kolab_user_calendar($folder->name, $this->cal); $calendar->subscriptions = count($folder->children) > 0; } else { $calendar = new kolab_calendar($folder->name, $this->cal); } if ($calendar->ready) { $this->calendars[$calendar->id] = $calendar; if ($calendar->editable) $this->has_writeable = true; } } return $this->calendars; } /** * Get a list of available calendars from this source * * @param integer $filter Bitmask defining filter criterias * @param object $tree Reference to hierarchical folder tree object * * @return array List of calendars */ public function list_calendars($filter = 0, &$tree = null) { // attempt to create a default calendar for this user if (!$this->has_writeable) { if ($this->create_calendar(array('name' => 'Calendar', 'color' => 'cc0000'))) { unset($this->calendars); $this->_read_calendars(); } } $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); $folders = $this->filter_calendars($filter); $calendars = array(); // include virtual folders for a full folder tree if (!is_null($tree)) $folders = kolab_storage::folder_hierarchy($folders, $tree); foreach ($folders as $id => $cal) { $fullname = $cal->get_name(); $listname = $cal->get_foldername(); $imap_path = explode($delim, $cal->name); // find parent do { array_pop($imap_path); $parent_id = kolab_storage::folder_id(join($delim, $imap_path)); } while (count($imap_path) > 1 && !$this->calendars[$parent_id]); // restore "real" parent ID if ($parent_id && !$this->calendars[$parent_id]) { $parent_id = kolab_storage::folder_id($cal->get_parent()); } // turn a kolab_storage_folder object into a kolab_calendar if ($cal instanceof kolab_storage_folder) { $cal = new kolab_calendar($cal->name, $this->cal); $this->calendars[$cal->id] = $cal; } // special handling for user or virtual folders if ($cal instanceof kolab_storage_folder_user) { $calendars[$cal->id] = array( 'id' => $cal->id, 'name' => kolab_storage::object_name($fullname), 'listname' => $listname, 'editname' => $cal->get_foldername(), 'color' => $cal->get_color(), 'active' => $cal->is_active(), 'title' => $cal->get_owner(), 'owner' => $cal->get_owner(), 'history' => false, 'virtual' => false, 'editable' => false, 'group' => 'other', 'class' => 'user', 'removable' => true, ); } else if ($cal->virtual) { $calendars[$cal->id] = array( 'id' => $cal->id, 'name' => $fullname, 'listname' => $listname, 'editname' => $cal->get_foldername(), 'virtual' => true, 'editable' => false, 'group' => $cal->get_namespace(), 'class' => 'folder', ); } else { $calendars[$cal->id] = array( 'id' => $cal->id, 'name' => $fullname, 'listname' => $listname, 'editname' => $cal->get_foldername(), 'title' => $cal->get_title(), 'color' => $cal->get_color(), 'editable' => $cal->editable, 'rights' => $cal->rights, 'showalarms' => $cal->alarms, 'history' => !empty($this->bonnie_api), 'group' => $cal->get_namespace(), 'default' => $cal->default, 'active' => $cal->is_active(), 'owner' => $cal->get_owner(), 'children' => true, // TODO: determine if that folder indeed has child folders 'parent' => $parent_id, 'subtype' => $cal->subtype, 'caldavurl' => $cal->get_caldav_url(), 'removable' => !$cal->default, ); } if ($cal->subscriptions) { $calendars[$cal->id]['subscribed'] = $cal->is_subscribed(); } } // list virtual calendars showing invitations if ($this->rc->config->get('kolab_invitation_calendars')) { foreach (array(self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED) as $id) { $cal = new kolab_invitation_calendar($id, $this->cal); $this->calendars[$cal->id] = $cal; if (!($filter & self::FILTER_ACTIVE) || $cal->is_active()) { $calendars[$id] = array( 'id' => $cal->id, 'name' => $cal->get_name(), 'listname' => $cal->get_name(), 'editname' => $cal->get_foldername(), 'title' => $cal->get_title(), 'color' => $cal->get_color(), 'editable' => $cal->editable, 'rights' => $cal->rights, 'showalarms' => $cal->alarms, 'history' => !empty($this->bonnie_api), 'group' => 'x-invitations', 'default' => false, 'active' => $cal->is_active(), 'owner' => $cal->get_owner(), 'children' => false, ); if ($id == self::INVITATIONS_CALENDAR_PENDING) { $calendars[$id]['counts'] = true; } if (is_object($tree)) { $tree->children[] = $cal; } } } } // append the virtual birthdays calendar if ($this->rc->config->get('calendar_contact_birthdays', false)) { $id = self::BIRTHDAY_CALENDAR_ID; $prefs = $this->rc->config->get('kolab_calendars', array()); // read local prefs if (!($filter & self::FILTER_ACTIVE) || $prefs[$id]['active']) { $calendars[$id] = array( 'id' => $id, 'name' => $this->cal->gettext('birthdays'), 'listname' => $this->cal->gettext('birthdays'), 'color' => $prefs[$id]['color'] ?: '87CEFA', 'active' => (bool)$prefs[$id]['active'], 'showalarms' => (bool)$this->rc->config->get('calendar_birthdays_alarm_type'), 'group' => 'x-birthdays', 'editable' => false, 'default' => false, 'children' => false, 'history' => false, ); } } return $calendars; } /** * Get list of calendars according to specified filters * * @param integer Bitmask defining restrictions. See FILTER_* constants for possible values. * * @return array List of calendars */ protected function filter_calendars($filter) { $calendars = array(); $plugin = $this->rc->plugins->exec_hook('calendar_list_filter', array( 'list' => $this->calendars, 'calendars' => $calendars, 'filter' => $filter, )); if ($plugin['abort']) { return $plugin['calendars']; } $personal = $filter & self::FILTER_PERSONAL; $shared = $filter & self::FILTER_SHARED; foreach ($this->calendars as $cal) { if (!$cal->ready) { continue; } if (($filter & self::FILTER_WRITEABLE) && !$cal->editable) { continue; } if (($filter & self::FILTER_INSERTABLE) && !$cal->insert) { continue; } if (($filter & self::FILTER_ACTIVE) && !$cal->is_active()) { continue; } if (($filter & self::FILTER_PRIVATE) && $cal->subtype != 'private') { continue; } if (($filter & self::FILTER_CONFIDENTIAL) && $cal->subtype != 'confidential') { continue; } if ($personal || $shared) { $ns = $cal->get_namespace(); if (!(($personal && $ns == 'personal') || ($shared && $ns == 'shared'))) { continue; } } $calendars[$cal->id] = $cal; } return $calendars; } /** * Get the kolab_calendar instance for the given calendar ID * * @param string Calendar identifier (encoded imap folder name) * @return object kolab_calendar Object nor null if calendar doesn't exist */ public function get_calendar($id) { // create calendar object if necesary if (!$this->calendars[$id] && in_array($id, array(self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED))) { $this->calendars[$id] = new kolab_invitation_calendar($id, $this->cal); } else if (!$this->calendars[$id] && $id !== self::BIRTHDAY_CALENDAR_ID) { $calendar = kolab_calendar::factory($id, $this->cal); if ($calendar->ready) $this->calendars[$calendar->id] = $calendar; } return $this->calendars[$id]; } /** * Create a new calendar assigned to the current user * * @param array Hash array with calendar properties * name: Calendar name * color: The color of the calendar * @return mixed ID of the calendar on success, False on error */ public function create_calendar($prop) { $prop['type'] = 'event'; $prop['active'] = true; $prop['subscribed'] = true; $folder = kolab_storage::folder_update($prop); if ($folder === false) { $this->last_error = $this->cal->gettext(kolab_storage::$last_error); return false; } // create ID $id = kolab_storage::folder_id($folder); // save color in user prefs (temp. solution) $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); if (isset($prop['color'])) $prefs['kolab_calendars'][$id]['color'] = $prop['color']; if (isset($prop['showalarms'])) $prefs['kolab_calendars'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if ($prefs['kolab_calendars'][$id]) $this->rc->user->save_prefs($prefs); return $id; } /** * Update properties of an existing calendar * * @see calendar_driver::edit_calendar() */ public function edit_calendar($prop) { if ($prop['id'] && ($cal = $this->get_calendar($prop['id']))) { $id = $cal->update($prop); } else { $id = $prop['id']; } // fallback to local prefs $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); unset($prefs['kolab_calendars'][$prop['id']]['color'], $prefs['kolab_calendars'][$prop['id']]['showalarms']); if (isset($prop['color'])) $prefs['kolab_calendars'][$id]['color'] = $prop['color']; if (isset($prop['showalarms']) && $id == self::BIRTHDAY_CALENDAR_ID) $prefs['calendar_birthdays_alarm_type'] = $prop['showalarms'] ? $this->alarm_types[0] : ''; else if (isset($prop['showalarms'])) $prefs['kolab_calendars'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if (!empty($prefs['kolab_calendars'][$id])) $this->rc->user->save_prefs($prefs); return true; } /** * Set active/subscribed state of a calendar * * @see calendar_driver::subscribe_calendar() */ public function subscribe_calendar($prop) { if ($prop['id'] && ($cal = $this->get_calendar($prop['id'])) && is_object($cal->storage)) { $ret = false; if (isset($prop['permanent'])) $ret |= $cal->storage->subscribe(intval($prop['permanent'])); if (isset($prop['active'])) $ret |= $cal->storage->activate(intval($prop['active'])); // apply to child folders, too if ($prop['recursive']) { foreach ((array)kolab_storage::list_folders($cal->storage->name, '*', 'event') as $subfolder) { if (isset($prop['permanent'])) ($prop['permanent'] ? kolab_storage::folder_subscribe($subfolder) : kolab_storage::folder_unsubscribe($subfolder)); if (isset($prop['active'])) ($prop['active'] ? kolab_storage::folder_activate($subfolder) : kolab_storage::folder_deactivate($subfolder)); } } return $ret; } else { // save state in local prefs $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); $prefs['kolab_calendars'][$prop['id']]['active'] = (bool)$prop['active']; $this->rc->user->save_prefs($prefs); return true; } return false; } /** * Delete the given calendar with all its contents * * @see calendar_driver::delete_calendar() */ public function delete_calendar($prop) { if ($prop['id'] && ($cal = $this->get_calendar($prop['id']))) { $folder = $cal->get_realname(); // TODO: unsubscribe if no admin rights if (kolab_storage::folder_delete($folder)) { // remove color in user prefs (temp. solution) $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); unset($prefs['kolab_calendars'][$prop['id']]); $this->rc->user->save_prefs($prefs); return true; } else $this->last_error = kolab_storage::$last_error; } return false; } /** * Search for shared or otherwise not listed calendars the user has access * * @param string Search string * @param string Section/source to search * @return array List of calendars */ public function search_calendars($query, $source) { if (!kolab_storage::setup()) return array(); $this->calendars = array(); $this->search_more_results = false; // find unsubscribed IMAP folders that have "event" type if ($source == 'folders') { foreach ((array)kolab_storage::search_folders('event', $query, array('other')) as $folder) { $calendar = new kolab_calendar($folder->name, $this->cal); $this->calendars[$calendar->id] = $calendar; } } // find other user's virtual calendars else if ($source == 'users') { $limit = $this->rc->config->get('autocomplete_max', 15) * 2; // we have slightly more space, so display twice the number foreach (kolab_storage::search_users($query, 0, array(), $limit, $count) as $user) { $calendar = new kolab_user_calendar($user, $this->cal); $this->calendars[$calendar->id] = $calendar; // search for calendar folders shared by this user foreach (kolab_storage::list_user_folders($user, 'event', false) as $foldername) { $cal = new kolab_calendar($foldername, $this->cal); $this->calendars[$cal->id] = $cal; $calendar->subscriptions = true; } } if ($count > $limit) { $this->search_more_results = true; } } // don't list the birthday calendar $this->rc->config->set('calendar_contact_birthdays', false); $this->rc->config->set('kolab_invitation_calendars', false); return $this->list_calendars(); } /** * Fetch a single event * * @see calendar_driver::get_event() * @return array Hash array with event properties, false if not found */ public function get_event($event, $scope = 0, $full = false) { if (is_array($event)) { $id = $event['id'] ?: $event['uid']; $cal = $event['calendar']; // we're looking for a recurring instance: expand the ID to our internal convention for recurring instances if (!$event['id'] && $event['_instance']) { $id .= '-' . $event['_instance']; } } else { $id = $event; } if ($cal) { if ($storage = $this->get_calendar($cal)) { $result = $storage->get_event($id); return self::to_rcube_event($result); } // get event from the address books birthday calendar else if ($cal == self::BIRTHDAY_CALENDAR_ID) { return $this->get_birthday_event($id); } } // iterate over all calendar folders and search for the event ID else { foreach ($this->filter_calendars($scope) as $calendar) { if ($result = $calendar->get_event($id)) { return self::to_rcube_event($result); } } } return false; } /** * Add a single event to the database * * @see calendar_driver::new_event() */ public function new_event($event) { if (!$this->validate($event)) return false; $event = self::from_rcube_event($event); $cid = $event['calendar'] ? $event['calendar'] : reset(array_keys($this->calendars)); if ($storage = $this->get_calendar($cid)) { // if this is a recurrence instance, append as exception to an already existing object for this UID if (!empty($event['recurrence_date']) && ($master = $storage->get_event($event['uid']))) { self::add_exception($master, $event); $success = $storage->update_event($master); } else { $success = $storage->insert_event($event); } if ($success && $this->freebusy_trigger) { $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); $this->freebusy_trigger = false; // disable after first execution (#2355) } return $success; } return false; } /** * Update an event entry with the given data * * @see calendar_driver::new_event() * @return boolean True on success, False on error */ public function edit_event($event) { if (!($storage = $this->get_calendar($event['calendar']))) return false; return $this->update_event(self::from_rcube_event($event, $storage->get_event($event['id']))); } /** * Extended event editing with possible changes to the argument * * @param array Hash array with event properties * @param string New participant status * @param array List of hash arrays with updated attendees * @return boolean True on success, False on error */ public function edit_rsvp(&$event, $status, $attendees) { $update_event = $event; // apply changes to master (and all exceptions) if ($event['_savemode'] == 'all' && $event['recurrence_id']) { if ($storage = $this->get_calendar($event['calendar'])) { $update_event = $storage->get_event($event['recurrence_id']); $update_event['_savemode'] = $event['_savemode']; $update_event['id'] = $update_event['uid']; unset($update_event['recurrence_id']); calendar::merge_attendee_data($update_event, $attendees); } } if ($ret = $this->update_attendees($update_event, $attendees)) { // replace with master event (for iTip reply) $event = self::to_rcube_event($update_event); // re-assign to the according (virtual) calendar if ($this->rc->config->get('kolab_invitation_calendars')) { if (strtoupper($status) == 'DECLINED') $event['calendar'] = self::INVITATIONS_CALENDAR_DECLINED; else if (strtoupper($status) == 'NEEDS-ACTION') $event['calendar'] = self::INVITATIONS_CALENDAR_PENDING; else if ($event['_folder_id']) $event['calendar'] = $event['_folder_id']; } } return $ret; } /** * Update the participant status for the given attendees * * @see calendar_driver::update_attendees() */ public function update_attendees(&$event, $attendees) { // for this-and-future updates, merge the updated attendees onto all exceptions in range if (($event['_savemode'] == 'future' && $event['recurrence_id']) || (!empty($event['recurrence']) && !$event['recurrence_id'])) { if (!($storage = $this->get_calendar($event['calendar']))) return false; // load master event $master = $event['recurrence_id'] ? $storage->get_event($event['recurrence_id']) : $event; // apply attendee update to each existing exception if ($master['recurrence'] && !empty($master['recurrence']['EXCEPTIONS'])) { $saved = false; foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { // merge the new event properties onto future exceptions if ($exception['_instance'] >= strval($event['_instance'])) { calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $attendees); } // update a specific instance if ($exception['_instance'] == $event['_instance'] && $exception['thisandfuture']) { $saved = true; } } // add the given event as new exception if (!$saved && $event['id'] != $master['id']) { $event['thisandfuture'] = true; $master['recurrence']['EXCEPTIONS'][] = $event; } // set link to top-level exceptions $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; return $this->update_event($master); } } // just update the given event (instance) return $this->update_event($event); } /** * Move a single event * * @see calendar_driver::move_event() * @return boolean True on success, False on error */ public function move_event($event) { if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { unset($ev['sequence']); self::clear_attandee_noreply($ev); return $this->update_event($event + $ev); } return false; } /** * Resize a single event * * @see calendar_driver::resize_event() * @return boolean True on success, False on error */ public function resize_event($event) { if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { unset($ev['sequence']); self::clear_attandee_noreply($ev); return $this->update_event($event + $ev); } return false; } /** * Remove a single event * * @param array Hash array with event properties: * id: Event identifier * @param boolean Remove record(s) irreversible (mark as deleted otherwise) * * @return boolean True on success, False on error */ public function remove_event($event, $force = true) { $ret = true; $success = false; $savemode = $event['_savemode']; $decline = $event['_decline']; if (($storage = $this->get_calendar($event['calendar'])) && ($event = $storage->get_event($event['id']))) { $event['_savemode'] = $savemode; $savemode = 'all'; $master = $event; $this->rc->session->remove('calendar_restore_event_data'); // read master if deleting a recurring event if ($event['recurrence'] || $event['recurrence_id'] || $event['isexception']) { $master = $storage->get_event($event['uid']); $savemode = $event['_savemode'] ?: ($event['_instance'] || $event['isexception'] ? 'current' : 'all'); // force 'current' mode for single occurrences stored as exception if (!$event['recurrence'] && !$event['recurrence_id'] && $event['isexception']) $savemode = 'current'; } // removing an exception instance if (($event['recurrence_id'] || $event['isexception']) && is_array($master['exceptions'])) { foreach ($master['exceptions'] as $i => $exception) { if ($exception['_instance'] == $event['_instance']) { unset($master['exceptions'][$i]); // set event date back to the actual occurrence if ($exception['recurrence_date']) $event['start'] = $exception['recurrence_date']; } } if (is_array($master['recurrence'])) { $master['recurrence']['EXCEPTIONS'] = &$master['exceptions']; } } switch ($savemode) { case 'current': $_SESSION['calendar_restore_event_data'] = $master; // removing the first instance => just move to next occurence if ($master['recurrence'] && $event['_instance'] == libcalendaring::recurrence_instance_identifier($master)) { $recurring = reset($storage->get_recurring_events($event, $event['start'], null, $event['id'].'-1')); // no future instances found: delete the master event (bug #1677) if (!$recurring['start']) { $success = $storage->delete_event($master, $force); break; } $master['start'] = $recurring['start']; $master['end'] = $recurring['end']; if ($master['recurrence']['COUNT']) $master['recurrence']['COUNT']--; } // remove the matching RDATE entry else if ($master['recurrence']['RDATE']) { foreach ($master['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { unset($master['recurrence']['RDATE'][$j]); break; } } } else { // add exception to master event $master['recurrence']['EXDATE'][] = $event['start']; } $success = $storage->update_event($master); break; case 'future': $master['_instance'] = libcalendaring::recurrence_instance_identifier($master); if ($master['_instance'] != $event['_instance']) { $_SESSION['calendar_restore_event_data'] = $master; // set until-date on master event $master['recurrence']['UNTIL'] = clone $event['start']; $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); unset($master['recurrence']['COUNT']); // if all future instances are deleted, remove recurrence rule entirely (bug #1677) if ($master['recurrence']['UNTIL']->format('Ymd') == $master['start']->format('Ymd')) { $master['recurrence'] = array(); } // remove matching RDATE entries else if ($master['recurrence']['RDATE']) { foreach ($master['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { $master['recurrence']['RDATE'] = array_slice($master['recurrence']['RDATE'], 0, $j); break; } } } $success = $storage->update_event($master); $ret = $master['uid']; break; } default: // 'all' is default // removing the master event with loose exceptions (not recurring though) if (!empty($event['recurrence_date']) && empty($master['recurrence']) && !empty($master['exceptions'])) { // make the first exception the new master $newmaster = array_shift($master['exceptions']); $newmaster['exceptions'] = $master['exceptions']; $newmaster['_attachments'] = $master['_attachments']; $newmaster['_mailbox'] = $master['_mailbox']; $newmaster['_msguid'] = $master['_msguid']; $success = $storage->update_event($newmaster); } else if ($decline && $this->rc->config->get('kolab_invitation_calendars')) { // don't delete but set PARTSTAT=DECLINED if ($this->cal->lib->set_partstat($master, 'DECLINED')) { $success = $storage->update_event($master); } } if (!$success) $success = $storage->delete_event($master, $force); break; } } if ($success && $this->freebusy_trigger) $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); return $success ? $ret : false; } /** * Restore a single deleted event * * @param array Hash array with event properties: * id: Event identifier * @return boolean True on success, False on error */ public function restore_event($event) { if ($storage = $this->get_calendar($event['calendar'])) { if (!empty($_SESSION['calendar_restore_event_data'])) $success = $storage->update_event($_SESSION['calendar_restore_event_data']); else $success = $storage->restore_event($event); if ($success && $this->freebusy_trigger) $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); return $success; } return false; } /** * Wrapper to update an event object depending on the given savemode */ private function update_event($event) { if (!($storage = $this->get_calendar($event['calendar']))) return false; // move event to another folder/calendar if ($event['_fromcalendar'] && $event['_fromcalendar'] != $event['calendar']) { if (!($fromcalendar = $this->get_calendar($event['_fromcalendar']))) return false; $old = $fromcalendar->get_event($event['id']); if ($event['_savemode'] != 'new') { if (!$fromcalendar->storage->move($old['uid'], $storage->storage)) { return false; } $fromcalendar = $storage; } } else $fromcalendar = $storage; $success = false; $savemode = 'all'; $attachments = array(); $old = $master = $storage->get_event($event['id']); if (!$old || !$old['start']) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed to load event object to update: id=" . $event['id']), true, false); return false; } // modify a recurring event, check submitted savemode to do the right things if ($old['recurrence'] || $old['recurrence_id'] || $old['isexception']) { $master = $storage->get_event($old['uid']); $savemode = $event['_savemode'] ?: ($old['recurrence_id'] || $old['isexception'] ? 'current' : 'all'); // this-and-future on the first instance equals to 'all' if ($savemode == 'future' && $master['start'] && $old['_instance'] == libcalendaring::recurrence_instance_identifier($master)) $savemode = 'all'; // force 'current' mode for single occurrences stored as exception else if (!$old['recurrence'] && !$old['recurrence_id'] && $old['isexception']) $savemode = 'current'; } // check if update affects scheduling and update attendee status accordingly $reschedule = $this->check_scheduling($event, $old, true); // keep saved exceptions (not submitted by the client) if ($old['recurrence']['EXDATE'] && !isset($event['recurrence']['EXDATE'])) $event['recurrence']['EXDATE'] = $old['recurrence']['EXDATE']; if (isset($event['recurrence']['EXCEPTIONS'])) $with_exceptions = true; // exceptions already provided (e.g. from iCal import) else if ($old['recurrence']['EXCEPTIONS']) $event['recurrence']['EXCEPTIONS'] = $old['recurrence']['EXCEPTIONS']; else if ($old['exceptions']) $event['exceptions'] = $old['exceptions']; // remove some internal properties which should not be saved unset($event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_owner'], $event['_notify'], $event['_method'], $event['_sender'], $event['_sender_utf'], $event['_size']); switch ($savemode) { case 'new': // save submitted data as new (non-recurring) event $event['recurrence'] = array(); $event['_copyfrom'] = $master['_msguid']; $event['_mailbox'] = $master['_mailbox']; $event['uid'] = $this->cal->generate_uid(); unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); // copy attachment metadata to new event $event = self::from_rcube_event($event, $master); self::clear_attandee_noreply($event); if ($success = $storage->insert_event($event)) $success = $event['uid']; break; case 'future': // create a new recurring event $event['_copyfrom'] = $master['_msguid']; $event['_mailbox'] = $master['_mailbox']; $event['uid'] = $this->cal->generate_uid(); unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); // copy attachment metadata to new event $event = self::from_rcube_event($event, $master); // remove recurrence exceptions on re-scheduling if ($reschedule) { unset($event['recurrence']['EXCEPTIONS'], $event['exceptions'], $master['recurrence']['EXDATE']); } else if (is_array($event['recurrence']['EXCEPTIONS'])) { // only keep relevant exceptions $event['recurrence']['EXCEPTIONS'] = array_filter($event['recurrence']['EXCEPTIONS'], function($exception) use ($event) { return $exception['start'] > $event['start']; }); if (is_array($event['recurrence']['EXDATE'])) { $event['recurrence']['EXDATE'] = array_filter($event['recurrence']['EXDATE'], function($exdate) use ($event) { return $exdate > $event['start']; }); } // set link to top-level exceptions $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; } // compute remaining occurrences if ($event['recurrence']['COUNT']) { if (!$old['_count']) $old['_count'] = $this->get_recurrence_count($master, $old['start']); $event['recurrence']['COUNT'] -= intval($old['_count']); } // remove fixed weekday when date changed if ($old['start']->format('Y-m-d') != $event['start']->format('Y-m-d')) { if (strlen($event['recurrence']['BYDAY']) == 2) unset($event['recurrence']['BYDAY']); if ($old['recurrence']['BYMONTH'] == $old['start']->format('n')) unset($event['recurrence']['BYMONTH']); } // set until-date on master event $master['recurrence']['UNTIL'] = clone $old['start']; $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); unset($master['recurrence']['COUNT']); // remove all exceptions after $event['start'] if (is_array($master['recurrence']['EXCEPTIONS'])) { $master['recurrence']['EXCEPTIONS'] = array_filter($master['recurrence']['EXCEPTIONS'], function($exception) use ($event) { return $exception['start'] < $event['start']; }); // set link to top-level exceptions $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; } if (is_array($master['recurrence']['EXDATE'])) { $master['recurrence']['EXDATE'] = array_filter($master['recurrence']['EXDATE'], function($exdate) use ($event) { return $exdate < $event['start']; }); } // save new event if ($success = $storage->insert_event($event)) { $success = $event['uid']; // update master event (no rescheduling!) self::clear_attandee_noreply($master); $storage->update_event($master); } break; case 'current': // recurring instances shall not store recurrence rules and attachments $event['recurrence'] = array(); $event['thisandfuture'] = $savemode == 'future'; unset($event['attachments'], $event['id']); // increment sequence of this instance if scheduling is affected if ($reschedule) { $event['sequence'] = max($old['sequence'], $master['sequence']) + 1; } else if (!isset($event['sequence'])) { $event['sequence'] = $old['sequence'] ?: $master['sequence']; } // save properties to a recurrence exception instance if ($old['_instance'] && is_array($master['recurrence']['EXCEPTIONS'])) { if ($this->update_recurrence_exceptions($master, $event, $old, $savemode)) { $success = $storage->update_event($master, $old['id']); break; } } $add_exception = true; // adjust matching RDATE entry if dates changed if (is_array($master['recurrence']['RDATE']) && ($old_date = $old['start']->format('Ymd')) != $event['start']->format('Ymd')) { foreach ($master['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $old_date) { $master['recurrence']['RDATE'][$j] = $event['start']; sort($master['recurrence']['RDATE']); $add_exception = false; break; } } } // save as new exception to master event if ($add_exception) { self::add_exception($master, $event, $old); } $success = $storage->update_event($master); break; default: // 'all' is default $event['id'] = $master['uid']; $event['uid'] = $master['uid']; // use start date from master but try to be smart on time or duration changes $old_start_date = $old['start']->format('Y-m-d'); $old_start_time = $old['allday'] ? '' : $old['start']->format('H:i'); - $old_duration = $old['end']->format('U') - $old['start']->format('U'); - + $old_duration = $old['allday'] ? 0 : $old['end']->format('U') - $old['start']->format('U'); + $new_start_date = $event['start']->format('Y-m-d'); $new_start_time = $event['allday'] ? '' : $event['start']->format('H:i'); - $new_duration = $event['end']->format('U') - $event['start']->format('U'); - + $new_duration = $event['allday'] ? 0 : $event['end']->format('U') - $event['start']->format('U'); + $diff = $old_start_date != $new_start_date || $old_start_time != $new_start_time || $old_duration != $new_duration; $date_shift = $old['start']->diff($event['start']); - + // shifted or resized if ($diff && ($old_start_date == $new_start_date || $old_duration == $new_duration)) { $event['start'] = $master['start']->add($date_shift); $event['end'] = clone $event['start']; $event['end']->add(new DateInterval('PT'.$new_duration.'S')); - + // remove fixed weekday, will be re-set to the new weekday in kolab_calendar::update_event() if ($old_start_date != $new_start_date) { if (strlen($event['recurrence']['BYDAY']) == 2) unset($event['recurrence']['BYDAY']); if ($old['recurrence']['BYMONTH'] == $old['start']->format('n')) unset($event['recurrence']['BYMONTH']); } } // dates did not change, use the ones from master else if ($new_start_date . $new_start_time == $old_start_date . $old_start_time) { $event['start'] = $master['start']; $event['end'] = $master['end']; } // when saving an instance in 'all' mode, copy recurrence exceptions over if ($old['recurrence_id']) { $event['recurrence']['EXCEPTIONS'] = $master['recurrence']['EXCEPTIONS']; + $event['recurrence']['EXDATE'] = $master['recurrence']['EXDATE']; } else if ($master['_instance']) { $event['_instance'] = $master['_instance']; $event['recurrence_date'] = $master['recurrence_date']; } // TODO: forward changes to exceptions (which do not yet have differing values stored) if (is_array($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) { // determine added and removed attendees $old_attendees = $current_attendees = $added_attendees = array(); foreach ((array)$old['attendees'] as $attendee) { $old_attendees[] = $attendee['email']; } foreach ((array)$event['attendees'] as $attendee) { $current_attendees[] = $attendee['email']; if (!in_array($attendee['email'], $old_attendees)) { $added_attendees[] = $attendee; } } $removed_attendees = array_diff($old_attendees, $current_attendees); foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { calendar::merge_attendee_data($event['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); } // adjust recurrence-id when start changed and therefore the entire recurrence chain changes if ($old_start_date != $new_start_date || $old_start_time != $new_start_time) { $recurrence_id_format = libcalendaring::recurrence_id_format($event); foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { $recurrence_id = is_a($exception['recurrence_date'], 'DateTime') ? $exception['recurrence_date'] : rcube_utils::anytodatetime($exception['_instance'], $old['start']->getTimezone()); if (is_a($recurrence_id, 'DateTime')) { $recurrence_id->add($date_shift); $event['recurrence']['EXCEPTIONS'][$i]['recurrence_date'] = $recurrence_id; $event['recurrence']['EXCEPTIONS'][$i]['_instance'] = $recurrence_id->format($recurrence_id_format); } } } // set link to top-level exceptions $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; } // unset _dateonly flags in (cached) date objects unset($event['start']->_dateonly, $event['end']->_dateonly); $success = $storage->update_event($event) ? $event['id'] : false; // return master UID break; } if ($success && $this->freebusy_trigger) $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); - + return $success; } /** * Determine whether the current change affects scheduling and reset attendee status accordingly */ public function check_scheduling(&$event, $old, $update = true) { // skip this check when importing iCal/iTip events if (isset($event['sequence']) || !empty($event['_method'])) { return false; } // iterate through the list of properties considered 'significant' for scheduling $kolab_event = $old['_formatobj'] ?: new kolab_format_event(); $reschedule = $kolab_event->check_rescheduling($event, $old); // reset all attendee status to needs-action (#4360) if ($update && $reschedule && is_array($event['attendees'])) { $is_organizer = false; $emails = $this->cal->get_user_emails(); $attendees = $event['attendees']; foreach ($attendees as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER' && $attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $is_organizer = true; } else if ($attendee['role'] != 'ORGANIZER' && $attendee['role'] != 'NON-PARTICIPANT' && $attendee['status'] != 'DELEGATED') { $attendees[$i]['status'] = 'NEEDS-ACTION'; $attendees[$i]['rsvp'] = true; } } // update attendees only if I'm the organizer if ($is_organizer || ($event['organizer'] && in_array(strtolower($event['organizer']['email']), $emails))) { $event['attendees'] = $attendees; } } return $reschedule; } /** * Apply the given changes to already existing exceptions */ protected function update_recurrence_exceptions(&$master, $event, $old, $savemode) { $saved = false; $existing = null; // determine added and removed attendees $added_attendees = $removed_attendees = array(); if ($savemode == 'future') { $old_attendees = $current_attendees = array(); foreach ((array)$old['attendees'] as $attendee) { $old_attendees[] = $attendee['email']; } foreach ((array)$event['attendees'] as $attendee) { $current_attendees[] = $attendee['email']; if (!in_array($attendee['email'], $old_attendees)) { $added_attendees[] = $attendee; } } $removed_attendees = array_diff($old_attendees, $current_attendees); } foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { // update a specific instance if ($exception['_instance'] == $old['_instance']) { $existing = $i; // check savemode against existing exception mode. // if matches, we can update this existing exception if ((bool)$exception['thisandfuture'] === ($savemode == 'future')) { $event['_instance'] = $old['_instance']; $event['thisandfuture'] = $old['thisandfuture']; $event['recurrence_date'] = $old['recurrence_date']; $master['recurrence']['EXCEPTIONS'][$i] = $event; $saved = true; } } // merge the new event properties onto future exceptions if ($savemode == 'future' && $exception['_instance'] >= $old['_instance']) { unset($event['thisandfuture']); self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event, array('attendees')); if (!empty($added_attendees) || !empty($removed_attendees)) { calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); } } } /* // we could not update the existing exception due to savemode mismatch... if (!$saved && $existing !== null && $master['recurrence']['EXCEPTIONS'][$existing]['thisandfuture']) { // ... try to move the existing this-and-future exception to the next occurrence foreach ($this->get_recurring_events($master, $existing['start']) as $candidate) { // our old this-and-future exception is obsolete if ($candidate['thisandfuture']) { unset($master['recurrence']['EXCEPTIONS'][$existing]); $saved = true; break; } // this occurrence doesn't yet have an exception else if (!$candidate['isexception']) { $event['_instance'] = $candidate['_instance']; $event['recurrence_date'] = $candidate['recurrence_date']; $master['recurrence']['EXCEPTIONS'][$i] = $event; $saved = true; break; } } } */ // set link to top-level exceptions $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; // returning false here will add a new exception return $saved; } /** * Add or update the given event as an exception to $master */ public static function add_exception(&$master, $event, $old = null) { if ($old) { $event['_instance'] = $old['_instance']; if (!$event['recurrence_date']) $event['recurrence_date'] = $old['recurrence_date'] ?: $old['start']; } else if (!$event['recurrence_date']) { $event['recurrence_date'] = $event['start']; } if (!$event['_instance'] && is_a($event['recurrence_date'], 'DateTime')) { $event['_instance'] = libcalendaring::recurrence_instance_identifier($event, $master['allday']); } if (!is_array($master['exceptions']) && is_array($master['recurrence']['EXCEPTIONS'])) { $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; } $existing = false; foreach ((array)$master['exceptions'] as $i => $exception) { if ($exception['_instance'] == $event['_instance']) { $master['exceptions'][$i] = $event; $existing = true; } } if (!$existing) { $master['exceptions'][] = $event; } return true; } /** * Remove the noreply flags from attendees */ public static function clear_attandee_noreply(&$event) { foreach ((array)$event['attendees'] as $i => $attendee) { unset($event['attendees'][$i]['noreply']); } } /** * 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 * @param array List of properties not allowed to be overwritten */ public static function merge_exception_data(&$event, $overlay, $blacklist = null) { $forbidden = array('id','uid','recurrence','recurrence_date','thisandfuture','organizer','_attachments'); if (is_array($blacklist)) $forbidden = array_merge($forbidden, $blacklist); foreach ($overlay as $prop => $value) { if ($prop == 'start' || $prop == 'end') { // handled by merge_exception_dates() below } else if ($prop == 'thisandfuture' && $overlay['_instance'] == $event['_instance']) { $event[$prop] = $value; } else if ($prop[0] != '_' && !in_array($prop, $forbidden)) $event[$prop] = $value; } self::merge_exception_dates($event, $overlay); } /** * Merge start/end date 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 */ public static function merge_exception_dates(&$event, $overlay) { // compute date offset from the exception if ($overlay['start'] instanceof DateTime && $overlay['recurrence_date'] instanceof DateTime) { $date_offset = $overlay['recurrence_date']->diff($overlay['start']); } foreach (array('start', 'end') as $prop) { $value = $overlay[$prop]; if (is_object($event[$prop]) && $event[$prop] instanceof DateTime) { // set date value if overlay is an exception of the current instance if (substr($overlay['_instance'], 0, 8) == substr($event['_instance'], 0, 8)) { $event[$prop]->setDate(intval($value->format('Y')), intval($value->format('n')), intval($value->format('j'))); } // apply date offset else if ($date_offset) { $event[$prop]->add($date_offset); } // adjust time of the recurring event instance $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s'))); } } } /** * Get events from source. * * @param integer Event's new start (unix timestamp) * @param integer Event's new end (unix timestamp) * @param string Search query (optional) * @param mixed List of calendar IDs to load events from (either as array or comma-separated string) * @param boolean Include virtual events (optional) * @param integer Only list events modified since this time (unix timestamp) * @return array A list of event records */ public function load_events($start, $end, $search = null, $calendars = null, $virtual = 1, $modifiedsince = null) { if ($calendars && is_string($calendars)) $calendars = explode(',', $calendars); else if (!$calendars) $calendars = array_keys($this->calendars); $query = array(); if ($modifiedsince) $query[] = array('changed', '>=', $modifiedsince); $events = $categories = array(); foreach ($calendars as $cid) { if ($storage = $this->get_calendar($cid)) { $events = array_merge($events, $storage->list_events($start, $end, $search, $virtual, $query)); $categories += $storage->categories; } } // add events from the address books birthday calendar if (in_array(self::BIRTHDAY_CALENDAR_ID, $calendars)) { $events = array_merge($events, $this->load_birthday_events($start, $end, $search, $modifiedsince)); } // add new categories to user prefs $old_categories = $this->rc->config->get('calendar_categories', $this->default_categories); if ($newcats = array_udiff(array_keys($categories), array_keys($old_categories), function($a, $b){ return strcasecmp($a, $b); })) { foreach ($newcats as $category) $old_categories[$category] = ''; // no color set yet $this->rc->user->save_prefs(array('calendar_categories' => $old_categories)); } array_walk($events, 'kolab_driver::to_rcube_event'); return $events; } /** * Get number of events in the given calendar * * @param mixed List of calendar IDs to count events (either as array or comma-separated string) * @param integer Date range start (unix timestamp) * @param integer Date range end (unix timestamp) * @return array Hash array with counts grouped by calendar ID */ public function count_events($calendars, $start, $end = null) { $counts = array(); if ($calendars && is_string($calendars)) $calendars = explode(',', $calendars); else if (!$calendars) $calendars = array_keys($this->calendars); foreach ($calendars as $cid) { if ($storage = $this->get_calendar($cid)) { $counts[$cid] = $storage->count_events($start, $end); } } return $counts; } /** * Get a list of pending alarms to be displayed to the user * * @see calendar_driver::pending_alarms() */ public function pending_alarms($time, $calendars = null) { $interval = 300; $time -= $time % 60; $slot = $time; $slot -= $slot % $interval; $last = $time - max(60, $this->rc->config->get('refresh_interval', 0)); $last -= $last % $interval; // only check for alerts once in 5 minutes if ($last == $slot) return array(); if ($calendars && is_string($calendars)) $calendars = explode(',', $calendars); $time = $slot + $interval; $candidates = array(); $query = array(array('tags', '=', 'x-has-alarms')); foreach ($this->calendars as $cid => $calendar) { // skip calendars with alarms disabled if (!$calendar->alarms || ($calendars && !in_array($cid, $calendars))) continue; foreach ($calendar->list_events($time, $time + 86400 * 365, null, 1, $query) as $e) { // add to list if alarm is set $alarm = libcalendaring::get_next_alarm($e); if ($alarm && $alarm['time'] && $alarm['time'] >= $last && in_array($alarm['action'], $this->alarm_types)) { $id = $alarm['id']; // use alarm-id as primary identifier $candidates[$id] = array( 'id' => $id, 'title' => $e['title'], 'location' => $e['location'], 'start' => $e['start'], 'end' => $e['end'], 'notifyat' => $alarm['time'], 'action' => $alarm['action'], ); } } } // get alarm information stored in local database if (!empty($candidates)) { $alarm_ids = array_map(array($this->rc->db, 'quote'), array_keys($candidates)); $result = $this->rc->db->query("SELECT *" . " FROM " . $this->rc->db->table_name('kolab_alarms', true) . " WHERE `alarm_id` IN (" . join(',', $alarm_ids) . ")" . " AND `user_id` = ?", $this->rc->user->ID ); while ($result && ($e = $this->rc->db->fetch_assoc($result))) { $dbdata[$e['alarm_id']] = $e; } } $alarms = array(); foreach ($candidates as $id => $alarm) { // skip dismissed alarms if ($dbdata[$id]['dismissed']) continue; // snooze function may have shifted alarm time $notifyat = $dbdata[$id]['notifyat'] ? strtotime($dbdata[$id]['notifyat']) : $alarm['notifyat']; if ($notifyat <= $time) $alarms[] = $alarm; } return $alarms; } /** * Feedback after showing/sending an alarm notification * * @see calendar_driver::dismiss_alarm() */ public function dismiss_alarm($alarm_id, $snooze = 0) { $alarms_table = $this->rc->db->table_name('kolab_alarms', true); // delete old alarm entry $this->rc->db->query("DELETE FROM $alarms_table" . " WHERE `alarm_id` = ? AND `user_id` = ?", $alarm_id, $this->rc->user->ID ); // set new notifyat time or unset if not snoozed $notifyat = $snooze > 0 ? date('Y-m-d H:i:s', time() + $snooze) : null; $query = $this->rc->db->query("INSERT INTO $alarms_table" . " (`alarm_id`, `user_id`, `dismissed`, `notifyat`)" . " VALUES (?, ?, ?, ?)", $alarm_id, $this->rc->user->ID, $snooze > 0 ? 0 : 1, $notifyat ); return $this->rc->db->affected_rows($query); } /** * List attachments from the given event */ public function list_attachments($event) { if (!($storage = $this->get_calendar($event['calendar']))) return false; $event = $storage->get_event($event['id']); return $event['attachments']; } /** * Get attachment properties */ public function get_attachment($id, $event) { if (!($storage = $this->get_calendar($event['calendar']))) return false; // get old revision of event if ($event['rev']) { $event = $this->get_event_revison($event, $event['rev'], true); } else { $event = $storage->get_event($event['id']); } if ($event) { $attachments = isset($event['_attachments']) ? $event['_attachments'] : $event['attachments']; foreach ((array) $attachments as $att) { if ($att['id'] == $id) { return $att; } } } } /** * Get attachment body * @see calendar_driver::get_attachment_body() */ public function get_attachment_body($id, $event) { if (!($cal = $this->get_calendar($event['calendar']))) return false; // get old revision of event if ($event['rev']) { if (empty($this->bonnie_api)) { return false; } $cid = substr($id, 4); // call Bonnie API and get the raw mime message list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); if ($msg_raw = $this->bonnie_api->rawdata('event', $uid, $event['rev'], $mailbox, $msguid)) { // parse the message and find the part with the matching content-id $message = rcube_mime::parse_message($msg_raw); foreach ((array)$message->parts as $part) { if ($part->headers['content-id'] && trim($part->headers['content-id'], '<>') == $cid) { return $part->body; } } } return false; } return $cal->get_attachment_body($id, $event); } /** * Build a struct representing the given message reference * * @see calendar_driver::get_message_reference() */ public function get_message_reference($uri_or_headers, $folder = null) { if (is_object($uri_or_headers)) { $uri_or_headers = kolab_storage_config::get_message_uri($uri_or_headers, $folder); } if (is_string($uri_or_headers)) { return kolab_storage_config::get_message_reference($uri_or_headers, 'event'); } return false; } /** * List availabale categories * The default implementation reads them from config/user prefs */ public function list_categories() { // FIXME: complete list with categories saved in config objects (KEP:12) return $this->rc->config->get('calendar_categories', $this->default_categories); } /** * 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 * @return array List of recurring event instances */ public function get_recurring_events($event, $start, $end = null) { // load the given event data into a libkolabxml container if (!$event['_formatobj']) { $event_xml = new kolab_format_event(); $event_xml->set($event); $event['_formatobj'] = $event_xml; } $this->_read_calendars(); $storage = reset($this->calendars); return $storage->get_recurring_events($event, $start, $end); } /** * */ private function get_recurrence_count($event, $dtstart) { // use libkolab to compute recurring events if (class_exists('kolabcalendaring') && $event['_formatobj']) { $recurrence = new kolab_date_recurrence($event['_formatobj']); } else { // fallback to local recurrence implementation require_once($this->cal->home . '/lib/calendar_recurrence.php'); $recurrence = new calendar_recurrence($this->cal, $event); } $count = 0; while (($next_event = $recurrence->next_instance()) && $next_event['start'] <= $dtstart && $count < 1000) { $count++; } return $count; } /** * Fetch free/busy information from a person within the given range */ public function get_freebusy_list($email, $start, $end) { if (empty($email)/* || $end < time()*/) return false; // map vcalendar fbtypes to internal values $fbtypemap = array( 'FREE' => calendar::FREEBUSY_FREE, 'BUSY-TENTATIVE' => calendar::FREEBUSY_TENTATIVE, 'X-OUT-OF-OFFICE' => calendar::FREEBUSY_OOF, 'OOF' => calendar::FREEBUSY_OOF); // ask kolab server first try { $request_config = array( 'store_body' => true, 'follow_redirects' => true, ); $request = libkolab::http_request(kolab_storage::get_freebusy_url($email), 'GET', $request_config); $response = $request->send(); // authentication required if ($response->getStatus() == 401) { $request->setAuth($this->rc->user->get_username(), $this->rc->decrypt($_SESSION['password'])); $response = $request->send(); } if ($response->getStatus() == 200) $fbdata = $response->getBody(); unset($request, $response); } catch (Exception $e) { PEAR::raiseError("Error fetching free/busy information: " . $e->getMessage()); } // get free-busy url from contacts if (!$fbdata) { $fburl = null; foreach ((array)$this->rc->config->get('autocomplete_addressbooks', 'sql') as $book) { $abook = $this->rc->get_address_book($book); if ($result = $abook->search(array('email'), $email, true, true, true/*, 'freebusyurl'*/)) { while ($contact = $result->iterate()) { if ($fburl = $contact['freebusyurl']) { $fbdata = @file_get_contents($fburl); break; } } } if ($fbdata) break; } } // parse free-busy information using Horde classes if ($fbdata) { $ical = $this->cal->get_ical(); $ical->import($fbdata); if ($fb = $ical->freebusy) { $result = array(); foreach ($fb['periods'] as $tuple) { list($from, $to, $type) = $tuple; $result[] = array($from->format('U'), $to->format('U'), isset($fbtypemap[$type]) ? $fbtypemap[$type] : calendar::FREEBUSY_BUSY); } // we take 'dummy' free-busy lists as "unknown" if (empty($result) && !empty($fb['comment']) && stripos($fb['comment'], 'dummy')) return false; // set period from $start till the begin of the free-busy information as 'unknown' if ($fb['start'] && ($fbstart = $fb['start']->format('U')) && $start < $fbstart) { array_unshift($result, array($start, $fbstart, calendar::FREEBUSY_UNKNOWN)); } // pad period till $end with status 'unknown' if ($fb['end'] && ($fbend = $fb['end']->format('U')) && $fbend < $end) { $result[] = array($fbend, $end, calendar::FREEBUSY_UNKNOWN); } return $result; } } return false; } /** * Handler to push folder triggers when sent from client. * Used to push free-busy changes asynchronously after updating an event */ public function push_freebusy() { // make shure triggering completes set_time_limit(0); ignore_user_abort(true); $cal = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); if (!($cal = $this->get_calendar($cal))) return false; // trigger updates on folder $trigger = $cal->storage->trigger(); if (is_object($trigger) && is_a($trigger, 'PEAR_Error')) { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed triggering folder. Error was " . $trigger->getMessage()), true, false); } exit; } /** * Convert from driver format to external caledar app data */ public static function to_rcube_event(&$record) { if (!is_array($record)) return $record; $record['id'] = $record['uid']; if ($record['_instance']) { $record['id'] .= '-' . $record['_instance']; if (!$record['recurrence_id'] && !empty($record['recurrence'])) $record['recurrence_id'] = $record['uid']; } // all-day events go from 12:00 - 13:00 if (is_a($record['start'], 'DateTime') && $record['end'] <= $record['start'] && $record['allday']) { $record['end'] = clone $record['start']; $record['end']->add(new DateInterval('PT1H')); } // translate internal '_attachments' to external 'attachments' list 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; } if (!empty($record['attendees'])) { foreach ((array)$record['attendees'] as $i => $attendee) { if (is_array($attendee['delegated-from'])) { $record['attendees'][$i]['delegated-from'] = join(', ', $attendee['delegated-from']); } if (is_array($attendee['delegated-to'])) { $record['attendees'][$i]['delegated-to'] = join(', ', $attendee['delegated-to']); } } } // Roundcube only supports one category assignment if (is_array($record['categories'])) $record['categories'] = $record['categories'][0]; // the cancelled flag transltes into status=CANCELLED if ($record['cancelled']) $record['status'] = 'CANCELLED'; // 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']); // clean up exception data if (is_array($record['recurrence']['EXCEPTIONS'])) { array_walk($record['recurrence']['EXCEPTIONS'], function(&$exception) { unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj'], $exception['_attachments']); }); } unset($record['_mailbox'], $record['_msguid'], $record['_type'], $record['_size'], $record['_formatobj'], $record['_attachments'], $record['exceptions'], $record['x-custom']); return $record; } /** * */ public static function from_rcube_event($event, $old = array()) { kolab_format::merge_attachments($event, $old); return $event; } /** * Set CSS class according to the event's attendde partstat */ public static function add_partstat_class($event, $partstats, $user = null) { // set classes according to PARTSTAT if (is_array($event['attendees'])) { $user_emails = libcalendaring::get_instance()->get_user_emails($user); $partstat = 'UNKNOWN'; foreach ($event['attendees'] as $attendee) { if (in_array($attendee['email'], $user_emails)) { $partstat = $attendee['status']; break; } } if (in_array($partstat, $partstats)) { $event['className'] = trim($event['className'] . ' fc-invitation-' . strtolower($partstat)); } } return $event; } /** * Provide a list of revisions for the given event * * @param array $event Hash array with event properties * * @return array List of changes, each as a hash array * @see calendar_driver::get_event_changelog() */ public function get_event_changelog($event) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); $result = $this->bonnie_api->changelog('event', $uid, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid) { return $result['changes']; } return false; } /** * Get a list of property changes beteen two revisions of an event * * @param array $event Hash array with event properties * @param mixed $rev1 Old Revision * @param mixed $rev2 New Revision * * @return array List of property changes, each as a hash array * @see calendar_driver::get_event_diff() */ public function get_event_diff($event, $rev1, $rev2) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); // get diff for the requested recurrence instance $instance_id = $event['id'] != $uid ? substr($event['id'], strlen($uid) + 1) : null; // call Bonnie API $result = $this->bonnie_api->diff('event', $uid, $rev1, $rev2, $mailbox, $msguid, $instance_id); if (is_array($result) && $result['uid'] == $uid) { $result['rev1'] = $rev1; $result['rev2'] = $rev2; $keymap = array( 'dtstart' => 'start', 'dtend' => 'end', 'dstamp' => 'changed', 'summary' => 'title', 'alarm' => 'alarms', 'attendee' => 'attendees', 'attach' => 'attachments', 'rrule' => 'recurrence', 'transparency' => 'free_busy', 'classification' => 'sensitivity', 'lastmodified-date' => 'changed', ); $prop_keymaps = array( 'attachments' => array('fmttype' => 'mimetype', 'label' => 'name'), 'attendees' => array('partstat' => 'status'), ); $special_changes = array(); // map kolab event properties to keys the client expects array_walk($result['changes'], function(&$change, $i) use ($keymap, $prop_keymaps, $special_changes) { if (array_key_exists($change['property'], $keymap)) { $change['property'] = $keymap[$change['property']]; } // translate free_busy values if ($change['property'] == 'free_busy') { $change['old'] = $old['old'] ? 'free' : 'busy'; $change['new'] = $old['new'] ? 'free' : 'busy'; } // map alarms trigger value if ($change['property'] == 'alarms') { if (is_array($change['old']) && is_array($change['old']['trigger'])) $change['old']['trigger'] = $change['old']['trigger']['value']; if (is_array($change['new']) && is_array($change['new']['trigger'])) $change['new']['trigger'] = $change['new']['trigger']['value']; } // make all property keys uppercase if ($change['property'] == 'recurrence') { $special_changes['recurrence'] = $i; foreach (array('old','new') as $m) { if (is_array($change[$m])) { $props = array(); foreach ($change[$m] as $k => $v) $props[strtoupper($k)] = $v; $change[$m] = $props; } } } // map property keys names if (is_array($prop_keymaps[$change['property']])) { foreach ($prop_keymaps[$change['property']] as $k => $dest) { if (is_array($change['old']) && array_key_exists($k, $change['old'])) { $change['old'][$dest] = $change['old'][$k]; unset($change['old'][$k]); } if (is_array($change['new']) && array_key_exists($k, $change['new'])) { $change['new'][$dest] = $change['new'][$k]; unset($change['new'][$k]); } } } if ($change['property'] == 'exdate') { $special_changes['exdate'] = $i; } else if ($change['property'] == 'rdate') { $special_changes['rdate'] = $i; } }); // merge some recurrence changes foreach (array('exdate','rdate') as $prop) { if (array_key_exists($prop, $special_changes)) { $exdate = $result['changes'][$special_changes[$prop]]; if (array_key_exists('recurrence', $special_changes)) { $recurrence = &$result['changes'][$special_changes['recurrence']]; } else { $i = count($result['changes']); $result['changes'][$i] = array('property' => 'recurrence', 'old' => array(), 'new' => array()); $recurrence = &$result['changes'][$i]['recurrence']; } $key = strtoupper($prop); $recurrence['old'][$key] = $exdate['old']; $recurrence['new'][$key] = $exdate['new']; unset($result['changes'][$special_changes[$prop]]); } } return $result; } return false; } /** * Return full data of a specific revision of an event * * @param array Hash array with event properties * @param mixed $rev Revision number * * @return array Event object as hash array * @see calendar_driver::get_event_revison() */ public function get_event_revison($event, $rev, $internal = false) { if (empty($this->bonnie_api)) { return false; } $eventid = $event['id']; $calid = $event['calendar']; list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); // call Bonnie API $result = $this->bonnie_api->get('event', $uid, $rev, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { $format = kolab_format::factory('event'); $format->load($result['xml']); $event = $format->to_array(); $format->get_attachments($event, true); // get the right instance from a recurring event if ($eventid != $event['uid']) { $instance_id = substr($eventid, strlen($event['uid']) + 1); // check for recurrence exception first if ($instance = $format->get_instance($instance_id)) { $event = $instance; } else { // not a exception, compute recurrence... $event['_formatobj'] = $format; $recurrence_date = rcube_utils::anytodatetime($instance_id, $event['start']->getTimezone()); foreach ($this->get_recurring_events($event, $event['start'], $recurrence_date) as $instance) { if ($instance['id'] == $eventid) { $event = $instance; break; } } } } if ($format->is_valid()) { $event['calendar'] = $calid; $event['rev'] = $result['rev']; return $internal ? $event : self::to_rcube_event($event); } } return false; } /** * Command the backend to restore a certain revision of an event. * This shall replace the current event with an older version. * * @param mixed UID string or hash array with event properties: * id: Event identifier * calendar: Calendar identifier * @param mixed $rev Revision number * * @return boolean True on success, False on failure */ public function restore_event_revision($event, $rev) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); $calendar = $this->get_calendar($event['calendar']); $success = false; if ($calendar && $calendar->storage && $calendar->editable) { if ($raw_msg = $this->bonnie_api->rawdata('event', $uid, $rev, $mailbox)) { $imap = $this->rc->get_storage(); // insert $raw_msg as new message if ($imap->save_message($calendar->storage->name, $raw_msg, null, false)) { $success = true; // delete old revision from imap and cache $imap->delete_message($msguid, $calendar->storage->name); $calendar->storage->cache->set($msguid, false); } } } return $success; } /** * Helper method to resolved the given event identifier into uid and folder * * @return array (uid,folder,msguid) tuple */ private function _resolve_event_identity($event) { $mailbox = $msguid = null; if (is_array($event)) { $uid = $event['uid'] ?: $event['id']; if (($cal = $this->get_calendar($event['calendar'])) && !($cal instanceof kolab_invitation_calendar)) { $mailbox = $cal->get_mailbox_id(); // get event object from storage in order to get the real object uid an msguid if ($ev = $cal->get_event($event['id'])) { $msguid = $ev['_msguid']; $uid = $ev['uid']; } } } else { $uid = $event; // get event object from storage in order to get the real object uid an msguid if ($ev = $this->get_event($event)) { $mailbox = $ev['_mailbox']; $msguid = $ev['_msguid']; $uid = $ev['uid']; } } return array($uid, $mailbox, $msguid); } /** * Callback function to produce driver-specific calendar create/edit form * * @param string Request action 'form-edit|form-new' * @param array Calendar properties (e.g. id, color) * @param array Edit form fields * * @return string HTML content of the form */ public function calendar_form($action, $calendar, $formfields) { // show default dialog for birthday calendar if (in_array($calendar['id'], array(self::BIRTHDAY_CALENDAR_ID, self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED))) { if ($calendar['id'] != self::BIRTHDAY_CALENDAR_ID) unset($formfields['showalarms']); return parent::calendar_form($action, $calendar, $formfields); } if ($calendar['id'] && ($cal = $this->calendars[$calendar['id']])) { $folder = $cal->get_realname(); // UTF7 $color = $cal->get_color(); } else { $folder = ''; $color = ''; } $hidden_fields[] = array('name' => 'oldname', 'value' => $folder); $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = array(); if (strlen($folder)) { $path_imap = explode($delim, $folder); array_pop($path_imap); // pop off name part $path_imap = implode($path_imap, $delim); $options = $storage->folder_info($folder); } else { $path_imap = ''; } // General tab $form['props'] = array( 'name' => $this->rc->gettext('properties'), ); // Disable folder name input if (!empty($options) && ($options['norename'] || $options['protected'])) { $input_name = new html_hiddenfield(array('name' => 'name', 'id' => 'calendar-name')); $formfields['name']['value'] = kolab_storage::object_name($folder) . $input_name->show($folder); } // calendar name (default field) $form['props']['fieldsets']['location'] = array( 'name' => $this->rc->gettext('location'), 'content' => array( 'name' => $formfields['name'] ), ); if (!empty($options) && ($options['norename'] || $options['protected'])) { // prevent user from moving folder $hidden_fields[] = array('name' => 'parent', 'value' => $path_imap); } else { $select = kolab_storage::folder_selector('event', array('name' => 'parent', 'id' => 'calendar-parent'), $folder); $form['props']['fieldsets']['location']['content']['path'] = array( 'id' => 'calendar-parent', 'label' => $this->cal->gettext('parentcalendar'), 'value' => $select->show(strlen($folder) ? $path_imap : ''), ); } // calendar color (default field) $form['props']['fieldsets']['settings'] = array( 'name' => $this->rc->gettext('settings'), 'content' => array( 'color' => $formfields['color'], 'showalarms' => $formfields['showalarms'], ), ); if ($action != 'form-new') { $form['sharing'] = array( 'name' => rcube::Q($this->cal->gettext('tabsharing')), 'content' => html::tag('iframe', array( 'src' => $this->cal->rc->url(array('_action' => 'calendar-acl', 'id' => $calendar['id'], 'framed' => 1)), 'width' => '100%', 'height' => 350, 'border' => 0, 'style' => 'border:0'), ''), ); } $this->form_html = ''; if (is_array($hidden_fields)) { foreach ($hidden_fields as $field) { $hiddenfield = new html_hiddenfield($field); $this->form_html .= $hiddenfield->show() . "\n"; } } // Create form output foreach ($form as $tab) { if (!empty($tab['fieldsets']) && is_array($tab['fieldsets'])) { $content = ''; foreach ($tab['fieldsets'] as $fieldset) { $subcontent = $this->get_form_part($fieldset); if ($subcontent) { $content .= html::tag('fieldset', null, html::tag('legend', null, rcube::Q($fieldset['name'])) . $subcontent) ."\n"; } } } else { $content = $this->get_form_part($tab); } if ($content) { $this->form_html .= html::tag('fieldset', null, html::tag('legend', null, rcube::Q($tab['name'])) . $content) ."\n"; } } // Parse form template for skin-dependent stuff $this->rc->output->add_handler('calendarform', array($this, 'calendar_form_html')); return $this->rc->output->parse('calendar.kolabform', false, false); } /** * Handler for template object */ public function calendar_form_html() { return $this->form_html; } /** * Helper function used in calendar_form_content(). Creates a part of the form. */ private function get_form_part($form) { $content = ''; if (is_array($form['content']) && !empty($form['content'])) { $table = new html_table(array('cols' => 2)); foreach ($form['content'] as $col => $colprop) { $label = !empty($colprop['label']) ? $colprop['label'] : $this->rc->gettext($col); $table->add('title', html::label($colprop['id'], rcube::Q($label))); $table->add(null, $colprop['value']); } $content = $table->show(); } else { $content = $form['content']; } return $content; } /** * Handler to render ACL form for a calendar folder */ public function calendar_acl() { $this->rc->output->add_handler('folderacl', array($this, 'calendar_acl_form')); $this->rc->output->send('calendar.kolabacl'); } /** * Handler for ACL form template object */ public function calendar_acl_form() { $calid = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); if ($calid && ($cal = $this->get_calendar($calid))) { $folder = $cal->get_realname(); // UTF7 $color = $cal->get_color(); } else { $folder = ''; $color = ''; } $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = array(); if (strlen($folder)) { $path_imap = explode($delim, $folder); array_pop($path_imap); // pop off name part $path_imap = implode($path_imap, $delim); $options = $storage->folder_info($folder); // Allow plugins to modify the form content (e.g. with ACL form) $plugin = $this->rc->plugins->exec_hook('calendar_form_kolab', array('form' => $form, 'options' => $options, 'name' => $folder)); } if (!$plugin['form']['sharing']['content']) $plugin['form']['sharing']['content'] = html::div('hint', $this->cal->gettext('aclnorights')); return $plugin['form']['sharing']['content']; } /** * Handler for user_delete plugin hook */ public function user_delete($args) { $db = $this->rc->get_dbh(); foreach (array('kolab_alarms', 'itipinvitations') as $table) { $db->query("DELETE FROM " . $this->rc->db->table_name($table, true) . " WHERE `user_id` = ?", $args['user']->ID); } } }