diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index f5e57089..ea58a4f1 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -1,2534 +1,2413 @@ * @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->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, $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) { $this->_read_calendars(); // 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' => $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) { $this->_read_calendars(); $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) { $this->_read_calendars(); // create calendar object if necesary if (!$this->calendars[$id]) { if (in_array($id, array(self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED))) { $this->calendars[$id] = new kolab_invitation_calendar($id, $this->cal); } else if ($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); if (!$event['calendar']) { $this->_read_calendars(); $event['calendar'] = reset(array_keys($this->calendars)); } if ($storage = $this->get_calendar($event['calendar'])) { // 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 = self::event_duration($old['start'], $old['end'], $old['allday']); $new_start_date = $event['start']->format('Y-m-d'); $new_start_time = $event['allday'] ? '' : $event['start']->format('H:i'); $new_duration = self::event_duration($event['start'], $event['end'], $event['allday']); $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($new_duration)); // remove fixed weekday, will be re-set to the new weekday in kolab_calendar::update_event() if ($old_start_date != $new_start_date && $event['recurrence']) { 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; } /** * Calculate event duration, returns string in DateInterval format */ protected static function event_duration($start, $end, $allday = false) { if ($allday) { $diff = $start->diff($end); return 'P' . $diff->days . 'D'; } return 'PT' . ($end->format('U') - $start->format('U')) . 'S'; } /** * 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) { $this->_read_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) { $this->_read_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')); $this->_read_calendars(); 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) { // 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; } // use libkolab to compute recurring events $recurrence = new kolab_date_recurrence($event['_formatobj']); $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']); // General tab $form['props'] = array( - 'name' => $this->rc->gettext('properties'), - 'content' => $formfields, + 'name' => $this->rc->gettext('properties'), + 'fields' => $formfields, ); - $form_html = ''; - - // Create form output - foreach ($form as $tab) { - if ($content = $this->get_form_part($tab)) { - $form_html .= html::tag('fieldset', null, html::tag('legend', null, rcube::Q($tab['name'])) . $content) ."\n"; - } - } - - return $form_html; + return kolab_utils::folder_form($form, '', 'calendar'); } $this->_read_calendars(); 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'), - 'content' => array(), + 'name' => $this->rc->gettext('properties'), + 'fields' => array(), ); // 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']['content']['location'] = $formfields['name']; + $form['props']['fields']['location'] = $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']['content']['path'] = array( + $form['props']['fields']['path'] = array( 'id' => 'calendar-parent', 'label' => $this->cal->gettext('parentcalendar'), 'value' => $select->show(strlen($folder) ? $path_imap : ''), ); } // calendar color (default field) - $form['props']['content']['color'] = $formfields['color']; - $form['props']['content']['alarms'] = $formfields['showalarms']; - - if ($action != 'form-new') { - $form['sharing'] = array( - 'name' => rcube::Q($this->cal->gettext('tabsharing')), - 'content' => $this->folder_acl_form($folder) - ); - } - - $form_html = ''; - - if (is_array($hidden_fields)) { - foreach ($hidden_fields as $field) { - $hiddenfield = new html_hiddenfield($field); - $form_html .= $hiddenfield->show() . "\n"; - } - } - - // Create form output - foreach ($form as $tab) { - if ($content = $this->get_form_part($tab)) { - $form_html .= html::tag('fieldset', null, html::tag('legend', null, rcube::Q($tab['name'])) . $content) ."\n"; - } - } - - return $form_html; - } - - /** - * Returns ACL form - */ - public function folder_acl_form($folder) - { - if (strlen($folder)) { - $this->cal->require_plugin('acl'); - $storage = $this->rc->get_storage(); - $options = $storage->folder_info($folder); - - // get sharing UI from acl plugin - $acl = $this->rc->plugins->exec_hook('folder_form', - array('form' => array(), 'options' => $options, 'name' => $folder)); - } - - return $acl['form']['sharing']['content'] ?: html::div('hint', $this->cal->gettext('aclnorights')); - } - - /** - * 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, 'class' => 'propform')); - foreach ($form['content'] as $col => $colprop) { - $label = !empty($colprop['label']) ? $colprop['label'] : $this->cal->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')); + $form['props']['fields']['color'] = $formfields['color']; + $form['props']['fields']['alarms'] = $formfields['showalarms']; - return $plugin['form']['sharing']['content']; + return kolab_utils::folder_form($form, $folder, 'calendar', $hidden_fields); } /** * 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); } } } diff --git a/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php b/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php index 47a2fc39..60b475ff 100644 --- a/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php +++ b/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php @@ -1,343 +1,256 @@ * * 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_addressbook_ui { private $plugin; private $rc; /** * Class constructor * * @param kolab_addressbook $plugin Plugin object */ public function __construct($plugin) { $this->rc = rcube::get_instance(); $this->plugin = $plugin; $this->init_ui(); } /** * Adds folders management functionality to Addressbook UI */ private function init_ui() { if (!empty($this->rc->action) && !preg_match('/^plugin\.book/', $this->rc->action) && $this->rc->action != 'show') { return; } // Include script $this->plugin->include_script('kolab_addressbook.js'); if (empty($this->rc->action)) { // Include stylesheet (for directorylist) $this->plugin->include_stylesheet($this->plugin->local_skin_path().'/kolab_addressbook.css'); // include kolab folderlist widget if available if (in_array('libkolab', $this->plugin->api->loaded_plugins())) { $this->plugin->api->include_script('libkolab/js/folderlist.js'); } $this->rc->output->add_footer($this->rc->output->parse('kolab_addressbook.search_addon', false, false)); // Add actions on address books $options = array('book-create', 'book-edit', 'book-delete', 'book-remove'); $idx = 0; if ($dav_url = $this->rc->config->get('kolab_addressbook_carddav_url')) { $options[] = 'book-showurl'; $this->rc->output->set_env('kolab_addressbook_carddav_url', true); // set CardDAV URI for specified ldap addressbook if ($ldap_abook = $this->rc->config->get('kolab_addressbook_carddav_ldap')) { $dav_ldap_url = strtr($dav_url, array( '%h' => $_SERVER['HTTP_HOST'], '%u' => urlencode($this->rc->get_user_name()), '%i' => 'ldap-directory', '%n' => '', )); $this->rc->output->set_env('kolab_addressbook_carddav_ldap', $ldap_abook); $this->rc->output->set_env('kolab_addressbook_carddav_ldap_url', $dav_ldap_url); } } foreach ($options as $command) { $content = html::tag('li', $idx ? null : array('class' => 'separator_above'), $this->plugin->api->output->button(array( 'label' => 'kolab_addressbook.'.str_replace('-', '', $command), 'domain' => $this->ID, 'class' => str_replace('-', ' ', $command) . ' disabled', 'classact' => str_replace('-', ' ', $command) . ' active', 'command' => $command, 'type' => 'link' ))); $this->plugin->api->add_content($content, 'groupoptions'); $idx++; } // Link to Settings/Folders $content = html::tag('li', array('class' => 'separator_above'), $this->plugin->api->output->button(array( 'label' => 'managefolders', 'type' => 'link', 'class' => 'folders disabled', 'classact' => 'folders active', 'command' => 'folders', 'task' => 'settings', ))); $this->plugin->api->add_content($content, 'groupoptions'); $this->rc->output->add_label('kolab_addressbook.bookdeleteconfirm', 'kolab_addressbook.bookdeleting', 'kolab_addressbook.bookshowurl', 'kolab_addressbook.carddavurldescription', 'kolab_addressbook.bookedit', 'kolab_addressbook.bookdelete', 'kolab_addressbook.bookshowurl', 'kolab_addressbook.findaddressbooks', 'kolab_addressbook.searchterms', 'kolab_addressbook.foldersearchform', 'kolab_addressbook.listsearchresults', 'kolab_addressbook.nraddressbooksfound', 'kolab_addressbook.noaddressbooksfound', 'kolab_addressbook.foldersubscribe', 'kolab_addressbook.bookedit', 'kolab_addressbook.bookcreate', 'kolab_addressbook.nobooknamewarning', 'kolab_addressbook.booksaving', 'resetsearch' ); if ($this->plugin->bonnie_api) { $this->rc->output->set_env('kolab_audit_trail', true); $this->plugin->api->include_script('libkolab/libkolab.js'); $this->rc->output->add_label( 'kolab_addressbook.showhistory', 'kolab_addressbook.objectchangelog', 'kolab_addressbook.objectdiff', 'kolab_addressbook.objectdiffnotavailable', 'kolab_addressbook.objectchangelognotavailable', 'kolab_addressbook.revisionrestoreconfirm' ); $this->plugin->add_hook('render_page', array($this, 'render_audittrail_page')); $this->plugin->register_handler('plugin.object_changelog_table', array('libkolab', 'object_changelog_table')); } } // include stylesheet for audit trail else if ($this->rc->action == 'show' && $this->plugin->bonnie_api) { $this->plugin->include_stylesheet($this->plugin->local_skin_path().'/kolab_addressbook.css', true); $this->rc->output->add_label('kolab_addressbook.showhistory'); } } /** * Handler for address book create/edit action */ public function book_edit() { $this->rc->output->set_env('pagetitle', $this->plugin->gettext('bookproperties')); $this->rc->output->add_handler('folderform', array($this, 'book_form')); $this->rc->output->send('libkolab.folderform'); } /** * Handler for 'bookdetails' object returning form content for book create/edit * * @param array $attr Object attributes * * @return string HTML output */ public function book_form($attrib) { $action = trim(rcube_utils::get_input_value('_act', rcube_utils::INPUT_GPC)); $folder = trim(rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC, true)); // UTF8 $hidden_fields[] = array('name' => '_source', 'value' => $folder); $folder = rcube_charset::convert($folder, RCUBE_CHARSET, 'UTF7-IMAP'); $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); if ($action == 'edit') { $path_imap = explode($delim, $folder); $name = rcube_charset::convert(array_pop($path_imap), 'UTF7-IMAP'); $path_imap = implode($path_imap, $delim); } else { // create $path_imap = $folder; $name = ''; $folder = ''; } // Store old name, get folder options if (strlen($folder)) { $hidden_fields[] = array('name' => '_oldname', 'value' => $folder); $options = $storage->folder_info($folder); } $form = array(); // General tab $form['properties'] = array( 'name' => $this->rc->gettext('properties'), 'fields' => array(), ); if (!empty($options) && ($options['norename'] || $options['protected'])) { $foldername = rcube::Q(str_replace($delim, ' » ', kolab_storage::object_name($folder))); } else { $foldername = new html_inputfield(array('name' => '_name', 'id' => '_name', 'size' => 30)); $foldername = $foldername->show($name); } $form['properties']['fields']['name'] = array( 'label' => $this->plugin->gettext('bookname'), 'value' => $foldername, 'id' => '_name', ); if (!empty($options) && ($options['norename'] || $options['protected'])) { // prevent user from moving folder $hidden_fields[] = array('name' => '_parent', 'value' => $path_imap); } else { $prop = array('name' => '_parent', 'id' => '_parent'); $select = kolab_storage::folder_selector('contact', $prop, $folder); $form['properties']['fields']['parent'] = array( 'label' => $this->plugin->gettext('parentbook'), 'value' => $select->show(strlen($folder) ? $path_imap : ''), 'id' => '_parent', ); } - // Allow plugins to modify address book form content (e.g. with ACL form) - $plugin = $this->rc->plugins->exec_hook('addressbook_form', - array('form' => $form, 'options' => $options, 'name' => $folder)); - - $form = $plugin['form']; - $form_html = ''; - - if (is_array($hidden_fields)) { - foreach ($hidden_fields as $field) { - $hiddenfield = new html_hiddenfield($field); - $form_html .= $hiddenfield->show() . "\n"; - } - } - - // create form output - foreach ($form as $tab) { - if (is_array($tab['fields']) && empty($tab['content'])) { - $table = new html_table(array('cols' => 2, 'class' => 'propform')); - foreach ($tab['fields'] as $col => $colprop) { - $label = !empty($colprop['label']) ? $colprop['label'] : $this->plugin->gettext($col); - - $table->add('title', html::label($colprop['id'], rcube::Q($label))); - $table->add(null, $colprop['value']); - } - $content = $table->show(); - } - else { - $content = $tab['content']; - } - - if (!empty($content)) { - $form_html .= html::tag('fieldset', null, html::tag('legend', null, rcube::Q($tab['name'])) . $content) . "\n"; - } - } + $form_html = kolab_utils::folder_form($form, $folder, 'calendar', $hidden_fields); return html::tag('form', $attrib + array('action' => 'plugin.book-save', 'method' => 'post', 'id' => 'bookpropform'), $form_html); } /** * */ public function render_audittrail_page($p) { // append audit trail UI elements to contact page if ($p['template'] === 'addressbook' && !$p['kolab-audittrail']) { $this->rc->output->add_footer($this->rc->output->parse('kolab_addressbook.audittrail', false, false)); $p['kolab-audittrail'] = true; } return $p; } - - - private function get_form_part($form) - { - $content = ''; - - if (is_array($form['content']) && !empty($form['content'])) { - $table = new html_table(array('cols' => 2, 'class' => 'propform')); - foreach ($form['content'] as $col => $colprop) { - $colprop['id'] = '_'.$col; - $label = !empty($colprop['label']) ? $colprop['label'] : $this->rc->gettext($col); - - $table->add('title', sprintf('', $colprop['id'], rcube::Q($label))); - $table->add(null, $colprop['value']); - } - $content = $table->show(); - } - else { - $content = $form['content']; - } - - return $content; - } - - - private function get_form_tags($attrib, $action, $id = null, $hidden = null) - { - $form_start = $form_end = ''; - - $request_key = $action . (isset($id) ? '.'.$id : ''); - $form_start = $this->rc->output->request_form(array( - 'name' => 'form', - 'method' => 'post', - 'task' => $this->rc->task, - 'action' => $action, - 'request' => $request_key, - 'noclose' => true, - ) + $attrib); - - if (is_array($hidden)) { - foreach ($hidden as $field) { - $hiddenfield = new html_hiddenfield($field); - $form_start .= $hiddenfield->show(); - } - } - - $form_end = !strlen($attrib['form']) ? '' : ''; - - $EDIT_FORM = !empty($attrib['form']) ? $attrib['form'] : 'form'; - $this->rc->output->add_gui_object('editform', $EDIT_FORM); - - return array($form_start, $form_end); - } - } diff --git a/plugins/kolab_notes/kolab_notes_ui.php b/plugins/kolab_notes/kolab_notes_ui.php index 41de349c..b39a57f4 100644 --- a/plugins/kolab_notes/kolab_notes_ui.php +++ b/plugins/kolab_notes/kolab_notes_ui.php @@ -1,400 +1,347 @@ plugin = $plugin; $this->rc = $plugin->rc; } /** * Calendar UI initialization and requests handlers */ public function init() { if ($this->ready) // already done return; // add taskbar button $this->plugin->add_button(array( 'command' => 'notes', 'class' => 'button-notes', 'classsel' => 'button-notes button-selected', 'innerclass' => 'button-inner', 'label' => 'kolab_notes.navtitle', 'type' => 'link' ), 'taskbar'); $this->plugin->include_stylesheet($this->plugin->local_skin_path() . '/notes.css'); $this->ready = true; } /** * Register handler methods for the template engine */ public function init_templates() { $this->plugin->register_handler('plugin.notebooks', array($this, 'folders')); #$this->plugin->register_handler('plugin.folders_select', array($this, 'folders_select')); $this->plugin->register_handler('plugin.searchform', array($this->rc->output, 'search_form')); $this->plugin->register_handler('plugin.listing', array($this, 'listing')); $this->plugin->register_handler('plugin.editform', array($this, 'editform')); $this->plugin->register_handler('plugin.notetitle', array($this, 'notetitle')); $this->plugin->register_handler('plugin.detailview', array($this, 'detailview')); $this->plugin->register_handler('plugin.attachments_list', array($this, 'attachments_list')); $this->plugin->register_handler('plugin.object_changelog_table', array('libkolab', 'object_changelog_table')); $this->rc->output->include_script('list.js'); $this->rc->output->include_script('treelist.js'); $this->plugin->include_script('notes.js'); $this->plugin->api->include_script('libkolab/libkolab.js'); // load config options and user prefs relevant for the UI $settings = array( 'sort_col' => $this->rc->config->get('kolab_notes_sort_col', 'changed'), ); if ($list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC)) { $settings['selected_list'] = $list; } if ($uid = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC)) { $settings['selected_uid'] = $uid; } $this->rc->html_editor(); $this->rc->output->set_env('kolab_notes_settings', $settings); $this->rc->output->add_label('save','cancel','delete','close','listoptionstitle'); } public function folders($attrib) { $attrib += array('id' => 'rcmkolabnotebooks'); if ($attrib['type'] == 'select') { $attrib['is_escaped'] = true; $select = new html_select($attrib); } $tree = $attrib['type'] != 'select' ? true : null; $lists = $this->plugin->get_lists($tree); $jsenv = array(); if (is_object($tree)) { $html = $this->folder_tree_html($tree, $lists, $jsenv, $attrib); } else { $html = ''; foreach ($lists as $prop) { $id = $prop['id']; if (!$prop['virtual']) { unset($prop['user_id']); $jsenv[$id] = $prop; } if ($attrib['type'] == 'select') { if ($prop['editable'] || strpos($prop['rights'], 'i') !== false) { $select->add($prop['name'], $prop['id']); } } else { $html .= html::tag('li', array('id' => 'rcmliknb' . rcube_utils::html_identifier($id), 'class' => $prop['group']), $this->folder_list_item($id, $prop, $jsenv) ); } } } $this->rc->output->set_env('kolab_notebooks', $jsenv); $this->rc->output->add_gui_object('notebooks', $attrib['id']); return $attrib['type'] == 'select' ? $select->show() : html::tag('ul', $attrib, $html, html::$common_attrib); } /** * Return html for a structured list
    for the folder tree */ public function folder_tree_html($node, $data, &$jsenv, $attrib) { $out = ''; foreach ($node->children as $folder) { $id = $folder->id; $prop = $data[$id]; $is_collapsed = false; // TODO: determine this somehow? $content = $this->folder_list_item($id, $prop, $jsenv); if (!empty($folder->children)) { $content .= html::tag('ul', array('style' => ($is_collapsed ? "display:none;" : null)), $this->folder_tree_html($folder, $data, $jsenv, $attrib)); } if (strlen($content)) { $out .= html::tag('li', array( 'id' => 'rcmliknb' . rcube_utils::html_identifier($id), 'class' => $prop['group'] . ($prop['virtual'] ? ' virtual' : ''), ), $content); } } return $out; } /** * Helper method to build a tasklist item (HTML content and js data) */ public function folder_list_item($id, $prop, &$jsenv, $checkbox = false) { if (!$prop['virtual']) { unset($prop['user_id']); $jsenv[$id] = $prop; } $classes = array('folder'); if ($prop['virtual']) { $classes[] = 'virtual'; } else if (!$prop['editable']) { $classes[] = 'readonly'; } if ($prop['subscribed']) { $classes[] = 'subscribed'; } if ($prop['class']) { $classes[] = $prop['class']; } $title = $prop['title'] ?: ($prop['name'] != $prop['listname'] || strlen($prop['name']) > 25 ? html_entity_decode($prop['name'], ENT_COMPAT, RCUBE_CHARSET) : ''); $label_id = 'nl:' . $id; $attr = $prop['virtual'] ? array('tabindex' => '0') : array('href' => $this->rc->url(array('_list' => $id))); return html::div(join(' ', $classes), html::a($attr + array('class' => 'listname', 'title' => $title, 'id' => $label_id), $prop['listname'] ?: $prop['name']) . ($prop['virtual'] ? '' : ($checkbox ? html::tag('input', array('type' => 'checkbox', 'name' => '_list[]', 'value' => $id, 'checked' => $prop['active'], 'aria-labelledby' => $label_id)) : '' ) . html::span('handle', '') . html::span('actions', (!$prop['default'] ? html::a(array('href' => '#', 'class' => 'remove', 'title' => $this->plugin->gettext('removelist')), ' ') : '' ) . (isset($prop['subscribed']) ? html::a(array('href' => '#', 'class' => 'subscribed', 'title' => $this->plugin->gettext('foldersubscribe'), 'role' => 'checkbox', 'aria-checked' => $prop['subscribed'] ? 'true' : 'false'), ' ') : '' ) ) ) ); return ''; } public function listing($attrib) { $attrib += array('id' => 'rcmkolabnoteslist'); $this->rc->output->add_gui_object('noteslist', $attrib['id']); return html::tag('table', $attrib, '', html::$common_attrib); } public function editform($attrib) { $attrib += array('action' => '#', 'id' => 'rcmkolabnoteseditform'); $textarea = new html_textarea(array( 'name' => 'content', 'id' => 'notecontent', 'cols' => 60, 'rows' => 20, 'tabindex' => 0, 'class' => 'mce_editor form-control', )); $this->rc->output->add_gui_object('noteseditform', $attrib['id']); return html::tag('form', $attrib, $textarea->show(), array_merge(html::$common_attrib, array('action'))); } public function detailview($attrib) { $attrib += array('id' => 'rcmkolabnotesdetailview'); $this->rc->output->add_gui_object('notesdetailview', $attrib['id']); return html::div($attrib, ''); } public function notetitle($attrib) { $attrib += array('id' => 'rcmkolabnotestitle'); $this->rc->output->add_gui_object('noteviewtitle', $attrib['id']); $summary = new html_inputfield(array( 'name' => 'summary', 'class' => 'notetitle inline-edit form-control', 'size' => 60, 'id' => 'notetitleinput', 'tabindex' => 0 )); $html = html::div('form-group row', html::label(array('class' => 'col-sm-2 col-form-label', 'for' => 'notetitleinput'), $this->plugin->gettext('kolab_notes.title')) . html::span('col-sm-10', $summary->show()) ) . html::div('form-group row', html::label(array('class' => 'col-sm-2 col-form-label'), $this->plugin->gettext('kolab_notes.tags')) . html::div(array('class' => 'tagline tagedit col-sm-10'), ' ') ) . html::div(array('class' => 'dates text-only', 'style' => 'display:none'), html::div('form-group row', html::label(array('class' => 'col-sm-2 col-form-label'), $this->plugin->gettext('created')) . html::span('col-sm-10', html::span('notecreated form-control-plaintext', '')) ) . html::div('form-group row', html::label(array('class' => 'col-sm-2 col-form-label'), $this->plugin->gettext('changed')) . html::span('col-sm-10', html::span('notechanged form-control-plaintext', '')) ) ); return html::div($attrib, $html); } public function attachments_list($attrib) { $attrib += array('id' => 'rcmkolabnotesattachmentslist'); $this->rc->output->add_gui_object('notesattachmentslist', $attrib['id']); return html::tag('ul', $attrib, '', html::$common_attrib); } /** * Render create/edit form for notes lists (folders) */ public function list_editform($action, $list, $folder) { $this->action = $action; $this->list = $list; $this->folder = is_object($folder) ? $folder->name : ''; // UTF7; $this->rc->output->set_env('pagetitle', $this->plugin->gettext('arialabelnotebookform')); $this->rc->output->add_handler('folderform', array($this, 'notebookform')); $this->rc->output->send('libkolab.folderform'); } /** * Render create/edit form for notes lists (folders) */ public function notebookform($attrib) { $folder_name = $this->folder; $hidden_fields[] = array('name' => 'oldname', 'value' => $folder_name); $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = array(); if (strlen($folder_name)) { $options = $storage->folder_info($folder_name); $path_imap = explode($delim, $folder_name); array_pop($path_imap); // pop off name part $path_imap = implode($path_imap, $delim); } else { $path_imap = ''; $options = array(); } // General tab $form['properties'] = array( 'name' => $this->rc->gettext('properties'), 'fields' => array(), ); // folder name (default field) $input_name = new html_inputfield(array('name' => 'name', 'id' => 'noteslist-name', 'size' => 20)); $form['properties']['fields']['name'] = array( 'label' => $this->plugin->gettext('listname'), 'value' => $input_name->show($this->list['editname'], array('disabled' => ($options['norename'] || $options['protected']))), 'id' => 'noteslist-name', ); // prevent user from moving folder if (!empty($options) && ($options['norename'] || $options['protected'])) { $hidden_fields[] = array('name' => 'parent', 'value' => $path_imap); } else { $select = kolab_storage::folder_selector('note', array('name' => 'parent', 'id' => 'parent-folder'), $folder_name); $form['properties']['fields']['path'] = array( 'label' => $this->plugin->gettext('parentfolder'), 'value' => $select->show(strlen($folder_name) ? $path_imap : ''), 'id' => 'parent-folder', ); } - // add folder ACL tab - if ($this->action != 'form-new') { - $form['sharing'] = array( - 'name' => rcube::Q($this->plugin->gettext('tabsharing')), - 'content' => $this->folder_acl_form() - ); - } - - $form_html = ''; - if (is_array($hidden_fields)) { - foreach ($hidden_fields as $field) { - $hiddenfield = new html_hiddenfield($field); - $form_html .= $hiddenfield->show() . "\n"; - } - } - - // create form output - foreach ($form as $tab) { - if (is_array($tab['fields']) && empty($tab['content'])) { - $table = new html_table(array('cols' => 2, 'class' => 'propform')); - foreach ($tab['fields'] as $col => $colprop) { - $label = !empty($colprop['label']) ? $colprop['label'] : $this->plugin->gettext($col); - - $table->add('title', html::label($colprop['id'], rcube::Q($label))); - $table->add(null, $colprop['value']); - } - $content = $table->show(); - } - else { - $content = $tab['content']; - } - - if (!empty($content)) { - $form_html .= html::tag('fieldset', null, html::tag('legend', null, rcube::Q($tab['name'])) . $content) . "\n"; - } - } + $form_html = kolab_utils::folder_form($form, $folder_name, 'kolab_notes', $hidden_fields); return html::tag('form', $attrib + array('action' => '#', 'method' => 'post', 'id' => 'noteslistpropform'), $form_html); } - - /** - * Returns ACL form - */ - public function folder_acl_form() - { - if (strlen($this->folder)) { - $this->plugin->require_plugin('acl'); - $storage = $this->rc->get_storage(); - $options = $storage->folder_info($this->folder); - - // get sharing UI from acl plugin - $acl = $this->rc->plugins->exec_hook('folder_form', - array('form' => array(), 'options' => $options, 'name' => $this->folder)); - } - - return $acl['form']['sharing']['content'] ?: html::div('hint', $this->plugin->gettext('aclnorights')); - } } diff --git a/plugins/libkolab/lib/kolab_utils.php b/plugins/libkolab/lib/kolab_utils.php new file mode 100644 index 00000000..c61ec49e --- /dev/null +++ b/plugins/libkolab/lib/kolab_utils.php @@ -0,0 +1,93 @@ + + * @author Aleksander Machniak + * + * Copyright (C) 2012-2018, 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_utils +{ + public static function folder_form($form, $folder, $domain, $hidden_fields = array()) + { + $rcmail = rcmail::get_instance(); + + // add folder ACL tab + if (is_string($folder) && strlen($folder)) { + $form['sharing'] = array( + 'name' => rcube::Q($rcmail->gettext('libkolab.tabsharing')), + 'content' => self::folder_acl_form($folder), + ); + } + + $form_html = ''; + + if (is_array($hidden_fields)) { + foreach ($hidden_fields as $field) { + $hiddenfield = new html_hiddenfield($field); + $form_html .= $hiddenfield->show() . "\n"; + } + } + + // create form output + foreach ($form as $tab) { + if (is_array($tab['fields']) && empty($tab['content'])) { + $table = new html_table(array('cols' => 2, 'class' => 'propform')); + foreach ($tab['fields'] as $col => $colprop) { + $label = !empty($colprop['label']) ? $colprop['label'] : $rcmail->gettext("$domain.$col"); + + $table->add('title', html::label($colprop['id'], rcube::Q($label))); + $table->add(null, $colprop['value']); + } + $content = $table->show(); + } + else { + $content = $tab['content']; + } + + if (!empty($content)) { + $form_html .= html::tag('fieldset', null, html::tag('legend', null, rcube::Q($tab['name'])) . $content) . "\n"; + } + } + + return $form_html; + } + + /** + * Handler for ACL form template object + */ + public static function folder_acl_form($folder) + { + $rcmail = rcmail::get_instance(); + $storage = $rcmail->get_storage(); + $options = $storage->folder_info($folder); + + $rcmail->plugins->load_plugin('acl', true); + + // get sharing UI from acl plugin + $acl = $rcmail->plugins->exec_hook('folder_form', array( + 'form' => array(), + 'options' => $options, + 'name' => $folder + )); + + return $acl['form']['sharing']['content'] ?: html::div('hint', $rcmail->gettext('libkolab.aclnorights')); + } +} diff --git a/plugins/libkolab/localization/en_US.inc b/plugins/libkolab/localization/en_US.inc index 79ec513d..9cbe8b75 100644 --- a/plugins/libkolab/localization/en_US.inc +++ b/plugins/libkolab/localization/en_US.inc @@ -1,30 +1,32 @@ * * 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 tasklist_kolab_driver extends tasklist_driver { // features supported by the backend public $alarms = false; public $attachments = true; public $attendees = true; public $undelete = false; // task undelete action public $alarm_types = array('DISPLAY','AUDIO'); public $search_more_results; private $rc; private $plugin; private $lists; private $folders = array(); private $tasks = array(); private $tags = array(); private $bonnie_api = false; /** * Default constructor */ public function __construct($plugin) { $this->rc = $plugin->rc; $this->plugin = $plugin; if (kolab_storage::$version == '2.0') { $this->alarm_absolute = false; } // tasklist use fully encoded identifiers kolab_storage::$encode_ids = true; // get configuration for the Bonnie API $this->bonnie_api = libkolab::get_bonnie_api(); $this->plugin->register_action('folder-acl', array($this, 'folder_acl')); } /** * Read available calendars for the current user and store them internally */ private function _read_lists($force = false) { // already read sources if (isset($this->lists) && !$force) { return $this->lists; } // get all folders that have type "task" $folders = kolab_storage::sort_folders(kolab_storage::get_folders('task')); $this->lists = $this->folders = array(); $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); // find default folder $default_index = 0; foreach ($folders as $i => $folder) { if ($folder->default && strpos($folder->name, $delim) === false) $default_index = $i; } // put default folder (aka INBOX) on top of the list if ($default_index > 0) { $default_folder = $folders[$default_index]; unset($folders[$default_index]); array_unshift($folders, $default_folder); } $prefs = $this->rc->config->get('kolab_tasklists', array()); foreach ($folders as $folder) { $tasklist = $this->folder_props($folder, $prefs); $this->lists[$tasklist['id']] = $tasklist; $this->folders[$tasklist['id']] = $folder; $this->folders[$folder->name] = $folder; } return $this->lists; } /** * Derive list properties from the given kolab_storage_folder object */ protected function folder_props($folder, $prefs) { if ($folder->get_namespace() == 'personal') { $norename = false; $editable = true; $rights = 'lrswikxtea'; $alarms = true; } else { $alarms = false; $rights = 'lr'; $editable = false; if ($myrights = $folder->get_myrights()) { $rights = $myrights; if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) $editable = strpos($rights, 'i') !== false; } $info = $folder->get_folder_info(); $norename = $readonly || $info['norename'] || $info['protected']; } $list_id = $folder->id; #kolab_storage::folder_id($folder->name); $old_id = kolab_storage::folder_id($folder->name, false); if (!isset($prefs[$list_id]['showalarms']) && isset($prefs[$old_id]['showalarms'])) { $prefs[$list_id]['showalarms'] = $prefs[$old_id]['showalarms']; } return array( 'id' => $list_id, 'name' => $folder->get_name(), 'listname' => $folder->get_foldername(), 'editname' => $folder->get_foldername(), 'color' => $folder->get_color('0000CC'), 'showalarms' => isset($prefs[$list_id]['showalarms']) ? $prefs[$list_id]['showalarms'] : $alarms, 'editable' => $editable, 'rights' => $rights, 'norename' => $norename, 'active' => $folder->is_active(), 'owner' => $folder->get_owner(), 'parentfolder' => $folder->get_parent(), 'default' => $folder->default, 'virtual' => $folder->virtual, 'children' => true, // TODO: determine if that folder indeed has child folders 'subscribed' => (bool)$folder->is_subscribed(), 'removable' => !$folder->default, 'subtype' => $folder->subtype, 'group' => $folder->default ? 'default' : $folder->get_namespace(), 'class' => trim($folder->get_namespace() . ($folder->default ? ' default' : '')), 'caldavuid' => $folder->get_uid(), 'history' => !empty($this->bonnie_api), ); } /** * Get a list of available task lists from this source * * @param integer Bitmask defining filter criterias. * See FILTER_* constants for possible values. */ public function get_lists($filter = 0, &$tree = null) { $this->_read_lists(); // attempt to create a default list for this user if (empty($this->lists) && !isset($this->search_more_results)) { $prop = array('name' => 'Tasks', 'color' => '0000CC', 'default' => true); if ($this->create_list($prop)) $this->_read_lists(true); } $folders = $this->filter_folders($filter); // include virtual folders for a full folder tree if (!is_null($tree)) { $folders = kolab_storage::folder_hierarchy($folders, $tree); } $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); $prefs = $this->rc->config->get('kolab_tasklists', array()); $lists = array(); foreach ($folders as $folder) { $list_id = $folder->id; // kolab_storage::folder_id($folder->name); $imap_path = explode($delim, $folder->name); // find parent do { array_pop($imap_path); $parent_id = kolab_storage::folder_id(join($delim, $imap_path)); } while (count($imap_path) > 1 && !$this->folders[$parent_id]); // restore "real" parent ID if ($parent_id && !$this->folders[$parent_id]) { $parent_id = kolab_storage::folder_id($folder->get_parent()); } $fullname = $folder->get_name(); $listname = $folder->get_foldername(); // special handling for virtual folders if ($folder instanceof kolab_storage_folder_user) { $lists[$list_id] = array( 'id' => $list_id, 'name' => $fullname, 'listname' => $listname, 'title' => $folder->get_title(), 'virtual' => true, 'editable' => false, 'rights' => 'l', 'group' => 'other virtual', 'class' => 'user', 'parent' => $parent_id, ); } else if ($folder->virtual) { $lists[$list_id] = array( 'id' => $list_id, 'name' => $fullname, 'listname' => $listname, 'virtual' => true, 'editable' => false, 'rights' => 'l', 'group' => $folder->get_namespace(), 'class' => 'folder', 'parent' => $parent_id, ); } else { if (!$this->lists[$list_id]) { $this->lists[$list_id] = $this->folder_props($folder, $prefs); $this->folders[$list_id] = $folder; } $this->lists[$list_id]['parent'] = $parent_id; $lists[$list_id] = $this->lists[$list_id]; } } return $lists; } /** * Get list of folders according to specified filters * * @param integer Bitmask defining restrictions. See FILTER_* constants for possible values. * * @return array List of task folders */ protected function filter_folders($filter) { $this->_read_lists(); $folders = array(); foreach ($this->lists as $id => $list) { if (!empty($this->folders[$id])) { $folder = $this->folders[$id]; if ($folder->get_namespace() == 'personal') { $folder->editable = true; } else if ($rights = $folder->get_myrights()) { if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) { $folder->editable = strpos($rights, 'i') !== false; } } $folders[] = $folder; } } $plugin = $this->rc->plugins->exec_hook('tasklist_list_filter', array( 'list' => $folders, 'filter' => $filter, 'tasklists' => $folders, )); if ($plugin['abort'] || !$filter) { return $plugin['tasklists']; } $personal = $filter & self::FILTER_PERSONAL; $shared = $filter & self::FILTER_SHARED; $tasklists = array(); foreach ($folders as $folder) { if (($filter & self::FILTER_WRITEABLE) && !$folder->editable) { continue; } /* if (($filter & self::FILTER_INSERTABLE) && !$folder->insert) { continue; } if (($filter & self::FILTER_ACTIVE) && !$folder->is_active()) { continue; } if (($filter & self::FILTER_PRIVATE) && $folder->subtype != 'private') { continue; } if (($filter & self::FILTER_CONFIDENTIAL) && $folder->subtype != 'confidential') { continue; } */ if ($personal || $shared) { $ns = $folder->get_namespace(); if (!(($personal && $ns == 'personal') || ($shared && $ns == 'shared'))) { continue; } } $tasklists[$folder->id] = $folder; } return $tasklists; } /** * Get the kolab_calendar instance for the given calendar ID * * @param string List identifier (encoded imap folder name) * @return object kolab_storage_folder Object nor null if list doesn't exist */ protected function get_folder($id) { $this->_read_lists(); // create list and folder instance if necesary if (!$this->lists[$id]) { $folder = kolab_storage::get_folder(kolab_storage::id_decode($id)); if ($folder->type) { $this->folders[$id] = $folder; $this->lists[$id] = $this->folder_props($folder, $this->rc->config->get('kolab_tasklists', array())); } } return $this->folders[$id]; } /** * Create a new list assigned to the current user * * @param array Hash array with list properties * name: List name * color: The color of the list * showalarms: True if alarms are enabled * @return mixed ID of the new list on success, False on error */ public function create_list(&$prop) { $prop['type'] = 'task' . ($prop['default'] ? '.default' : ''); $prop['active'] = true; // activate folder by default $prop['subscribed'] = true; $folder = kolab_storage::folder_update($prop); if ($folder === false) { $this->last_error = kolab_storage::$last_error; return false; } // create ID $id = kolab_storage::folder_id($folder); $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', array()); if (isset($prop['showalarms'])) $prefs['kolab_tasklists'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if ($prefs['kolab_tasklists'][$id]) $this->rc->user->save_prefs($prefs); // force page reload to properly render folder hierarchy if (!empty($prop['parent'])) { $prop['_reload'] = true; } else { $folder = kolab_storage::get_folder($folder); $prop += $this->folder_props($folder, array()); } return $id; } /** * Update properties of an existing tasklist * * @param array Hash array with list properties * id: List Identifier * name: List name * color: The color of the list * showalarms: True if alarms are enabled (if supported) * @return boolean True on success, Fales on failure */ public function edit_list(&$prop) { if ($prop['id'] && ($folder = $this->get_folder($prop['id']))) { $prop['oldname'] = $folder->name; $prop['type'] = 'task'; $newfolder = kolab_storage::folder_update($prop); if ($newfolder === false) { $this->last_error = kolab_storage::$last_error; return false; } // create ID $id = kolab_storage::folder_id($newfolder); // fallback to local prefs $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', array()); unset($prefs['kolab_tasklists'][$prop['id']]); if (isset($prop['showalarms'])) $prefs['kolab_tasklists'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if ($prefs['kolab_tasklists'][$id]) $this->rc->user->save_prefs($prefs); // force page reload if folder name/hierarchy changed if ($newfolder != $prop['oldname']) $prop['_reload'] = true; return $id; } return false; } /** * Set active/subscribed state of a list * * @param array Hash array with list properties * id: List Identifier * active: True if list is active, false if not * permanent: True if list is to be subscribed permanently * @return boolean True on success, Fales on failure */ public function subscribe_list($prop) { if ($prop['id'] && ($folder = $this->get_folder($prop['id']))) { $ret = false; if (isset($prop['permanent'])) $ret |= $folder->subscribe(intval($prop['permanent'])); if (isset($prop['active'])) $ret |= $folder->activate(intval($prop['active'])); // apply to child folders, too if ($prop['recursive']) { foreach ((array)kolab_storage::list_folders($folder->name, '*', 'task') 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; } return false; } /** * Delete the given list with all its contents * * @param array Hash array with list properties * id: list Identifier * @return boolean True on success, Fales on failure */ public function delete_list($prop) { if ($prop['id'] && ($folder = $this->get_folder($prop['id']))) { if (kolab_storage::folder_delete($folder->name)) { return true; } $this->last_error = kolab_storage::$last_error; } return false; } /** * Search for shared or otherwise not listed tasklists the user has access * * @param string Search string * @param string Section/source to search * @return array List of tasklists */ public function search_lists($query, $source) { if (!kolab_storage::setup()) { return array(); } $this->search_more_results = false; $this->lists = $this->folders = array(); // find unsubscribed IMAP folders that have "event" type if ($source == 'folders') { foreach ((array)kolab_storage::search_folders('task', $query, array('other')) as $folder) { $this->folders[$folder->id] = $folder; $this->lists[$folder->id] = $this->folder_props($folder, array()); } } // search other user's namespace via LDAP 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 * 10) as $user) { $folders = array(); // search for tasks folders shared by this user foreach (kolab_storage::list_user_folders($user, 'task', false) as $foldername) { $folders[] = new kolab_storage_folder($foldername, 'task'); } if (count($folders)) { $userfolder = new kolab_storage_folder_user($user['kolabtargetfolder'], '', $user); $this->folders[$userfolder->id] = $userfolder; $this->lists[$userfolder->id] = $this->folder_props($userfolder, array()); foreach ($folders as $folder) { $this->folders[$folder->id] = $folder; $this->lists[$folder->id] = $this->folder_props($folder, array()); $count++; } } if ($count >= $limit) { $this->search_more_results = true; break; } } } return $this->get_lists(); } /** * Get a list of tags to assign tasks to * * @return array List of tags */ public function get_tags() { $config = kolab_storage_config::get_instance(); $tags = $config->get_tags(); $backend_tags = array_map(function($v) { return $v['name']; }, $tags); return array_values(array_unique(array_merge($this->tags, $backend_tags))); } /** * Get number of tasks matching the given filter * * @param array List of lists to count tasks of * @return array Hash array with counts grouped by status (all|flagged|completed|today|tomorrow|nodate) */ public function count_tasks($lists = null) { if (empty($lists)) { $lists = $this->_read_lists(); $lists = array_keys($lists); } else if (is_string($lists)) { $lists = explode(',', $lists); } $today_date = new DateTime('now', $this->plugin->timezone); $today = $today_date->format('Y-m-d'); $tomorrow_date = new DateTime('now + 1 day', $this->plugin->timezone); $tomorrow = $tomorrow_date->format('Y-m-d'); $counts = array('all' => 0, 'today' => 0, 'tomorrow' => 0, 'later' => 0, 'overdue' => 0); foreach ($lists as $list_id) { if (!$folder = $this->get_folder($list_id)) { continue; } foreach ($folder->select(array(array('tags','!~','x-complete'))) as $record) { $rec = $this->_to_rcube_task($record, $list_id, false); if ($this->is_complete($rec)) // don't count complete tasks continue; $counts['all']++; if (empty($rec['date'])) $counts['later']++; else if ($rec['date'] == $today) $counts['today']++; else if ($rec['date'] == $tomorrow) $counts['tomorrow']++; else if ($rec['date'] < $today) $counts['overdue']++; else if ($rec['date'] > $tomorrow) $counts['later']++; } } // avoid session race conditions that will loose temporary subscriptions $this->plugin->rc->session->nowrite = true; return $counts; } /** * Get all task records matching the given filter * * @param array Hash array with filter criterias: * - mask: Bitmask representing the filter selection (check against tasklist::FILTER_MASK_* constants) * - from: Date range start as string (Y-m-d) * - to: Date range end as string (Y-m-d) * - search: Search query string * - uid: Task UIDs * @param array List of lists to get tasks from * @return array List of tasks records matchin the criteria */ public function list_tasks($filter, $lists = null) { if (empty($lists)) { $lists = $this->_read_lists(); $lists = array_keys($lists); } else if (is_string($lists)) { $lists = explode(',', $lists); } $config = kolab_storage_config::get_instance(); $results = array(); // query Kolab storage $query = array(); if ($filter['mask'] & tasklist::FILTER_MASK_COMPLETE) $query[] = array('tags','~','x-complete'); else if (empty($filter['since'])) $query[] = array('tags','!~','x-complete'); // full text search (only works with cache enabled) if ($filter['search']) { $search = mb_strtolower($filter['search']); foreach (rcube_utils::normalize_string($search, true) as $word) { $query[] = array('words', '~', $word); } } if ($filter['since']) { $query[] = array('changed', '>=', $filter['since']); } if ($filter['uid']) { $query[] = array('uid', '=', (array) $filter['uid']); } foreach ($lists as $list_id) { if (!$folder = $this->get_folder($list_id)) { continue; } foreach ($folder->select($query) as $record) { // TODO: post-filter tasks returned from storage $record['list_id'] = $list_id; $results[] = $record; } } $config->apply_tags($results, true); $config->apply_links($results); foreach (array_keys($results) as $idx) { $results[$idx] = $this->_to_rcube_task($results[$idx], $results[$idx]['list_id']); } // avoid session race conditions that will loose temporary subscriptions $this->plugin->rc->session->nowrite = true; return $results; } /** * Return data of a specific task * * @param mixed Hash array with task properties or task UID * @param integer Bitmask defining filter criterias for folders. * See FILTER_* constants for possible values. * * @return array Hash array with task properties or false if not found */ public function get_task($prop, $filter = 0) { $this->_parse_id($prop); $id = $prop['uid']; $list_id = $prop['list']; $folders = $list_id ? array($list_id => $this->get_folder($list_id)) : $this->get_lists($filter); // find task in the available folders foreach ($folders as $list_id => $folder) { if (is_array($folder)) $folder = $this->folders[$list_id]; if (is_numeric($list_id) || !$folder) continue; if (!$this->tasks[$id] && ($object = $folder->get_object($id))) { $this->load_tags($object); $this->tasks[$id] = $this->_to_rcube_task($object, $list_id); break; } } return $this->tasks[$id]; } /** * Get all decendents of the given task record * * @param mixed Hash array with task properties or task UID * @param boolean True if all childrens children should be fetched * @return array List of all child task IDs */ public function get_childs($prop, $recursive = false) { if (is_string($prop)) { $task = $this->get_task($prop); $prop = array('uid' => $task['uid'], 'list' => $task['list']); } else { $this->_parse_id($prop); } $childs = array(); $list_id = $prop['list']; $task_ids = array($prop['uid']); $folder = $this->get_folder($list_id); // query for childs (recursively) while ($folder && !empty($task_ids)) { $query_ids = array(); foreach ($task_ids as $task_id) { $query = array(array('tags','=','x-parent:' . $task_id)); foreach ($folder->select($query) as $record) { // don't rely on kolab_storage_folder filtering if ($record['parent_id'] == $task_id) { $childs[] = $list_id . ':' . $record['uid']; $query_ids[] = $record['uid']; } } } if (!$recursive) break; $task_ids = $query_ids; } return $childs; } /** * Provide a list of revisions for the given task * * @param array $task Hash array with task properties * @return array List of changes, each as a hash array * @see tasklist_driver::get_task_changelog() */ public function get_task_changelog($prop) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($prop); $result = $uid && $mailbox ? $this->bonnie_api->changelog('task', $uid, $mailbox, $msguid) : null; if (is_array($result) && $result['uid'] == $uid) { return $result['changes']; } return false; } /** * Return full data of a specific revision of an event * * @param mixed $task UID string or hash array with task properties * @param mixed $rev Revision number * * @return array Task object as hash array * @see tasklist_driver::get_task_revision() */ public function get_task_revison($prop, $rev) { if (empty($this->bonnie_api)) { return false; } $this->_parse_id($prop); $uid = $prop['uid']; $list_id = $prop['list']; list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($prop); // call Bonnie API $result = $this->bonnie_api->get('task', $uid, $rev, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { $format = kolab_format::factory('task'); $format->load($result['xml']); $rec = $format->to_array(); $format->get_attachments($rec, true); if ($format->is_valid()) { $rec = self::_to_rcube_task($rec, $list_id, false); $rec['rev'] = $result['rev']; return $rec; } } return false; } /** * Command the backend to restore a certain revision of a task. * This shall replace the current object with an older version. * * @param mixed $task UID string or hash array with task properties * @param mixed $rev Revision number * * @return boolean True on success, False on failure * @see tasklist_driver::restore_task_revision() */ public function restore_task_revision($prop, $rev) { if (empty($this->bonnie_api)) { return false; } $this->_parse_id($prop); $uid = $prop['uid']; $list_id = $prop['list']; list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($prop); $folder = $this->get_folder($list_id); $success = false; if ($folder && ($raw_msg = $this->bonnie_api->rawdata('task', $uid, $rev, $mailbox))) { $imap = $this->rc->get_storage(); // insert $raw_msg as new message if ($imap->save_message($folder->name, $raw_msg, null, false)) { $success = true; // delete old revision from imap and cache $imap->delete_message($msguid, $folder->name); $folder->cache->set($msguid, false); } } return $success; } /** * Get a list of property changes beteen two revisions of a task object * * @param array $task Hash array with task properties * @param mixed $rev Revisions: "from:to" * * @return array List of property changes, each as a hash array * @see tasklist_driver::get_task_diff() */ public function get_task_diff($prop, $rev1, $rev2) { $this->_parse_id($prop); $uid = $prop['uid']; $list_id = $prop['list']; list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($prop); // call Bonnie API $result = $this->bonnie_api->diff('task', $uid, $rev1, $rev2, $mailbox, $msguid, $instance_id); if (is_array($result) && $result['uid'] == $uid) { $result['rev1'] = $rev1; $result['rev2'] = $rev2; $keymap = array( 'start' => 'start', 'due' => 'date', 'dstamp' => 'changed', 'summary' => 'title', 'alarm' => 'alarms', 'attendee' => 'attendees', 'attach' => 'attachments', 'rrule' => 'recurrence', 'related-to' => 'parent_id', 'percent-complete' => 'complete', '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']]; } if ($change['property'] == 'priority') { $change['property'] = 'flagged'; $change['old'] = $change['old'] == 1 ? $this->plugin->gettext('yes') : null; $change['new'] = $change['new'] == 1 ? $this->plugin->gettext('yes') : null; } // 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; } /** * Helper method to resolved the given task identifier into uid and folder * * @return array (uid,folder,msguid) tuple */ private function _resolve_task_identity($prop) { $mailbox = $msguid = null; $this->_parse_id($prop); $uid = $prop['uid']; $list_id = $prop['list']; if ($folder = $this->get_folder($list_id)) { $mailbox = $folder->get_mailbox_id(); // get task object from storage in order to get the real object uid an msguid if ($rec = $folder->get_object($uid)) { $msguid = $rec['_msguid']; $uid = $rec['uid']; } } return array($uid, $mailbox, $msguid); } /** * Get a list of pending alarms to be displayed to the user * * @param integer Current time (unix timestamp) * @param mixed List of list IDs to show alarms for (either as array or comma-separated string) * @return array A list of alarms, each encoded as hash array with task properties * @see tasklist_driver::pending_alarms() */ public function pending_alarms($time, $lists = 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 ($lists && is_string($lists)) $lists = explode(',', $lists); $time = $slot + $interval; $candidates = array(); $query = array( array('tags', '=', 'x-has-alarms'), array('tags', '!=', 'x-complete') ); $this->_read_lists(); foreach ($this->lists as $lid => $list) { // skip lists with alarms disabled if (!$list['showalarms'] || ($lists && !in_array($lid, $lists))) continue; $folder = $this->get_folder($lid); foreach ($folder->select($query) as $record) { if (!($record['valarms'] || $record['alarms']) || $record['status'] == 'COMPLETED' || $record['complete'] == 100) // don't trust query :-) continue; $task = $this->_to_rcube_task($record, $lid, false); // add to list if alarm is set $alarm = libcalendaring::get_next_alarm($task, 'task'); if ($alarm && $alarm['time'] && $alarm['time'] <= $time && in_array($alarm['action'], $this->alarm_types)) { $id = $alarm['id']; // use alarm-id as primary identifier $candidates[$id] = array( 'id' => $id, 'title' => $task['title'], 'date' => $task['date'], 'time' => $task['time'], '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 && ($rec = $this->rc->db->fetch_assoc($result))) { $dbdata[$rec['alarm_id']] = $rec; } } $alarms = array(); foreach ($candidates as $id => $task) { // skip dismissed if ($dbdata[$id]['dismissed']) continue; // snooze function may have shifted alarm time $notifyat = $dbdata[$id]['notifyat'] ? strtotime($dbdata[$id]['notifyat']) : $task['notifyat']; if ($notifyat <= $time) $alarms[] = $task; } return $alarms; } /** * (User) feedback after showing an alarm notification * This should mark the alarm as 'shown' or snooze it for the given amount of time * * @param string Task identifier * @param integer Suspend the alarm for this number of seconds */ public function dismiss_alarm($id, $snooze = 0) { // delete old alarm entry $this->rc->db->query( "DELETE FROM " . $this->rc->db->table_name('kolab_alarms', true) . " WHERE `alarm_id` = ? AND `user_id` = ?", $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 " . $this->rc->db->table_name('kolab_alarms', true) . " (`alarm_id`, `user_id`, `dismissed`, `notifyat`) VALUES (?, ?, ?, ?)", $id, $this->rc->user->ID, $snooze > 0 ? 0 : 1, $notifyat ); return $this->rc->db->affected_rows($query); } /** * Remove alarm dismissal or snooze state * * @param string Task identifier */ public function clear_alarms($id) { // delete alarm entry $this->rc->db->query( "DELETE FROM " . $this->rc->db->table_name('kolab_alarms', true) . " WHERE `alarm_id` = ? AND `user_id` = ?", $id, $this->rc->user->ID ); return true; } /** * Get task tags */ private function load_tags(&$object) { // this task hasn't been migrated yet if (!empty($object['categories'])) { // OPTIONAL: call kolab_storage_config::apply_tags() to migrate the object $object['tags'] = (array)$object['categories']; if (!empty($object['tags'])) { $this->tags = array_merge($this->tags, $object['tags']); } } else { $config = kolab_storage_config::get_instance(); $tags = $config->get_tags($object['uid']); $object['tags'] = array_map(function($v) { return $v['name']; }, $tags); } } /** * Update task tags */ private function save_tags($uid, $tags) { $config = kolab_storage_config::get_instance(); $config->save_tags($uid, $tags); } /** * Find messages linked with a task record */ private function get_links($uid) { $config = kolab_storage_config::get_instance(); return $config->get_object_links($uid); } /** * */ private function save_links($uid, $links) { $config = kolab_storage_config::get_instance(); return $config->save_object_links($uid, (array) $links); } /** * Extract uid + list identifiers from the given input * * @param mixed array or string with task identifier(s) */ private function _parse_id(&$prop) { $id_ = null; if (is_array($prop)) { // 'uid' + 'list' available, nothing to be done if (!empty($prop['uid']) && !empty($prop['list'])) { return; } // 'id' is given if (!empty($prop['id'])) { if (!empty($prop['list'])) { $list_id = $prop['_fromlist'] ?: $prop['list']; if (strpos($prop['id'], $list_id.':') === 0) { $prop['uid'] = substr($prop['id'], strlen($list_id)+1); } else { $prop['uid'] = $prop['id']; } } else { $id_ = $prop['id']; } } } else { $id_ = strval($prop); $prop = array(); } // split 'id' into list + uid if (!empty($id_)) { list($list, $uid) = explode(':', $id_, 2); if (!empty($uid)) { $prop['uid'] = $uid; $prop['list'] = $list; } else { $prop['uid'] = $id_; } } } /** * Convert from Kolab_Format to internal representation */ private function _to_rcube_task($record, $list_id, $all = true) { $id_prefix = $list_id . ':'; $task = array( 'id' => $id_prefix . $record['uid'], 'uid' => $record['uid'], 'title' => $record['title'], // 'location' => $record['location'], 'description' => $record['description'], 'flagged' => $record['priority'] == 1, 'complete' => floatval($record['complete'] / 100), 'status' => $record['status'], 'parent_id' => $record['parent_id'] ? $id_prefix . $record['parent_id'] : null, 'recurrence' => $record['recurrence'], 'attendees' => $record['attendees'], 'organizer' => $record['organizer'], 'sequence' => $record['sequence'], 'tags' => $record['tags'], 'list' => $list_id, 'links' => $record['links'], ); // we can sometimes skip this expensive operation if ($all && !array_key_exists('links', $task)) { $task['links'] = $this->get_links($task['uid']); } // convert from DateTime to internal date format if (is_a($record['due'], 'DateTime')) { $due = $this->plugin->lib->adjust_timezone($record['due']); $task['date'] = $due->format('Y-m-d'); if (!$record['due']->_dateonly) $task['time'] = $due->format('H:i'); } // convert from DateTime to internal date format if (is_a($record['start'], 'DateTime')) { $start = $this->plugin->lib->adjust_timezone($record['start']); $task['startdate'] = $start->format('Y-m-d'); if (!$record['start']->_dateonly) $task['starttime'] = $start->format('H:i'); } if (is_a($record['changed'], 'DateTime')) { $task['changed'] = $record['changed']; } if (is_a($record['created'], 'DateTime')) { $task['created'] = $record['created']; } if ($record['valarms']) { $task['valarms'] = $record['valarms']; } else if ($record['alarms']) { $task['alarms'] = $record['alarms']; } if (!empty($task['attendees'])) { foreach ((array)$task['attendees'] as $i => $attendee) { if (is_array($attendee['delegated-from'])) { $task['attendees'][$i]['delegated-from'] = join(', ', $attendee['delegated-from']); } if (is_array($attendee['delegated-to'])) { $task['attendees'][$i]['delegated-to'] = join(', ', $attendee['delegated-to']); } } } if (!empty($record['_attachments'])) { foreach ($record['_attachments'] as $key => $attachment) { if ($attachment !== false) { if (!$attachment['name']) $attachment['name'] = $key; $attachments[] = $attachment; } } $task['attachments'] = $attachments; } return $task; } /** * Convert the given task 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_task($task, $old = array()) { $object = $task; $id_prefix = $task['list'] . ':'; if (!empty($task['date'])) { $object['due'] = rcube_utils::anytodatetime($task['date'].' '.$task['time'], $this->plugin->timezone); if (empty($task['time'])) $object['due']->_dateonly = true; unset($object['date']); } if (!empty($task['startdate'])) { $object['start'] = rcube_utils::anytodatetime($task['startdate'].' '.$task['starttime'], $this->plugin->timezone); if (empty($task['starttime'])) $object['start']->_dateonly = true; unset($object['startdate']); } // as per RFC (and the Kolab schema validation), start and due dates need to be of the same type (#3614) // this should be catched in the client already but just make sure we don't write invalid objects if (!empty($object['start']) && !empty($object['due']) && $object['due']->_dateonly != $object['start']->_dateonly) { $object['start']->_dateonly = true; $object['due']->_dateonly = true; } $object['complete'] = $task['complete'] * 100; if ($task['complete'] == 1.0 && empty($task['complete'])) $object['status'] = 'COMPLETED'; if ($task['flagged']) $object['priority'] = 1; else $object['priority'] = $old['priority'] > 1 ? $old['priority'] : 0; // remove list: prefix from parent_id if (!empty($task['parent_id']) && strpos($task['parent_id'], $id_prefix) === 0) { $object['parent_id'] = substr($task['parent_id'], strlen($id_prefix)); } // copy meta data (starting with _) from old object foreach ((array)$old as $key => $val) { if (!isset($object[$key]) && $key[0] == '_') $object[$key] = $val; } // copy recurrence rules if the client didn't submit it (#2713) if (!array_key_exists('recurrence', $object) && $old['recurrence']) { $object['recurrence'] = $old['recurrence']; } unset($task['attachments']); kolab_format::merge_attachments($object, $old); // allow sequence increments if I'm the organizer if ($this->plugin->is_organizer($object) && empty($object['_method'])) { unset($object['sequence']); } else if (isset($old['sequence']) && empty($object['_method'])) { $object['sequence'] = $old['sequence']; } unset($object['tempid'], $object['raw'], $object['list'], $object['flagged'], $object['tags'], $object['created']); return $object; } /** * Add a single task to the database * * @param array Hash array with task properties (see header of tasklist_driver.php) * @return mixed New task ID on success, False on error */ public function create_task($task) { return $this->edit_task($task); } /** * Update an task entry with the given data * * @param array Hash array with task properties (see header of tasklist_driver.php) * @return boolean True on success, False on error */ public function edit_task($task) { $this->_parse_id($task); $list_id = $task['list']; if (!$list_id || !($folder = $this->get_folder($list_id))) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Invalid list identifer to save task: " . print_r($list_id, true)), true, false); return false; } // email links and tags are stored separately $links = $task['links']; $tags = $task['tags']; unset($task['tags'], $task['links']); // moved from another folder if ($task['_fromlist'] && ($fromfolder = $this->get_folder($task['_fromlist']))) { if (!$fromfolder->move($task['uid'], $folder)) return false; unset($task['_fromlist']); } // load previous version of this task to merge if ($task['id']) { $old = $folder->get_object($task['uid']); if (!$old || PEAR::isError($old)) return false; // merge existing properties if the update isn't complete if (!isset($task['title']) || !isset($task['complete'])) $task += $this->_to_rcube_task($old, $list_id); } // generate new task object from RC input $object = $this->_from_rcube_task($task, $old); $saved = $folder->save($object, 'task', $task['uid']); if (!$saved) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving task object to Kolab server"), true, false); $saved = false; } else { // save links in configuration.relation object $this->save_links($object['uid'], $links); // save tags in configuration.relation object $this->save_tags($object['uid'], $tags); $task = $this->_to_rcube_task($object, $list_id); $task['tags'] = (array) $tags; $this->tasks[$task['uid']] = $task; } return $saved; } /** * Move a single task to another list * * @param array Hash array with task properties: * @return boolean True on success, False on error * @see tasklist_driver::move_task() */ public function move_task($task) { $this->_parse_id($task); $list_id = $task['list']; if (!$list_id || !($folder = $this->get_folder($list_id))) return false; // execute move command if ($task['_fromlist'] && ($fromfolder = $this->get_folder($task['_fromlist']))) { return $fromfolder->move($task['uid'], $folder); } return false; } /** * Remove a single task from the database * * @param array Hash array with task properties: * id: Task identifier * @param boolean Remove record irreversible (mark as deleted otherwise, if supported by the backend) * @return boolean True on success, False on error */ public function delete_task($task, $force = true) { $this->_parse_id($task); $list_id = $task['list']; if (!$list_id || !($folder = $this->get_folder($list_id))) return false; $status = $folder->delete($task['uid']); if ($status) { // remove tag assignments // @TODO: don't do this when undelete feature will be implemented $this->save_tags($task['uid'], null); } return $status; } /** * Restores a single deleted task (if supported) * * @param array Hash array with task properties: * id: Task identifier * @return boolean True on success, False on error */ public function undelete_task($prop) { // TODO: implement this return false; } /** * Get attachment properties * * @param string $id Attachment identifier * @param array $task Hash array with event properties: * id: Task identifier * list: List identifier * rev: Revision (optional) * * @return array Hash array with attachment properties: * id: Attachment identifier * name: Attachment name * mimetype: MIME content type of the attachment * size: Attachment size */ public function get_attachment($id, $task) { // get old revision of the object if ($task['rev']) { $task = $this->get_task_revison($task, $task['rev']); } else { $task = $this->get_task($task); } if ($task && !empty($task['attachments'])) { foreach ($task['attachments'] as $att) { if ($att['id'] == $id) return $att; } } return null; } /** * Get attachment body * * @param string $id Attachment identifier * @param array $task Hash array with event properties: * id: Task identifier * list: List identifier * rev: Revision (optional) * * @return string Attachment body */ public function get_attachment_body($id, $task) { $this->_parse_id($task); // get old revision of event if ($task['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_task_identity($task); if ($msg_raw = $this->bonnie_api->rawdata('task', $uid, $task['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; } if ($storage = $this->get_folder($task['list'])) { return $storage->get_attachment($task['uid'], $id); } return false; } /** * Build a struct representing the given message reference * * @see tasklist_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, 'task'); } return false; } /** * Find tasks assigned to a specified message * * @see tasklist_driver::get_message_related_tasks() */ public function get_message_related_tasks($headers, $folder) { $config = kolab_storage_config::get_instance(); $result = $config->get_message_relations($headers, $folder, 'task'); foreach ($result as $idx => $rec) { $result[$idx] = $this->_to_rcube_task($rec, kolab_storage::folder_id($rec['_mailbox'])); } return $result; } /** * */ public function tasklist_edit_form($action, $list, $fieldprop) { $this->_read_lists(); if ($list['id'] && ($list = $this->lists[$list['id']])) { $folder_name = $this->get_folder($list['id'])->name; // UTF7 } else { $folder_name = ''; } $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = array(); if (strlen($folder_name)) { $path_imap = explode($delim, $folder_name); array_pop($path_imap); // pop off name part $path_imap = implode($path_imap, $delim); $options = $storage->folder_info($folder_name); } else { $path_imap = ''; } $hidden_fields[] = array('name' => 'oldname', 'value' => $folder_name); // folder name (default field) $input_name = new html_inputfield(array('name' => 'name', 'id' => 'taskedit-tasklistname', 'size' => 20)); $fieldprop['name']['value'] = $input_name->show($list['editname'], array('disabled' => ($options['norename'] || $options['protected']))); // prevent user from moving folder if (!empty($options) && ($options['norename'] || $options['protected'])) { $hidden_fields[] = array('name' => 'parent', 'value' => $path_imap); } else { $select = kolab_storage::folder_selector('task', array('name' => 'parent', 'id' => 'taskedit-parentfolder'), $folder_name); $fieldprop['parent'] = array( 'id' => 'taskedit-parentfolder', 'label' => $this->plugin->gettext('parentfolder'), 'value' => $select->show($path_imap), ); } // General tab $form['properties'] = array( 'name' => $this->rc->gettext('properties'), 'fields' => array(), ); foreach (array('name','parent','showalarms') as $f) { $form['properties']['fields'][$f] = $fieldprop[$f]; } - // add folder ACL tab - if ($action != 'form-new') { - $form['sharing'] = array( - 'name' => rcube::Q($this->plugin->gettext('tabsharing')), - 'content' => $this->folder_acl_form($folder_name) - ); - } - - $form_html = ''; - if (is_array($hidden_fields)) { - foreach ($hidden_fields as $field) { - $hiddenfield = new html_hiddenfield($field); - $form_html .= $hiddenfield->show() . "\n"; - } - } - - // create form output - foreach ($form as $tab) { - if (is_array($tab['fields']) && empty($tab['content'])) { - $table = new html_table(array('cols' => 2, 'class' => 'propform')); - foreach ($tab['fields'] as $col => $colprop) { - $label = !empty($colprop['label']) ? $colprop['label'] : $this->plugin->gettext($col); - - $table->add('title', html::label($colprop['id'], rcube::Q($label))); - $table->add(null, $colprop['value']); - } - $content = $table->show(); - } - else { - $content = $tab['content']; - } - - if (!empty($content)) { - $form_html .= html::tag('fieldset', null, html::tag('legend', null, rcube::Q($tab['name'])) . $content) . "\n"; - } - } - - return $form_html; - } - - /** - * Handler for ACL form template object - */ - public function folder_acl_form($folder) - { - if (strlen($folder)) { - $this->plugin->require_plugin('acl'); - $storage = $this->rc->get_storage(); - $options = $storage->folder_info($folder); - - // get sharing UI from acl plugin - $acl = $this->rc->plugins->exec_hook('folder_form', - array('form' => array(), 'options' => $options, 'name' => $folder)); - } - - return $acl['form']['sharing']['content'] ?: html::div('hint', $this->plugin->gettext('aclnorights')); + return kolab_utils::folder_form($form, $folder_name, 'tasklist', $hidden_fields); } }