diff --git a/.tx/config b/.tx/config index fa5255f4..309d2750 100644 --- a/.tx/config +++ b/.tx/config @@ -1,130 +1,135 @@ [main] host = https://www.transifex.com lang_map = en: en_US, de: de_DE, da: da_DK, es: es_ES, fi: fi_FI, fr: fr_FR, ja: ja_JP, nl: nl_NL, cs: cs_CZ, vi: vi_VN, pl: pl_PL, th: th_TH, sv: sv_SE, he: he_IL, hr: hr_HR, sk: sk_SK, sl: sl_SI, uk: uk_UA type = PHP_ALT_ARRAY [kolab.calendar] file_filter = plugins/calendar/localization/.inc source_file = plugins/calendar/localization/en_US.inc source_lang = en_US [kolab-documentation.calendar-helpdocs--index] file_filter = plugins/calendar/helpdocs/locale//LC_MESSAGES/index.po source_file = plugins/calendar/helpdocs/po/index.pot source_lang = en_US type = PO [kolab-documentation.calendar-helpdocs--importexport] file_filter = plugins/calendar/helpdocs/locale//LC_MESSAGES/importexport.po source_file = plugins/calendar/helpdocs/po/importexport.pot source_lang = en_US type = PO [kolab-documentation.calendar-helpdocs--invitations] file_filter = plugins/calendar/helpdocs/locale//LC_MESSAGES/invitations.po source_file = plugins/calendar/helpdocs/po/invitations.pot source_lang = en_US type = PO [kolab-documentation.calendar-helpdocs--manage] file_filter = plugins/calendar/helpdocs/locale//LC_MESSAGES/manage.po source_file = plugins/calendar/helpdocs/po/manage.pot source_lang = en_US type = PO [kolab-documentation.calendar-helpdocs--overview] file_filter = plugins/calendar/helpdocs/locale//LC_MESSAGES/overview.po source_file = plugins/calendar/helpdocs/po/overview.pot source_lang = en_US type = PO [kolab-documentation.calendar-helpdocs--settings] file_filter = plugins/calendar/helpdocs/locale//LC_MESSAGES/settings.po source_file = plugins/calendar/helpdocs/po/settings.pot source_lang = en_US type = PO [kolab-documentation.calendar-helpdocs--sharing] file_filter = plugins/calendar/helpdocs/locale//LC_MESSAGES/sharing.po source_file = plugins/calendar/helpdocs/po/sharing.pot source_lang = en_US type = PO [kolab.kolab_activesync] file_filter = plugins/kolab_activesync/localization/.inc source_file = plugins/kolab_activesync/localization/en_US.inc source_lang = en_US [kolab.kolab_addressbook] file_filter = plugins/kolab_addressbook/localization/.inc source_file = plugins/kolab_addressbook/localization/en_US.inc source_lang = en_US [kolab-documentation.kolab_addressbook-helpdocs--addressbook] file_filter = plugins/kolab_addressbook/helpdocs/locale//LC_MESSAGES/addressbook.po source_file = plugins/kolab_addressbook/helpdocs/po/addressbook.pot source_lang = en_US type = PO [kolab.kolab_auth] file_filter = plugins/kolab_auth/localization/.inc source_file = plugins/kolab_auth/localization/en_US.inc source_lang = en_US [kolab.kolab_delegation] file_filter = plugins/kolab_delegation/localization/.inc source_file = plugins/kolab_delegation/localization/en_US.inc source_lang = en_US [kolab.kolab_files] file_filter = plugins/kolab_files/localization/.inc source_file = plugins/kolab_files/localization/en_US.inc source_lang = en_US [kolab.kolab_folders] file_filter = plugins/kolab_folders/localization/.inc source_file = plugins/kolab_folders/localization/en_US.inc source_lang = en_US [kolab.kolab_notes] file_filter = plugins/kolab_notes/localization/.inc source_file = plugins/kolab_notes/localization/en_US.inc source_lang = en_US [kolab.kolab_tags] file_filter = plugins/kolab_tags/localization/.inc source_file = plugins/kolab_tags/localization/en_US.inc source_lang = en_US [kolab.owncloud] file_filter = plugins/owncloud/localization/.inc source_file = plugins/owncloud/localization/en_US.inc source_lang = en_US [kolab.tasklist] file_filter = plugins/tasklist/localization/.inc source_file = plugins/tasklist/localization/en_US.inc source_lang = en_US [kolab-documentation.tasklist-helpdocs--index] file_filter = plugins/tasklist/helpdocs/locale//LC_MESSAGES/index.po source_file = plugins/tasklist/helpdocs/po/index.pot source_lang = en_US type = PO [kolab-documentation.tasklist-helpdocs--manage] file_filter = plugins/tasklist/helpdocs/locale//LC_MESSAGES/manage.po source_file = plugins/tasklist/helpdocs/po/manage.pot source_lang = en_US type = PO [kolab-documentation.tasklist-helpdocs--overview] file_filter = plugins/tasklist/helpdocs/locale//LC_MESSAGES/overview.po source_file = plugins/tasklist/helpdocs/po/overview.pot source_lang = en_US type = PO +[kolab.libkolab] +file_filter = plugins/libkolab/localization/.inc +source_file = plugins/libkolab/localization/en_US.inc +source_lang = en_US + [kolab.libcalendaring] file_filter = plugins/libcalendaring/localization/.inc source_file = plugins/libcalendaring/localization/en_US.inc source_lang = en_US diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index d4f9a199..0cd273a4 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -1,2526 +1,2525 @@ * @author Aleksander Machniak * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_driver extends calendar_driver { const INVITATIONS_CALENDAR_PENDING = '--invitation--pending'; const INVITATIONS_CALENDAR_DECLINED = '--invitation--declined'; // features this backend supports public $alarms = true; public $attendees = true; public $freebusy = true; public $attachments = true; public $undelete = true; public $alarm_types = array('DISPLAY','AUDIO'); public $categoriesimmutable = true; private $rc; private $cal; private $calendars; private $has_writeable = false; private $freebusy_trigger = false; private $bonnie_api = false; /** * Default constructor */ public function __construct($cal) { $cal->require_plugin('libkolab'); // load helper classes *after* libkolab has been loaded (#3248) require_once(dirname(__FILE__) . '/kolab_calendar.php'); require_once(dirname(__FILE__) . '/kolab_user_calendar.php'); require_once(dirname(__FILE__) . '/kolab_invitation_calendar.php'); $this->cal = $cal; $this->rc = $cal->rc; $this->_read_calendars(); $this->cal->register_action('push-freebusy', array($this, 'push_freebusy')); $this->cal->register_action('calendar-acl', array($this, 'calendar_acl')); $this->freebusy_trigger = $this->rc->config->get('calendar_freebusy_trigger', false); if (kolab_storage::$version == '2.0') { $this->alarm_types = array('DISPLAY'); $this->alarm_absolute = false; } // get configuration for the Bonnie API - if ($bonnie_config = $this->cal->rc->config->get('kolab_bonnie_api', false)) - $this->bonnie_api = new kolab_bonnie_api($bonnie_config); + $this->bonnie_api = libkolab::get_bonnie_api(); // calendar uses fully encoded identifiers kolab_storage::$encode_ids = true; } /** * Read available calendars from server */ private function _read_calendars() { // already read sources if (isset($this->calendars)) return $this->calendars; // get all folders that have "event" type, sorted by namespace/name $folders = kolab_storage::sort_folders(kolab_storage::get_folders('event') + kolab_storage::get_user_folders('event', true)); $this->calendars = array(); foreach ($folders as $folder) { if ($folder instanceof kolab_storage_folder_user) { $calendar = new kolab_user_calendar($folder->name, $this->cal); $calendar->subscriptions = count($folder->children) > 0; } else { $calendar = new kolab_calendar($folder->name, $this->cal); } if ($calendar->ready) { $this->calendars[$calendar->id] = $calendar; if ($calendar->editable) $this->has_writeable = true; } } return $this->calendars; } /** * Get a list of available calendars from this source * * @param integer $filter Bitmask defining filter criterias * @param object $tree Reference to hierarchical folder tree object * * @return array List of calendars */ public function list_calendars($filter = 0, &$tree = null) { // attempt to create a default calendar for this user if (!$this->has_writeable) { if ($this->create_calendar(array('name' => 'Calendar', 'color' => 'cc0000'))) { unset($this->calendars); $this->_read_calendars(); } } $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); $folders = $this->filter_calendars($filter); $calendars = array(); // include virtual folders for a full folder tree if (!is_null($tree)) $folders = kolab_storage::folder_hierarchy($folders, $tree); foreach ($folders as $id => $cal) { $fullname = $cal->get_name(); $listname = $cal->get_foldername(); $imap_path = explode($delim, $cal->name); // find parent do { array_pop($imap_path); $parent_id = kolab_storage::folder_id(join($delim, $imap_path)); } while (count($imap_path) > 1 && !$this->calendars[$parent_id]); // restore "real" parent ID if ($parent_id && !$this->calendars[$parent_id]) { $parent_id = kolab_storage::folder_id($cal->get_parent()); } // turn a kolab_storage_folder object into a kolab_calendar if ($cal instanceof kolab_storage_folder) { $cal = new kolab_calendar($cal->name, $this->cal); $this->calendars[$cal->id] = $cal; } // special handling for user or virtual folders if ($cal instanceof kolab_storage_folder_user) { $calendars[$cal->id] = array( 'id' => $cal->id, 'name' => kolab_storage::object_name($fullname), 'listname' => $listname, 'editname' => $cal->get_foldername(), 'color' => $cal->get_color(), 'active' => $cal->is_active(), 'title' => $cal->get_owner(), 'owner' => $cal->get_owner(), 'history' => false, 'virtual' => false, 'editable' => false, 'group' => 'other', 'class' => 'user', 'removable' => true, ); } else if ($cal->virtual) { $calendars[$cal->id] = array( 'id' => $cal->id, 'name' => $fullname, 'listname' => $listname, 'editname' => $cal->get_foldername(), 'virtual' => true, 'editable' => false, 'group' => $cal->get_namespace(), 'class' => 'folder', ); } else { $calendars[$cal->id] = array( 'id' => $cal->id, 'name' => $fullname, 'listname' => $listname, 'editname' => $cal->get_foldername(), 'title' => $cal->get_title(), 'color' => $cal->get_color(), 'editable' => $cal->editable, 'rights' => $cal->rights, 'showalarms' => $cal->alarms, 'history' => !empty($this->bonnie_api), 'group' => $cal->get_namespace(), 'default' => $cal->default, 'active' => $cal->is_active(), 'owner' => $cal->get_owner(), 'children' => true, // TODO: determine if that folder indeed has child folders 'parent' => $parent_id, 'subtype' => $cal->subtype, 'caldavurl' => $cal->get_caldav_url(), 'removable' => !$cal->default, ); } if ($cal->subscriptions) { $calendars[$cal->id]['subscribed'] = $cal->is_subscribed(); } } // list virtual calendars showing invitations if ($this->rc->config->get('kolab_invitation_calendars')) { foreach (array(self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED) as $id) { $cal = new kolab_invitation_calendar($id, $this->cal); $this->calendars[$cal->id] = $cal; if (!($filter & self::FILTER_ACTIVE) || $cal->is_active()) { $calendars[$id] = array( 'id' => $cal->id, 'name' => $cal->get_name(), 'listname' => $cal->get_name(), 'editname' => $cal->get_foldername(), 'title' => $cal->get_title(), 'color' => $cal->get_color(), 'editable' => $cal->editable, 'rights' => $cal->rights, 'showalarms' => $cal->alarms, 'history' => !empty($this->bonnie_api), 'group' => 'x-invitations', 'default' => false, 'active' => $cal->is_active(), 'owner' => $cal->get_owner(), 'children' => false, ); if ($id == self::INVITATIONS_CALENDAR_PENDING) { $calendars[$id]['counts'] = true; } if (is_object($tree)) { $tree->children[] = $cal; } } } } // append the virtual birthdays calendar if ($this->rc->config->get('calendar_contact_birthdays', false)) { $id = self::BIRTHDAY_CALENDAR_ID; $prefs = $this->rc->config->get('kolab_calendars', array()); // read local prefs if (!($filter & self::FILTER_ACTIVE) || $prefs[$id]['active']) { $calendars[$id] = array( 'id' => $id, 'name' => $this->cal->gettext('birthdays'), 'listname' => $this->cal->gettext('birthdays'), 'color' => $prefs[$id]['color'] ?: '87CEFA', 'active' => (bool)$prefs[$id]['active'], 'showalarms' => (bool)$this->rc->config->get('calendar_birthdays_alarm_type'), 'group' => 'x-birthdays', 'editable' => false, 'default' => false, 'children' => false, 'history' => false, ); } } return $calendars; } /** * Get list of calendars according to specified filters * * @param integer Bitmask defining restrictions. See FILTER_* constants for possible values. * * @return array List of calendars */ protected function filter_calendars($filter) { $calendars = array(); $plugin = $this->rc->plugins->exec_hook('calendar_list_filter', array( 'list' => $this->calendars, 'calendars' => $calendars, 'filter' => $filter, 'editable' => ($filter & self::FILTER_WRITEABLE), 'insert' => ($filter & self::FILTER_INSERTABLE), 'active' => ($filter & self::FILTER_ACTIVE), 'personal' => ($filter & self::FILTER_PERSONAL) )); if ($plugin['abort']) { return $plugin['calendars']; } 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 (($filter & self::FILTER_PERSONAL) && $cal->get_namespace() != 'personal') { continue; } $calendars[$cal->id] = $cal; } return $calendars; } /** * Get the kolab_calendar instance for the given calendar ID * * @param string Calendar identifier (encoded imap folder name) * @return object kolab_calendar Object nor null if calendar doesn't exist */ public function get_calendar($id) { // create calendar object if necesary if (!$this->calendars[$id] && in_array($id, array(self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED))) { $this->calendars[$id] = new kolab_invitation_calendar($id, $this->cal); } else if (!$this->calendars[$id] && $id !== self::BIRTHDAY_CALENDAR_ID) { $calendar = kolab_calendar::factory($id, $this->cal); if ($calendar->ready) $this->calendars[$calendar->id] = $calendar; } return $this->calendars[$id]; } /** * Create a new calendar assigned to the current user * * @param array Hash array with calendar properties * name: Calendar name * color: The color of the calendar * @return mixed ID of the calendar on success, False on error */ public function create_calendar($prop) { $prop['type'] = 'event'; $prop['active'] = true; $prop['subscribed'] = true; $folder = kolab_storage::folder_update($prop); if ($folder === false) { $this->last_error = $this->cal->gettext(kolab_storage::$last_error); return false; } // create ID $id = kolab_storage::folder_id($folder); // save color in user prefs (temp. solution) $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); if (isset($prop['color'])) $prefs['kolab_calendars'][$id]['color'] = $prop['color']; if (isset($prop['showalarms'])) $prefs['kolab_calendars'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if ($prefs['kolab_calendars'][$id]) $this->rc->user->save_prefs($prefs); return $id; } /** * Update properties of an existing calendar * * @see calendar_driver::edit_calendar() */ public function edit_calendar($prop) { if ($prop['id'] && ($cal = $this->get_calendar($prop['id']))) { $id = $cal->update($prop); } else { $id = $prop['id']; } // fallback to local prefs $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); unset($prefs['kolab_calendars'][$prop['id']]['color'], $prefs['kolab_calendars'][$prop['id']]['showalarms']); if (isset($prop['color'])) $prefs['kolab_calendars'][$id]['color'] = $prop['color']; if (isset($prop['showalarms']) && $id == self::BIRTHDAY_CALENDAR_ID) $prefs['calendar_birthdays_alarm_type'] = $prop['showalarms'] ? $this->alarm_types[0] : ''; else if (isset($prop['showalarms'])) $prefs['kolab_calendars'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if (!empty($prefs['kolab_calendars'][$id])) $this->rc->user->save_prefs($prefs); return true; } /** * Set active/subscribed state of a calendar * * @see calendar_driver::subscribe_calendar() */ public function subscribe_calendar($prop) { if ($prop['id'] && ($cal = $this->get_calendar($prop['id'])) && is_object($cal->storage)) { $ret = false; if (isset($prop['permanent'])) $ret |= $cal->storage->subscribe(intval($prop['permanent'])); if (isset($prop['active'])) $ret |= $cal->storage->activate(intval($prop['active'])); // apply to child folders, too if ($prop['recursive']) { foreach ((array)kolab_storage::list_folders($cal->storage->name, '*', 'event') as $subfolder) { if (isset($prop['permanent'])) ($prop['permanent'] ? kolab_storage::folder_subscribe($subfolder) : kolab_storage::folder_unsubscribe($subfolder)); if (isset($prop['active'])) ($prop['active'] ? kolab_storage::folder_activate($subfolder) : kolab_storage::folder_deactivate($subfolder)); } } return $ret; } else { // save state in local prefs $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); $prefs['kolab_calendars'][$prop['id']]['active'] = (bool)$prop['active']; $this->rc->user->save_prefs($prefs); return true; } return false; } /** * Delete the given calendar with all its contents * * @see calendar_driver::delete_calendar() */ public function delete_calendar($prop) { if ($prop['id'] && ($cal = $this->get_calendar($prop['id']))) { $folder = $cal->get_realname(); // TODO: unsubscribe if no admin rights if (kolab_storage::folder_delete($folder)) { // remove color in user prefs (temp. solution) $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); unset($prefs['kolab_calendars'][$prop['id']]); $this->rc->user->save_prefs($prefs); return true; } else $this->last_error = kolab_storage::$last_error; } return false; } /** * Search for shared or otherwise not listed calendars the user has access * * @param string Search string * @param string Section/source to search * @return array List of calendars */ public function search_calendars($query, $source) { if (!kolab_storage::setup()) return array(); $this->calendars = array(); $this->search_more_results = false; // find unsubscribed IMAP folders that have "event" type if ($source == 'folders') { foreach ((array)kolab_storage::search_folders('event', $query, array('other')) as $folder) { $calendar = new kolab_calendar($folder->name, $this->cal); $this->calendars[$calendar->id] = $calendar; } } // find other user's virtual calendars else if ($source == 'users') { $limit = $this->rc->config->get('autocomplete_max', 15) * 2; // we have slightly more space, so display twice the number foreach (kolab_storage::search_users($query, 0, array(), $limit, $count) as $user) { $calendar = new kolab_user_calendar($user, $this->cal); $this->calendars[$calendar->id] = $calendar; // search for calendar folders shared by this user foreach (kolab_storage::list_user_folders($user, 'event', false) as $foldername) { $cal = new kolab_calendar($foldername, $this->cal); $this->calendars[$cal->id] = $cal; $calendar->subscriptions = true; } } if ($count > $limit) { $this->search_more_results = true; } } // don't list the birthday calendar $this->rc->config->set('calendar_contact_birthdays', false); $this->rc->config->set('kolab_invitation_calendars', false); return $this->list_calendars(); } /** * Fetch a single event * * @see calendar_driver::get_event() * @return array Hash array with event properties, false if not found */ public function get_event($event, $scope = 0, $full = false) { if (is_array($event)) { $id = $event['id'] ?: $event['uid']; $cal = $event['calendar']; // we're looking for a recurring instance: expand the ID to our internal convention for recurring instances if (!$event['id'] && $event['_instance']) { $id .= '-' . $event['_instance']; } } else { $id = $event; } if ($cal) { if ($storage = $this->get_calendar($cal)) { $result = $storage->get_event($id); return self::to_rcube_event($result); } // get event from the address books birthday calendar else if ($cal == self::BIRTHDAY_CALENDAR_ID) { return $this->get_birthday_event($id); } } // iterate over all calendar folders and search for the event ID else { foreach ($this->filter_calendars($scope) as $calendar) { if ($result = $calendar->get_event($id)) { return self::to_rcube_event($result); } } } return false; } /** * Add a single event to the database * * @see calendar_driver::new_event() */ public function new_event($event) { if (!$this->validate($event)) return false; $event = self::from_rcube_event($event); $cid = $event['calendar'] ? $event['calendar'] : reset(array_keys($this->calendars)); if ($storage = $this->get_calendar($cid)) { // if this is a recurrence instance, append as exception to an already existing object for this UID if (!empty($event['recurrence_date']) && ($master = $storage->get_event($event['uid']))) { self::add_exception($master, $event); $success = $storage->update_event($master); } else { $success = $storage->insert_event($event); } if ($success && $this->freebusy_trigger) { $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); $this->freebusy_trigger = false; // disable after first execution (#2355) } return $success; } return false; } /** * Update an event entry with the given data * * @see calendar_driver::new_event() * @return boolean True on success, False on error */ public function edit_event($event) { if (!($storage = $this->get_calendar($event['calendar']))) return false; return $this->update_event(self::from_rcube_event($event, $storage->get_event($event['id']))); } /** * Extended event editing with possible changes to the argument * * @param array Hash array with event properties * @param string New participant status * @param array List of hash arrays with updated attendees * @return boolean True on success, False on error */ public function edit_rsvp(&$event, $status, $attendees) { $update_event = $event; // apply changes to master (and all exceptions) if ($event['_savemode'] == 'all' && $event['recurrence_id']) { if ($storage = $this->get_calendar($event['calendar'])) { $update_event = $storage->get_event($event['recurrence_id']); $update_event['_savemode'] = $event['_savemode']; $update_event['id'] = $update_event['uid']; unset($update_event['recurrence_id']); calendar::merge_attendee_data($update_event, $attendees); } } if ($ret = $this->update_attendees($update_event, $attendees)) { // replace with master event (for iTip reply) $event = self::to_rcube_event($update_event); // re-assign to the according (virtual) calendar if ($this->rc->config->get('kolab_invitation_calendars')) { if (strtoupper($status) == 'DECLINED') $event['calendar'] = self::INVITATIONS_CALENDAR_DECLINED; else if (strtoupper($status) == 'NEEDS-ACTION') $event['calendar'] = self::INVITATIONS_CALENDAR_PENDING; else if ($event['_folder_id']) $event['calendar'] = $event['_folder_id']; } } return $ret; } /** * Update the participant status for the given attendees * * @see calendar_driver::update_attendees() */ public function update_attendees(&$event, $attendees) { // for this-and-future updates, merge the updated attendees onto all exceptions in range if (($event['_savemode'] == 'future' && $event['recurrence_id']) || (!empty($event['recurrence']) && !$event['recurrence_id'])) { if (!($storage = $this->get_calendar($event['calendar']))) return false; // load master event $master = $event['recurrence_id'] ? $storage->get_event($event['recurrence_id']) : $event; // apply attendee update to each existing exception if ($master['recurrence'] && !empty($master['recurrence']['EXCEPTIONS'])) { $saved = false; foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { // merge the new event properties onto future exceptions if ($exception['_instance'] >= strval($event['_instance'])) { calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $attendees); } // update a specific instance if ($exception['_instance'] == $event['_instance'] && $exception['thisandfuture']) { $saved = true; } } // add the given event as new exception if (!$saved && $event['id'] != $master['id']) { $event['thisandfuture'] = true; $master['recurrence']['EXCEPTIONS'][] = $event; } // set link to top-level exceptions $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; return $this->update_event($master); } } // just update the given event (instance) return $this->update_event($event); } /** * Move a single event * * @see calendar_driver::move_event() * @return boolean True on success, False on error */ public function move_event($event) { if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { unset($ev['sequence']); self::clear_attandee_noreply($ev); return $this->update_event($event + $ev); } return false; } /** * Resize a single event * * @see calendar_driver::resize_event() * @return boolean True on success, False on error */ public function resize_event($event) { if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { unset($ev['sequence']); self::clear_attandee_noreply($ev); return $this->update_event($event + $ev); } return false; } /** * Remove a single event * * @param array Hash array with event properties: * id: Event identifier * @param boolean Remove record(s) irreversible (mark as deleted otherwise) * * @return boolean True on success, False on error */ public function remove_event($event, $force = true) { $ret = true; $success = false; $savemode = $event['_savemode']; $decline = $event['_decline']; if (($storage = $this->get_calendar($event['calendar'])) && ($event = $storage->get_event($event['id']))) { $event['_savemode'] = $savemode; $savemode = 'all'; $master = $event; $this->rc->session->remove('calendar_restore_event_data'); // read master if deleting a recurring event if ($event['recurrence'] || $event['recurrence_id'] || $event['isexception']) { $master = $storage->get_event($event['uid']); $savemode = $event['_savemode'] ?: ($event['_instance'] || $event['isexception'] ? 'current' : 'all'); // force 'current' mode for single occurrences stored as exception if (!$event['recurrence'] && !$event['recurrence_id'] && $event['isexception']) $savemode = 'current'; } // removing an exception instance if (($event['recurrence_id'] || $event['isexception']) && is_array($master['exceptions'])) { foreach ($master['exceptions'] as $i => $exception) { if ($exception['_instance'] == $event['_instance']) { unset($master['exceptions'][$i]); // set event date back to the actual occurrence if ($exception['recurrence_date']) $event['start'] = $exception['recurrence_date']; } } if (is_array($master['recurrence'])) { $master['recurrence']['EXCEPTIONS'] = &$master['exceptions']; } } switch ($savemode) { case 'current': $_SESSION['calendar_restore_event_data'] = $master; // removing the first instance => just move to next occurence if ($master['recurrence'] && $event['_instance'] == libcalendaring::recurrence_instance_identifier($master)) { $recurring = reset($storage->get_recurring_events($event, $event['start'], null, $event['id'].'-1')); // no future instances found: delete the master event (bug #1677) if (!$recurring['start']) { $success = $storage->delete_event($master, $force); break; } $master['start'] = $recurring['start']; $master['end'] = $recurring['end']; if ($master['recurrence']['COUNT']) $master['recurrence']['COUNT']--; } // remove the matching RDATE entry else if ($master['recurrence']['RDATE']) { foreach ($master['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { unset($master['recurrence']['RDATE'][$j]); break; } } } else { // add exception to master event $master['recurrence']['EXDATE'][] = $event['start']; } $success = $storage->update_event($master); break; case 'future': $master['_instance'] = libcalendaring::recurrence_instance_identifier($master); if ($master['_instance'] != $event['_instance']) { $_SESSION['calendar_restore_event_data'] = $master; // set until-date on master event $master['recurrence']['UNTIL'] = clone $event['start']; $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); unset($master['recurrence']['COUNT']); // if all future instances are deleted, remove recurrence rule entirely (bug #1677) if ($master['recurrence']['UNTIL']->format('Ymd') == $master['start']->format('Ymd')) { $master['recurrence'] = array(); } // remove matching RDATE entries else if ($master['recurrence']['RDATE']) { foreach ($master['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { $master['recurrence']['RDATE'] = array_slice($master['recurrence']['RDATE'], 0, $j); break; } } } $success = $storage->update_event($master); $ret = $master['uid']; break; } default: // 'all' is default // removing the master event with loose exceptions (not recurring though) if (!empty($event['recurrence_date']) && empty($master['recurrence']) && !empty($master['exceptions'])) { // make the first exception the new master $newmaster = array_shift($master['exceptions']); $newmaster['exceptions'] = $master['exceptions']; $newmaster['_attachments'] = $master['_attachments']; $newmaster['_mailbox'] = $master['_mailbox']; $newmaster['_msguid'] = $master['_msguid']; $success = $storage->update_event($newmaster); } else if ($decline && $this->rc->config->get('kolab_invitation_calendars')) { // don't delete but set PARTSTAT=DECLINED if ($this->cal->lib->set_partstat($master, 'DECLINED')) { $success = $storage->update_event($master); } } if (!$success) $success = $storage->delete_event($master, $force); break; } } if ($success && $this->freebusy_trigger) $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); return $success ? $ret : false; } /** * Restore a single deleted event * * @param array Hash array with event properties: * id: Event identifier * @return boolean True on success, False on error */ public function restore_event($event) { if ($storage = $this->get_calendar($event['calendar'])) { if (!empty($_SESSION['calendar_restore_event_data'])) $success = $storage->update_event($_SESSION['calendar_restore_event_data']); else $success = $storage->restore_event($event); if ($success && $this->freebusy_trigger) $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); return $success; } return false; } /** * Wrapper to update an event object depending on the given savemode */ private function update_event($event) { if (!($storage = $this->get_calendar($event['calendar']))) return false; // move event to another folder/calendar if ($event['_fromcalendar'] && $event['_fromcalendar'] != $event['calendar']) { if (!($fromcalendar = $this->get_calendar($event['_fromcalendar']))) return false; $old = $fromcalendar->get_event($event['id']); if ($event['_savemode'] != 'new') { if (!$fromcalendar->storage->move($old['uid'], $storage->storage)) { return false; } $fromcalendar = $storage; } } else $fromcalendar = $storage; $success = false; $savemode = 'all'; $attachments = array(); $old = $master = $storage->get_event($event['id']); if (!$old || !$old['start']) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed to load event object to update: id=" . $event['id']), true, false); return false; } // modify a recurring event, check submitted savemode to do the right things if ($old['recurrence'] || $old['recurrence_id'] || $old['isexception']) { $master = $storage->get_event($old['uid']); $savemode = $event['_savemode'] ?: ($old['recurrence_id'] || $old['isexception'] ? 'current' : 'all'); // this-and-future on the first instance equals to 'all' if ($savemode == 'future' && $master['start'] && $old['_instance'] == libcalendaring::recurrence_instance_identifier($master)) $savemode = 'all'; // force 'current' mode for single occurrences stored as exception else if (!$old['recurrence'] && !$old['recurrence_id'] && $old['isexception']) $savemode = 'current'; } // check if update affects scheduling and update attendee status accordingly $reschedule = $this->check_scheduling($event, $old, true); // keep saved exceptions (not submitted by the client) if ($old['recurrence']['EXDATE'] && !isset($event['recurrence']['EXDATE'])) $event['recurrence']['EXDATE'] = $old['recurrence']['EXDATE']; if (isset($event['recurrence']['EXCEPTIONS'])) $with_exceptions = true; // exceptions already provided (e.g. from iCal import) else if ($old['recurrence']['EXCEPTIONS']) $event['recurrence']['EXCEPTIONS'] = $old['recurrence']['EXCEPTIONS']; else if ($old['exceptions']) $event['exceptions'] = $old['exceptions']; // remove some internal properties which should not be saved unset($event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_owner'], $event['_notify'], $event['_method'], $event['_sender'], $event['_sender_utf'], $event['_size']); switch ($savemode) { case 'new': // save submitted data as new (non-recurring) event $event['recurrence'] = array(); $event['_copyfrom'] = $master['_msguid']; $event['_mailbox'] = $master['_mailbox']; $event['uid'] = $this->cal->generate_uid(); unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); // copy attachment metadata to new event $event = self::from_rcube_event($event, $master); self::clear_attandee_noreply($event); if ($success = $storage->insert_event($event)) $success = $event['uid']; break; case 'future': // create a new recurring event $event['_copyfrom'] = $master['_msguid']; $event['_mailbox'] = $master['_mailbox']; $event['uid'] = $this->cal->generate_uid(); unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); // copy attachment metadata to new event $event = self::from_rcube_event($event, $master); // remove recurrence exceptions on re-scheduling if ($reschedule) { unset($event['recurrence']['EXCEPTIONS'], $event['exceptions'], $master['recurrence']['EXDATE']); } else if (is_array($event['recurrence']['EXCEPTIONS'])) { // only keep relevant exceptions $event['recurrence']['EXCEPTIONS'] = array_filter($event['recurrence']['EXCEPTIONS'], function($exception) use ($event) { return $exception['start'] > $event['start']; }); if (is_array($event['recurrence']['EXDATE'])) { $event['recurrence']['EXDATE'] = array_filter($event['recurrence']['EXDATE'], function($exdate) use ($event) { return $exdate > $event['start']; }); } // set link to top-level exceptions $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; } // compute remaining occurrences if ($event['recurrence']['COUNT']) { if (!$old['_count']) $old['_count'] = $this->get_recurrence_count($master, $old['start']); $event['recurrence']['COUNT'] -= intval($old['_count']); } // remove fixed weekday when date changed if ($old['start']->format('Y-m-d') != $event['start']->format('Y-m-d')) { if (strlen($event['recurrence']['BYDAY']) == 2) unset($event['recurrence']['BYDAY']); if ($old['recurrence']['BYMONTH'] == $old['start']->format('n')) unset($event['recurrence']['BYMONTH']); } // set until-date on master event $master['recurrence']['UNTIL'] = clone $old['start']; $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); unset($master['recurrence']['COUNT']); // remove all exceptions after $event['start'] if (is_array($master['recurrence']['EXCEPTIONS'])) { $master['recurrence']['EXCEPTIONS'] = array_filter($master['recurrence']['EXCEPTIONS'], function($exception) use ($event) { return $exception['start'] < $event['start']; }); // set link to top-level exceptions $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; } if (is_array($master['recurrence']['EXDATE'])) { $master['recurrence']['EXDATE'] = array_filter($master['recurrence']['EXDATE'], function($exdate) use ($event) { return $exdate < $event['start']; }); } // save new event if ($success = $storage->insert_event($event)) { $success = $event['uid']; // update master event (no rescheduling!) self::clear_attandee_noreply($master); $storage->update_event($master); } break; case 'current': // recurring instances shall not store recurrence rules and attachments $event['recurrence'] = array(); $event['thisandfuture'] = $savemode == 'future'; unset($event['attachments'], $event['id']); // increment sequence of this instance if scheduling is affected if ($reschedule) { $event['sequence'] = max($old['sequence'], $master['sequence']) + 1; } else if (!isset($event['sequence'])) { $event['sequence'] = $old['sequence'] ?: $master['sequence']; } // save properties to a recurrence exception instance if ($old['_instance'] && is_array($master['recurrence']['EXCEPTIONS'])) { if ($this->update_recurrence_exceptions($master, $event, $old, $savemode)) { $success = $storage->update_event($master, $old['id']); break; } } $add_exception = true; // adjust matching RDATE entry if dates changed if (is_array($master['recurrence']['RDATE']) && ($old_date = $old['start']->format('Ymd')) != $event['start']->format('Ymd')) { foreach ($master['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $old_date) { $master['recurrence']['RDATE'][$j] = $event['start']; sort($master['recurrence']['RDATE']); $add_exception = false; break; } } } // save as new exception to master event if ($add_exception) { self::add_exception($master, $event, $old); } $success = $storage->update_event($master); break; default: // 'all' is default $event['id'] = $master['uid']; $event['uid'] = $master['uid']; // use start date from master but try to be smart on time or duration changes $old_start_date = $old['start']->format('Y-m-d'); $old_start_time = $old['allday'] ? '' : $old['start']->format('H:i'); $old_duration = $old['end']->format('U') - $old['start']->format('U'); $new_start_date = $event['start']->format('Y-m-d'); $new_start_time = $event['allday'] ? '' : $event['start']->format('H:i'); $new_duration = $event['end']->format('U') - $event['start']->format('U'); $diff = $old_start_date != $new_start_date || $old_start_time != $new_start_time || $old_duration != $new_duration; $date_shift = $old['start']->diff($event['start']); // shifted or resized if ($diff && ($old_start_date == $new_start_date || $old_duration == $new_duration)) { $event['start'] = $master['start']->add($date_shift); $event['end'] = clone $event['start']; $event['end']->add(new DateInterval('PT'.$new_duration.'S')); // remove fixed weekday, will be re-set to the new weekday in kolab_calendar::update_event() if ($old_start_date != $new_start_date) { if (strlen($event['recurrence']['BYDAY']) == 2) unset($event['recurrence']['BYDAY']); if ($old['recurrence']['BYMONTH'] == $old['start']->format('n')) unset($event['recurrence']['BYMONTH']); } } // dates did not change, use the ones from master else if ($new_start_date . $new_start_time == $old_start_date . $old_start_time) { $event['start'] = $master['start']; $event['end'] = $master['end']; } // when saving an instance in 'all' mode, copy recurrence exceptions over if ($old['recurrence_id']) { $event['recurrence']['EXCEPTIONS'] = $master['recurrence']['EXCEPTIONS']; } else if ($master['_instance']) { $event['_instance'] = $master['_instance']; $event['recurrence_date'] = $master['recurrence_date']; } // TODO: forward changes to exceptions (which do not yet have differing values stored) if (is_array($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) { // determine added and removed attendees $old_attendees = $current_attendees = $added_attendees = array(); foreach ((array)$old['attendees'] as $attendee) { $old_attendees[] = $attendee['email']; } foreach ((array)$event['attendees'] as $attendee) { $current_attendees[] = $attendee['email']; if (!in_array($attendee['email'], $old_attendees)) { $added_attendees[] = $attendee; } } $removed_attendees = array_diff($old_attendees, $current_attendees); foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { calendar::merge_attendee_data($event['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); } // adjust recurrence-id when start changed and therefore the entire recurrence chain changes if ($old_start_date != $new_start_date || $old_start_time != $new_start_time) { $recurrence_id_format = libcalendaring::recurrence_id_format($event); foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { $recurrence_id = is_a($exception['recurrence_date'], 'DateTime') ? $exception['recurrence_date'] : rcube_utils::anytodatetime($exception['_instance'], $old['start']->getTimezone()); if (is_a($recurrence_id, 'DateTime')) { $recurrence_id->add($date_shift); $event['recurrence']['EXCEPTIONS'][$i]['recurrence_date'] = $recurrence_id; $event['recurrence']['EXCEPTIONS'][$i]['_instance'] = $recurrence_id->format($recurrence_id_format); } } } // set link to top-level exceptions $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; } // unset _dateonly flags in (cached) date objects unset($event['start']->_dateonly, $event['end']->_dateonly); $success = $storage->update_event($event) ? $event['id'] : false; // return master UID break; } if ($success && $this->freebusy_trigger) $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); return $success; } /** * Determine whether the current change affects scheduling and reset attendee status accordingly */ public function check_scheduling(&$event, $old, $update = true) { // skip this check when importing iCal/iTip events if (isset($event['sequence']) || !empty($event['_method'])) { return false; } // iterate through the list of properties considered 'significant' for scheduling $kolab_event = $old['_formatobj'] ?: new kolab_format_event(); $reschedule = $kolab_event->check_rescheduling($event, $old); // reset all attendee status to needs-action (#4360) if ($update && $reschedule && is_array($event['attendees'])) { $is_organizer = false; $emails = $this->cal->get_user_emails(); $attendees = $event['attendees']; foreach ($attendees as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER' && $attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $is_organizer = true; } else if ($attendee['role'] != 'ORGANIZER' && $attendee['role'] != 'NON-PARTICIPANT' && $attendee['status'] != 'DELEGATED') { $attendees[$i]['status'] = 'NEEDS-ACTION'; $attendees[$i]['rsvp'] = true; } } // update attendees only if I'm the organizer if ($is_organizer || ($event['organizer'] && in_array(strtolower($event['organizer']['email']), $emails))) { $event['attendees'] = $attendees; } } return $reschedule; } /** * Apply the given changes to already existing exceptions */ protected function update_recurrence_exceptions(&$master, $event, $old, $savemode) { $saved = false; $existing = null; // determine added and removed attendees $added_attendees = $removed_attendees = array(); if ($savemode == 'future') { $old_attendees = $current_attendees = array(); foreach ((array)$old['attendees'] as $attendee) { $old_attendees[] = $attendee['email']; } foreach ((array)$event['attendees'] as $attendee) { $current_attendees[] = $attendee['email']; if (!in_array($attendee['email'], $old_attendees)) { $added_attendees[] = $attendee; } } $removed_attendees = array_diff($old_attendees, $current_attendees); } foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { // update a specific instance if ($exception['_instance'] == $old['_instance']) { $existing = $i; // check savemode against existing exception mode. // if matches, we can update this existing exception if ((bool)$exception['thisandfuture'] === ($savemode == 'future')) { $event['_instance'] = $old['_instance']; $event['thisandfuture'] = $old['thisandfuture']; $event['recurrence_date'] = $old['recurrence_date']; $master['recurrence']['EXCEPTIONS'][$i] = $event; $saved = true; } } // merge the new event properties onto future exceptions if ($savemode == 'future' && $exception['_instance'] >= $old['_instance']) { unset($event['thisandfuture']); self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event, array('attendees')); if (!empty($added_attendees) || !empty($removed_attendees)) { calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); } } } /* // we could not update the existing exception due to savemode mismatch... if (!$saved && $existing !== null && $master['recurrence']['EXCEPTIONS'][$existing]['thisandfuture']) { // ... try to move the existing this-and-future exception to the next occurrence foreach ($this->get_recurring_events($master, $existing['start']) as $candidate) { // our old this-and-future exception is obsolete if ($candidate['thisandfuture']) { unset($master['recurrence']['EXCEPTIONS'][$existing]); $saved = true; break; } // this occurrence doesn't yet have an exception else if (!$candidate['isexception']) { $event['_instance'] = $candidate['_instance']; $event['recurrence_date'] = $candidate['recurrence_date']; $master['recurrence']['EXCEPTIONS'][$i] = $event; $saved = true; break; } } } */ // set link to top-level exceptions $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; // returning false here will add a new exception return $saved; } /** * Add or update the given event as an exception to $master */ public static function add_exception(&$master, $event, $old = null) { if ($old) { $event['_instance'] = $old['_instance']; if (!$event['recurrence_date']) $event['recurrence_date'] = $old['recurrence_date'] ?: $old['start']; } else if (!$event['recurrence_date']) { $event['recurrence_date'] = $event['start']; } if (!$event['_instance'] && is_a($event['recurrence_date'], 'DateTime')) { $event['_instance'] = libcalendaring::recurrence_instance_identifier($event); } 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); // 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 ($overlay as $prop => $value) { if ($prop == 'start' || $prop == 'end') { 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'))); } } else if ($prop == 'thisandfuture' && $overlay['_instance'] == $event['_instance']) { $event[$prop] = $value; } else if ($prop[0] != '_' && !in_array($prop, $forbidden)) $event[$prop] = $value; } } /** * Get events from source. * * @param integer Event's new start (unix timestamp) * @param integer Event's new end (unix timestamp) * @param string Search query (optional) * @param mixed List of calendar IDs to load events from (either as array or comma-separated string) * @param boolean Include virtual events (optional) * @param integer Only list events modified since this time (unix timestamp) * @return array A list of event records */ public function load_events($start, $end, $search = null, $calendars = null, $virtual = 1, $modifiedsince = null) { if ($calendars && is_string($calendars)) $calendars = explode(',', $calendars); else if (!$calendars) $calendars = array_keys($this->calendars); $query = array(); if ($modifiedsince) $query[] = array('changed', '>=', $modifiedsince); $events = $categories = array(); foreach ($calendars as $cid) { if ($storage = $this->get_calendar($cid)) { $events = array_merge($events, $storage->list_events($start, $end, $search, $virtual, $query)); $categories += $storage->categories; } } // add events from the address books birthday calendar if (in_array(self::BIRTHDAY_CALENDAR_ID, $calendars)) { $events = array_merge($events, $this->load_birthday_events($start, $end, $search, $modifiedsince)); } // add new categories to user prefs $old_categories = $this->rc->config->get('calendar_categories', $this->default_categories); if ($newcats = array_udiff(array_keys($categories), array_keys($old_categories), function($a, $b){ return strcasecmp($a, $b); })) { foreach ($newcats as $category) $old_categories[$category] = ''; // no color set yet $this->rc->user->save_prefs(array('calendar_categories' => $old_categories)); } array_walk($events, 'kolab_driver::to_rcube_event'); return $events; } /** * Get number of events in the given calendar * * @param mixed List of calendar IDs to count events (either as array or comma-separated string) * @param integer Date range start (unix timestamp) * @param integer Date range end (unix timestamp) * @return array Hash array with counts grouped by calendar ID */ public function count_events($calendars, $start, $end = null) { $counts = array(); if ($calendars && is_string($calendars)) $calendars = explode(',', $calendars); else if (!$calendars) $calendars = array_keys($this->calendars); foreach ($calendars as $cid) { if ($storage = $this->get_calendar($cid)) { $counts[$cid] = $storage->count_events($start, $end); } } return $counts; } /** * Get a list of pending alarms to be displayed to the user * * @see calendar_driver::pending_alarms() */ public function pending_alarms($time, $calendars = null) { $interval = 300; $time -= $time % 60; $slot = $time; $slot -= $slot % $interval; $last = $time - max(60, $this->rc->config->get('refresh_interval', 0)); $last -= $last % $interval; // only check for alerts once in 5 minutes if ($last == $slot) return array(); if ($calendars && is_string($calendars)) $calendars = explode(',', $calendars); $time = $slot + $interval; $candidates = array(); $query = array(array('tags', '=', 'x-has-alarms')); foreach ($this->calendars as $cid => $calendar) { // skip calendars with alarms disabled if (!$calendar->alarms || ($calendars && !in_array($cid, $calendars))) continue; foreach ($calendar->list_events($time, $time + 86400 * 365, null, 1, $query) as $e) { // add to list if alarm is set $alarm = libcalendaring::get_next_alarm($e); if ($alarm && $alarm['time'] && $alarm['time'] >= $last && in_array($alarm['action'], $this->alarm_types)) { $id = $alarm['id']; // use alarm-id as primary identifier $candidates[$id] = array( 'id' => $id, 'title' => $e['title'], 'location' => $e['location'], 'start' => $e['start'], 'end' => $e['end'], 'notifyat' => $alarm['time'], 'action' => $alarm['action'], ); } } } // get alarm information stored in local database if (!empty($candidates)) { $alarm_ids = array_map(array($this->rc->db, 'quote'), array_keys($candidates)); $result = $this->rc->db->query("SELECT *" . " FROM " . $this->rc->db->table_name('kolab_alarms', true) . " WHERE `alarm_id` IN (" . join(',', $alarm_ids) . ")" . " AND `user_id` = ?", $this->rc->user->ID ); while ($result && ($e = $this->rc->db->fetch_assoc($result))) { $dbdata[$e['alarm_id']] = $e; } } $alarms = array(); foreach ($candidates as $id => $alarm) { // skip dismissed alarms if ($dbdata[$id]['dismissed']) continue; // snooze function may have shifted alarm time $notifyat = $dbdata[$id]['notifyat'] ? strtotime($dbdata[$id]['notifyat']) : $alarm['notifyat']; if ($notifyat <= $time) $alarms[] = $alarm; } return $alarms; } /** * Feedback after showing/sending an alarm notification * * @see calendar_driver::dismiss_alarm() */ public function dismiss_alarm($alarm_id, $snooze = 0) { $alarms_table = $this->rc->db->table_name('kolab_alarms', true); // delete old alarm entry $this->rc->db->query("DELETE FROM $alarms_table" . " WHERE `alarm_id` = ? AND `user_id` = ?", $alarm_id, $this->rc->user->ID ); // set new notifyat time or unset if not snoozed $notifyat = $snooze > 0 ? date('Y-m-d H:i:s', time() + $snooze) : null; $query = $this->rc->db->query("INSERT INTO $alarms_table" . " (`alarm_id`, `user_id`, `dismissed`, `notifyat`)" . " VALUES (?, ?, ?, ?)", $alarm_id, $this->rc->user->ID, $snooze > 0 ? 0 : 1, $notifyat ); return $this->rc->db->affected_rows($query); } /** * List attachments from the given event */ public function list_attachments($event) { if (!($storage = $this->get_calendar($event['calendar']))) return false; $event = $storage->get_event($event['id']); return $event['attachments']; } /** * Get attachment properties */ public function get_attachment($id, $event) { if (!($storage = $this->get_calendar($event['calendar']))) return false; // get old revision of event if ($event['rev']) { $event = $this->get_event_revison($event, $event['rev'], true); } else { $event = $storage->get_event($event['id']); } if ($event && !empty($event['_attachments'])) { foreach ($event['_attachments'] as $att) { if ($att['id'] == $id) { return $att; } } } return null; } /** * Get attachment body * @see calendar_driver::get_attachment_body() */ public function get_attachment_body($id, $event) { if (!($cal = $this->get_calendar($event['calendar']))) return false; // get old revision of event if ($event['rev']) { if (empty($this->bonnie_api)) { return false; } $cid = substr($id, 4); // call Bonnie API and get the raw mime message list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); if ($msg_raw = $this->bonnie_api->rawdata('event', $uid, $event['rev'], $mailbox, $msguid)) { // parse the message and find the part with the matching content-id $message = rcube_mime::parse_message($msg_raw); foreach ((array)$message->parts as $part) { if ($part->headers['content-id'] && trim($part->headers['content-id'], '<>') == $cid) { return $part->body; } } } return false; } return $cal->get_attachment_body($id, $event); } /** * Build a struct representing the given message reference * * @see calendar_driver::get_message_reference() */ public function get_message_reference($uri_or_headers, $folder = null) { if (is_object($uri_or_headers)) { $uri_or_headers = kolab_storage_config::get_message_uri($uri_or_headers, $folder); } if (is_string($uri_or_headers)) { return kolab_storage_config::get_message_reference($uri_or_headers, 'event'); } return false; } /** * List availabale categories * The default implementation reads them from config/user prefs */ public function list_categories() { // FIXME: complete list with categories saved in config objects (KEP:12) return $this->rc->config->get('calendar_categories', $this->default_categories); } /** * Create instances of a recurring event * * @param array Hash array with event properties * @param object DateTime Start date of the recurrence window * @param object DateTime End date of the recurrence window * @return array List of recurring event instances */ public function get_recurring_events($event, $start, $end = null) { // load the given event data into a libkolabxml container if (!$event['_formatobj']) { $event_xml = new kolab_format_event(); $event_xml->set($event); $event['_formatobj'] = $event_xml; } $this->_read_calendars(); $storage = reset($this->calendars); return $storage->get_recurring_events($event, $start, $end); } /** * */ private function get_recurrence_count($event, $dtstart) { // use libkolab to compute recurring events if (class_exists('kolabcalendaring') && $event['_formatobj']) { $recurrence = new kolab_date_recurrence($event['_formatobj']); } else { // fallback to local recurrence implementation require_once($this->cal->home . '/lib/calendar_recurrence.php'); $recurrence = new calendar_recurrence($this->cal, $event); } $count = 0; while (($next_event = $recurrence->next_instance()) && $next_event['start'] <= $dtstart && $count < 1000) { $count++; } return $count; } /** * Fetch free/busy information from a person within the given range */ public function get_freebusy_list($email, $start, $end) { if (empty($email)/* || $end < time()*/) return false; // map vcalendar fbtypes to internal values $fbtypemap = array( 'FREE' => calendar::FREEBUSY_FREE, 'BUSY-TENTATIVE' => calendar::FREEBUSY_TENTATIVE, 'X-OUT-OF-OFFICE' => calendar::FREEBUSY_OOF, 'OOF' => calendar::FREEBUSY_OOF); // ask kolab server first try { $request_config = array( 'store_body' => true, 'follow_redirects' => true, ); $request = libkolab::http_request(kolab_storage::get_freebusy_url($email), 'GET', $request_config); $response = $request->send(); // authentication required if ($response->getStatus() == 401) { $request->setAuth($this->rc->user->get_username(), $this->rc->decrypt($_SESSION['password'])); $response = $request->send(); } if ($response->getStatus() == 200) $fbdata = $response->getBody(); unset($request, $response); } catch (Exception $e) { PEAR::raiseError("Error fetching free/busy information: " . $e->getMessage()); } // get free-busy url from contacts if (!$fbdata) { $fburl = null; foreach ((array)$this->rc->config->get('autocomplete_addressbooks', 'sql') as $book) { $abook = $this->rc->get_address_book($book); if ($result = $abook->search(array('email'), $email, true, true, true/*, 'freebusyurl'*/)) { while ($contact = $result->iterate()) { if ($fburl = $contact['freebusyurl']) { $fbdata = @file_get_contents($fburl); break; } } } if ($fbdata) break; } } // parse free-busy information using Horde classes if ($fbdata) { $ical = $this->cal->get_ical(); $ical->import($fbdata); if ($fb = $ical->freebusy) { $result = array(); foreach ($fb['periods'] as $tuple) { list($from, $to, $type) = $tuple; $result[] = array($from->format('U'), $to->format('U'), isset($fbtypemap[$type]) ? $fbtypemap[$type] : calendar::FREEBUSY_BUSY); } // we take 'dummy' free-busy lists as "unknown" if (empty($result) && !empty($fb['comment']) && stripos($fb['comment'], 'dummy')) return false; // set period from $start till the begin of the free-busy information as 'unknown' if ($fb['start'] && ($fbstart = $fb['start']->format('U')) && $start < $fbstart) { array_unshift($result, array($start, $fbstart, calendar::FREEBUSY_UNKNOWN)); } // pad period till $end with status 'unknown' if ($fb['end'] && ($fbend = $fb['end']->format('U')) && $fbend < $end) { $result[] = array($fbend, $end, calendar::FREEBUSY_UNKNOWN); } return $result; } } return false; } /** * Handler to push folder triggers when sent from client. * Used to push free-busy changes asynchronously after updating an event */ public function push_freebusy() { // make shure triggering completes set_time_limit(0); ignore_user_abort(true); $cal = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); if (!($cal = $this->get_calendar($cal))) return false; // trigger updates on folder $trigger = $cal->storage->trigger(); if (is_object($trigger) && is_a($trigger, 'PEAR_Error')) { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed triggering folder. Error was " . $trigger->getMessage()), true, false); } exit; } /** * Convert from driver format to external caledar app data */ public static function to_rcube_event(&$record) { if (!is_array($record)) return $record; $record['id'] = $record['uid']; if ($record['_instance']) { $record['id'] .= '-' . $record['_instance']; if (!$record['recurrence_id'] && !empty($record['recurrence'])) $record['recurrence_id'] = $record['uid']; } // all-day events go from 12:00 - 13:00 if (is_a($record['start'], 'DateTime') && $record['end'] <= $record['start'] && $record['allday']) { $record['end'] = clone $record['start']; $record['end']->add(new DateInterval('PT1H')); } // translate internal '_attachments' to external 'attachments' list if (!empty($record['_attachments'])) { foreach ($record['_attachments'] as $key => $attachment) { if ($attachment !== false) { if (!$attachment['name']) $attachment['name'] = $key; unset($attachment['path'], $attachment['content']); $attachments[] = $attachment; } } $record['attachments'] = $attachments; } if (!empty($record['attendees'])) { foreach ((array)$record['attendees'] as $i => $attendee) { if (is_array($attendee['delegated-from'])) { $record['attendees'][$i]['delegated-from'] = join(', ', $attendee['delegated-from']); } if (is_array($attendee['delegated-to'])) { $record['attendees'][$i]['delegated-to'] = join(', ', $attendee['delegated-to']); } } } // Roundcube only supports one category assignment if (is_array($record['categories'])) $record['categories'] = $record['categories'][0]; // the cancelled flag transltes into status=CANCELLED if ($record['cancelled']) $record['status'] = 'CANCELLED'; // The web client only supports DISPLAY type of alarms if (!empty($record['alarms'])) $record['alarms'] = preg_replace('/:[A-Z]+$/', ':DISPLAY', $record['alarms']); // remove empty recurrence array if (empty($record['recurrence'])) unset($record['recurrence']); // clean up exception data if (is_array($record['recurrence']['EXCEPTIONS'])) { array_walk($record['recurrence']['EXCEPTIONS'], function(&$exception) { unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj'], $exception['_attachments']); }); } unset($record['_mailbox'], $record['_msguid'], $record['_type'], $record['_size'], $record['_formatobj'], $record['_attachments'], $record['exceptions'], $record['x-custom']); return $record; } /** * */ public static function from_rcube_event($event, $old = array()) { // in kolab_storage attachments are indexed by content-id if (is_array($event['attachments']) || !empty($event['deleted_attachments'])) { $event['_attachments'] = array(); foreach ($event['attachments'] as $attachment) { $key = null; // Roundcube ID has nothing to do with the storage ID, remove it if ($attachment['content'] || $attachment['path']) { unset($attachment['id']); } else { foreach ((array)$old['_attachments'] as $cid => $oldatt) { if ($attachment['id'] == $oldatt['id']) $key = $cid; } } // flagged for deletion => set to false if ($attachment['_deleted'] || in_array($attachment['id'], (array)$event['deleted_attachments'])) { $event['_attachments'][$key] = false; } // replace existing entry else if ($key) { $event['_attachments'][$key] = $attachment; } // append as new attachment else { $event['_attachments'][] = $attachment; } } $event['_attachments'] = array_merge((array)$old['_attachments'], $event['_attachments']); // attachments flagged for deletion => set to false foreach ($event['_attachments'] as $key => $attachment) { if ($attachment['_deleted'] || in_array($attachment['id'], (array)$event['deleted_attachments'])) { $event['_attachments'][$key] = false; } } } return $event; } /** * Set CSS class according to the event's attendde partstat */ public static function add_partstat_class($event, $partstats, $user = null) { // set classes according to PARTSTAT if (is_array($event['attendees'])) { $user_emails = libcalendaring::get_instance()->get_user_emails($user); $partstat = 'UNKNOWN'; foreach ($event['attendees'] as $attendee) { if (in_array($attendee['email'], $user_emails)) { $partstat = $attendee['status']; break; } } if (in_array($partstat, $partstats)) { $event['className'] = trim($event['className'] . ' fc-invitation-' . strtolower($partstat)); } } return $event; } /** * Provide a list of revisions for the given event * * @param array $event Hash array with event properties * * @return array List of changes, each as a hash array * @see calendar_driver::get_event_changelog() */ public function get_event_changelog($event) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); $result = $this->bonnie_api->changelog('event', $uid, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid) { return $result['changes']; } return false; } /** * Get a list of property changes beteen two revisions of an event * * @param array $event Hash array with event properties * @param mixed $rev1 Old Revision * @param mixed $rev2 New Revision * * @return array List of property changes, each as a hash array * @see calendar_driver::get_event_diff() */ public function get_event_diff($event, $rev1, $rev2) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); // get diff for the requested recurrence instance $instance_id = $event['id'] != $uid ? substr($event['id'], strlen($uid) + 1) : null; // call Bonnie API $result = $this->bonnie_api->diff('event', $uid, $rev1, $rev2, $mailbox, $msguid, $instance_id); if (is_array($result) && $result['uid'] == $uid) { $result['rev1'] = $rev1; $result['rev2'] = $rev2; $keymap = array( 'dtstart' => 'start', 'dtend' => 'end', 'dstamp' => 'changed', 'summary' => 'title', 'alarm' => 'alarms', 'attendee' => 'attendees', 'attach' => 'attachments', 'rrule' => 'recurrence', 'transparency' => 'free_busy', 'classification' => 'sensitivity', 'lastmodified-date' => 'changed', ); $prop_keymaps = array( 'attachments' => array('fmttype' => 'mimetype', 'label' => 'name'), 'attendees' => array('partstat' => 'status'), ); $special_changes = array(); // map kolab event properties to keys the client expects array_walk($result['changes'], function(&$change, $i) use ($keymap, $prop_keymaps, $special_changes) { if (array_key_exists($change['property'], $keymap)) { $change['property'] = $keymap[$change['property']]; } // translate free_busy values if ($change['property'] == 'free_busy') { $change['old'] = $old['old'] ? 'free' : 'busy'; $change['new'] = $old['new'] ? 'free' : 'busy'; } // map alarms trigger value if ($change['property'] == 'alarms') { if (is_array($change['old']) && is_array($change['old']['trigger'])) $change['old']['trigger'] = $change['old']['trigger']['value']; if (is_array($change['new']) && is_array($change['new']['trigger'])) $change['new']['trigger'] = $change['new']['trigger']['value']; } // make all property keys uppercase if ($change['property'] == 'recurrence') { $special_changes['recurrence'] = $i; foreach (array('old','new') as $m) { if (is_array($change[$m])) { $props = array(); foreach ($change[$m] as $k => $v) $props[strtoupper($k)] = $v; $change[$m] = $props; } } } // map property keys names if (is_array($prop_keymaps[$change['property']])) { foreach ($prop_keymaps[$change['property']] as $k => $dest) { if (is_array($change['old']) && array_key_exists($k, $change['old'])) { $change['old'][$dest] = $change['old'][$k]; unset($change['old'][$k]); } if (is_array($change['new']) && array_key_exists($k, $change['new'])) { $change['new'][$dest] = $change['new'][$k]; unset($change['new'][$k]); } } } if ($change['property'] == 'exdate') { $special_changes['exdate'] = $i; } else if ($change['property'] == 'rdate') { $special_changes['rdate'] = $i; } }); // merge some recurrence changes foreach (array('exdate','rdate') as $prop) { if (array_key_exists($prop, $special_changes)) { $exdate = $result['changes'][$special_changes[$prop]]; if (array_key_exists('recurrence', $special_changes)) { $recurrence = &$result['changes'][$special_changes['recurrence']]; } else { $i = count($result['changes']); $result['changes'][$i] = array('property' => 'recurrence', 'old' => array(), 'new' => array()); $recurrence = &$result['changes'][$i]['recurrence']; } $key = strtoupper($prop); $recurrence['old'][$key] = $exdate['old']; $recurrence['new'][$key] = $exdate['new']; unset($result['changes'][$special_changes[$prop]]); } } return $result; } return false; } /** * Return full data of a specific revision of an event * * @param array Hash array with event properties * @param mixed $rev Revision number * * @return array Event object as hash array * @see calendar_driver::get_event_revison() */ public function get_event_revison($event, $rev, $internal = false) { if (empty($this->bonnie_api)) { return false; } $eventid = $event['id']; $calid = $event['calendar']; list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); // call Bonnie API $result = $this->bonnie_api->get('event', $uid, $rev, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { $format = kolab_format::factory('event'); $format->load($result['xml']); $event = $format->to_array(); $format->get_attachments($event, true); // get the right instance from a recurring event if ($eventid != $event['uid']) { $instance_id = substr($eventid, strlen($event['uid']) + 1); // check for recurrence exception first if ($instance = $format->get_instance($instance_id)) { $event = $instance; } else { // not a exception, compute recurrence... $event['_formatobj'] = $format; $recurrence_date = rcube_utils::anytodatetime($instance_id, $event['start']->getTimezone()); foreach ($this->get_recurring_events($event, $event['start'], $recurrence_date) as $instance) { if ($instance['id'] == $eventid) { $event = $instance; break; } } } } if ($format->is_valid()) { $event['calendar'] = $calid; $event['rev'] = $result['rev']; return $internal ? $event : self::to_rcube_event($event); } } return false; } /** * Command the backend to restore a certain revision of an event. * This shall replace the current event with an older version. * * @param mixed UID string or hash array with event properties: * id: Event identifier * calendar: Calendar identifier * @param mixed $rev Revision number * * @return boolean True on success, False on failure */ public function restore_event_revision($event, $rev) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); $calendar = $this->get_calendar($event['calendar']); $success = false; if ($calendar && $calendar->storage && $calendar->editable) { if ($raw_msg = $this->bonnie_api->rawdata('event', $uid, $rev, $mailbox)) { $imap = $this->rc->get_storage(); // insert $raw_msg as new message if ($imap->save_message($calendar->storage->name, $raw_msg, null, false)) { $success = true; // delete old revision from imap and cache $imap->delete_message($msguid, $calendar->storage->name); $calendar->storage->cache->set($msguid, false); } } } return $success; } /** * Helper method to resolved the given event identifier into uid and folder * * @return array (uid,folder,msguid) tuple */ private function _resolve_event_identity($event) { $mailbox = $msguid = null; if (is_array($event)) { $uid = $event['uid'] ?: $event['id']; if (($cal = $this->get_calendar($event['calendar'])) && !($cal instanceof kolab_invitation_calendar)) { $mailbox = $cal->get_mailbox_id(); // get event object from storage in order to get the real object uid an msguid if ($ev = $cal->get_event($event['id'])) { $msguid = $ev['_msguid']; $uid = $ev['uid']; } } } else { $uid = $event; // get event object from storage in order to get the real object uid an msguid if ($ev = $this->get_event($event)) { $mailbox = $ev['_mailbox']; $msguid = $ev['_msguid']; $uid = $ev['uid']; } } return array($uid, $mailbox, $msguid); } /** * Callback function to produce driver-specific calendar create/edit form * * @param string Request action 'form-edit|form-new' * @param array Calendar properties (e.g. id, color) * @param array Edit form fields * * @return string HTML content of the form */ public function calendar_form($action, $calendar, $formfields) { // show default dialog for birthday calendar if (in_array($calendar['id'], array(self::BIRTHDAY_CALENDAR_ID, self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED))) { if ($calendar['id'] != self::BIRTHDAY_CALENDAR_ID) unset($formfields['showalarms']); return parent::calendar_form($action, $calendar, $formfields); } if ($calendar['id'] && ($cal = $this->calendars[$calendar['id']])) { $folder = $cal->get_realname(); // UTF7 $color = $cal->get_color(); } else { $folder = ''; $color = ''; } $hidden_fields[] = array('name' => 'oldname', 'value' => $folder); $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = array(); if (strlen($folder)) { $path_imap = explode($delim, $folder); array_pop($path_imap); // pop off name part $path_imap = implode($path_imap, $delim); $options = $storage->folder_info($folder); } else { $path_imap = ''; } // General tab $form['props'] = array( 'name' => $this->rc->gettext('properties'), ); // Disable folder name input if (!empty($options) && ($options['norename'] || $options['protected'])) { $input_name = new html_hiddenfield(array('name' => 'name', 'id' => 'calendar-name')); $formfields['name']['value'] = kolab_storage::object_name($folder) . $input_name->show($folder); } // calendar name (default field) $form['props']['fieldsets']['location'] = array( 'name' => $this->rc->gettext('location'), 'content' => array( 'name' => $formfields['name'] ), ); if (!empty($options) && ($options['norename'] || $options['protected'])) { // prevent user from moving folder $hidden_fields[] = array('name' => 'parent', 'value' => $path_imap); } else { $select = kolab_storage::folder_selector('event', array('name' => 'parent', 'id' => 'calendar-parent'), $folder); $form['props']['fieldsets']['location']['content']['path'] = array( 'id' => 'calendar-parent', 'label' => $this->cal->gettext('parentcalendar'), 'value' => $select->show(strlen($folder) ? $path_imap : ''), ); } // calendar color (default field) $form['props']['fieldsets']['settings'] = array( 'name' => $this->rc->gettext('settings'), 'content' => array( 'color' => $formfields['color'], 'showalarms' => $formfields['showalarms'], ), ); if ($action != 'form-new') { $form['sharing'] = array( 'name' => Q($this->cal->gettext('tabsharing')), 'content' => html::tag('iframe', array( 'src' => $this->cal->rc->url(array('_action' => 'calendar-acl', 'id' => $calendar['id'], 'framed' => 1)), 'width' => '100%', 'height' => 350, 'border' => 0, 'style' => 'border:0'), ''), ); } $this->form_html = ''; if (is_array($hidden_fields)) { foreach ($hidden_fields as $field) { $hiddenfield = new html_hiddenfield($field); $this->form_html .= $hiddenfield->show() . "\n"; } } // Create form output foreach ($form as $tab) { if (!empty($tab['fieldsets']) && is_array($tab['fieldsets'])) { $content = ''; foreach ($tab['fieldsets'] as $fieldset) { $subcontent = $this->get_form_part($fieldset); if ($subcontent) { $content .= html::tag('fieldset', null, html::tag('legend', null, Q($fieldset['name'])) . $subcontent) ."\n"; } } } else { $content = $this->get_form_part($tab); } if ($content) { $this->form_html .= html::tag('fieldset', null, html::tag('legend', null, Q($tab['name'])) . $content) ."\n"; } } // Parse form template for skin-dependent stuff $this->rc->output->add_handler('calendarform', array($this, 'calendar_form_html')); return $this->rc->output->parse('calendar.kolabform', false, false); } /** * Handler for template object */ public function calendar_form_html() { return $this->form_html; } /** * Helper function used in calendar_form_content(). Creates a part of the form. */ private function get_form_part($form) { $content = ''; if (is_array($form['content']) && !empty($form['content'])) { $table = new html_table(array('cols' => 2)); foreach ($form['content'] as $col => $colprop) { $label = !empty($colprop['label']) ? $colprop['label'] : rcube_label($col); $table->add('title', html::label($colprop['id'], Q($label))); $table->add(null, $colprop['value']); } $content = $table->show(); } else { $content = $form['content']; } return $content; } /** * Handler to render ACL form for a calendar folder */ public function calendar_acl() { $this->rc->output->add_handler('folderacl', array($this, 'calendar_acl_form')); $this->rc->output->send('calendar.kolabacl'); } /** * Handler for ACL form template object */ public function calendar_acl_form() { $calid = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); if ($calid && ($cal = $this->get_calendar($calid))) { $folder = $cal->get_realname(); // UTF7 $color = $cal->get_color(); } else { $folder = ''; $color = ''; } $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = array(); if (strlen($folder)) { $path_imap = explode($delim, $folder); array_pop($path_imap); // pop off name part $path_imap = implode($path_imap, $delim); $options = $storage->folder_info($folder); // Allow plugins to modify the form content (e.g. with ACL form) $plugin = $this->rc->plugins->exec_hook('calendar_form_kolab', array('form' => $form, 'options' => $options, 'name' => $folder)); } if (!$plugin['form']['sharing']['content']) $plugin['form']['sharing']['content'] = html::div('hint', $this->cal->gettext('aclnorights')); return $plugin['form']['sharing']['content']; } /** * Handler for user_delete plugin hook */ public function user_delete($args) { $db = $this->rc->get_dbh(); foreach (array('kolab_alarms', 'itipinvitations') as $table) { $db->query("DELETE FROM " . $this->rc->db->table_name($table, true) . " WHERE `user_id` = ?", $args['user']->ID); } } } diff --git a/plugins/calendar/localization/en_US.inc b/plugins/calendar/localization/en_US.inc index d967fc72..2a5d2359 100644 --- a/plugins/calendar/localization/en_US.inc +++ b/plugins/calendar/localization/en_US.inc @@ -1,310 +1,301 @@ CalDAV client application (e.g. Evolution or Mozilla Thunderbird) to fully synchronize this specific calendar with your computer or mobile device.'; $labels['findcalendars'] = 'Find calendars...'; $labels['searchterms'] = 'Search terms'; $labels['calsearchresults'] = 'Available Calendars'; $labels['calendarsubscribe'] = 'List permanently'; $labels['nocalendarsfound'] = 'No calendars found'; $labels['nrcalendarsfound'] = '$nr calendars found'; $labels['quickview'] = 'View only this calendar'; $labels['invitationspending'] = 'Pending invitations'; $labels['invitationsdeclined'] = 'Declined invitations'; $labels['changepartstat'] = 'Change participant status'; $labels['rsvpcomment'] = 'Invitation text'; // agenda view $labels['listrange'] = 'Range to display:'; $labels['listsections'] = 'Divide into:'; $labels['smartsections'] = 'Smart sections'; $labels['until'] = 'until'; $labels['today'] = 'Today'; $labels['tomorrow'] = 'Tomorrow'; $labels['thisweek'] = 'This week'; $labels['nextweek'] = 'Next week'; $labels['prevweek'] = 'Previous week'; $labels['thismonth'] = 'This month'; $labels['nextmonth'] = 'Next month'; $labels['weekofyear'] = 'Week'; $labels['pastevents'] = 'Past'; $labels['futureevents'] = 'Future'; // alarm/reminder settings $labels['showalarms'] = 'Show reminders'; $labels['defaultalarmtype'] = 'Default reminder setting'; $labels['defaultalarmoffset'] = 'Default reminder time'; // attendees $labels['attendee'] = 'Participant'; $labels['role'] = 'Role'; $labels['availability'] = 'Avail.'; $labels['confirmstate'] = 'Status'; $labels['addattendee'] = 'Add participant'; $labels['roleorganizer'] = 'Organizer'; $labels['rolerequired'] = 'Required'; $labels['roleoptional'] = 'Optional'; $labels['rolechair'] = 'Chair'; $labels['rolenonparticipant'] = 'Absent'; $labels['cutypeindividual'] = 'Individual'; $labels['cutypegroup'] = 'Group'; $labels['cutyperesource'] = 'Resource'; $labels['cutyperoom'] = 'Room'; $labels['availfree'] = 'Free'; $labels['availbusy'] = 'Busy'; $labels['availunknown'] = 'Unknown'; $labels['availtentative'] = 'Tentative'; $labels['availoutofoffice'] = 'Out of Office'; $labels['delegatedto'] = 'Delegated to: '; $labels['delegatedfrom'] = 'Delegated from: '; $labels['scheduletime'] = 'Find availability'; $labels['sendinvitations'] = 'Send invitations'; $labels['sendnotifications'] = 'Notify participants about modifications'; $labels['sendcancellation'] = 'Notify participants about event cancellation'; $labels['onlyworkinghours'] = 'Find availability within my working hours'; $labels['reqallattendees'] = 'Required/all participants'; $labels['prevslot'] = 'Previous Slot'; $labels['nextslot'] = 'Next Slot'; $labels['suggestedslot'] = 'Suggested Slot'; $labels['noslotfound'] = 'Unable to find a free time slot'; $labels['invitationsubject'] = 'You\'ve been invited to "$title"'; $labels['invitationmailbody'] = "*\$title*\n\nWhen: \$date\n\nInvitees: \$attendees\n\nPlease find attached an iCalendar file with all the event details which you can import to your calendar application."; $labels['invitationattendlinks'] = "In case your email client doesn't support iTip requests you can use the following link to either accept or decline this invitation:\n\$url"; $labels['eventupdatesubject'] = '"$title" has been updated'; $labels['eventupdatesubjectempty'] = 'An event that concerns you has been updated'; $labels['eventupdatemailbody'] = "*\$title*\n\nWhen: \$date\n\nInvitees: \$attendees\n\nPlease find attached an iCalendar file with the updated event details which you can import to your calendar application."; $labels['eventcancelsubject'] = '"$title" has been canceled'; $labels['eventcancelmailbody'] = "*\$title*\n\nWhen: \$date\n\nInvitees: \$attendees\n\nThe event has been cancelled by \$organizer.\n\nPlease find attached an iCalendar file with the updated event details."; // invitation handling (overrides labels from libcalendaring) $labels['itipobjectnotfound'] = 'The event referred by this message was not found in your calendar.'; $labels['itipmailbodyaccepted'] = "\$sender has accepted the invitation to the following event:\n\n*\$title*\n\nWhen: \$date\n\nInvitees: \$attendees"; $labels['itipmailbodytentative'] = "\$sender has tentatively accepted the invitation to the following event:\n\n*\$title*\n\nWhen: \$date\n\nInvitees: \$attendees"; $labels['itipmailbodydeclined'] = "\$sender has declined the invitation to the following event:\n\n*\$title*\n\nWhen: \$date\n\nInvitees: \$attendees"; $labels['itipmailbodycancel'] = "\$sender has rejected your participation in the following event:\n\n*\$title*\n\nWhen: \$date"; $labels['itipmailbodydelegated'] = "\$sender has delegated the participation in the following event:\n\n*\$title*\n\nWhen: \$date"; $labels['itipmailbodydelegatedto'] = "\$sender has delegated the participation in the following event to you:\n\n*\$title*\n\nWhen: \$date"; $labels['itipdeclineevent'] = 'Do you want to decline your invitation to this event?'; $labels['declinedeleteconfirm'] = 'Do you also want to delete this declined event from your calendar?'; $labels['itipcomment'] = 'Invitation/notification comment'; $labels['itipcommenttitle'] = 'This comment will be attached to the invitation/notification message sent to participants'; $labels['notanattendee'] = 'You\'re not listed as an attendee of this event'; $labels['eventcancelled'] = 'The event has been cancelled'; $labels['saveincalendar'] = 'save in'; $labels['updatemycopy'] = 'Update in my calendar'; $labels['savetocalendar'] = 'Save to calendar'; $labels['openpreview'] = 'Check Calendar'; $labels['noearlierevents'] = 'No earlier events'; $labels['nolaterevents'] = 'No later events'; // resources $labels['resource'] = 'Resource'; $labels['addresource'] = 'Book resource'; $labels['findresources'] = 'Find resources'; $labels['resourcedetails'] = 'Details'; $labels['resourceavailability'] = 'Availability'; $labels['resourceowner'] = 'Owner'; $labels['resourceadded'] = 'The resource was added to your event'; // event dialog tabs $labels['tabsummary'] = 'Summary'; $labels['tabrecurrence'] = 'Recurrence'; $labels['tabattendees'] = 'Participants'; $labels['tabresources'] = 'Resources'; $labels['tabattachments'] = 'Attachments'; $labels['tabsharing'] = 'Sharing'; // messages $labels['deleteobjectconfirm'] = 'Do you really want to delete this event?'; $labels['deleteventconfirm'] = 'Do you really want to delete this event?'; $labels['deletecalendarconfirm'] = 'Do you really want to delete this calendar with all its events?'; $labels['deletecalendarconfirmrecursive'] = 'Do you really want to delete this calendar with all its events and sub-calendars?'; $labels['savingdata'] = 'Saving data...'; $labels['errorsaving'] = 'Failed to save changes.'; $labels['operationfailed'] = 'The requested operation failed.'; $labels['invalideventdates'] = 'Invalid dates entered! Please check your input.'; $labels['invalidcalendarproperties'] = 'Invalid calendar properties! Please set a valid name.'; $labels['searchnoresults'] = 'No events found in the selected calendars.'; $labels['successremoval'] = 'The event has been deleted successfully.'; $labels['successrestore'] = 'The event has been restored successfully.'; $labels['errornotifying'] = 'Failed to send notifications to event participants'; $labels['errorimportingevent'] = 'Failed to import the event'; $labels['importwarningexists'] = 'A copy of this event already exists in your calendar.'; $labels['newerversionexists'] = 'A newer version of this event already exists! Aborted.'; $labels['nowritecalendarfound'] = 'No calendar found to save the event'; $labels['importedsuccessfully'] = 'The event was successfully added to \'$calendar\''; $labels['updatedsuccessfully'] = 'The event was successfully updated in \'$calendar\''; $labels['attendeupdateesuccess'] = 'Successfully updated the participant\'s status'; $labels['itipsendsuccess'] = 'Invitation sent to participants.'; $labels['itipresponseerror'] = 'Failed to send the response to this event invitation'; $labels['itipinvalidrequest'] = 'This invitation is no longer valid'; $labels['sentresponseto'] = 'Successfully sent invitation response to $mailto'; $labels['localchangeswarning'] = 'You are about to make changes that will only be reflected on your calendar and not be sent to the organizer of the event.'; $labels['importsuccess'] = 'Successfully imported $nr events'; $labels['importnone'] = 'No events found to be imported'; $labels['importerror'] = 'An error occured while importing'; $labels['aclnorights'] = 'You do not have administrator rights on this calendar.'; $labels['changeeventconfirm'] = 'Change event'; $labels['removeeventconfirm'] = 'Delete event'; $labels['changerecurringeventwarning'] = 'This is a recurring event. Would you like to edit the current event only, this and all future occurences, all occurences or save it as a new event?'; $labels['removerecurringeventwarning'] = 'This is a recurring event. Would you like to delete the current event only, this and all future occurences or all occurences of this event?'; $labels['removerecurringallonly'] = 'This is a recurring event. As a participant, you can only delete the entire event with all occurences.'; $labels['currentevent'] = 'Current'; $labels['futurevents'] = 'Future'; $labels['allevents'] = 'All'; $labels['saveasnew'] = 'Save as new'; // birthdays calendar $labels['birthdays'] = 'Birthdays'; $labels['birthdayscalendar'] = 'Birthdays Calendar'; $labels['displaybirthdayscalendar'] = 'Display birthdays calendar'; $labels['birthdayscalendarsources'] = 'From these address books'; $labels['birthdayeventtitle'] = '$name\'s Birthday'; $labels['birthdayage'] = 'Age $age'; // history dialog $labels['objectchangelog'] = 'Change History'; $labels['objectdiff'] = 'Changes from $rev1 to $rev2'; -$labels['revision'] = 'Revision'; -$labels['user'] = 'User'; -$labels['operation'] = 'Action'; -$labels['actionappend'] = 'Saved'; -$labels['actionmove'] = 'Moved'; -$labels['actiondelete'] = 'Deleted'; -$labels['compare'] = 'Compare'; -$labels['showrevision'] = 'Show this version'; -$labels['restore'] = 'Restore this version'; $labels['objectnotfound'] = 'Failed to load event data'; $labels['objectchangelognotavailable'] = 'Change history is not available for this event'; $labels['objectdiffnotavailable'] = 'No comparison possible for the selected revisions'; $labels['revisionrestoreconfirm'] = 'Do you really want to restore revision $rev of this event? This will replace the current event with the old version.'; $labels['objectrestoresuccess'] = 'Revision $rev successfully restored'; $labels['objectrestoreerror'] = 'Failed to restore the old revision'; // (hidden) titles and labels for accessibility annotations $labels['arialabelminical'] = 'Calendar date selection'; $labels['arialabelcalendarview'] = 'Calendar view'; $labels['arialabelsearchform'] = 'Event search form'; $labels['arialabelquicksearchbox'] = 'Event search input'; $labels['arialabelcalsearchform'] = 'Calendars search form'; $labels['calendaractions'] = 'Calendar actions'; $labels['arialabeleventattendees'] = 'Event participants list'; $labels['arialabeleventresources'] = 'Event resources list'; $labels['arialabelresourcesearchform'] = 'Resources search form'; $labels['arialabelresourceselection'] = 'Available resources'; ?> diff --git a/plugins/calendar/skins/larry/templates/calendar.html b/plugins/calendar/skins/larry/templates/calendar.html index 8d8e0105..8dc779d5 100644 --- a/plugins/calendar/skins/larry/templates/calendar.html +++ b/plugins/calendar/skins/larry/templates/calendar.html @@ -1,532 +1,532 @@ <roundcube:object name="pagetitle" />

diff --git a/plugins/kolab_addressbook/kolab_addressbook.php b/plugins/kolab_addressbook/kolab_addressbook.php index 93215e40..7dd23e2c 100644 --- a/plugins/kolab_addressbook/kolab_addressbook.php +++ b/plugins/kolab_addressbook/kolab_addressbook.php @@ -1,1192 +1,1191 @@ * @author Aleksander Machniak * * Copyright (C) 2011-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_addressbook extends rcube_plugin { public $task = '?(?!login|logout).*'; private $sources; private $folders; private $rc; private $ui; public $bonnie_api = false; const GLOBAL_FIRST = 0; const PERSONAL_FIRST = 1; const GLOBAL_ONLY = 2; const PERSONAL_ONLY = 3; /** * Startup method of a Roundcube plugin */ public function init() { require_once(dirname(__FILE__) . '/lib/rcube_kolab_contacts.php'); $this->rc = rcube::get_instance(); // load required plugin $this->require_plugin('libkolab'); // register hooks $this->add_hook('addressbooks_list', array($this, 'address_sources')); $this->add_hook('addressbook_get', array($this, 'get_address_book')); $this->add_hook('config_get', array($this, 'config_get')); if ($this->rc->task == 'addressbook') { $this->add_texts('localization'); $this->add_hook('contact_form', array($this, 'contact_form')); $this->add_hook('contact_photo', array($this, 'contact_photo')); $this->add_hook('template_object_directorylist', array($this, 'directorylist_html')); // Plugin actions $this->register_action('plugin.book', array($this, 'book_actions')); $this->register_action('plugin.book-save', array($this, 'book_save')); $this->register_action('plugin.book-search', array($this, 'book_search')); $this->register_action('plugin.book-subscribe', array($this, 'book_subscribe')); $this->register_action('plugin.contact-changelog', array($this, 'contact_changelog')); $this->register_action('plugin.contact-diff', array($this, 'contact_diff')); $this->register_action('plugin.contact-restore', array($this, 'contact_restore')); // get configuration for the Bonnie API - if ($bonnie_config = $this->rc->config->get('kolab_bonnie_api', false)) { - $this->bonnie_api = new kolab_bonnie_api($bonnie_config); - } + $this->bonnie_api = libkolab::get_bonnie_api(); // Load UI elements if ($this->api->output->type == 'html') { $this->load_config(); require_once($this->home . '/lib/kolab_addressbook_ui.php'); $this->ui = new kolab_addressbook_ui($this); } } else if ($this->rc->task == 'settings') { $this->add_texts('localization'); $this->add_hook('preferences_list', array($this, 'prefs_list')); $this->add_hook('preferences_save', array($this, 'prefs_save')); } $this->add_hook('folder_delete', array($this, 'prefs_folder_delete')); $this->add_hook('folder_rename', array($this, 'prefs_folder_rename')); $this->add_hook('folder_update', array($this, 'prefs_folder_update')); } /** * Handler for the addressbooks_list hook. * * This will add all instances of available Kolab-based address books * to the list of address sources of Roundcube. * This will also hide some addressbooks according to kolab_addressbook_prio setting. * * @param array $p Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function address_sources($p) { $abook_prio = $this->addressbook_prio(); // Disable all global address books // Assumes that all non-kolab_addressbook sources are global if ($abook_prio == self::PERSONAL_ONLY) { $p['sources'] = array(); } $sources = array(); foreach ($this->_list_sources() as $abook_id => $abook) { // register this address source $sources[$abook_id] = $this->abook_prop($abook_id, $abook); // flag folders with 'i' right as writeable if ($this->rc->action == 'add' && strpos($abook->rights, 'i') !== false) { $sources[$abook_id]['readonly'] = false; } } // Add personal address sources to the list if ($abook_prio == self::PERSONAL_FIRST) { // $p['sources'] = array_merge($sources, $p['sources']); // Don't use array_merge(), because if you have folders name // that resolve to numeric identifier it will break output array keys foreach ($p['sources'] as $idx => $value) $sources[$idx] = $value; $p['sources'] = $sources; } else { // $p['sources'] = array_merge($p['sources'], $sources); foreach ($sources as $idx => $value) $p['sources'][$idx] = $value; } return $p; } /** * Helper method to build a hash array of address book properties */ protected function abook_prop($id, $abook) { if ($abook->virtual) { return array( 'id' => $id, 'name' => $abook->get_name(), 'listname' => $abook->get_foldername(), 'group' => $abook instanceof kolab_storage_folder_user ? 'user' : $abook->get_namespace(), 'readonly' => true, 'rights' => 'l', 'kolab' => true, 'virtual' => true, ); } else { return array( 'id' => $id, 'name' => $abook->get_name(), 'listname' => $abook->get_foldername(), 'readonly' => $abook->readonly, 'rights' => $abook->rights, 'groups' => $abook->groups, 'undelete' => $abook->undelete && $this->rc->config->get('undo_timeout'), 'realname' => rcube_charset::convert($abook->get_realname(), 'UTF7-IMAP'), // IMAP folder name 'group' => $abook->get_namespace(), 'subscribed' => $abook->is_subscribed(), 'carddavurl' => $abook->get_carddav_url(), 'removable' => true, 'kolab' => true, 'audittrail' => !empty($this->bonnie_api), ); } } /** * */ public function directorylist_html($args) { $out = ''; $jsdata = array(); $sources = (array)$this->rc->get_address_sources(); // list all non-kolab sources first foreach (array_filter($sources, function($source){ return empty($source['kolab']); }) as $j => $source) { $id = strval(strlen($source['id']) ? $source['id'] : $j); $out .= $this->addressbook_list_item($id, $source, $jsdata) . ''; } // render a hierarchical list of kolab contact folders kolab_storage::folder_hierarchy($this->folders, $tree); $out .= $this->folder_tree_html($tree, $sources, $jsdata); $this->rc->output->set_env('contactgroups', array_filter($jsdata, function($src){ return $src['type'] == 'group'; })); $this->rc->output->set_env('address_sources', array_filter($jsdata, function($src){ return $src['type'] != 'group'; })); $args['content'] = html::tag('ul', $args, $out, html::$common_attrib); return $args; } /** * Return html for a structured list
    for the folder tree */ public function folder_tree_html($node, $data, &$jsdata) { $out = ''; foreach ($node->children as $folder) { $id = $folder->id; $source = $data[$id]; $is_collapsed = strpos($this->rc->config->get('collapsed_abooks',''), '&'.rawurlencode($id).'&') !== false; if ($folder->virtual) { $source = $this->abook_prop($folder->id, $folder); } else if (empty($source)) { $this->sources[$id] = new rcube_kolab_contacts($folder->name); $source = $this->abook_prop($id, $this->sources[$id]); } $content = $this->addressbook_list_item($id, $source, $jsdata); if (!empty($folder->children)) { $child_html = $this->folder_tree_html($folder, $data, $jsdata); // copy group items... if (preg_match('!]*>(.*)
\n*$!Ums', $content, $m)) { $child_html = $m[1] . $child_html; $content = substr($content, 0, -strlen($m[0]) - 1); } // ... and re-create the subtree if (!empty($child_html)) { $content .= html::tag('ul', array('class' => 'groups', 'style' => ($is_collapsed ? "display:none;" : null)), $child_html); } } $out .= $content . ''; } return $out; } /** * */ protected function addressbook_list_item($id, $source, &$jsdata, $search_mode = false) { $current = rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC); if (!$source['virtual']) { $jsdata[$id] = $source; $jsdata[$id]['name'] = html_entity_decode($source['name'], ENT_NOQUOTES, RCUBE_CHARSET); } // set class name(s) $classes = array('addressbook'); if ($source['group']) $classes[] = $source['group']; if ($current === $id) $classes[] = 'selected'; if ($source['readonly']) $classes[] = 'readonly'; if ($source['virtual']) $classes[] = 'virtual'; if ($source['class_name']) $classes[] = $source['class_name']; $name = !empty($source['listname']) ? $source['listname'] : (!empty($source['name']) ? $source['name'] : $id); $label_id = 'kabt:' . $id; $inner = ($source['virtual'] ? html::a(array('tabindex' => '0'), $name) : html::a(array( 'href' => $this->rc->url(array('_source' => $id)), 'rel' => $source['id'], 'id' => $label_id, 'onclick' => "return " . rcmail_output::JS_OBJECT_NAME.".command('list','" . rcube::JQ($id) . "',this)", ), $name) ); if (isset($source['subscribed'])) { $inner .= html::span(array( 'class' => 'subscribed', 'title' => $this->gettext('foldersubscribe'), 'role' => 'checkbox', 'aria-checked' => $source['subscribed'] ? 'true' : 'false', ), ''); } // don't wrap in
  • but add a checkbox for search results listing if ($search_mode) { $jsdata[$id]['group'] = join(' ', $classes); if (!$source['virtual']) { $inner .= html::tag('input', array( 'type' => 'checkbox', 'name' => '_source[]', 'value' => $id, 'checked' => false, 'aria-labelledby' => $label_id, )); } return html::div(null, $inner); } $out .= html::tag('li', array( 'id' => 'rcmli' . rcube_utils::html_identifier($id, true), 'class' => join(' ', $classes), 'noclose' => true, ), html::div($source['subscribed'] ? 'subscribed' : null, $inner) ); $groupdata = array('out' => '', 'jsdata' => $jsdata, 'source' => $id); if ($source['groups'] && function_exists('rcmail_contact_groups')) { $groupdata = rcmail_contact_groups($groupdata); } $jsdata = $groupdata['jsdata']; $out .= $groupdata['out']; return $out; } /** * Sets autocomplete_addressbooks option according to * kolab_addressbook_prio setting extending list of address sources * to be used for autocompletion. */ public function config_get($args) { if ($args['name'] != 'autocomplete_addressbooks') { return $args; } $abook_prio = $this->addressbook_prio(); // here we cannot use rc->config->get() $sources = $GLOBALS['CONFIG']['autocomplete_addressbooks']; // Disable all global address books // Assumes that all non-kolab_addressbook sources are global if ($abook_prio == self::PERSONAL_ONLY) { $sources = array(); } if (!is_array($sources)) { $sources = array(); } $kolab_sources = array(); foreach (array_keys($this->_list_sources()) as $abook_id) { if (!in_array($abook_id, $sources)) $kolab_sources[] = $abook_id; } // Add personal address sources to the list if (!empty($kolab_sources)) { if ($abook_prio == self::PERSONAL_FIRST) { $sources = array_merge($kolab_sources, $sources); } else { $sources = array_merge($sources, $kolab_sources); } } $args['result'] = $sources; return $args; } /** * Getter for the rcube_addressbook instance * * @param array $p Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function get_address_book($p) { if ($p['id']) { $id = kolab_storage::id_decode($p['id']); // check for falsely base64 decoded identifier if (preg_match('![^A-Za-z0-9=/+&._ -]!', $id)) { $id = $p['id']; } $folder = kolab_storage::get_folder($id); // try with unencoded (old-style) identifier if ((!$folder || $folder->type != 'contact') && $id != $p['id']) { $folder = kolab_storage::get_folder($p['id']); } if ($folder && $folder->type == 'contact') { $p['instance'] = new rcube_kolab_contacts($folder->name); // flag source as writeable if 'i' right is given if ($p['writeable'] && $this->rc->action == 'save' && strpos($p['instance']->rights, 'i') !== false) { $p['instance']->readonly = false; } else if ($this->rc->action == 'delete' && strpos($p['instance']->rights, 't') !== false) { $p['instance']->readonly = false; } } } return $p; } private function _list_sources() { // already read sources if (isset($this->sources)) return $this->sources; kolab_storage::$encode_ids = true; $this->sources = array(); $this->folders = array(); $abook_prio = $this->addressbook_prio(); // Personal address source(s) disabled? if ($abook_prio == self::GLOBAL_ONLY) { return $this->sources; } // get all folders that have "contact" type $folders = kolab_storage::sort_folders(kolab_storage::get_folders('contact')); if (PEAR::isError($folders)) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed to list contact folders from Kolab server:" . $folders->getMessage()), true, false); } else { // we need at least one folder to prevent from errors in Roundcube core // when there's also no sql nor ldap addressbook (Bug #2086) if (empty($folders)) { if ($folder = kolab_storage::create_default_folder('contact')) { $folders = array(new kolab_storage_folder($folder, 'contact')); } } // convert to UTF8 and sort foreach ($folders as $folder) { // create instance of rcube_contacts $abook_id = $folder->id; $abook = new rcube_kolab_contacts($folder->name); $this->sources[$abook_id] = $abook; $this->folders[$abook_id] = $folder; } } return $this->sources; } /** * Plugin hook called before rendering the contact form or detail view * * @param array $p Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function contact_form($p) { // none of our business if (!is_object($GLOBALS['CONTACTS']) || !is_a($GLOBALS['CONTACTS'], 'rcube_kolab_contacts')) return $p; // extend the list of contact fields to be displayed in the 'personal' section if (is_array($p['form']['personal'])) { $p['form']['personal']['content']['profession'] = array('size' => 40); $p['form']['personal']['content']['children'] = array('size' => 40); $p['form']['personal']['content']['freebusyurl'] = array('size' => 40); $p['form']['personal']['content']['pgppublickey'] = array('size' => 70); $p['form']['personal']['content']['pkcs7publickey'] = array('size' => 70); // re-order fields according to the coltypes list $p['form']['contact']['content'] = $this->_sort_form_fields($p['form']['contact']['content'], $GLOBALS['CONTACTS']); $p['form']['personal']['content'] = $this->_sort_form_fields($p['form']['personal']['content'], $GLOBALS['CONTACTS']); /* define a separate section 'settings' $p['form']['settings'] = array( 'name' => $this->gettext('settings'), 'content' => array( 'freebusyurl' => array('size' => 40, 'visible' => true), 'pgppublickey' => array('size' => 70, 'visible' => true), 'pkcs7publickey' => array('size' => 70, 'visible' => false), ) ); */ } if ($this->bonnie_api && $this->rc->action == 'show' && empty($p['record']['rev'])) { $this->rc->output->set_env('kolab_audit_trail', true); } return $p; } /** * Plugin hook for the contact photo image */ public function contact_photo($p) { // add photo data from old revision inline as data url if (!empty($p['record']['rev']) && !empty($p['data'])) { $p['url'] = 'data:image/gif;base64,' . base64_encode($p['data']); } return $p; } /** * Handler for contact audit trail changelog requests */ public function contact_changelog() { if (empty($this->bonnie_api)) { return false; } $contact = rcube_utils::get_input_value('cid', rcube_utils::INPUT_POST, true); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST); list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($contact, $source); $result = $uid && $mailbox ? $this->bonnie_api->changelog('contact', $uid, $mailbox, $msguid) : null; if (is_array($result) && $result['uid'] == $uid) { if (is_array($result['changes'])) { + $rcmail = $this->rc; $dtformat = $this->rc->config->get('date_format') . ' ' . $this->rc->config->get('time_format'); - array_walk($result['changes'], function(&$change) use ($dtformat) { + array_walk($result['changes'], function(&$change) use ($rcmail, $dtformat) { if ($change['date']) { $dt = rcube_utils::anytodatetime($change['date']); if ($dt instanceof DateTime) { - $change['date'] = $this->rc->format_date($dt, $dtformat); + $change['date'] = $rcmail->format_date($dt, $dtformat); } } }); } $this->rc->output->command('contact_render_changelog', $result['changes']); } else { $this->rc->output->command('contact_render_changelog', false); } $this->rc->output->send(); } /** * Handler for audit trail diff view requests */ public function contact_diff() { if (empty($this->bonnie_api)) { return false; } $contact = rcube_utils::get_input_value('cid', rcube_utils::INPUT_POST, true); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST); $rev1 = rcube_utils::get_input_value('rev1', rcube_utils::INPUT_POST); $rev2 = rcube_utils::get_input_value('rev2', rcube_utils::INPUT_POST); list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($contact, $source); $result = $this->bonnie_api->diff('contact', $uid, $rev1, $rev2, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid) { $result['rev1'] = $rev1; $result['rev2'] = $rev2; $result['cid'] = $contact; // convert some properties, similar to rcube_kolab_contacts::_to_rcube_contact() $keymap = array( 'lastmodified-date' => 'changed', 'additional' => 'middlename', 'fn' => 'name', 'tel' => 'phone', 'url' => 'website', 'bday' => 'birthday', 'note' => 'notes', 'role' => 'profession', 'title' => 'jobtitle', ); $propmap = array('email' => 'address', 'website' => 'url', 'phone' => 'number'); $date_format = $this->rc->config->get('date_format', 'Y-m-d'); // map kolab object properties to keys and values the client expects array_walk($result['changes'], function(&$change, $i) use ($keymap, $propmap, $date_format) { if (array_key_exists($change['property'], $keymap)) { $change['property'] = $keymap[$change['property']]; } // format date-time values if ($change['property'] == 'created' || $change['property'] == 'changed') { if ($old_ = rcube_utils::anytodatetime($change['old'])) { $change['old_'] = $this->rc->format_date($old_); } if ($new_ = rcube_utils::anytodatetime($change['new'])) { $change['new_'] = $this->rc->format_date($new_); } } // format dates else if ($change['property'] == 'birthday' || $change['property'] == 'anniversary') { if ($old_ = rcube_utils::anytodatetime($change['old'])) { $change['old_'] = $this->rc->format_date($old_, $date_format); } if ($new_ = rcube_utils::anytodatetime($change['new'])) { $change['new_'] = $this->rc->format_date($new_, $date_format); } } // convert email, website, phone values else if (array_key_exists($change['property'], $propmap)) { $propname = $propmap[$change['property']]; foreach (array('old','new') as $k) { $k_ = $k . '_'; if (!empty($change[$k])) { $change[$k_] = html::quote($change[$k][$propname] ?: '--'); if ($change[$k]['type']) { $change[$k_] .= ' ' . html::span('subtype', rcmail_get_type_label($change[$k]['type'])); } $change['ishtml'] = true; } } } // serialize address structs if ($change['property'] == 'address') { foreach (array('old','new') as $k) { $k_ = $k . '_'; $change[$k]['zipcode'] = $change[$k]['code']; $template = $this->rc->config->get('address_template', '{'.join('} {', array_keys($change[$k])).'}'); $composite = array(); foreach ($change[$k] as $p => $val) { if (strlen($val)) $composite['{'.$p.'}'] = $val; } $change[$k_] = preg_replace('/\{\w+\}/', '', strtr($template, $composite)); if ($change[$k]['type']) { $change[$k_] .= html::div('subtype', rcmail_get_type_label($change[$k]['type'])); } $change['ishtml'] = true; } $change['diff_'] = libkolab::html_diff($change['old_'], $change['new_'], true); } // localize gender values else if ($change['property'] == 'gender') { if ($change['old']) $change['old_'] = $this->rc->gettext($change['old']); if ($change['new']) $change['new_'] = $this->rc->gettext($change['new']); } // translate 'key' entries in individual properties else if ($change['property'] == 'key') { $p = $change['old'] ?: $change['new']; $t = $p['type']; $change['property'] = $t . 'publickey'; $change['old'] = $change['old'] ? $change['old']['key'] : ''; $change['new'] = $change['new'] ? $change['new']['key'] : ''; } // compute a nice diff of notes else if ($change['property'] == 'notes') { $change['diff_'] = libkolab::html_diff($change['old'], $change['new'], false); } }); $this->rc->output->command('contact_show_diff', $result); } else { $this->rc->output->command('display_message', $this->gettext('objectdiffnotavailable'), 'error'); } $this->rc->output->send(); } /** * Handler for audit trail revision restore requests */ public function contact_restore() { if (empty($this->bonnie_api)) { return false; } $success = false; $contact = rcube_utils::get_input_value('cid', rcube_utils::INPUT_POST, true); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST); $rev = rcube_utils::get_input_value('rev', rcube_utils::INPUT_POST); list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($contact, $source, $folder); if ($folder && ($raw_msg = $this->bonnie_api->rawdata('contact', $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); $this->cache = array(); } } if ($success) { $this->rc->output->command('display_message', $this->gettext(array('name' => 'objectrestoresuccess', 'vars' => array('rev' => $rev))), 'confirmation'); $this->rc->output->command('close_contact_history_dialog', $contact); } else { $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error'); } $this->rc->output->send(); } /** * Get a previous revision of the given contact record from the Bonnie API */ public function get_revision($cid, $source, $rev) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($cid, $source); // call Bonnie API $result = $this->bonnie_api->get('contact', $uid, $rev, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { $format = kolab_format::factory('contact'); $format->load($result['xml']); $rec = $format->to_array(); if ($format->is_valid()) { $rec['rev'] = $result['rev']; return $rec; } } return false; } /** * Helper method to resolved the given contact identifier into uid and mailbox * * @return array (uid,mailbox,msguid) tuple */ private function _resolve_contact_identity($id, $abook, &$folder = null) { $mailbox = $msguid = null; $source = $this->get_address_book(array('id' => $abook)); if ($source['instance']) { $uid = $source['instance']->id2uid($id); $list = kolab_storage::id_decode($abook); } else { return array(null, $mailbox, $msguid); } // get resolve message UID and mailbox identifier if ($folder = kolab_storage::get_folder($list)) { $mailbox = $folder->get_mailbox_id(); $msguid = $folder->cache->uid2msguid($uid); } return array($uid, $mailbox, $msguid); } /** * */ private function _sort_form_fields($contents, $source) { $block = array(); foreach (array_keys($source->coltypes) as $col) { if (isset($contents[$col])) $block[$col] = $contents[$col]; } return $block; } /** * Handler for user preferences form (preferences_list hook) * * @param array $args Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function prefs_list($args) { if ($args['section'] != 'addressbook') { return $args; } $ldap_public = $this->rc->config->get('ldap_public'); $abook_type = $this->rc->config->get('address_book_type'); // Hide option if there's no global addressbook if (empty($ldap_public) || $abook_type != 'ldap') { return $args; } // Check that configuration is not disabled $dont_override = (array) $this->rc->config->get('dont_override', array()); $prio = $this->addressbook_prio(); if (!in_array('kolab_addressbook_prio', $dont_override)) { // Load localization $this->add_texts('localization'); $field_id = '_kolab_addressbook_prio'; $select = new html_select(array('name' => $field_id, 'id' => $field_id)); $select->add($this->gettext('globalfirst'), self::GLOBAL_FIRST); $select->add($this->gettext('personalfirst'), self::PERSONAL_FIRST); $select->add($this->gettext('globalonly'), self::GLOBAL_ONLY); $select->add($this->gettext('personalonly'), self::PERSONAL_ONLY); $args['blocks']['main']['options']['kolab_addressbook_prio'] = array( 'title' => html::label($field_id, Q($this->gettext('addressbookprio'))), 'content' => $select->show($prio), ); } return $args; } /** * Handler for user preferences save (preferences_save hook) * * @param array $args Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function prefs_save($args) { if ($args['section'] != 'addressbook') { return $args; } // Check that configuration is not disabled $dont_override = (array) $this->rc->config->get('dont_override', array()); $key = 'kolab_addressbook_prio'; if (!in_array('kolab_addressbook_prio', $dont_override) || !isset($_POST['_'.$key])) { $args['prefs'][$key] = (int) rcube_utils::get_input_value('_'.$key, rcube_utils::INPUT_POST); } return $args; } /** * Handler for plugin actions */ public function book_actions() { $action = trim(rcube_utils::get_input_value('_act', rcube_utils::INPUT_GPC)); if ($action == 'create') { $this->ui->book_edit(); } else if ($action == 'edit') { $this->ui->book_edit(); } else if ($action == 'delete') { $this->book_delete(); } } /** * Handler for address book create/edit form submit */ public function book_save() { $prop = array( 'name' => trim(rcube_utils::get_input_value('_name', rcube_utils::INPUT_POST)), 'oldname' => trim(rcube_utils::get_input_value('_oldname', rcube_utils::INPUT_POST, true)), // UTF7-IMAP 'parent' => trim(rcube_utils::get_input_value('_parent', rcube_utils::INPUT_POST, true)), // UTF7-IMAP 'type' => 'contact', 'subscribed' => true, ); $result = $error = false; $type = strlen($prop['oldname']) ? 'update' : 'create'; $prop = $this->rc->plugins->exec_hook('addressbook_'.$type, $prop); if (!$prop['abort']) { if ($newfolder = kolab_storage::folder_update($prop)) { $folder = $newfolder; $result = true; } else { $error = kolab_storage::$last_error; } } else { $result = $prop['result']; $folder = $prop['name']; } if ($result) { $kolab_folder = kolab_storage::get_folder($folder); // get folder/addressbook properties $abook = new rcube_kolab_contacts($folder); $props = $this->abook_prop(kolab_storage::folder_id($folder, true), $abook); $props['parent'] = kolab_storage::folder_id($kolab_folder->get_parent(), true); $this->rc->output->show_message('kolab_addressbook.book'.$type.'d', 'confirmation'); $this->rc->output->command('book_update', $props, kolab_storage::folder_id($prop['oldname'], true)); $this->rc->output->send('iframe'); } if (!$error) $error = $plugin['message'] ? $plugin['message'] : 'kolab_addressbook.book'.$type.'error'; $this->rc->output->show_message($error, 'error'); // display the form again $this->ui->book_edit(); } /** * */ public function book_search() { $results = array(); $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); kolab_storage::$encode_ids = true; $search_more_results = false; $this->sources = array(); $this->folders = array(); // find unsubscribed IMAP folders that have "event" type if ($source == 'folders') { foreach ((array)kolab_storage::search_folders('contact', $query, array('other')) as $folder) { $this->folders[$folder->id] = $folder; $this->sources[$folder->id] = new rcube_kolab_contacts($folder->name); } } // 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 contact folders shared by this user foreach (kolab_storage::list_user_folders($user, 'contact', false) as $foldername) { $folders[] = new kolab_storage_folder($foldername, 'contact'); } if (count($folders)) { $userfolder = new kolab_storage_folder_user($user['kolabtargetfolder'], '', $user); $this->folders[$userfolder->id] = $userfolder; $this->sources[$userfolder->id] = $userfolder; foreach ($folders as $folder) { $this->folders[$folder->id] = $folder; $this->sources[$folder->id] = new rcube_kolab_contacts($folder->name);; $count++; } } if ($count >= $limit) { $search_more_results = true; break; } } } $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); // build results list foreach ($this->sources as $id => $source) { $folder = $this->folders[$id]; $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()); } $prop = $this->abook_prop($id, $source); $prop['parent'] = $parent_id; $html = $this->addressbook_list_item($id, $prop, $jsdata, true); unset($prop['group']); $prop += (array)$jsdata[$id]; $prop['html'] = $html; $results[] = $prop; } // report more results available if ($search_more_results) { $this->rc->output->show_message('autocompletemore', 'info'); } $this->rc->output->command('multi_thread_http_response', $results, rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC)); } /** * */ public function book_subscribe() { $success = false; $id = rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC); if ($id && ($folder = kolab_storage::get_folder(kolab_storage::id_decode($id)))) { if (isset($_POST['_permanent'])) $success |= $folder->subscribe(intval($_POST['_permanent'])); if (isset($_POST['_active'])) $success |= $folder->activate(intval($_POST['_active'])); // list groups for this address book if (!empty($_POST['_groups'])) { $abook = new rcube_kolab_contacts($folder->name); foreach ((array)$abook->list_groups() as $prop) { $prop['source'] = $id; $prop['id'] = $prop['ID']; unset($prop['ID']); $this->rc->output->command('insert_contact_group', $prop); } } } if ($success) { $this->rc->output->show_message('successfullysaved', 'confirmation'); } else { $this->rc->output->show_message($this->gettext('errorsaving'), 'error'); } $this->rc->output->send(); } /** * Handler for address book delete action (AJAX) */ private function book_delete() { $folder = trim(rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC, true, 'UTF7-IMAP')); if (kolab_storage::folder_delete($folder)) { $storage = $this->rc->get_storage(); $delimiter = $storage->get_hierarchy_delimiter(); $this->rc->output->show_message('kolab_addressbook.bookdeleted', 'confirmation'); $this->rc->output->set_env('pagecount', 0); $this->rc->output->command('set_rowcount', rcmail_get_rowcount_text(new rcube_result_set())); $this->rc->output->command('set_env', 'delimiter', $delimiter); $this->rc->output->command('list_contacts_clear'); $this->rc->output->command('book_delete_done', kolab_storage::folder_id($folder, true)); } else { $this->rc->output->show_message('kolab_addressbook.bookdeleteerror', 'error'); } $this->rc->output->send(); } /** * Returns value of kolab_addressbook_prio setting */ private function addressbook_prio() { // Load configuration if (!$this->config_loaded) { $this->load_config(); $this->config_loaded = true; } $abook_prio = (int) $this->rc->config->get('kolab_addressbook_prio'); // Make sure any global addressbooks are defined if ($abook_prio == 0 || $abook_prio == 2) { $ldap_public = $this->rc->config->get('ldap_public'); $abook_type = $this->rc->config->get('address_book_type'); if (empty($ldap_public) || $abook_type != 'ldap') { $abook_prio = 1; } } return $abook_prio; } /** * Hook for (contact) folder deletion */ function prefs_folder_delete($args) { // ignore... if ($args['abort'] && !$args['result']) { return $args; } $this->_contact_folder_rename($args['name'], false); } /** * Hook for (contact) folder renaming */ function prefs_folder_rename($args) { // ignore... if ($args['abort'] && !$args['result']) { return $args; } $this->_contact_folder_rename($args['oldname'], $args['newname']); } /** * Hook for (contact) folder updates. Forward to folder_rename handler if name was changed */ function prefs_folder_update($args) { // ignore... if ($args['abort'] && !$args['result']) { return $args; } if ($args['record']['name'] != $args['record']['oldname']) { $this->_contact_folder_rename($args['record']['oldname'], $args['record']['name']); } } /** * Apply folder renaming or deletion to the registered birthday calendar address books */ private function _contact_folder_rename($oldname, $newname = false) { $update = false; $delimiter = $this->rc->get_storage()->get_hierarchy_delimiter(); $bday_addressbooks = (array)$this->rc->config->get('calendar_birthday_adressbooks', array()); foreach ($bday_addressbooks as $i => $id) { $folder_name = kolab_storage::id_decode($id); if ($oldname === $folder_name || strpos($folder_name, $oldname.$delimiter) === 0) { if ($newname) { // rename $new_folder = $newname . substr($folder_name, strlen($oldname)); $bday_addressbooks[$i] = kolab_storage::id_encode($new_folder); } else { // delete unset($bday_addressbooks[$i]); } $update = true; } } if ($update) { $this->rc->user->save_prefs(array('calendar_birthday_adressbooks' => $bday_addressbooks)); } } } diff --git a/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php b/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php index 55fa6dd6..38ce9456 100644 --- a/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php +++ b/plugins/kolab_addressbook/lib/kolab_addressbook_ui.php @@ -1,346 +1,340 @@ * * 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'); } // Add actions on address books $options = array('book-create', 'book-edit', 'book-delete', 'book-remove'); $idx = 0; if ($this->rc->config->get('kolab_addressbook_carddav_url')) { $options[] = 'book-showurl'; $this->rc->output->set_env('kolab_addressbook_carddav_url', true); } 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, 'classact' => 'active', 'command' => $command ))); $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', 'classact' => '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', 'resetsearch'); if ($this->plugin->bonnie_api) { $this->rc->output->set_env('kolab_audit_trail', true); $this->plugin->api->include_script('libkolab/js/audittrail.js'); $this->rc->output->add_label( 'kolab_addressbook.showhistory', - 'kolab_addressbook.compare', 'kolab_addressbook.objectchangelog', 'kolab_addressbook.objectdiff', - 'kolab_addressbook.showrevision', - 'kolab_addressbook.actionappend', - 'kolab_addressbook.actionmove', - 'kolab_addressbook.actiondelete', 'kolab_addressbook.objectdiffnotavailable', 'kolab_addressbook.objectchangelognotavailable', - 'kolab_addressbook.revisionrestoreconfirm', - 'close' + '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'); $this->rc->output->add_label('kolab_addressbook.showhistory'); } // book create/edit form else { $this->rc->output->add_label('kolab_addressbook.nobooknamewarning', 'kolab_addressbook.booksaving'); } } /** * Handler for address book create/edit action */ public function book_edit() { $this->rc->output->add_handler('bookdetails', array($this, 'book_form')); $this->rc->output->send('kolab_addressbook.bookedit'); } /** * 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, RCMAIL_CHARSET, 'UTF7-IMAP'); $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); if ($this->rc->action == 'plugin.book-save') { // save error $name = trim(rcube_utils::get_input_value('_name', rcube_utils::INPUT_GPC, true)); // UTF8 $old = trim(rcube_utils::get_input_value('_oldname', rcube_utils::INPUT_GPC, true)); // UTF7-IMAP $path_imap = trim(rcube_utils::get_input_value('_parent', rcube_utils::INPUT_GPC, true)); // UTF7-IMAP $hidden_fields[] = array('name' => '_oldname', 'value' => $old); $folder = $old; } else 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['props'] = array( 'name' => $this->rc->gettext('properties'), ); if (!empty($options) && ($options['norename'] || $options['protected'])) { $foldername = 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['props']['fieldsets']['location'] = array( 'name' => $this->rc->gettext('location'), 'content' => array( 'name' => array( 'label' => $this->plugin->gettext('bookname'), 'value' => $foldername, ), ), ); 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('contact', array('name' => '_parent'), $folder); $form['props']['fieldsets']['location']['content']['path'] = array( 'label' => $this->plugin->gettext('parentbook'), 'value' => $select->show(strlen($folder) ? $path_imap : ''), ); } // 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']; // Set form tags and hidden fields list($form_start, $form_end) = $this->get_form_tags($attrib, 'plugin.book-save', null, $hidden_fields); unset($attrib['form']); // return the complete edit form as table $out = "$form_start\n"; // Create form output foreach ($form as $tab) { if (!empty($tab['fieldsets']) && is_array($tab['fieldsets'])) { $content = ''; foreach ($tab['fieldsets'] as $fieldset) { $subcontent = $this->get_form_part($fieldset); if ($subcontent) { $content .= html::tag('fieldset', null, html::tag('legend', null, Q($fieldset['name'])) . $subcontent) ."\n"; } } } else { $content = $this->get_form_part($tab); } if ($content) { $out .= html::tag('fieldset', null, html::tag('legend', null, Q($tab['name'])) . $content) ."\n"; } } $out .= "\n$form_end"; return $out; } /** * */ 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'] : rcube_label($col); $table->add('title', sprintf('', $colprop['id'], 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_addressbook/localization/en_US.inc b/plugins/kolab_addressbook/localization/en_US.inc index 2547de2a..885d75d3 100644 --- a/plugins/kolab_addressbook/localization/en_US.inc +++ b/plugins/kolab_addressbook/localization/en_US.inc @@ -1,83 +1,77 @@ CardDAV client application to fully synchronize this specific address book with your computer or mobile device.'; $labels['addressbookprio'] = 'Address book(s) selection/behaviour'; $labels['personalfirst'] = 'Personal address book(s) first'; $labels['globalfirst'] = 'Global address book(s) first'; $labels['personalonly'] = 'Personal address book(s) only'; $labels['globalonly'] = 'Global address book(s) only'; $labels['findaddressbooks'] = 'Find address books'; $labels['searchterms'] = 'Search terms'; $labels['listsearchresults'] = 'Additional address books'; $labels['foldersearchform'] = 'Address book search form'; $labels['foldersubscribe'] = 'List permanently'; $labels['nraddressbooksfound'] = '$nr address books found'; $labels['noaddressbooksfound'] = 'No address books found'; // history dialog $labels['showhistory'] = 'Show History'; -$labels['compare'] = 'Compare'; $labels['objectchangelog'] = 'Change History'; $labels['objectdiff'] = 'Changes from $rev1 to $rev2'; -$labels['actionappend'] = 'Saved'; -$labels['actionmove'] = 'Moved'; -$labels['actiondelete'] = 'Deleted'; -$labels['showrevision'] = 'Show this version'; -$labels['restore'] = 'Restore this version'; $labels['revisionrestoreconfirm'] = 'Do you really want to restore revision $rev of this contact? This will replace the current contact with the old version.'; $labels['objectnotfound'] = 'Failed to load contact data'; $labels['objectchangelognotavailable'] = 'Change history is not available for this contact'; $labels['objectdiffnotavailable'] = 'No comparison possible for the selected revisions'; $labels['objectrestoresuccess'] = 'Revision $rev successfully restored'; $labels['objectrestoreerror'] = 'Failed to restore the old revision'; $messages['bookdeleteconfirm'] = 'Do you really want to delete the selected address book and all contacts in it?'; $messages['bookdeleting'] = 'Deleting address book...'; $messages['booksaving'] = 'Saving address book...'; $messages['bookdeleted'] = 'Address book deleted successfully.'; $messages['bookupdated'] = 'Address book updated successfully.'; $messages['bookcreated'] = 'Address book created successfully.'; $messages['bookdeleteerror'] = 'An error occured while deleting address book.'; $messages['bookupdateerror'] = 'An error occured while updating address book.'; $messages['bookcreateerror'] = 'An error occured while creating address book.'; $messages['nobooknamewarning'] = 'Please, enter address book name.'; $messages['noemailnamewarning'] = 'Please, enter email address or contact name.'; ?> diff --git a/plugins/kolab_addressbook/skins/larry/templates/audittrail.html b/plugins/kolab_addressbook/skins/larry/templates/audittrail.html index 9411b03a..ad28febc 100644 --- a/plugins/kolab_addressbook/skins/larry/templates/audittrail.html +++ b/plugins/kolab_addressbook/skins/larry/templates/audittrail.html @@ -1,144 +1,144 @@ \ No newline at end of file diff --git a/plugins/kolab_notes/kolab_notes.php b/plugins/kolab_notes/kolab_notes.php index a0da1201..6d17e197 100644 --- a/plugins/kolab_notes/kolab_notes.php +++ b/plugins/kolab_notes/kolab_notes.php @@ -1,1427 +1,1426 @@ * * Copyright (C) 2014-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_notes extends rcube_plugin { public $task = '?(?!login|logout).*'; public $allowed_prefs = array('kolab_notes_sort_col'); public $rc; private $ui; private $lists; private $folders; private $cache = array(); private $message_notes = array(); private $bonnie_api = false; /** * Required startup method of a Roundcube plugin */ public function init() { $this->require_plugin('libkolab'); $this->rc = rcube::get_instance(); $this->register_task('notes'); // load plugin configuration $this->load_config(); // proceed initialization in startup hook $this->add_hook('startup', array($this, 'startup')); } /** * Startup hook */ public function startup($args) { // the notes module can be enabled/disabled by the kolab_auth plugin if ($this->rc->config->get('kolab_notes_disabled', false) || !$this->rc->config->get('kolab_notes_enabled', true)) { return; } // load localizations $this->add_texts('localization/', $args['task'] == 'notes' && (!$args['action'] || $args['action'] == 'dialog-ui')); $this->rc->load_language($_SESSION['language'], array('notes.notes' => $this->gettext('navtitle'))); // add label for task title if ($args['task'] == 'notes') { $this->add_hook('storage_init', array($this, 'storage_init')); // register task actions $this->register_action('index', array($this, 'notes_view')); $this->register_action('fetch', array($this, 'notes_fetch')); $this->register_action('get', array($this, 'note_record')); $this->register_action('action', array($this, 'note_action')); $this->register_action('list', array($this, 'list_action')); $this->register_action('dialog-ui', array($this, 'dialog_view')); } else if ($args['task'] == 'mail') { $this->add_hook('storage_init', array($this, 'storage_init')); $this->add_hook('message_compose', array($this, 'mail_message_compose')); if (in_array($args['action'], array('show', 'preview', 'print'))) { $this->add_hook('message_load', array($this, 'mail_message_load')); $this->add_hook('template_object_messagebody', array($this, 'mail_messagebody_html')); } // add 'Append note' item to message menu if ($this->api->output->type == 'html' && $_REQUEST['_rel'] != 'note') { $this->api->add_content(html::tag('li', null, $this->api->output->button(array( 'command' => 'append-kolab-note', 'label' => 'kolab_notes.appendnote', 'type' => 'link', 'classact' => 'icon appendnote active', 'class' => 'icon appendnote', 'innerclass' => 'icon note', ))), 'messagemenu'); $this->api->output->add_label('kolab_notes.appendnote', 'kolab_notes.editnote', 'kolab_notes.deletenotesconfirm', 'kolab_notes.entertitle', 'save', 'delete', 'cancel', 'close'); $this->include_script('notes_mail.js'); } } if (!$this->rc->output->ajax_call && (!$this->rc->output->env['framed'] || in_array($args['action'], array('folder-acl','dialog-ui')))) { $this->load_ui(); } // get configuration for the Bonnie API - if ($bonnie_config = $this->rc->config->get('kolab_bonnie_api', false)) { - $this->bonnie_api = new kolab_bonnie_api($bonnie_config); - } + $this->bonnie_api = libkolab::get_bonnie_api(); // notes use fully encoded identifiers kolab_storage::$encode_ids = true; } /** * Hook into IMAP FETCH HEADER.FIELDS command and request MESSAGE-ID */ public function storage_init($p) { $p['fetch_headers'] = trim($p['fetch_headers'] . ' MESSAGE-ID'); return $p; } /** * Load and initialize UI class */ private function load_ui() { require_once($this->home . '/kolab_notes_ui.php'); $this->ui = new kolab_notes_ui($this); $this->ui->init(); } /** * 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('note')); $this->lists = $this->folders = array(); // find default folder $default_index = 0; foreach ($folders as $i => $folder) { if ($folder->default) $default_index = $i; } // put default folder on top of the list if ($default_index > 0) { $default_folder = $folders[$default_index]; unset($folders[$default_index]); array_unshift($folders, $default_folder); } foreach ($folders as $folder) { $item = $this->folder_props($folder); $this->lists[$item['id']] = $item; $this->folders[$item['id']] = $folder; $this->folders[$folder->name] = $folder; } } /** * Get a list of available folders from this source */ public function get_lists(&$tree = null) { $this->_read_lists(); // attempt to create a default folder for this user if (empty($this->lists)) { $folder = array('name' => 'Notes', 'type' => 'note', 'default' => true, 'subscribed' => true); if (kolab_storage::folder_update($folder)) { $this->_read_lists(true); } } $folders = array(); foreach ($this->lists as $id => $list) { if (!empty($this->folders[$id])) { $folders[] = $this->folders[$id]; } } // 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(); $lists = array(); foreach ($folders as $folder) { $list_id = $folder->id; $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' => kolab_storage::object_name($fullname), 'listname' => $listname, 'virtual' => true, 'editable' => false, 'rights' => 'l', 'group' => $folder->get_namespace(), 'parent' => $parent_id, ); } else { if (!$this->lists[$list_id]) { $this->lists[$list_id] = $this->folder_props($folder); $this->folders[$list_id] = $folder; } $this->lists[$list_id]['parent'] = $parent_id; $lists[$list_id] = $this->lists[$list_id]; } } return $lists; } /** * Search for shared or otherwise not listed folders the user has access * * @param string Search string * @param string Section/source to search * @return array List of notes folders */ protected 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('note', $query, array('other')) as $folder) { $this->folders[$folder->id] = $folder; $this->lists[$folder->id] = $this->folder_props($folder); } } // 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 note folders shared by this user foreach (kolab_storage::list_user_folders($user, 'note', false) as $foldername) { $folders[] = new kolab_storage_folder($foldername, 'note'); } 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); foreach ($folders as $folder) { $this->folders[$folder->id] = $folder; $this->lists[$folder->id] = $this->folder_props($folder); $count++; } } if ($count >= $limit) { $this->search_more_results = true; break; } } } return $this->get_lists(); } /** * Derive list properties from the given kolab_storage_folder object */ protected function folder_props($folder) { 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()) && !PEAR::isError($myrights)) { $rights = $myrights; if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) $editable = strpos($rights, 'i'); } $info = $folder->get_folder_info(); $norename = $readonly || $info['norename'] || $info['protected']; } $list_id = $folder->id; return array( 'id' => $list_id, 'name' => $folder->get_name(), 'listname' => $folder->get_foldername(), 'editname' => $folder->get_foldername(), 'editable' => $editable, 'rights' => $rights, 'norename' => $norename, 'parentfolder' => $folder->get_parent(), 'subscribed' => (bool)$folder->is_subscribed(), 'default' => $folder->default, 'group' => $folder->default ? 'default' : $folder->get_namespace(), 'class' => trim($folder->get_namespace() . ($folder->default ? ' default' : '')), ); } /** * 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 */ public function get_folder($id) { // 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); } } return $this->folders[$id]; } /******* UI functions ********/ /** * Render main view of the tasklist task */ public function notes_view() { $this->ui->init(); $this->ui->init_templates(); $this->rc->output->set_pagetitle($this->gettext('navtitle')); $this->rc->output->send('kolab_notes.notes'); } /** * Deliver a rediced UI for inline (dialog) */ public function dialog_view() { // resolve message reference if ($msgref = rcube_utils::get_input_value('_msg', rcube_utils::INPUT_GPC, true)) { $storage = $this->rc->get_storage(); list($uid, $folder) = explode('-', $msgref, 2); if ($message = $storage->get_message_headers($msgref)) { $this->rc->output->set_env('kolab_notes_template', array( '_from_mail' => true, 'title' => $message->get('subject'), 'links' => array(kolab_storage_config::get_message_reference( kolab_storage_config::get_message_uri($message, $folder), 'note' )), )); } } $this->ui->init_templates(); $this->rc->output->send('kolab_notes.dialogview'); } /** * Handler to retrieve note records for the given list and/or search query */ public function notes_fetch() { $search = rcube_utils::get_input_value('_q', rcube_utils::INPUT_GPC, true); $list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC); $data = $this->notes_data($this->list_notes($list, $search), $tags); $this->rc->output->command('plugin.data_ready', array( 'list' => $list, 'search' => $search, 'data' => $data, 'tags' => array_values($tags) )); } /** * Convert the given note records for delivery to the client */ protected function notes_data($records, &$tags) { $config = kolab_storage_config::get_instance(); $tags = $config->apply_tags($records); foreach ($records as $i => $rec) { unset($records[$i]['description']); $this->_client_encode($records[$i]); } return $records; } /** * Read note records for the given list from the storage backend */ protected function list_notes($list_id, $search = null) { $results = array(); // query Kolab storage $query = array(); // full text search (only works with cache enabled) if (strlen($search)) { $words = array_filter(rcube_utils::normalize_string(mb_strtolower($search), true)); foreach ($words as $word) { if (strlen($word) > 2) { // only words > 3 chars are stored in DB $query[] = array('words', '~', $word); } } } $this->_read_lists(); if ($folder = $this->get_folder($list_id)) { foreach ($folder->select($query) as $record) { // post-filter search results if (strlen($search)) { $matches = 0; $contents = mb_strtolower( $record['title'] . ($this->is_html($record) ? strip_tags($record['description']) : $record['description']) ); foreach ($words as $word) { if (mb_strpos($contents, $word) !== false) { $matches++; } } // skip records not matching all search words if ($matches < count($words)) { continue; } } $record['list'] = $list_id; $results[] = $record; } } return $results; } /** * Handler for delivering a full note record to the client */ public function note_record() { $data = $this->get_note(array( 'uid' => rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC), 'list' => rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC), )); // encode for client use if (is_array($data)) { $this->_client_encode($data, true); } $this->rc->output->command('plugin.render_note', $data); } /** * Get the full note record identified by the given UID + Lolder identifier */ public function get_note($note) { if (is_array($note)) { $uid = $note['uid'] ?: $note['id']; $list_id = $note['list']; } else { $uid = $note; } // deliver from in-memory cache $key = $list_id . ':' . $uid; if ($this->cache[$key]) { return $this->cache[$key]; } $result = false; $this->_read_lists(); if ($list_id) { if ($folder = $this->get_folder($list_id)) { $result = $folder->get_object($uid); } } // iterate over all calendar folders and search for the event ID else { foreach ($this->folders as $list_id => $folder) { if ($result = $folder->get_object($uid)) { $result['list'] = $list_id; break; } } } if ($result) { // get note tags $result['tags'] = $this->get_tags($result['uid']); } return $result; } /** * Helper method to encode the given note record for use in the client */ private function _client_encode(&$note, $resolve = false) { foreach ($note as $key => $prop) { if ($key[0] == '_' || $key == 'x-custom') { unset($note[$key]); } } foreach (array('created','changed') as $key) { if (is_object($note[$key]) && $note[$key] instanceof DateTime) { $note[$key.'_'] = $note[$key]->format('U'); $note[$key] = $this->rc->format_date($note[$key]); } } // clean HTML contents if (!empty($note['description']) && $this->is_html($note)) { $note['html'] = $this->_wash_html($note['description']); } // resolve message links $note['links'] = array_map(function($link) { return kolab_storage_config::get_message_reference($link, 'note') ?: array('uri' => $link); }, $this->get_links($note['uid'])); return $note; } /** * Handler for client-initiated actions on a single note record */ public function note_action() { $action = rcube_utils::get_input_value('_do', rcube_utils::INPUT_POST); $note = rcube_utils::get_input_value('_data', rcube_utils::INPUT_POST, true); $success = $silent = false; switch ($action) { case 'new': $temp_id = $rec['tempid']; case 'edit': if ($success = $this->save_note($note)) { $refresh = $this->get_note($note); $refresh['tempid'] = $temp_id; } break; case 'move': $uids = explode(',', $note['uid']); foreach ($uids as $uid) { $note['uid'] = $uid; if (!($success = $this->move_note($note, $note['to']))) { $refresh = $this->get_note($note); break; } } break; case 'delete': $uids = explode(',', $note['uid']); foreach ($uids as $uid) { $note['uid'] = $uid; if (!($success = $this->delete_note($note))) { $refresh = $this->get_note($note); break; } } break; case 'changelog': $data = $this->get_changelog($note); if (is_array($data) && !empty($data)) { - $dtformat = $this->rc->config->get('date_format') . ' ' . $this->rc->config->get('time_format'); - array_walk($data, function(&$change) use ($lib, $dtformat) { + $rcmail = $this->rc; + $dtformat = $rcmail->config->get('date_format') . ' ' . $this->rc->config->get('time_format'); + array_walk($data, function(&$change) use ($lib, $rcmail, $dtformat) { if ($change['date']) { $dt = rcube_utils::anytodatetime($change['date']); if ($dt instanceof DateTime) { - $change['date'] = $this->rc->format_date($dt, $dtformat); + $change['date'] = $rcmail->format_date($dt, $dtformat); } } }); $this->rc->output->command('plugin.note_render_changelog', $data); } else { $this->rc->output->command('plugin.note_render_changelog', false); } $silent = true; break; case 'diff': $silent = true; $data = $this->get_diff($note, $note['rev1'], $note['rev2']); if (is_array($data)) { $this->rc->output->command('plugin.note_show_diff', $data); } else { $this->rc->output->command('display_message', $this->gettext('objectdiffnotavailable'), 'error'); } break; case 'show': if ($rec = $this->get_revison($note, $note['rev'])) { $this->rc->output->command('plugin.note_show_revision', $this->_client_encode($rec)); } else { $this->rc->output->command('display_message', $this->gettext('objectnotfound'), 'error'); } $silent = true; break; case 'restore': if ($this->restore_revision($note, $note['rev'])) { $refresh = $this->get_note($note); $this->rc->output->command('display_message', $this->gettext(array('name' => 'objectrestoresuccess', 'vars' => array('rev' => $note['rev']))), 'confirmation'); $this->rc->output->command('plugin.close_history_dialog'); } else { $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error'); } $silent = true; break; } // show confirmation/error message if ($success) { $this->rc->output->show_message('successfullysaved', 'confirmation'); } else if (!$silent) { $this->rc->output->show_message('errorsaving', 'error'); } // unlock client $this->rc->output->command('plugin.unlock_saving'); if ($refresh) { $this->rc->output->command('plugin.update_note', $this->_client_encode($refresh)); } } /** * Update an note record with the given data * * @param array Hash array with note properties (id, list) * @return boolean True on success, False on error */ private function save_note(&$note) { $this->_read_lists(); $list_id = $note['list']; if (!$list_id || !($folder = $this->get_folder($list_id))) return false; // moved from another folder if ($note['_fromlist'] && ($fromfolder = $this->get_folder($note['_fromlist']))) { if (!$fromfolder->move($note['uid'], $folder->name)) return false; unset($note['_fromlist']); } // load previous version of this record to merge if ($note['uid']) { $old = $folder->get_object($note['uid']); if (!$old || PEAR::isError($old)) return false; // merge existing properties if the update isn't complete if (!isset($note['title']) || !isset($note['description'])) $note += $old; } // generate new note object from input $object = $this->_write_preprocess($note, $old); // email links and tags are handled separately $links = $object['links']; $tags = $object['tags']; unset($object['links']); unset($object['tags']); $saved = $folder->save($object, 'note', $note['uid']); if (!$saved) { raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving note 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); $note = $object; $note['list'] = $list_id; $note['tags'] = (array) $tags; // cache this in memory for later read $key = $list_id . ':' . $note['uid']; $this->cache[$key] = $note; } return $saved; } /** * Move the given note to another folder */ function move_note($note, $list_id) { $this->_read_lists(); $tofolder = $this->get_folder($list_id); $fromfolder = $this->get_folder($note['list']); if ($fromfolder && $tofolder) { return $fromfolder->move($note['uid'], $tofolder->name); } return false; } /** * Remove a single note record from the backend * * @param array Hash array with note properties (id, list) * @param boolean Remove record irreversible (mark as deleted otherwise) * @return boolean True on success, False on error */ public function delete_note($note, $force = true) { $this->_read_lists(); $list_id = $note['list']; if (!$list_id || !($folder = $this->get_folder($list_id))) { return false; } $status = $folder->delete($note['uid'], $force); if ($status) { $this->save_links($note['uid'], null); $this->save_tags($note['uid'], null); } return $status; } /** * Provide a list of revisions for the given object * * @param array $note Hash array with note properties * @return array List of changes, each as a hash array */ public function get_changelog($note) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_note_identity($note); $result = $uid && $mailbox ? $this->bonnie_api->changelog('note', $uid, $mailbox, $msguid) : null; if (is_array($result) && $result['uid'] == $uid) { return $result['changes']; } return false; } /** * Return full data of a specific revision of a note record * * @param mixed $note UID string or hash array with note properties * @param mixed $rev Revision number * * @return array Note object as hash array */ public function get_revison($note, $rev) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_note_identity($note); // call Bonnie API $result = $this->bonnie_api->get('note', $uid, $rev, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { $format = kolab_format::factory('note'); $format->load($result['xml']); $rec = $format->to_array(); if ($format->is_valid()) { $rec['rev'] = $result['rev']; return $rec; } } return false; } /** * Get a list of property changes beteen two revisions of a note object * * @param array $$note Hash array with note properties * @param mixed $rev Revisions: "from:to" * * @return array List of property changes, each as a hash array */ public function get_diff($note, $rev1, $rev2) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_note_identity($note); // call Bonnie API $result = $this->bonnie_api->diff('note', $uid, $rev1, $rev2, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid) { $result['rev1'] = $rev1; $result['rev2'] = $rev2; // convert some properties, similar to self::_client_encode() $keymap = array( 'summary' => 'title', 'lastmodified-date' => 'changed', ); // map kolab object properties to keys and values the client expects array_walk($result['changes'], function(&$change, $i) use ($keymap) { if (array_key_exists($change['property'], $keymap)) { $change['property'] = $keymap[$change['property']]; } if ($change['property'] == 'created' || $change['property'] == 'changed') { if ($old_ = rcube_utils::anytodatetime($change['old'])) { $change['old_'] = $this->rc->format_date($old_); } if ($new_ = rcube_utils::anytodatetime($change['new'])) { $change['new_'] = $this->rc->format_date($new_); } } // compute a nice diff of note contents if ($change['property'] == 'description') { $change['diff_'] = libkolab::html_diff($change['old'], $change['new']); if (!empty($change['diff_'])) { unset($change['old'], $change['new']); $change['diff_'] = preg_replace(array('!^.*]*>!Uims','!.*$!Uims'), '', $change['diff_']); $change['diff_'] = preg_replace("!\n!", '', $change['diff_']); } } }); return $result; } return false; } /** * Command the backend to restore a certain revision of a note. * This shall replace the current object with an older version. * * @param array $note Hash array with note properties (id, list) * @param mixed $rev Revision number * * @return boolean True on success, False on failure */ public function restore_revision($note, $rev) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_note_identity($note); $folder = $this->get_folder($note['list']); $success = false; if ($folder && ($raw_msg = $this->bonnie_api->rawdata('note', $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); $this->cache = array(); } } return $success; } /** * Helper method to resolved the given note identifier into uid and mailbox * * @return array (uid,mailbox,msguid) tuple */ private function _resolve_note_identity($note) { $mailbox = $msguid = null; if (!is_array($note)) { $note = $this->get_note($note); } if (is_array($note)) { $uid = $note['uid'] ?: $note['id']; $list = $note['list']; } else { return array(null, $mailbox, $msguid); } if ($folder = $this->get_folder($list)) { $mailbox = $folder->get_mailbox_id(); // get 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); } /** * Handler for client requests to list (aka folder) actions */ public function list_action() { $action = rcube_utils::get_input_value('_do', rcube_utils::INPUT_GPC); $list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC, true); $success = $update_cmd = false; if (empty($action)) { $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); } switch ($action) { case 'form-new': case 'form-edit': $this->_read_lists(); echo $this->ui->list_editform($action, $this->lists[$list['id']], $this->folders[$list['id']]); exit; case 'new': $list['type'] = 'note'; $list['subscribed'] = true; $folder = kolab_storage::folder_update($list); if ($folder === false) { $save_error = $this->gettext(kolab_storage::$last_error); } else { $success = true; $update_cmd = 'plugin.update_list'; $list['id'] = kolab_storage::folder_id($folder); $list['_reload'] = true; } break; case 'edit': $this->_read_lists(); $oldparent = $this->lists[$list['id']]['parentfolder']; $newfolder = kolab_storage::folder_update($list); if ($newfolder === false) { $save_error = $this->gettext(kolab_storage::$last_error); } else { $success = true; $update_cmd = 'plugin.update_list'; $list['newid'] = kolab_storage::folder_id($newfolder); $list['_reload'] = $list['parent'] != $oldparent; // compose the new display name $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); $path_imap = explode($delim, $newfolder); $list['name'] = kolab_storage::object_name($newfolder); $list['editname'] = rcube_charset::convert(array_pop($path_imap), 'UTF7-IMAP'); $list['listname'] = str_repeat('   ', count($path_imap)) . '» ' . $list['editname']; } break; case 'delete': $this->_read_lists(); $folder = $this->get_folder($list['id']); if ($folder && kolab_storage::folder_delete($folder->name)) { $success = true; $update_cmd = 'plugin.destroy_list'; } else { $save_error = $this->gettext(kolab_storage::$last_error); } break; case 'search': $this->load_ui(); $results = array(); foreach ((array)$this->search_lists(rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC), rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC)) as $id => $prop) { $editname = $prop['editname']; unset($prop['editname']); // force full name to be displayed // let the UI generate HTML and CSS representation for this calendar $html = $this->ui->folder_list_item($id, $prop, $jsenv, true); $prop += (array)$jsenv[$id]; $prop['editname'] = $editname; $prop['html'] = $html; $results[] = $prop; } // report more results available if ($this->driver->search_more_results) { $this->rc->output->show_message('autocompletemore', 'info'); } $this->rc->output->command('multi_thread_http_response', $results, rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC)); return; case 'subscribe': $success = false; if ($list['id'] && ($folder = $this->get_folder($list['id']))) { if (isset($list['permanent'])) $success |= $folder->subscribe(intval($list['permanent'])); if (isset($list['active'])) $success |= $folder->activate(intval($list['active'])); // apply to child folders, too if ($list['recursive']) { foreach ((array)kolab_storage::list_folders($folder->name, '*', 'node') as $subfolder) { if (isset($list['permanent'])) ($list['permanent'] ? kolab_storage::folder_subscribe($subfolder) : kolab_storage::folder_unsubscribe($subfolder)); if (isset($list['active'])) ($list['active'] ? kolab_storage::folder_activate($subfolder) : kolab_storage::folder_deactivate($subfolder)); } } } break; } $this->rc->output->command('plugin.unlock_saving'); if ($success) { $this->rc->output->show_message('successfullysaved', 'confirmation'); if ($update_cmd) { $this->rc->output->command($update_cmd, $list); } } else { $error_msg = $this->gettext('errorsaving') . ($save_error ? ': ' . $save_error :''); $this->rc->output->show_message($error_msg, 'error'); } } /** * Hook to add note attachments to message compose if the according parameter is present. * This completes the 'send note by mail' feature. */ public function mail_message_compose($args) { if (!empty($args['param']['with_notes'])) { $uids = explode(',', $args['param']['with_notes']); $list = $args['param']['notes_list']; foreach ($uids as $uid) { if ($note = $this->get_note(array('uid' => $uid, 'list' => $list))) { $args['attachments'][] = array( 'name' => abbreviate_string($note['title'], 50, ''), 'mimetype' => 'message/rfc822', 'data' => $this->note2message($note), ); if (empty($args['param']['subject'])) { $args['param']['subject'] = $note['title']; } } } unset($args['param']['with_notes'], $args['param']['notes_list']); } return $args; } /** * Lookup backend storage and find notes associated with the given message */ public function mail_message_load($p) { if (!$p['object']->headers->others['x-kolab-type']) { $this->message_notes = $this->get_message_notes($p['object']->headers, $p['object']->folder); } } /** * Handler for 'messagebody_html' hook */ public function mail_messagebody_html($args) { $html = ''; foreach ($this->message_notes as $note) { $html .= html::a(array( 'href' => $this->rc->url(array('task' => 'notes', '_list' => $note['list'], '_id' => $note['uid'])), 'class' => 'kolabnotesref', 'rel' => $note['uid'] . '@' . $note['list'], 'target' => '_blank', ), Q($note['title'])); } // prepend note links to message body if ($html) { $this->load_ui(); $args['content'] = html::div('kolabmessagenotes', $html) . $args['content']; } return $args; } /** * Determine whether the given note is HTML formatted */ private function is_html($note) { // check for opening and closing or tags return (preg_match('/<(html|body)(\s+[a-z]|>)/', $note['description'], $m) && strpos($note['description'], '') > 0); } /** * Build an RFC 822 message from the given note */ private function note2message($note) { $message = new Mail_mime("\r\n"); $message->setParam('text_encoding', '8bit'); $message->setParam('html_encoding', 'quoted-printable'); $message->setParam('head_encoding', 'quoted-printable'); $message->setParam('head_charset', RCUBE_CHARSET); $message->setParam('html_charset', RCUBE_CHARSET); $message->setParam('text_charset', RCUBE_CHARSET); $message->headers(array( 'Subject' => $note['title'], 'Date' => $note['changed']->format('r'), )); if ($this->is_html($note)) { $message->setHTMLBody($note['description']); // add a plain text version of the note content as an alternative part. $h2t = new rcube_html2text($note['description'], false, true, 0, RCUBE_CHARSET); $plain_part = rcube_mime::wordwrap($h2t->get_text(), $this->rc->config->get('line_length', 72), "\r\n", false, RCUBE_CHARSET); $plain_part = trim(wordwrap($plain_part, 998, "\r\n", true)); // make sure all line endings are CRLF $plain_part = preg_replace('/\r?\n/', "\r\n", $plain_part); $message->setTXTBody($plain_part); } else { $message->setTXTBody($note['description']); } return $message->getMessage(); } private function save_links($uid, $links) { if (empty($links)) { $links = array(); } $config = kolab_storage_config::get_instance(); $remove = array_diff($config->get_object_links($uid), $links); return $config->save_object_links($uid, $links, $remove); } /** * Find messages assigned to specified note */ private function get_links($uid) { $config = kolab_storage_config::get_instance(); return $config->get_object_links($uid); } /** * Get note tags */ private function get_tags($uid) { $config = kolab_storage_config::get_instance(); $tags = $config->get_tags($uid); $tags = array_map(function($v) { return $v['name']; }, $tags); return $tags; } /** * Find notes assigned to specified message */ private function get_message_notes($message, $folder) { $config = kolab_storage_config::get_instance(); $result = $config->get_message_relations($message, $folder, 'note'); foreach ($result as $idx => $note) { $result[$idx]['list'] = kolab_storage::folder_id($note['_mailbox']); } return $result; } /** * Update note tags */ private function save_tags($uid, $tags) { $config = kolab_storage_config::get_instance(); $config->save_tags($uid, $tags); } /** * Process the given note data (submitted by the client) before saving it */ private function _write_preprocess($note, $old = array()) { $object = $note; // TODO: handle attachments // convert link references into simple URIs if (array_key_exists('links', $note)) { $object['links'] = array_map(function($link){ return is_array($link) ? $link['uri'] : strval($link); }, $note['links']); } else { $object['links'] = $old['links']; } // clean up HTML content $object['description'] = $this->_wash_html($note['description']); $is_html = true; // try to be smart and convert to plain-text if no real formatting is detected if (preg_match('!<(?:p|pre)>(.*)!Uims', $object['description'], $m)) { if (!preg_match('!<(a|b|i|strong|em|p|span|div|pre|li)(\s+[a-z]|>)!im', $m[1], $n) || !strpos($m[1], '')) { // $converter = new rcube_html2text($m[1], false, true, 0); // $object['description'] = rtrim($converter->get_text()); $object['description'] = html_entity_decode(preg_replace('!!', "\n", $m[1])); $is_html = false; } } // Add proper HTML header, otherwise Kontact renders it as plain text if ($is_html) { $object['description'] = ''."\n" . str_replace('', '', $object['description']); } // copy meta data (starting with _) from old object foreach ((array)$old as $key => $val) { if (!isset($object[$key]) && $key[0] == '_') $object[$key] = $val; } // make list of categories unique if (is_array($object['tags'])) { $object['tags'] = array_unique(array_filter($object['tags'])); } unset($object['list'], $object['tempid'], $object['created'], $object['changed'], $object['created_'], $object['changed_']); return $object; } /** * Sanity checks/cleanups HTML content */ private function _wash_html($html) { // Add header with charset spec., washtml cannot work without that $html = '' . '' . '' . $html . ''; // clean HTML with washtml by Frederic Motte $wash_opts = array( 'show_washed' => false, 'allow_remote' => 1, 'charset' => RCUBE_CHARSET, 'html_elements' => array('html', 'head', 'meta', 'body', 'link'), 'html_attribs' => array('rel', 'type', 'name', 'http-equiv'), ); // initialize HTML washer $washer = new rcube_washtml($wash_opts); $washer->add_callback('form', array($this, '_washtml_callback')); $washer->add_callback('a', array($this, '_washtml_callback')); // Remove non-UTF8 characters $html = rcube_charset::clean($html); $html = $washer->wash($html); // remove unwanted comments (produced by washtml) $html = preg_replace('//', '', $html); return $html; } /** * Callback function for washtml cleaning class */ public function _washtml_callback($tagname, $attrib, $content, $washtml) { switch ($tagname) { case 'form': $out = html::div('form', $content); break; case 'a': // strip temporary link tags from plain-text markup $attrib = html::parse_attrib_string($attrib); if (!empty($attrib['class']) && strpos($attrib['class'], 'x-templink') !== false) { // remove link entirely if (strpos($attrib['href'], html_entity_decode($content)) !== false) { $out = $content; break; } $attrib['class'] = trim(str_replace('x-templink', '', $attrib['class'])); } $out = html::a($attrib, $content); break; default: $out = ''; } return $out; } } diff --git a/plugins/kolab_notes/localization/en_US.inc b/plugins/kolab_notes/localization/en_US.inc index 1c1eed07..9bd46c5d 100644 --- a/plugins/kolab_notes/localization/en_US.inc +++ b/plugins/kolab_notes/localization/en_US.inc @@ -1,85 +1,79 @@ <roundcube:object name="pagetitle" /> diff --git a/plugins/libkolab/js/audittrail.js b/plugins/libkolab/js/audittrail.js index b680114e..3b389fff 100644 --- a/plugins/libkolab/js/audittrail.js +++ b/plugins/libkolab/js/audittrail.js @@ -1,261 +1,269 @@ /** * Kolab groupware audit trail utilities * * @author Thomas Bruederli * * @licstart The following is the entire license notice for the * JavaScript code in this file. * * Copyright (C) 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 . * * @licend The above is the entire license notice * for the JavaScript code in this file. */ var libkolab_audittrail = {} libkolab_audittrail.quote_html = function(str) { return String(str).replace(//g, '>').replace(/"/g, '"'); }; // show object changelog in a dialog libkolab_audittrail.object_history_dialog = function(p) { // render dialog var $dialog = $(p.container); // close show dialog first if ($dialog.is(':ui-dialog')) $dialog.dialog('close'); // hide and reset changelog table $dialog.find('div.notfound-message').remove(); $dialog.find('.changelog-table').show().children('tbody') .html('' + rcmail.gettext('loading') + ''); // open jquery UI dialog $dialog.dialog({ modal: false, resizable: true, closeOnEscape: true, title: p.title, open: function() { $dialog.attr('aria-hidden', 'false'); }, close: function() { $dialog.dialog('destroy').attr('aria-hidden', 'true').hide(); }, buttons: [ { text: rcmail.gettext('close'), click: function() { $dialog.dialog('close'); }, autofocus: true } ], minWidth: 450, width: 650, height: 350, minHeight: 200 }) .show().children('.compare-button').hide(); // initialize event handlers for history dialog UI elements if (!$dialog.data('initialized')) { // compare button $dialog.find('.compare-button input').click(function(e) { var rev1 = $dialog.find('.changelog-table input.diff-rev1:checked').val(), rev2 = $dialog.find('.changelog-table input.diff-rev2:checked').val(); if (rev1 && rev2 && rev1 != rev2) { // swap revisions if the user got it wrong if (rev1 > rev2) { var tmp = rev2; rev2 = rev1; rev1 = tmp; } if (p.comparefunc) { p.comparefunc(rev1, rev2); } } else { alert('Invalid selection!') } if (!rcube_event.is_keyboard(e) && this.blur) { this.blur(); } return false; }); // delegate handlers for list actions $dialog.find('.changelog-table tbody').on('click', 'td.actions a', function(e) { var link = $(this), action = link.hasClass('restore') ? 'restore' : 'show', event = $('#eventhistory').data('event'), rev = link.attr('data-rev'); // ignore clicks on first row (current revision) if (link.closest('tr').hasClass('first')) { return false; } // let the user confirm the restore action if (action == 'restore' && !confirm(rcmail.gettext('revisionrestoreconfirm', p.module).replace('$rev', rev))) { return false; } if (p.listfunc) { p.listfunc(action, rev); } if (!rcube_event.is_keyboard(e) && this.blur) { this.blur(); } return false; }) .on('click', 'input.diff-rev1', function(e) { if (!this.checked) return true; var rev1 = this.value, selection_valid = false; $dialog.find('.changelog-table input.diff-rev2').each(function(i, elem) { $(elem).prop('disabled', elem.value <= rev1); if (elem.checked && elem.value > rev1) { selection_valid = true; } }); if (!selection_valid) { $dialog.find('.changelog-table input.diff-rev2:not([disabled])').last().prop('checked', true); } }); $dialog.addClass('changelog-dialog').data('initialized', true); } return $dialog; }; // callback from server with changelog data libkolab_audittrail.render_changelog = function(data, object, folder) { var Q = libkolab_audittrail.quote_html; var $dialog = $('.changelog-dialog') if (data === false || !data.length) { return false; } var i, change, accessible, op_append, first = data.length - 1, last = 0, is_writeable = !!folder.editable, - op_labels = { RECEIVE: 'actionreceive', APPEND: 'actionappend', MOVE: 'actionmove', DELETE: 'actiondelete', READ: 'actionread', FLAGSET: 'actionflagset', FLAGCLEAR: 'actionflagclear' }, - actions = ' ' + - (is_writeable ? '' : ''), + op_labels = { + RECEIVE: 'actionreceive', + APPEND: 'actionappend', + MOVE: 'actionmove', + DELETE: 'actiondelete', + READ: 'actionread', + FLAGSET: 'actionflagset', + FLAGCLEAR: 'actionflagclear' + }, + actions = ' ' + + (is_writeable ? '' : ''), tbody = $dialog.find('.changelog-table tbody').html(''); for (i=first; i >= 0; i--) { change = data[i]; accessible = change.date && change.user; if (change.op == 'MOVE' && change.mailbox) { op_append = ' ⇢ ' + change.mailbox; } else if ((change.op == 'FLAGSET' || change.op == 'FLAGCLEAR') && change.flags) { op_append = ': ' + change.flags; } else { op_append = ''; } $('') .append('' + (accessible && change.op != 'DELETE' ? ' '+ '' : '')) .append('' + Q(i+1) + '') .append('' + Q(change.date || '') + '') .append('' + Q(change.user || 'undisclosed') + '') - .append('' + Q(rcmail.gettext(op_labels[change.op] || '', data.module) + op_append) + '') + .append('' + Q(rcmail.gettext(op_labels[change.op] || '', 'libkolab') + op_append) + '') .append('' + (accessible && change.op != 'DELETE' ? actions.replace(/\{rev\}/g, change.rev) : '') + '') .appendTo(tbody); } if (first > 0) { $dialog.find('.compare-button').fadeIn(200); $dialog.find('.changelog-table tr.last input.diff-rev1').click(); } // set dialog size according to content libkolab_audittrail.dialog_resize($dialog.get(0), $dialog.height() + 15, 600); return $dialog; }; // resize and reposition (center) the dialog window libkolab_audittrail.dialog_resize = function(id, height, width) { var win = $(window), w = win.width(), h = win.height(); $(id).dialog('option', { height: Math.min(h-20, height+130), width: Math.min(w-20, width+50) }) .dialog('option', 'position', ['center', 'center']); // only works in a separate call (!?) }; // register handlers for mail message history window.rcmail && rcmail.addEventListener('init', function(e) { var loading_lock; if (rcmail.env.task == 'mail') { rcmail.register_command('kolab-mail-history', function() { var dialog, uid = rcmail.get_single_uid(), rec = { uid: uid, mbox: rcmail.get_message_mailbox(uid) }; if (!uid || !window.libkolab_audittrail) { return false; } // render dialog $dialog = libkolab_audittrail.object_history_dialog({ module: 'libkolab', container: '#mailmessagehistory', title: rcmail.gettext('objectchangelog','libkolab') }); $dialog.data('rec', rec); // fetch changelog data loading_lock = rcmail.set_busy(true, 'loading', loading_lock); rcmail.http_post('plugin.message-changelog', { _uid: rec.uid, _mbox: rec.mbox }, loading_lock); }, rcmail.env.action == 'show'); rcmail.addEventListener('plugin.message_render_changelog', function(data) { var $dialog = $('#mailmessagehistory'), rec = $dialog.data('rec'); if (data === false || !data.length || !event) { // display 'unavailable' message $('
    ' + rcmail.gettext('objectchangelognotavailable','libkolab') + '
    ') .insertBefore($dialog.find('.changelog-table').hide()); return; } data.module = 'libkolab'; libkolab_audittrail.render_changelog(data, rec, {}); }); rcmail.env.message_commands.push('kolab-mail-history'); } }); diff --git a/plugins/libkolab/libkolab.php b/plugins/libkolab/libkolab.php index 1dede0d3..0e4c8af3 100644 --- a/plugins/libkolab/libkolab.php +++ b/plugins/libkolab/libkolab.php @@ -1,303 +1,316 @@ * * 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 libkolab extends rcube_plugin { static $http_requests = array(); static $bonnie_api = false; /** * Required startup method of a Roundcube plugin */ public function init() { // load local config $this->load_config(); // extend include path to load bundled lib classes $include_path = $this->home . '/lib' . PATH_SEPARATOR . ini_get('include_path'); set_include_path($include_path); $this->add_hook('storage_init', array($this, 'storage_init')); $this->add_hook('user_delete', array('kolab_storage', 'delete_user_folders')); $rcmail = rcube::get_instance(); try { kolab_format::$timezone = new DateTimeZone($rcmail->config->get('timezone', 'GMT')); } catch (Exception $e) { rcube::raise_error($e, true); kolab_format::$timezone = new DateTimeZone('GMT'); } - $this->add_texts('localization/', $rcmail->output->type == 'html' && $rcmail->task == 'mail'); + $this->add_texts('localization/', false); // embed scripts and templates for email message audit trail if ($rcmail->task == 'mail' && self::get_bonnie_api()) { if ($rcmail->output->type == 'html') { $this->add_hook('render_page', array($this, 'bonnie_render_page')); $this->include_script('js/audittrail.js'); $this->include_stylesheet($this->local_skin_path() . '/libkolab.css'); // add 'Show history' item to message menu $this->api->add_content(html::tag('li', null, $this->api->output->button(array( 'command' => 'kolab-mail-history', 'label' => 'libkolab.showhistory', 'type' => 'link', 'classact' => 'icon history active', 'class' => 'icon history', 'innerclass' => 'icon history', ))), 'messagemenu'); } $this->register_action('plugin.message-changelog', array($this, 'message_changelog')); } } /** * Hook into IMAP FETCH HEADER.FIELDS command and request Kolab-specific headers */ function storage_init($p) { $p['fetch_headers'] = trim($p['fetch_headers'] .' X-KOLAB-TYPE X-KOLAB-MIME-VERSION'); return $p; } /** * Getter for a singleton instance of the Bonnie API * * @return mixed kolab_bonnie_api instance if configured, false otherwise */ public static function get_bonnie_api() { // get configuration for the Bonnie API if (!self::$bonnie_api && ($bonnie_config = rcube::get_instance()->config->get('kolab_bonnie_api', false))) { self::$bonnie_api = new kolab_bonnie_api($bonnie_config); } return self::$bonnie_api; } /** * Hook to append the message history dialog template to the mail view */ function bonnie_render_page($p) { if (($p['template'] === 'mail' || $p['template'] === 'message') && !$p['kolab-audittrail']) { // append a template for the audit trail dialog $this->api->output->add_footer( html::div(array('id' => 'mailmessagehistory', 'class' => 'uidialog', 'aria-hidden' => 'true', 'style' => 'display:none'), self::object_changelog_table(array('class' => 'records-table changelog-table')) ) ); $this->api->output->set_env('kolab_audit_trail', true); $p['kolab-audittrail'] = true; } return $p; } /** * Handler for message audit trail changelog requests */ public function message_changelog() { if (!self::$bonnie_api) { return false; } $rcmail = rcube::get_instance(); $msguid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST, true); $mailbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $result = $msguid && $mailbox ? self::$bonnie_api->changelog('mail', null, $mailbox, $msguid) : null; if (is_array($result)) { if (is_array($result['changes'])) { $dtformat = $rcmail->config->get('date_format') . ' ' . $rcmail->config->get('time_format'); array_walk($result['changes'], function(&$change) use ($dtformat, $rcmail) { if ($change['date']) { $dt = rcube_utils::anytodatetime($change['date']); if ($dt instanceof DateTime) { $change['date'] = $rcmail->format_date($dt, $dtformat); } } }); } $this->api->output->command('plugin.message_render_changelog', $result['changes']); } else { $this->api->output->command('plugin.message_render_changelog', false); } $this->api->output->send(); } /** * Wrapper function to load and initalize the HTTP_Request2 Object * * @param string|Net_Url2 Request URL * @param string Request method ('OPTIONS','GET','HEAD','POST','PUT','DELETE','TRACE','CONNECT') * @param array Configuration for this Request instance, that will be merged * with default configuration * * @return HTTP_Request2 Request object */ public static function http_request($url = '', $method = 'GET', $config = array()) { $rcube = rcube::get_instance(); $http_config = (array) $rcube->config->get('kolab_http_request'); // deprecated configuration options if (empty($http_config)) { foreach (array('ssl_verify_peer', 'ssl_verify_host') as $option) { $value = $rcube->config->get('kolab_' . $option, true); if (is_bool($value)) { $http_config[$option] = $value; } } } if (!empty($config)) { $http_config = array_merge($http_config, $config); } // force CURL adapter, this allows to handle correctly // compressed responses with SplObserver registered (kolab_files) (#4507) $http_config['adapter'] = 'HTTP_Request2_Adapter_Curl'; $key = md5(serialize($http_config)); if (!($request = self::$http_requests[$key])) { // load HTTP_Request2 require_once 'HTTP/Request2.php'; try { $request = new HTTP_Request2(); $request->setConfig($http_config); } catch (Exception $e) { rcube::raise_error($e, true, true); } // proxy User-Agent string $request->setHeader('user-agent', $_SERVER['HTTP_USER_AGENT']); self::$http_requests[$key] = $request; } // cleanup try { $request->setBody(''); $request->setUrl($url); $request->setMethod($method); } catch (Exception $e) { rcube::raise_error($e, true, true); } return $request; } /** * Table oultine for object changelog display */ public static function object_changelog_table($attrib = array()) { $rcube = rcube::get_instance(); $attrib += array('domain' => 'libkolab'); $table = new html_table(array('cols' => 5, 'border' => 0, 'cellspacing' => 0)); $table->add_header('diff', ''); $table->add_header('revision', $rcube->gettext('revision', $attrib['domain'])); $table->add_header('date', $rcube->gettext('date', $attrib['domain'])); $table->add_header('user', $rcube->gettext('user', $attrib['domain'])); $table->add_header('operation', $rcube->gettext('operation', $attrib['domain'])); $table->add_header('actions', ' '); + $rcube->output->add_label( + 'libkolab.showrevision', + 'libkolab.actionreceive', + 'libkolab.actionappend', + 'libkolab.actionmove', + 'libkolab.actiondelete', + 'libkolab.actionread', + 'libkolab.actionflagset', + 'libkolab.actionflagclear', + 'libkolab.objectchangelog', + 'close' + ); + return $table->show($attrib); } /** * Wrapper function for generating a html diff using the FineDiff class by Raymond Hill */ public static function html_diff($from, $to, $is_html = null) { // auto-detect text/html format if ($is_html === null) { $from_html = (preg_match('/<(html|body)(\s+[a-z]|>)/', $from, $m) && strpos($from, '') > 0); $to_html = (preg_match('/<(html|body)(\s+[a-z]|>)/', $to, $m) && strpos($to, '') > 0); $is_html = $from_html || $to_html; // ensure both parts are of the same format if ($is_html && !$from_html) { $converter = new rcube_text2html($from, false, array('wrap' => true)); $from = $converter->get_html(); } if ($is_html && !$to_html) { $converter = new rcube_text2html($to, false, array('wrap' => true)); $to = $converter->get_html(); } } // compute diff from HTML if ($is_html) { include_once __dir__ . '/vendor/Caxy/HtmlDiff/Match.php'; include_once __dir__ . '/vendor/Caxy/HtmlDiff/Operation.php'; include_once __dir__ . '/vendor/Caxy/HtmlDiff/HtmlDiff.php'; // replace data: urls with a transparent image to avoid memory problems $from = preg_replace('/src="data:image[^"]+/', 'src="data:image/gif;base64,R0lGODlhAQABAPAAAOjq6gAAACH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAAAsAAAAAAEAAQAAAgJEAQA7', $from); $to = preg_replace('/src="data:image[^"]+/', 'src="data:image/gif;base64,R0lGODlhAQABAPAAAOjq6gAAACH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAAAsAAAAAAEAAQAAAgJEAQA7', $to); $diff = new Caxy\HtmlDiff\HtmlDiff($from, $to); $diffhtml = $diff->build(); // remove empty inserts (from tables) return preg_replace('!\s*!Uims', '', $diffhtml); } else { include_once __dir__ . '/vendor/finediff.php'; $diff = new FineDiff($from, $to, FineDiff::$wordGranularity); return $diff->renderDiffToHTML(); } } /** * Return a date() format string to render identifiers for recurrence instances * * @param array Hash array with event properties * @return string Format string */ public static function recurrence_id_format($event) { return $event['allday'] ? 'Ymd' : 'Ymd\THis'; } } diff --git a/plugins/libkolab/localization/de_DE.inc b/plugins/libkolab/localization/de_DE.inc new file mode 100644 index 00000000..3ddc8848 --- /dev/null +++ b/plugins/libkolab/localization/de_DE.inc @@ -0,0 +1,25 @@ + * * 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 - if ($bonnie_config = $this->rc->config->get('kolab_bonnie_api', false)) { - $this->bonnie_api = new kolab_bonnie_api($bonnie_config); - } + $this->bonnie_api = libkolab::get_bonnie_api(); $this->_read_lists(); $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; } } /** * 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()) && !PEAR::isError($myrights)) { $rights = $myrights; if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) $editable = strpos($rights, 'i'); } $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(), '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 */ public function get_lists(&$tree = null) { // 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 = array(); foreach ($this->lists as $id => $list) { if (!empty($this->folders[$id])) { $folders[] = $this->folders[$id]; } } // 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' => $folder->get_name(), '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' => kolab_storage::object_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 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) { // 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; else $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 = array_keys($this->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, 'flagged' => 0, 'today' => 0, 'tomorrow' => 0, 'overdue' => 0, 'nodate' => 0, 'mytasks' => 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 ($rec['flagged']) $counts['flagged']++; if (empty($rec['date'])) $counts['nodate']++; else if ($rec['date'] == $today) $counts['today']++; else if ($rec['date'] == $tomorrow) $counts['tomorrow']++; else if ($rec['date'] < $today) $counts['overdue']++; if ($this->plugin->is_attendee($rec) !== false) $counts['mytasks']++; } } // avoid session race conditions that will loose temporary subscriptions $this->plugin->rc->session->nowrite = true; return $counts; } /** * Get all taks 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 * @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 = array_keys($this->lists); else if (is_string($lists)) $lists = explode(',', $lists); $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']); } // load all tags into memory first kolab_storage_config::get_instance()->get_tags(); foreach ($lists as $list_id) { if (!$folder = $this->get_folder($list_id)) { continue; } foreach ($folder->select($query) as $record) { $this->load_tags($record); $task = $this->_to_rcube_task($record, $list_id); // TODO: post-filter tasks returned from storage $results[] = $task; } } // 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 * @return array Hash array with task properties or false if not found */ public function get_task($prop) { $this->_parse_id($prop); $id = $prop['uid']; $list_id = $prop['list']; $folders = $list_id ? array($list_id => $this->get_folder($list_id)) : $this->folders; // find task in the available folders foreach ($folders as $list_id => $folder) { 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')); 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) { // make sure we have a valid array if (empty($links)) { $links = array(); } $config = kolab_storage_config::get_instance(); $remove = array_diff($config->get_object_links($uid), $links); return $config->save_object_links($uid, $links, $remove); } /** * 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, ); // we can sometimes skip this expensive operation if ($all) { $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']; } // delete existing attachment(s) if (!empty($task['deleted_attachments'])) { foreach ($task['deleted_attachments'] as $attachment) { if (is_array($object['_attachments'])) { foreach ($object['_attachments'] as $idx => $att) { if ($att['id'] == $attachment) $object['_attachments'][$idx] = false; } } } unset($task['deleted_attachments']); } // in kolab_storage attachments are indexed by content-id if (is_array($task['attachments'])) { foreach ($task['attachments'] as $idx => $attachment) { $key = null; // Roundcube ID has nothing to do with the storage ID, remove it if ($attachment['content'] || $attachment['path']) { unset($attachment['id']); } else { foreach ((array)$old['_attachments'] as $cid => $oldatt) { if ($oldatt && $attachment['id'] == $oldatt['id']) $key = $cid; } } // replace existing entry if ($key) { $object['_attachments'][$key] = $attachment; } // append as new attachment else { $object['_attachments'][] = $attachment; } } unset($object['attachments']); } // 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))) 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) { 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) { 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-tasklistame', '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' => Q($this->plugin->gettext('tabsharing')), 'content' => html::tag('iframe', array( 'src' => $this->rc->url(array('_action' => 'folder-acl', '_folder' => $folder_name, 'framed' => 1)), 'width' => '100%', 'height' => 280, 'border' => 0, 'style' => 'border:0'), '') ); } $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)); foreach ($tab['fields'] as $col => $colprop) { $label = !empty($colprop['label']) ? $colprop['label'] : $this->plugin->gettext($col); $table->add('title', html::label($colprop['id'], 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, Q($tab['name'])) . $content) . "\n"; } } return $form_html; } /** * Handler to render ACL form for a notes folder */ public function folder_acl() { $this->plugin->require_plugin('acl'); $this->rc->output->add_handler('folderacl', array($this, 'folder_acl_form')); $this->rc->output->send('tasklist.kolabacl'); } /** * Handler for ACL form template object */ public function folder_acl_form() { $folder = rcube_utils::get_input_value('_folder', rcube_utils::INPUT_GPC); if (strlen($folder)) { $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')); } } diff --git a/plugins/tasklist/localization/en_US.inc b/plugins/tasklist/localization/en_US.inc index 0bf88940..f9d8b397 100644 --- a/plugins/tasklist/localization/en_US.inc +++ b/plugins/tasklist/localization/en_US.inc @@ -1,219 +1,213 @@ CalDAV
    client application (e.g. Evolution or Mozilla Thunderbird) to synchronize this specific tasklist with your computer or mobile device.'; $labels['newtask'] = 'New Task'; $labels['createtask'] = 'Create Task '; $labels['createnewtask'] = 'Create new Task (e.g. Saturday, Mow the lawn)'; $labels['createfrommail'] = 'Save as task'; $labels['printtitle'] = 'Print tasks'; $labels['printdescriptions'] = 'Print descriptions'; $labels['mark'] = 'Mark'; $labels['unmark'] = 'Unmark'; $labels['edit'] = 'Edit'; $labels['delete'] = 'Delete'; $labels['title'] = 'Title'; $labels['description'] = 'Description'; $labels['datetime'] = 'Due'; $labels['duetime'] = 'Due time'; $labels['start'] = 'Start'; $labels['starttime'] = 'Start time'; $labels['alarms'] = 'Reminder'; $labels['repeat'] = 'Repeat'; $labels['links'] = 'Reference'; $labels['status'] = 'Status'; $labels['status-needs-action'] = 'Needs action'; $labels['status-in-process'] = 'In process'; $labels['status-completed'] = 'Completed'; $labels['status-cancelled'] = 'Cancelled'; $labels['assignedto'] = 'Assigned to'; $labels['created'] = 'Created'; $labels['changed'] = 'Last Modified'; $labels['taskoptions'] = 'Options'; $labels['all'] = 'All'; $labels['flagged'] = 'Flagged'; $labels['complete'] = 'Complete'; $labels['completeness'] = 'Progress'; $labels['overdue'] = 'Overdue'; $labels['today'] = 'Today'; $labels['tomorrow'] = 'Tomorrow'; $labels['next7days'] = 'Next 7 days'; $labels['later'] = 'Later'; $labels['assigned'] = 'Assigned'; $labels['assignedtitle'] = 'Tasks you assigned to others'; $labels['mytasks'] = 'My tasks'; $labels['mytaskstitle'] = 'Tasks assigned to you'; $labels['nodate'] = 'no date'; $labels['removetag'] = 'Remove'; $labels['removelink'] = 'Remove email reference'; $labels['auto'] = 'Auto'; $labels['taskdetails'] = 'Details'; $labels['newtask'] = 'New Task'; $labels['edittask'] = 'Edit Task'; $labels['save'] = 'Save'; $labels['cancel'] = 'Cancel'; $labels['saveandnotify'] = 'Save and Notify'; $labels['addsubtask'] = 'Add subtask'; $labels['deletetask'] = 'Delete task'; $labels['deletethisonly'] = 'Delete this task only'; $labels['deletewithchilds'] = 'Delete with all subtasks'; $labels['taskactions'] = 'Task options...'; $labels['tabsummary'] = 'Summary'; $labels['tabrecurrence'] = 'Recurrence'; $labels['tabassignments'] = 'Assignments'; $labels['tabattachments'] = 'Attachments'; $labels['tabsharing'] = 'Sharing'; $labels['editlist'] = 'Edit list'; $labels['createlist'] = 'Add list'; $labels['listactions'] = 'List options...'; $labels['listname'] = 'Name'; $labels['showalarms'] = 'Show reminders'; $labels['import'] = 'Import'; $labels['viewactions'] = 'View actions'; $labels['focusview'] = 'View only this list'; // date words $labels['on'] = 'on'; $labels['at'] = 'at'; $labels['this'] = 'this'; $labels['next'] = 'next'; $labels['yes'] = 'yes'; // messages $labels['savingdata'] = 'Saving data...'; $labels['errorsaving'] = 'Failed to save data.'; $labels['notasksfound'] = 'No tasks found for the given criteria'; $labels['invalidstartduedates'] = 'Start date must not be greater than due date.'; $labels['invalidstartduetimes'] = 'Start and due dates must either both or none specify a time.'; $labels['recurrencerequiresdate'] = 'Recurring tasks require either a start or due date.'; $labels['deletetasktconfirm'] = 'Do you really want to delete this task?'; $labels['deleteparenttasktconfirm'] = 'Do you really want to delete this task and all its subtasks?'; $labels['deletelistconfirm'] = 'Do you really want to delete this list with all its tasks?'; $labels['deletelistconfirmrecursive'] = 'Do you really want to delete this list with all its sub-lists and tasks?'; $labels['aclnorights'] = 'You do not have administrator rights on this task list.'; $labels['changetaskconfirm'] = 'Update task'; $labels['changeconfirmnotifications'] = 'Do you want to notify the attendees about the modification?'; $labels['partstatupdatenotification'] = 'Do you want to notify the organizer about the status change?'; // (hidden) titles and labels for accessibility annotations $labels['quickaddinput'] = 'New task date and title'; $labels['arialabelquickaddbox'] = 'Quick add new task'; $labels['arialabelsearchform'] = 'Task search form'; $labels['arialabelquicksearchbox'] = 'Task search input'; $labels['arialabellistsearchform'] = 'Tasklists search form'; $labels['arialabeltaskselector'] = 'List mode'; $labels['arialabeltasklisting'] = 'Tasks listing'; // attendees $labels['attendee'] = 'Assignee'; $labels['role'] = 'Role'; $labels['availability'] = 'Avail.'; $labels['confirmstate'] = 'Status'; $labels['addattendee'] = 'Add assignee'; $labels['roleorganizer'] = 'Organizer'; $labels['rolerequired'] = 'Required'; $labels['roleoptional'] = 'Optional'; $labels['rolechair'] = 'Chair'; $labels['rolenonparticipant'] = 'Observer'; $labels['sendinvitations'] = 'Send invitations'; $labels['sendnotifications'] = 'Notify assignees about modifications'; $labels['sendcancellation'] = 'Notify assignees about task cancellation'; $labels['invitationsubject'] = 'You\'ve been assigned to "$title"'; $labels['invitationmailbody'] = "*\$title*\n\nDue: \$date\n\nAssignees: \$attendees\n\nPlease find attached an iCalendar file with all the task details which you can import to your tasks application."; $labels['itipupdatesubject'] = '"$title" has been updated'; $labels['itipupdatesubjectempty'] = 'A task that concerns you has been updated'; $labels['itipupdatemailbody'] = "*\$title*\n\nDue: \$date\n\nAssignees: \$attendees\n\nPlease find attached an iCalendar file with the updated task details which you can import to your tasks application."; $labels['itipcancelsubject'] = '"$title" has been canceled'; $labels['itipcancelmailbody'] = "*\$title*\n\nDue: \$date\n\nAssignees: \$attendees\n\nThe task has been cancelled by \$organizer.\n\nPlease find attached an iCalendar file with the updated task details."; $labels['saveintasklist'] = 'save in '; // history dialog $labels['taskhistory'] = 'History'; $labels['objectchangelog'] = 'Change History'; $labels['objectdiff'] = 'Changes from $rev1 to $rev2'; -$labels['actionappend'] = 'Saved'; -$labels['actionmove'] = 'Moved'; -$labels['actiondelete'] = 'Deleted'; -$labels['compare'] = 'Compare'; -$labels['showrevision'] = 'Show this version'; -$labels['restore'] = 'Restore this version'; $labels['objectnotfound'] = 'Failed to load task data'; $labels['objectchangelognotavailable'] = 'Change history is not available for this task'; $labels['objectdiffnotavailable'] = 'No comparison possible for the selected revisions'; $labels['revisionrestoreconfirm'] = 'Do you really want to restore revision $rev of this task? This will replace the current task with the old version.'; $labels['objectrestoresuccess'] = 'Revision $rev successfully restored'; $labels['objectrestoreerror'] = 'Failed to restore the old revision'; // invitation handling (overrides labels from libcalendaring) $labels['itipobjectnotfound'] = 'The task referred by this message was not found in your tasks list.'; $labels['itipmailbodyaccepted'] = "\$sender has accepted the assignment to the following task:\n\n*\$title*\n\nDue: \$date\n\nAssignees: \$attendees"; $labels['itipmailbodytentative'] = "\$sender has tentatively accepted the assignment to the following task:\n\n*\$title*\n\nDue: \$date\n\nAssignees: \$attendees"; $labels['itipmailbodydeclined'] = "\$sender has declined the assignment to the following task:\n\n*\$title*\n\nDue: \$date\n\nAssignees: \$attendees"; $labels['itipmailbodycancel'] = "\$sender has rejected your assignment to the following task:\n\n*\$title*\n\nDue: \$date"; $labels['itipmailbodyin-process'] = "\$sender has set the status of the following task to in-process:\n\n*\$title*\n\nDue: \$date"; $labels['itipmailbodycompleted'] = "\$sender has completed the following task:\n\n*\$title*\n\nDue: \$date"; $labels['itipmailbodydelegated'] = "\$sender has delegated the following task:\n\n*\$title*\n\nDue: \$date"; $labels['itipmailbodydelegatedto'] = "\$sender has delegated the following task to you:\n\n*\$title*\n\nDue: \$date"; $labels['attendeeaccepted'] = 'Assignee has accepted'; $labels['attendeetentative'] = 'Assignee has tentatively accepted'; $labels['attendeedeclined'] = 'Assignee has declined'; $labels['attendeedelegated'] = 'Assignee has delegated to $delegatedto'; $labels['attendeein-process'] = 'Assignee is in-process'; $labels['attendeecompleted'] = 'Assignee has completed'; $labels['acceptinvitation'] = 'Do you accept this assignment?'; $labels['itipdeclinetask'] = 'Decline your assignment to this task to the organizer'; $labels['declinedeleteconfirm'] = 'Do you also want to delete this declined task from your tasks list?'; $labels['itipcomment'] = 'Invitation/notification comment'; $labels['itipcommenttitle'] = 'This comment will be attached to the invitation/notification message sent to assignees'; $labels['itipsendsuccess'] = 'Notification sent to assignees'; $labels['errornotifying'] = 'Failed to send notifications to task assignees'; $labels['removefromcalendar'] = 'Remove from my tasks'; $labels['delegateinvitation'] = 'Delegate assignment'; $labels['andnmore'] = '$nr more...'; $labels['delegatedto'] = 'Delegated to: '; $labels['delegatedfrom'] = 'Delegated from: '; $labels['savetotasklist'] = 'Save to tasks'; $labels['comment'] = 'Comment'; $labels['errorimportingtask'] = 'Failed to import task(s)'; $labels['importwarningexists'] = 'A copy of this task already exists in your tasklist.'; $labels['importsuccess'] = 'Successfully imported $nr tasks'; $labels['newerversionexists'] = 'A newer version of this task already exists! Aborted.'; $labels['nowritetasklistfound'] = 'No tasklist found to save the task'; $labels['importedsuccessfully'] = 'The task was successfully added to \'$list\''; $labels['updatedsuccessfully'] = 'The task was successfully updated in \'$list\''; $labels['attendeupdateesuccess'] = 'Successfully updated the participant\'s status'; $labels['itipresponseerror'] = 'Failed to send the response to this task assignment'; $labels['itipinvalidrequest'] = 'This invitation is no longer valid'; $labels['sentresponseto'] = 'Successfully sent assignment response to $mailto'; $labels['successremoval'] = 'The task has been deleted successfully.'; $labels['arialabelsortmenu'] = 'Tasks sorting options'; diff --git a/plugins/tasklist/skins/larry/templates/mainview.html b/plugins/tasklist/skins/larry/templates/mainview.html index 520d0712..24f71015 100644 --- a/plugins/tasklist/skins/larry/templates/mainview.html +++ b/plugins/tasklist/skins/larry/templates/mainview.html @@ -1,370 +1,370 @@ <roundcube:object name="pagetitle" />

    \ No newline at end of file