diff --git a/plugins/calendar/drivers/caldav/caldav_driver.php b/plugins/calendar/drivers/caldav/caldav_driver.php index b8fe13db..3b05cf03 100644 --- a/plugins/calendar/drivers/caldav/caldav_driver.php +++ b/plugins/calendar/drivers/caldav/caldav_driver.php @@ -1,692 +1,692 @@ <?php /** * CalDAV driver for the Calendar plugin. * * @author Aleksander Machniak <machniak@apheleia-it.ch> * * Copyright (C) 2012-2022, Apheleia IT AG <contact@apheleia-it.ch> * * 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 <http://www.gnu.org/licenses/>. */ require_once(__DIR__ . '/../kolab/kolab_driver.php'); class caldav_driver extends kolab_driver { // features this backend supports public $alarms = true; public $attendees = true; public $freebusy = true; public $attachments = true; public $undelete = false; // TODO public $alarm_types = ['DISPLAY', 'AUDIO']; public $categoriesimmutable = true; protected $scheduling_properties = ['start', 'end', 'location']; /** * Default constructor */ public function __construct($cal) { $cal->require_plugin('libkolab'); // load helper classes *after* libkolab has been loaded (#3248) require_once(__DIR__ . '/caldav_calendar.php'); // require_once(__DIR__ . '/kolab_user_calendar.php'); // require_once(__DIR__ . '/caldav_invitation_calendar.php'); $this->cal = $cal; $this->rc = $cal->rc; // Initialize the CalDAV storage $url = $this->rc->config->get('calendar_caldav_server', 'http://localhost'); $this->storage = new kolab_storage_dav($url); $this->cal->register_action('push-freebusy', [$this, 'push_freebusy']); $this->cal->register_action('calendar-acl', [$this, 'calendar_acl']); // $this->freebusy_trigger = $this->rc->config->get('calendar_freebusy_trigger', false); if (!$this->rc->config->get('kolab_freebusy_server', false)) { $this->freebusy = false; } // TODO: get configuration for the Bonnie API // $this->bonnie_api = libkolab::get_bonnie_api(); } /** * Read available calendars from server */ protected function _read_calendars() { // already read sources if (isset($this->calendars)) { return $this->calendars; } // get all folders that support VEVENT, sorted by namespace/name $folders = $this->storage->get_folders('event'); // + $this->storage->get_user_folders('event', true); $this->calendars = []; foreach ($folders as $folder) { $calendar = $this->_to_calendar($folder); if ($calendar->ready) { $this->calendars[$calendar->id] = $calendar; if ($calendar->editable) { $this->has_writeable = true; } } } return $this->calendars; } /** * Convert kolab_storage_folder into caldav_calendar */ protected function _to_calendar($folder) { if ($folder instanceof caldav_calendar) { return $folder; } if ($folder instanceof kolab_storage_folder_user) { $calendar = new kolab_user_calendar($folder, $this->cal); $calendar->subscriptions = count($folder->children) > 0; } else { $calendar = new caldav_calendar($folder, $this->cal); } return $calendar; } /** * Get a list of available calendars from this source. * * @param int $filter Bitmask defining filter criterias * @param object $tree Reference to hierarchical folder tree object * * @return array List of calendars */ public function list_calendars($filter = 0, &$tree = null) { $this->_read_calendars(); $folders = $this->filter_calendars($filter); $calendars = []; $prefs = $this->rc->config->get('kolab_calendars', []); // include virtual folders for a full folder tree /* if (!is_null($tree)) { $folders = $this->storage->folder_hierarchy($folders, $tree); } */ $parents = array_keys($this->calendars); foreach ($folders as $id => $cal) { $parent_id = null; /* $path = explode('/', $cal->name); // find parent do { array_pop($path); $parent_id = $this->storage->folder_id(implode('/', $path)); } while (count($path) > 1 && !in_array($parent_id, $parents)); // restore "real" parent ID if ($parent_id && !in_array($parent_id, $parents)) { $parent_id = $this->storage->folder_id($cal->get_parent()); } $parents[] = $cal->id; if ($cal->virtual) { $calendars[$cal->id] = [ 'id' => $cal->id, 'name' => $cal->get_name(), 'listname' => $cal->get_foldername(), 'editname' => $cal->get_foldername(), 'virtual' => true, 'editable' => false, 'group' => $cal->get_namespace(), ]; } else { */ // additional folders may come from kolab_storage_dav::folder_hierarchy() above // make sure we deal with caldav_calendar instances $cal = $this->_to_calendar($cal); $this->calendars[$cal->id] = $cal; $is_user = ($cal instanceof caldav_user_calendar); $calendars[$cal->id] = [ 'id' => $cal->id, 'name' => $cal->get_name(), 'listname' => $cal->get_foldername(), 'editname' => $cal->get_foldername(), 'title' => '', // $cal->get_title(), 'color' => $cal->get_color(), 'editable' => $cal->editable, 'group' => $is_user ? 'other user' : $cal->get_namespace(), 'active' => !isset($prefs[$cal->id]['active']) || !empty($prefs[$cal->id]['active']), 'owner' => $cal->get_owner(), 'removable' => !$cal->default, // extras to hide some elements in the UI 'subscriptions' => $cal->subscriptions, 'driver' => 'caldav', ]; if (!$is_user) { $calendars[$cal->id] += [ 'default' => $cal->default, 'rights' => $cal->rights, 'showalarms' => $cal->alarms, 'history' => !empty($this->bonnie_api), 'children' => true, // TODO: determine if that folder indeed has child folders 'parent' => $parent_id, 'subtype' => $cal->subtype, 'caldavurl' => '', // $cal->get_caldav_url(), ]; } /* } */ if ($cal->subscriptions) { $calendars[$cal->id]['subscribed'] = $cal->is_subscribed(); } } /* // list virtual calendars showing invitations if ($this->rc->config->get('kolab_invitation_calendars') && !($filter & self::FILTER_INSERTABLE)) { foreach ([self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED] as $id) { $cal = new caldav_invitation_calendar($id, $this->cal); if (!($filter & self::FILTER_ACTIVE) || $cal->is_active()) { $calendars[$id] = [ '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, 'counts' => $id == self::INVITATIONS_CALENDAR_PENDING, ]; if (is_object($tree)) { $tree->children[] = $cal; } } } } */ // append the virtual birthdays calendar if ($this->rc->config->get('calendar_contact_birthdays', false) && !($filter & self::FILTER_INSERTABLE)) { $id = self::BIRTHDAY_CALENDAR_ID; $prefs = $this->rc->config->get('kolab_calendars', []); // read local prefs if (!($filter & self::FILTER_ACTIVE) || !empty($prefs[$id]['active'])) { $calendars[$id] = [ 'id' => $id, 'name' => $this->cal->gettext('birthdays'), 'listname' => $this->cal->gettext('birthdays'), 'color' => !empty($prefs[$id]['color']) ? $prefs[$id]['color'] : '87CEFA', 'active' => !empty($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 the caldav_calendar instance for the given calendar ID * * @param string Calendar identifier * * @return ?caldav_calendar Object nor null if calendar doesn't exist */ public function get_calendar($id) { $this->_read_calendars(); // create calendar object if necessary if (empty($this->calendars[$id])) { if (in_array($id, [self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED])) { return new caldav_invitation_calendar($id, $this->cal); } // for unsubscribed calendar folders if ($id !== self::BIRTHDAY_CALENDAR_ID) { $calendar = caldav_calendar::factory($id, $this->cal); if ($calendar->ready) { $this->calendars[$calendar->id] = $calendar; } } } return !empty($this->calendars[$id]) ? $this->calendars[$id] : null; } /** * 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['alarms'] = !empty($prop['showalarms']); $id = $this->storage->folder_update($prop); if ($id === false) { return false; } $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); $prefs['kolab_calendars'][$id]['active'] = true; $this->rc->user->save_prefs($prefs); return $id; } /** * Update properties of an existing calendar * * @see calendar_driver::edit_calendar() */ public function edit_calendar($prop) { $id = $prop['id']; if (!in_array($id, [self::BIRTHDAY_CALENDAR_ID, self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED])) { $prop['type'] = 'event'; $prop['alarms'] = !empty($prop['showalarms']); return $this->storage->folder_update($prop) !== false; } // fallback to local prefs for special calendars $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); unset($prefs['kolab_calendars'][$id]['showalarms']); 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'] = !empty($prop['showalarms']); } 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 (empty($prop['id'])) { return false; } // save state in local prefs if (isset($prop['active'])) { $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); $prefs['kolab_calendars'][$prop['id']]['active'] = !empty($prop['active']); $this->rc->user->save_prefs($prefs); } return true; } /** * Delete the given calendar with all its contents * * @see calendar_driver::delete_calendar() */ public function delete_calendar($prop) { if (!empty($prop['id'])) { if ($this->storage->folder_delete($prop['id'], 'event')) { // remove folder from user prefs $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); if (isset($prefs['kolab_calendars'][$prop['id']])) { unset($prefs['kolab_calendars'][$prop['id']]); $this->rc->user->save_prefs($prefs); } return true; } } 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) { $this->calendars = []; $this->search_more_results = false; /* // find unsubscribed IMAP folders that have "event" type if ($source == 'folders') { foreach ((array) $this->storage->search_folders('event', $query, ['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') { // we have slightly more space, so display twice the number $limit = $this->rc->config->get('autocomplete_max', 15) * 2; foreach ($this->storage->search_users($query, 0, [], $limit, $count) as $user) { $calendar = new caldav_user_calendar($user, $this->cal); $this->calendars[$calendar->id] = $calendar; // search for calendar folders shared by this user foreach ($this->storage->list_user_folders($user, 'event', false) as $foldername) { $cal = new caldav_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(); } /** * Get events from source. * * @param int Event's new start (unix timestamp) * @param int 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 bool Include virtual events (optional) * @param int Only list events modified since this time (unix timestamp) * * @return array A list of event records */ public function load_events($start, $end, $search = null, $calendars = null, $virtual = 1, $modifiedsince = null) { if ($calendars && is_string($calendars)) { $calendars = explode(',', $calendars); } else if (!$calendars) { $this->_read_calendars(); $calendars = array_keys($this->calendars); } $query = []; $events = []; $categories = []; if ($modifiedsince) { $query[] = ['changed', '>=', $modifiedsince]; } 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); $newcats = array_udiff( array_keys($categories), array_keys($old_categories), function($a, $b) { return strcasecmp($a, $b); } ); if (!empty($newcats)) { foreach ($newcats as $category) { $old_categories[$category] = ''; // no color set yet } $this->rc->user->save_prefs(['calendar_categories' => $old_categories]); } array_walk($events, 'caldav_driver::to_rcube_event'); return $events; } /** * Create instances of a recurring event * * @param array Hash array with event properties * @param DateTime Start date of the recurrence window * @param DateTime End date of the recurrence window * * @return array List of recurring event instances */ public function get_recurring_events($event, $start, $end = null) { $this->_read_calendars(); $storage = reset($this->calendars); return $storage->get_recurring_events($event, $start, $end); } /** * */ protected function get_recurrence_count($event, $dtstart) { // use libkolab to compute recurring events $recurrence = libcalendaring::get_recurrence($event); $count = 0; while (($next_event = $recurrence->next_instance()) && $next_event['start'] <= $dtstart && $count < 1000) { $count++; } return $count; } /** * Determine whether the current change affects scheduling and reset attendee status accordingly */ protected 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 $reschedule = $this->is_rescheduling_needed($event, $old); // reset all attendee status to needs-action (#4360) if ($update && $reschedule && !empty($event['attendees'])) { $is_organizer = false; $emails = $this->cal->get_user_emails(); $attendees = $event['attendees']; foreach ($attendees as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER' && !empty($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 || (!empty($event['organizer']) && in_array(strtolower($event['organizer']['email']), $emails))) { $event['attendees'] = $attendees; } } return $reschedule; } /** * Identify changes considered relevant for scheduling * * @param array Hash array with NEW object properties * @param array Hash array with OLD object properties * * @return bool True if changes affect scheduling, False otherwise */ protected function is_rescheduling_needed($object, $old = null) { $reschedule = false; foreach ($this->scheduling_properties as $prop) { $a = $old[$prop] ?? null; $b = $object[$prop] ?? null; if (!empty($object['allday']) && ($prop == 'start' || $prop == 'end') && $a instanceof DateTimeInterface && $b instanceof DateTimeInterface ) { $a = $a->format('Y-m-d'); $b = $b->format('Y-m-d'); } if ($prop == 'recurrence' && is_array($a) && is_array($b)) { unset($a['EXCEPTIONS'], $b['EXCEPTIONS']); $a = array_filter($a); $b = array_filter($b); // advanced rrule comparison: no rescheduling if series was shortened if ($a['COUNT'] && $b['COUNT'] && $b['COUNT'] < $a['COUNT']) { unset($a['COUNT'], $b['COUNT']); } else if ($a['UNTIL'] && $b['UNTIL'] && $b['UNTIL'] < $a['UNTIL']) { unset($a['UNTIL'], $b['UNTIL']); } } if ($a != $b) { $reschedule = true; break; } } return $reschedule; } /** * 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) { $special_calendars = [ self::BIRTHDAY_CALENDAR_ID, self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED ]; // show default dialog for birthday calendar if (in_array($calendar['id'], $special_calendars)) { if ($calendar['id'] != self::BIRTHDAY_CALENDAR_ID) { unset($formfields['showalarms']); } // General tab $form['props'] = [ 'name' => $this->rc->gettext('properties'), 'fields' => $formfields, ]; return kolab_utils::folder_form($form, '', 'calendar'); } $form['props'] = [ 'name' => $this->rc->gettext('properties'), 'fields' => [ 'location' => $formfields['name'], 'color' => $formfields['color'], 'alarms' => $formfields['showalarms'], ], ]; - return kolab_utils::folder_form($form, $folder, 'calendar', [], true); + return kolab_utils::folder_form($form, '', 'calendar', [], true); } } diff --git a/plugins/calendar/lib/calendar_ui.php b/plugins/calendar/lib/calendar_ui.php index 83779834..ac7687c8 100644 --- a/plugins/calendar/lib/calendar_ui.php +++ b/plugins/calendar/lib/calendar_ui.php @@ -1,1011 +1,1014 @@ <?php /** * User Interface class for the Calendar plugin * * @author Lazlo Westerhof <hello@lazlo.me> * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2010, Lazlo Westerhof <hello@lazlo.me> * Copyright (C) 2014, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. */ class calendar_ui { private $rc; private $cal; private $ready = false; public $screen; + public $action; + public $calendar; + function __construct($cal) { $this->cal = $cal; $this->rc = $cal->rc; $this->screen = $this->rc->task == 'calendar' ? ($this->rc->action ?: 'calendar') : 'other'; } /** * Calendar UI initialization and requests handlers */ public function init() { if ($this->ready) { // already done return; } // add taskbar button $this->cal->add_button([ 'command' => 'calendar', 'class' => 'button-calendar', 'classsel' => 'button-calendar button-selected', 'innerclass' => 'button-inner', 'label' => 'calendar.calendar', 'type' => 'link' ], 'taskbar' ); // load basic client script if ($this->rc->action != 'print') { $this->cal->include_script('calendar_base.js'); } $this->addCSS(); $this->ready = true; } /** * Register handler methods for the template engine */ public function init_templates() { $this->cal->register_handler('plugin.calendar_css', [$this, 'calendar_css']); $this->cal->register_handler('plugin.calendar_list', [$this, 'calendar_list']); $this->cal->register_handler('plugin.calendar_select', [$this, 'calendar_select']); $this->cal->register_handler('plugin.identity_select', [$this, 'identity_select']); $this->cal->register_handler('plugin.category_select', [$this, 'category_select']); $this->cal->register_handler('plugin.status_select', [$this, 'status_select']); $this->cal->register_handler('plugin.freebusy_select', [$this, 'freebusy_select']); $this->cal->register_handler('plugin.priority_select', [$this, 'priority_select']); $this->cal->register_handler('plugin.alarm_select', [$this, 'alarm_select']); $this->cal->register_handler('plugin.recurrence_form', [$this->cal->lib, 'recurrence_form']); $this->cal->register_handler('plugin.attendees_list', [$this, 'attendees_list']); $this->cal->register_handler('plugin.attendees_form', [$this, 'attendees_form']); $this->cal->register_handler('plugin.resources_form', [$this, 'resources_form']); $this->cal->register_handler('plugin.resources_list', [$this, 'resources_list']); $this->cal->register_handler('plugin.resources_searchform', [$this, 'resources_search_form']); $this->cal->register_handler('plugin.resource_info', [$this, 'resource_info']); $this->cal->register_handler('plugin.resource_calendar', [$this, 'resource_calendar']); $this->cal->register_handler('plugin.attendees_freebusy_table', [$this, 'attendees_freebusy_table']); $this->cal->register_handler('plugin.edit_attendees_notify', [$this, 'edit_attendees_notify']); $this->cal->register_handler('plugin.edit_recurrence_sync', [$this, 'edit_recurrence_sync']); $this->cal->register_handler('plugin.edit_recurring_warning', [$this, 'recurring_event_warning']); $this->cal->register_handler('plugin.event_rsvp_buttons', [$this, 'event_rsvp_buttons']); $this->cal->register_handler('plugin.agenda_options', [$this, 'agenda_options']); $this->cal->register_handler('plugin.events_import_form', [$this, 'events_import_form']); $this->cal->register_handler('plugin.events_export_form', [$this, 'events_export_form']); $this->cal->register_handler('plugin.object_changelog_table', ['libkolab', 'object_changelog_table']); $this->cal->register_handler('plugin.searchform', [$this->rc->output, 'search_form']); kolab_attachments_handler::ui(); } /** * Adds CSS stylesheets to the page header */ public function addCSS() { $skin_path = $this->cal->local_skin_path(); if ( $this->rc->task == 'calendar' && (!$this->rc->action || in_array($this->rc->action, ['index', 'print'])) ) { // Include fullCalendar style before skin file for simpler style overriding $this->cal->include_stylesheet($skin_path . '/fullcalendar.css'); } $this->cal->include_stylesheet($skin_path . '/calendar.css'); if ($this->rc->task == 'calendar' && $this->rc->action == 'print') { $this->cal->include_stylesheet($skin_path . '/print.css'); } } /** * Adds JS files to the page header */ public function addJS() { $this->cal->include_script('lib/js/moment.js'); $this->cal->include_script('lib/js/fullcalendar.js'); if ($this->rc->task == 'calendar' && $this->rc->action == 'print') { $this->cal->include_script('print.js'); } else { $this->rc->output->include_script('treelist.js'); $this->cal->api->include_script('libkolab/libkolab.js'); $this->cal->include_script('calendar_ui.js'); jqueryui::miniColors(); } } /** * Add custom style for the calendar UI */ function calendar_css($attrib = []) { $categories = $this->cal->driver->list_categories(); $calendars = $this->cal->driver->list_calendars(); $js_categories = []; $mode = $this->rc->config->get('calendar_event_coloring', $this->cal->defaults['calendar_event_coloring']); $css = "\n"; foreach ((array) $categories as $class => $color) { if (!empty($color)) { $js_categories[$class] = $color; $color = ltrim($color, '#'); $class = 'cat-' . asciiwords(strtolower($class), true); $css .= ".$class { color: #$color; }\n"; } } $this->rc->output->set_env('calendar_categories', $js_categories); foreach ((array) $calendars as $id => $prop) { if (!empty($prop['color'])) { $css .= $this->calendar_css_classes($id, $prop, $mode, $attrib); } } return html::tag('style', ['type' => 'text/css'], $css); } /** * Calendar folder specific CSS classes */ public function calendar_css_classes($id, $prop, $mode, $attrib = []) { $color = $folder_color = $prop['color']; // replace white with skin-defined color if (!empty($attrib['folder-fallback-color']) && preg_match('/^f+$/i', $folder_color)) { $folder_color = ltrim($attrib['folder-fallback-color'], '#'); } $class = 'cal-' . asciiwords($id, true); $css = "li .$class"; if (!empty($attrib['folder-class'])) { $css = str_replace('$class', $class, $attrib['folder-class']); } $css .= " { color: #$folder_color; }\n"; return $css . ".$class .handle { background-color: #$color; }\n"; } /** * Generate HTML content of the calendars list (or metadata only) */ function calendar_list($attrib = [], $js_only = false) { $html = ''; $jsenv = []; $tree = true; $calendars = $this->cal->driver->list_calendars(0, $tree); // walk folder tree if (is_object($tree)) { $html = $this->list_tree_html($tree, $calendars, $jsenv, $attrib); // append birthdays calendar which isn't part of $tree if (!empty($calendars[calendar_driver::BIRTHDAY_CALENDAR_ID])) { $bdaycal = $calendars[calendar_driver::BIRTHDAY_CALENDAR_ID]; $calendars = [calendar_driver::BIRTHDAY_CALENDAR_ID => $bdaycal]; } else { $calendars = []; // clear array for flat listing } } else if (isset($attrib['class'])) { // fall-back to flat folder listing $attrib['class'] .= ' flat'; } foreach ((array) $calendars as $id => $prop) { if (!empty($attrib['activeonly']) && empty($prop['active'])) { continue; } $li_content = $this->calendar_list_item($id, $prop, $jsenv, !empty($attrib['activeonly'])); $li_attr = [ 'id' => 'rcmlical' . $id, 'class' => isset($prop['group']) ? $prop['group'] : null, ]; $html .= html::tag('li', $li_attr, $li_content); } $this->rc->output->set_env('calendars', $jsenv); if ($js_only) { return; } $this->rc->output->set_env('source', rcube_utils::get_input_value('source', rcube_utils::INPUT_GET)); $this->rc->output->add_gui_object('calendarslist', !empty($attrib['id']) ? $attrib['id'] : 'rccalendarlist'); return html::tag('ul', $attrib, $html, html::$common_attrib); } /** * Return html for a structured list <ul> for the folder tree */ public function list_tree_html($node, $data, &$jsenv, $attrib) { $out = ''; foreach ($node->children as $folder) { $id = $folder->id; $prop = $data[$id]; $is_collapsed = false; // TODO: determine this somehow? $content = $this->calendar_list_item($id, $prop, $jsenv, !empty($attrib['activeonly'])); if (!empty($folder->children)) { $content .= html::tag('ul', ['style' => $is_collapsed ? "display:none;" : null], $this->list_tree_html($folder, $data, $jsenv, $attrib) ); } if (strlen($content)) { $li_attr = [ 'id' => 'rcmlical' . rcube_utils::html_identifier($id), 'class' => $prop['group'] . (!empty($prop['virtual']) ? ' virtual' : ''), ]; $out .= html::tag('li', $li_attr, $content); } } return $out; } /** * Helper method to build a calendar list item (HTML content and js data) */ public function calendar_list_item($id, $prop, &$jsenv, $activeonly = false) { // enrich calendar properties with settings from the driver if (empty($prop['virtual'])) { unset($prop['user_id']); $prop['alarms'] = $this->cal->driver->alarms; $prop['attendees'] = $this->cal->driver->attendees; $prop['freebusy'] = $this->cal->driver->freebusy; $prop['attachments'] = $this->cal->driver->attachments; $prop['undelete'] = $this->cal->driver->undelete; $prop['feedurl'] = $this->cal->get_url([ '_cal' => $this->cal->ical_feed_hash($id) . '.ics', 'action' => 'feed' ] ); $jsenv[$id] = $prop; } if (!empty($prop['title'])) { $title = $prop['title']; } else if ($prop['name'] != $prop['listname'] || strlen($prop['name']) > 25) { $title = html_entity_decode($prop['name'], ENT_COMPAT, RCUBE_CHARSET); } else { $title = ''; } $classes = ['calendar', 'cal-' . asciiwords($id, true)]; if (!empty($prop['virtual'])) { $classes[] = 'virtual'; } else if (empty($prop['editable'])) { $classes[] = 'readonly'; } if (!empty($prop['subscribed'])) { $classes[] = 'subscribed'; if ($prop['subscribed'] === 2) { $classes[] = 'partial'; } } if (!empty($prop['class'])) { $classes[] = $prop['class']; } $content = ''; if (!$activeonly || !empty($prop['active'])) { $label_id = 'cl:' . $id; $content = html::a( ['class' => 'calname', 'id' => $label_id, 'title' => $title, 'href' => '#'], rcube::Q(!empty($prop['editname']) ? $prop['editname'] : $prop['listname']) ); if (empty($prop['virtual'])) { $color = !empty($prop['color']) ? $prop['color'] : 'f00'; $actions = ''; if (!empty($prop['removable'])) { $actions .= html::a([ 'href' => '#', 'class' => 'remove', 'title' => $this->cal->gettext('removelist') ], ' ' ); } $actions .= html::a([ 'href' => '#', 'class' => 'quickview', 'title' => $this->cal->gettext('quickview'), 'role' => 'checkbox', 'aria-checked' => 'false' ], '' ); if (!isset($prop['subscriptions']) || $prop['subscriptions'] !== false) { if (!empty($prop['subscribed'])) { $actions .= html::a([ 'href' => '#', 'class' => 'subscribed', 'title' => $this->cal->gettext('calendarsubscribe'), 'role' => 'checkbox', 'aria-checked' => !empty($prop['subscribed']) ? 'true' : 'false' ], ' ' ); } } $content .= html::tag('input', [ 'type' => 'checkbox', 'name' => '_cal[]', 'value' => $id, 'checked' => !empty($prop['active']), 'aria-labelledby' => $label_id ]) . html::span('actions', $actions) . html::span(['class' => 'handle', 'style' => "background-color: #$color"], ' '); } $content = html::div(join(' ', $classes), $content); } return $content; } /** * Render a HTML for agenda options form */ function agenda_options($attrib = []) { $attrib += ['id' => 'agendaoptions']; $attrib['style'] = 'display:none'; $select_range = new html_select(['name' => 'listrange', 'id' => 'agenda-listrange', 'class' => 'form-control custom-select']); $select_range->add(1 . ' ' . preg_replace('/\(.+\)/', '', $this->cal->lib->gettext('days')), ''); foreach ([2,5,7,14,30,60,90,180,365] as $days) { $select_range->add($days . ' ' . preg_replace('/\(|\)/', '', $this->cal->lib->gettext('days')), $days); } $html = html::span('input-group', html::label(['for' => 'agenda-listrange', 'class' => 'input-group-prepend'], html::span('input-group-text', $this->cal->gettext('listrange')) ) . $select_range->show($this->rc->config->get('calendar_agenda_range', $this->cal->defaults['calendar_agenda_range'])) ); return html::div($attrib, $html); } /** * Render a HTML select box for calendar selection */ function calendar_select($attrib = []) { $attrib['name'] = 'calendar'; $attrib['is_escaped'] = true; $select = new html_select($attrib); foreach ((array) $this->cal->driver->list_calendars() as $id => $prop) { if ( !empty($prop['editable']) || (!empty($prop['rights']) && strpos($prop['rights'], 'i') !== false) ) { $select->add($prop['name'], $id); } } return $select->show(null); } /** * Render a HTML select box for user identity selection */ function identity_select($attrib = []) { $attrib['name'] = 'identity'; $select = new html_select($attrib); $identities = $this->rc->user->list_emails(); foreach ($identities as $ident) { $select->add(format_email_recipient($ident['email'], $ident['name']), $ident['identity_id']); } return $select->show(null); } /** * Render a HTML select box to select an event category */ function category_select($attrib = []) { $attrib['name'] = 'categories'; $select = new html_select($attrib); $select->add('---', ''); foreach (array_keys((array) $this->cal->driver->list_categories()) as $cat) { $select->add($cat, $cat); } return $select->show(null); } /** * Render a HTML select box for status property */ function status_select($attrib = []) { $attrib['name'] = 'status'; $select = new html_select($attrib); $select->add('---', ''); $select->add($this->cal->gettext('status-confirmed'), 'CONFIRMED'); $select->add($this->cal->gettext('status-cancelled'), 'CANCELLED'); $select->add($this->cal->gettext('status-tentative'), 'TENTATIVE'); return $select->show(null); } /** * Render a HTML select box for free/busy/out-of-office property */ function freebusy_select($attrib = []) { $attrib['name'] = 'freebusy'; $select = new html_select($attrib); $select->add($this->cal->gettext('free'), 'free'); $select->add($this->cal->gettext('busy'), 'busy'); // out-of-office is not supported by libkolabxml (#3220) // $select->add($this->cal->gettext('outofoffice'), 'outofoffice'); $select->add($this->cal->gettext('tentative'), 'tentative'); return $select->show(null); } /** * Render a HTML select for event priorities */ function priority_select($attrib = []) { $attrib['name'] = 'priority'; $select = new html_select($attrib); $select->add('---', '0'); $select->add('1 ' . $this->cal->gettext('highest'), '1'); $select->add('2 ' . $this->cal->gettext('high'), '2'); $select->add('3 ', '3'); $select->add('4 ', '4'); $select->add('5 ' . $this->cal->gettext('normal'), '5'); $select->add('6 ', '6'); $select->add('7 ', '7'); $select->add('8 ' . $this->cal->gettext('low'), '8'); $select->add('9 ' . $this->cal->gettext('lowest'), '9'); return $select->show(null); } /** * Render HTML form for alarm configuration */ function alarm_select($attrib = []) { return $this->cal->lib->alarm_select($attrib, $this->cal->driver->alarm_types, $this->cal->driver->alarm_absolute); } /** * Render HTML for attendee notification warning */ function edit_attendees_notify($attrib = []) { $checkbox = new html_checkbox(['name' => '_notify', 'id' => 'edit-attendees-donotify', 'value' => 1, 'class' => 'pretty-checkbox']); return html::div($attrib, html::label(null, $checkbox->show(1) . ' ' . $this->cal->gettext('sendnotifications'))); } /** * Render HTML for recurrence option to align start date with the recurrence rule */ function edit_recurrence_sync($attrib = []) { $checkbox = new html_checkbox(['name' => '_start_sync', 'value' => 1, 'class' => 'pretty-checkbox']); return html::div($attrib, html::label(null, $checkbox->show(1) . ' ' . $this->cal->gettext('eventstartsync'))); } /** * Generate the form for recurrence settings */ function recurring_event_warning($attrib = []) { $attrib['id'] = 'edit-recurring-warning'; $radio = new html_radiobutton(['name' => '_savemode', 'class' => 'edit-recurring-savemode']); $form = html::label(null, $radio->show('', ['value' => 'current']) . $this->cal->gettext('currentevent')) . ' ' . html::label(null, $radio->show('', ['value' => 'future']) . $this->cal->gettext('futurevents')) . ' ' . html::label(null, $radio->show('all', ['value' => 'all']) . $this->cal->gettext('allevents')) . ' ' . html::label(null, $radio->show('', ['value' => 'new']) . $this->cal->gettext('saveasnew')); return html::div($attrib, html::div('message', $this->cal->gettext('changerecurringeventwarning')) . html::div('savemode', $form) ); } /** * Form for uploading and importing events */ function events_import_form($attrib = []) { if (empty($attrib['id'])) { $attrib['id'] = 'rcmImportForm'; } // Get max filesize, enable upload progress bar $max_filesize = $this->rc->upload_init(); $accept = '.ics, text/calendar, text/x-vcalendar, application/ics'; if (class_exists('ZipArchive', false)) { $accept .= ', .zip, application/zip'; } $input = new html_inputfield([ 'id' => 'importfile', 'type' => 'file', 'name' => '_data', 'size' => !empty($attrib['uploadfieldsize']) ? $attrib['uploadfieldsize'] : null, 'accept' => $accept ]); $select = new html_select(['name' => '_range', 'id' => 'event-import-range']); $select->add([ $this->cal->gettext('onemonthback'), $this->cal->gettext(['name' => 'nmonthsback', 'vars' => ['nr'=>2]]), $this->cal->gettext(['name' => 'nmonthsback', 'vars' => ['nr'=>3]]), $this->cal->gettext(['name' => 'nmonthsback', 'vars' => ['nr'=>6]]), $this->cal->gettext(['name' => 'nmonthsback', 'vars' => ['nr'=>12]]), $this->cal->gettext('all'), ], ['1','2','3','6','12',0] ); $html = html::div('form-section form-group row', html::label(['class' => 'col-sm-4 col-form-label', 'for' => 'importfile'], rcube::Q($this->rc->gettext('importfromfile')) ) . html::div('col-sm-8', $input->show() . html::div('hint', $this->rc->gettext(['name' => 'maxuploadsize', 'vars' => ['size' => $max_filesize]])) ) ); $html .= html::div('form-section form-group row', html::label(['for' => 'event-import-calendar', 'class' => 'col-form-label col-sm-4'], $this->cal->gettext('calendar') ) . html::div('col-sm-8', $this->calendar_select(['name' => 'calendar', 'id' => 'event-import-calendar'])) ); $html .= html::div('form-section form-group row', html::label(['for' => 'event-import-range', 'class' => 'col-form-label col-sm-4'], $this->cal->gettext('importrange') ) . html::div('col-sm-8', $select->show(1)) ); $this->rc->output->add_gui_object('importform', $attrib['id']); $this->rc->output->add_label('import'); return html::tag('p', null, $this->cal->gettext('importtext')) . html::tag('form', [ 'action' => $this->rc->url(['task' => 'calendar', 'action' => 'import_events']), 'method' => 'post', 'enctype' => 'multipart/form-data', 'id' => $attrib['id'] ], $html ); } /** * Form to select options for exporting events */ function events_export_form($attrib = []) { if (empty($attrib['id'])) { $attrib['id'] = 'rcmExportForm'; } $html = html::div('form-section form-group row', html::label(['for' => 'event-export-calendar', 'class' => 'col-sm-4 col-form-label'], $this->cal->gettext('calendar') ) . html::div('col-sm-8', $this->calendar_select(['name' => 'calendar', 'id' => 'event-export-calendar', 'class' => 'form-control custom-select'])) ); $select = new html_select([ 'name' => 'range', 'id' => 'event-export-range', 'class' => 'form-control custom-select rounded-right' ]); $select->add([ $this->cal->gettext('all'), $this->cal->gettext('onemonthback'), $this->cal->gettext(['name' => 'nmonthsback', 'vars' => ['nr' => 2]]), $this->cal->gettext(['name' => 'nmonthsback', 'vars' => ['nr' => 3]]), $this->cal->gettext(['name' => 'nmonthsback', 'vars' => ['nr' => 6]]), $this->cal->gettext(['name' => 'nmonthsback', 'vars' => ['nr' => 12]]), $this->cal->gettext('customdate'), ], [0,'1','2','3','6','12','custom'] ); $startdate = new html_inputfield([ 'name' => 'start', 'size' => 11, 'id' => 'event-export-startdate', 'style' => 'display:none' ]); $html .= html::div('form-section form-group row', html::label(['for' => 'event-export-range', 'class' => 'col-sm-4 col-form-label'], $this->cal->gettext('exportrange') ) . html::div('col-sm-8 input-group', $select->show(0) . $startdate->show()) ); $checkbox = new html_checkbox([ 'name' => 'attachments', 'id' => 'event-export-attachments', 'value' => 1, 'class' => 'form-check-input pretty-checkbox' ]); $html .= html::div('form-section form-check row', html::label(['for' => 'event-export-attachments', 'class' => 'col-sm-4 col-form-label'], $this->cal->gettext('exportattachments') ) . html::div('col-sm-8', $checkbox->show(1)) ); $this->rc->output->add_gui_object('exportform', $attrib['id']); return html::tag('form', $attrib + [ 'action' => $this->rc->url(['task' => 'calendar', 'action' => 'export_events']), 'method' => 'post', 'id' => $attrib['id'] ], $html ); } /** * Handler for calendar form template. * The form content could be overriden by the driver */ function calendar_editform($action, $calendar = []) { $this->action = $action; $this->calendar = $calendar; // load miniColors js/css files jqueryui::miniColors(); $this->rc->output->set_env('pagetitle', $this->cal->gettext('calendarprops')); $this->rc->output->add_handler('folderform', [$this, 'calendarform']); $this->rc->output->send('libkolab.folderform'); } /** * Handler for calendar form template. * The form content could be overriden by the driver */ function calendarform($attrib) { // compose default calendar form fields $input_name = new html_inputfield(['name' => 'name', 'id' => 'calendar-name', 'size' => 20]); $input_color = new html_inputfield(['name' => 'color', 'id' => 'calendar-color', 'size' => 7, 'class' => 'colors']); $formfields = [ 'name' => [ 'label' => $this->cal->gettext('name'), 'value' => $input_name->show(isset($this->calendar['name']) ? $this->calendar['name'] : ''), 'id' => 'calendar-name', ], 'color' => [ 'label' => $this->cal->gettext('color'), 'value' => $input_color->show(isset($this->calendar['color']) ? $this->calendar['color'] : ''), 'id' => 'calendar-color', ], ]; if (!empty($this->cal->driver->alarms)) { $checkbox = new html_checkbox(['name' => 'showalarms', 'id' => 'calendar-showalarms', 'value' => 1]); $formfields['showalarms'] = [ 'label' => $this->cal->gettext('showalarms'), 'value' => $checkbox->show(!empty($this->calendar['showalarms']) ? 1 : 0), 'id' => 'calendar-showalarms', ]; } // allow driver to extend or replace the form content return html::tag('form', $attrib + ['action' => '#', 'method' => 'get', 'id' => 'calendarpropform'], $this->cal->driver->calendar_form($this->action, $this->calendar, $formfields) ); } /** * Render HTML for attendees table */ function attendees_list($attrib = []) { // add "noreply" checkbox to attendees table only $invitations = strpos($attrib['id'], 'attend') !== false; $invite = new html_checkbox(['value' => 1, 'id' => 'edit-attendees-invite']); $table = new html_table(['cols' => 5 + intval($invitations), 'border' => 0, 'cellpadding' => 0, 'class' => 'rectable']); $table->add_header('role', $this->cal->gettext('role')); $table->add_header('name', $this->cal->gettext(!empty($attrib['coltitle']) ? $attrib['coltitle'] : 'attendee')); $table->add_header('availability', $this->cal->gettext('availability')); $table->add_header('confirmstate', $this->cal->gettext('confirmstate')); if ($invitations) { $table->add_header(['class' => 'invite', 'title' => $this->cal->gettext('sendinvitations')], $invite->show(1) . html::label('edit-attendees-invite', html::span('inner', $this->cal->gettext('sendinvitations'))) ); } $table->add_header('options', ''); // hide invite column if disabled by config $itip_notify = (int)$this->rc->config->get('calendar_itip_send_option', $this->cal->defaults['calendar_itip_send_option']); if ($invitations && !($itip_notify & 2)) { $css = sprintf('#%s td.invite, #%s th.invite { display:none !important }', $attrib['id'], $attrib['id']); $this->rc->output->add_footer(html::tag('style', ['type' => 'text/css'], $css)); } return $table->show($attrib); } /** * Render HTML for attendees adding form */ function attendees_form($attrib = []) { $input = new html_inputfield([ 'name' => 'participant', 'id' => 'edit-attendee-name', 'class' => 'form-control' ]); $textarea = new html_textarea([ 'name' => 'comment', 'id' => 'edit-attendees-comment', 'class' => 'form-control', 'rows' => 4, 'cols' => 55, 'title' => $this->cal->gettext('itipcommenttitle') ]); return html::div($attrib, html::div('form-searchbar', $input->show() . ' ' . html::tag('input', [ 'type' => 'button', 'class' => 'button', 'id' => 'edit-attendee-add', 'value' => $this->cal->gettext('addattendee') ]) . ' ' . html::tag('input', [ 'type' => 'button', 'class' => 'button', 'id' => 'edit-attendee-schedule', 'value' => $this->cal->gettext('scheduletime') . '...' ]) ) . html::p('attendees-commentbox', html::label('edit-attendees-comment', $this->cal->gettext('itipcomment')) . $textarea->show()) ); } /** * Render HTML for resources adding form */ function resources_form($attrib = []) { $input = new html_inputfield(['name' => 'resource', 'id' => 'edit-resource-name', 'class' => 'form-control']); return html::div($attrib, html::div('form-searchbar', $input->show() . ' ' . html::tag('input', [ 'type' => 'button', 'class' => 'button', 'id' => 'edit-resource-add', 'value' => $this->cal->gettext('addresource') ]) . ' ' . html::tag('input', [ 'type' => 'button', 'class' => 'button', 'id' => 'edit-resource-find', 'value' => $this->cal->gettext('findresources') . '...' ]) ) ); } /** * Render HTML for resources list */ function resources_list($attrib = []) { $attrib += ['id' => 'calendar-resources-list']; $this->rc->output->add_gui_object('resourceslist', $attrib['id']); return html::tag('ul', $attrib, '', html::$common_attrib); } /** * */ public function resource_info($attrib = []) { $attrib += ['id' => 'calendar-resources-info']; $this->rc->output->add_gui_object('resourceinfo', $attrib['id']); $this->rc->output->add_gui_object('resourceownerinfo', $attrib['id'] . '-owner'); // copy address book labels for owner details to client $this->rc->output->add_label('name','firstname','surname','department','jobtitle','email','phone','address'); $table_attrib = ['id','class','style','width','summary','cellpadding','cellspacing','border']; return html::tag('table', $attrib, html::tag('tbody', null, ''), $table_attrib) . html::tag('table', ['id' => $attrib['id'] . '-owner', 'style' => 'display:none'] + $attrib, html::tag('thead', null, html::tag('tr', null, html::tag('td', ['colspan' => 2], rcube::Q($this->cal->gettext('resourceowner'))) ) ) . html::tag('tbody', null, ''), $table_attrib ); } /** * */ public function resource_calendar($attrib = []) { $attrib += ['id' => 'calendar-resources-calendar']; $this->rc->output->add_gui_object('resourceinfocalendar', $attrib['id']); return html::div($attrib, ''); } /** * GUI object 'searchform' for the resource finder dialog * * @param array $attrib Named parameters * * @return string HTML code for the gui object */ function resources_search_form($attrib) { $attrib += [ 'command' => 'search-resource', 'reset-command' => 'reset-resource-search', 'id' => 'rcmcalresqsearchbox', 'autocomplete' => 'off', 'form-name' => 'rcmcalresoursqsearchform', 'gui-object' => 'resourcesearchform', ]; // add form tag around text field return $this->rc->output->search_form($attrib); } /** * */ function attendees_freebusy_table($attrib = []) { $table = new html_table(['cols' => 2, 'border' => 0, 'cellspacing' => 0]); $table->add('attendees', html::tag('h3', 'boxtitle', $this->cal->gettext('tabattendees')) . html::div('timesheader', ' ') . html::div(['id' => 'schedule-attendees-list', 'class' => 'attendees-list'], '') ); $table->add('times', html::div('scroll', html::tag('table', ['id' => 'schedule-freebusy-times', 'border' => 0, 'cellspacing' => 0], html::tag('thead') . html::tag('tbody') ) . html::div(['id' => 'schedule-event-time', 'style' => 'display:none'], ' ') ) ); return $table->show($attrib); } /** * */ function event_invitebox($attrib = []) { if (!empty($this->cal->event)) { return html::div($attrib, $this->cal->itip->itip_object_details_table($this->cal->event, $this->cal->itip->gettext('itipinvitation')) . $this->cal->invitestatus ); } return ''; } function event_rsvp_buttons($attrib = []) { $actions = ['accepted', 'tentative', 'declined']; if (empty($attrib['delegate']) || $attrib['delegate'] !== 'false') { $actions[] = 'delegated'; } return $this->cal->itip->itip_rsvp_buttons($attrib, $actions); } } diff --git a/plugins/libcalendaring/lib/libcalendaring_vcalendar.php b/plugins/libcalendaring/lib/libcalendaring_vcalendar.php index f344b41f..2a79f36d 100644 --- a/plugins/libcalendaring/lib/libcalendaring_vcalendar.php +++ b/plugins/libcalendaring/lib/libcalendaring_vcalendar.php @@ -1,1589 +1,1589 @@ <?php /** * iCalendar functions for the libcalendaring plugin * * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2013-2015, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. */ use \Sabre\VObject; use \Sabre\VObject\DateTimeParser; /** * Class to parse and build vCalendar (iCalendar) files * * Uses the Sabre VObject library, version 3.x. * */ class libcalendaring_vcalendar implements Iterator { private $timezone; - private $attach_uri = null; - private $prodid = '-//Roundcube libcalendaring//Sabre//Sabre VObject//EN'; + private $attach_uri; + private $prodid; private $type_component_map = array('event' => 'VEVENT', 'task' => 'VTODO'); private $attendee_keymap = array( 'name' => 'CN', 'status' => 'PARTSTAT', 'role' => 'ROLE', 'cutype' => 'CUTYPE', 'rsvp' => 'RSVP', 'delegated-from' => 'DELEGATED-FROM', 'delegated-to' => 'DELEGATED-TO', 'schedule-status' => 'SCHEDULE-STATUS', 'schedule-agent' => 'SCHEDULE-AGENT', 'sent-by' => 'SENT-BY', ); private $organizer_keymap = array( 'name' => 'CN', 'schedule-status' => 'SCHEDULE-STATUS', 'schedule-agent' => 'SCHEDULE-AGENT', 'sent-by' => 'SENT-BY', ); private $iteratorkey = 0; private $charset; private $forward_exceptions; private $vhead; private $fp; private $vtimezones = array(); public $method; public $agent = ''; public $objects = array(); public $freebusy = array(); /** * Default constructor */ function __construct($tz = null) { $this->timezone = $tz; - $this->prodid = '-//Roundcube libcalendaring ' . RCUBE_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN'; + $this->prodid = '-//Roundcube ' . RCUBE_VERSION . '//Sabre VObject ' . VObject\Version::VERSION . '//EN'; } /** * Setter for timezone information */ public function set_timezone($tz) { $this->timezone = $tz; } /** * Setter for URI template for attachment links */ public function set_attach_uri($uri) { $this->attach_uri = $uri; } /** * Setter for a custom PRODID attribute */ public function set_prodid($prodid) { $this->prodid = $prodid; } /** * Setter for a user-agent string to tweak input/output accordingly */ public function set_agent($agent) { $this->agent = $agent; } /** * Free resources by clearing member vars */ public function reset() { $this->vhead = ''; $this->method = ''; $this->objects = array(); $this->freebusy = array(); $this->vtimezones = array(); $this->iteratorkey = 0; if ($this->fp) { fclose($this->fp); $this->fp = null; } } /** * Import events from iCalendar format * * @param string vCalendar input * @param string Input charset (from envelope) * @param bool True if parsing exceptions should be forwarded to the caller * * @return array List of events extracted from the input */ public function import($vcal, $charset = 'UTF-8', $forward_exceptions = false, $memcheck = true) { // TODO: convert charset to UTF-8 if other try { // estimate the memory usage and try to avoid fatal errors when allowed memory gets exhausted if ($memcheck) { $count = substr_count($vcal, 'BEGIN:VEVENT') + substr_count($vcal, 'BEGIN:VTODO'); $expected_memory = $count * 70*1024; // assume ~ 70K per event (empirically determined) if (!rcube_utils::mem_check($expected_memory)) { throw new Exception("iCal file too big"); } } $vobject = VObject\Reader::read($vcal, VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES); if ($vobject) return $this->import_from_vobject($vobject); } catch (Exception $e) { if ($forward_exceptions) { throw $e; } else { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "iCal data parse error: " . $e->getMessage()), true, false); } } return []; } /** * Read iCalendar events from a file * * @param string File path to read from * @param string Input charset (from envelope) * @param bool True if parsing exceptions should be forwarded to the caller * * @return array List of events extracted from the file */ public function import_from_file($filepath, $charset = 'UTF-8', $forward_exceptions = false) { if ($this->fopen($filepath, $charset, $forward_exceptions)) { while ($this->_parse_next(false)) { // nop } fclose($this->fp); $this->fp = null; } return $this->objects; } /** * Open a file to read iCalendar events sequentially * * @param string File path to read from * @param string Input charset (from envelope) * @param boolean True if parsing exceptions should be forwarded to the caller * @return boolean True if file contents are considered valid */ public function fopen($filepath, $charset = 'UTF-8', $forward_exceptions = false) { $this->reset(); // just to be sure... @ini_set('auto_detect_line_endings', true); $this->charset = $charset; $this->forward_exceptions = $forward_exceptions; $this->fp = fopen($filepath, 'r'); // check file content first $begin = fread($this->fp, 1024); if (!preg_match('/BEGIN:VCALENDAR/i', $begin)) { return false; } fseek($this->fp, 0); return $this->_parse_next(); } /** * Parse the next event/todo/freebusy object from the input file */ private function _parse_next($reset = true) { if ($reset) { $this->iteratorkey = 0; $this->objects = array(); $this->freebusy = array(); } $next = $this->_next_component(); $buffer = $next; // load the next component(s) too, as they could contain recurrence exceptions while (preg_match('/(RRULE|RECURRENCE-ID)[:;]/i', $next)) { $next = $this->_next_component(); $buffer .= $next; } // parse the vevent block surrounded with the vcalendar heading if (strlen($buffer) && preg_match('/BEGIN:(VEVENT|VTODO|VFREEBUSY)/i', $buffer)) { try { $this->import($this->vhead . $buffer . "END:VCALENDAR", $this->charset, true, false); } catch (Exception $e) { if ($this->forward_exceptions) { throw new VObject\ParseException($e->getMessage() . " in\n" . $buffer); } else { // write the failing section to error log rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => $e->getMessage() . " in\n" . $buffer), true, false); } // advance to next return $this->_parse_next($reset); } return count($this->objects) > 0; } return false; } /** * Helper method to read the next calendar component from the file */ private function _next_component() { $buffer = ''; $vcalendar_head = false; while (($line = fgets($this->fp, 1024)) !== false) { // ignore END:VCALENDAR lines if (preg_match('/END:VCALENDAR/i', $line)) { continue; } // read vcalendar header (with timezone defintion) if (preg_match('/BEGIN:VCALENDAR/i', $line)) { $this->vhead = ''; $vcalendar_head = true; } // end of VCALENDAR header part if ($vcalendar_head && preg_match('/BEGIN:(VEVENT|VTODO|VFREEBUSY)/i', $line)) { $vcalendar_head = false; } if ($vcalendar_head) { $this->vhead .= $line; } else { $buffer .= $line; if (preg_match('/END:(VEVENT|VTODO|VFREEBUSY)/i', $line)) { break; } } } return $buffer; } /** * Import objects from an already parsed Sabre\VObject\Component object * * @param object Sabre\VObject\Component to read from * @return array List of events extracted from the file */ public function import_from_vobject($vobject) { $seen = array(); $exceptions = array(); if ($vobject->name == 'VCALENDAR') { $this->method = strval($vobject->METHOD); $this->agent = strval($vobject->PRODID); foreach ($vobject->getComponents() as $ve) { if ($ve->name == 'VEVENT' || $ve->name == 'VTODO') { // convert to hash array representation $object = $this->_to_array($ve); // temporarily store this as exception if (!empty($object['recurrence_date'])) { $exceptions[] = $object; } else if (empty($seen[$object['uid']])) { $seen[$object['uid']] = true; $this->objects[] = $object; } } else if ($ve->name == 'VFREEBUSY') { $this->objects[] = $this->_parse_freebusy($ve); } } // add exceptions to the according master events foreach ($exceptions as $exception) { $uid = $exception['uid']; // make this exception the master if (empty($seen[$uid])) { $seen[$uid] = true; $this->objects[] = $exception; } else { foreach ($this->objects as $i => $object) { // add as exception to existing entry with a matching UID if ($object['uid'] == $uid) { $this->objects[$i]['exceptions'][] = $exception; if (!empty($object['recurrence'])) { $this->objects[$i]['recurrence']['EXCEPTIONS'] = &$this->objects[$i]['exceptions']; } break; } } } } } return $this->objects; } /** * Getter for free-busy periods */ public function get_busy_periods() { $out = array(); foreach ((array)$this->freebusy['periods'] as $period) { if ($period[2] != 'FREE') { $out[] = $period; } } return $out; } /** * Helper method to determine whether the connected client is an Apple device */ private function is_apple() { return stripos($this->agent, 'Apple') !== false || stripos($this->agent, 'Mac OS X') !== false || stripos($this->agent, 'iOS/') !== false; } /** * Convert the given VEvent object to a libkolab compatible array representation * * @param object Vevent object to convert * @return array Hash array with object properties */ private function _to_array($ve) { $event = array( 'uid' => self::convert_string($ve->UID), 'title' => self::convert_string($ve->SUMMARY), '_type' => $ve->name == 'VTODO' ? 'task' : 'event', // set defaults 'priority' => 0, 'attendees' => array(), 'x-custom' => array(), ); // Catch possible exceptions when date is invalid (Bug #2144) // We can skip these fields, they aren't critical foreach (array('CREATED' => 'created', 'LAST-MODIFIED' => 'changed', 'DTSTAMP' => 'changed') as $attr => $field) { try { if (empty($event[$field]) && !empty($ve->{$attr})) { $event[$field] = $ve->{$attr}->getDateTime(); } } catch (Exception $e) {} } // map other attributes to internal fields foreach ($ve->children() as $prop) { if (!($prop instanceof VObject\Property)) continue; $value = strval($prop); switch ($prop->name) { case 'DTSTART': case 'DTEND': case 'DUE': $propmap = array('DTSTART' => 'start', 'DTEND' => 'end', 'DUE' => 'due'); $event[$propmap[$prop->name]] = self::convert_datetime($prop); break; case 'TRANSP': $event['free_busy'] = strval($prop) == 'TRANSPARENT' ? 'free' : 'busy'; break; case 'STATUS': if ($value == 'TENTATIVE') $event['free_busy'] = 'tentative'; else if ($value == 'CANCELLED') $event['cancelled'] = true; else if ($value == 'COMPLETED') $event['complete'] = 100; $event['status'] = $value; break; case 'COMPLETED': if (self::convert_datetime($prop)) { $event['status'] = 'COMPLETED'; $event['complete'] = 100; } break; case 'PRIORITY': if (is_numeric($value)) $event['priority'] = $value; break; case 'RRULE': $params = !empty($event['recurrence']) && is_array($event['recurrence']) ? $event['recurrence'] : array(); // parse recurrence rule attributes foreach ($prop->getParts() as $k => $v) { $params[strtoupper($k)] = is_array($v) ? implode(',', $v) : $v; } if (!empty($params['UNTIL'])) { $params['UNTIL'] = date_create($params['UNTIL']); } if (empty($params['INTERVAL'])) { $params['INTERVAL'] = 1; } $event['recurrence'] = array_filter($params); break; case 'EXDATE': if (!empty($value)) { $exdates = array_map(function($_) { return is_array($_) ? $_[0] : $_; }, self::convert_datetime($prop, true)); if (!empty($event['recurrence']['EXDATE'])) { $event['recurrence']['EXDATE'] = array_merge($event['recurrence']['EXDATE'], $exdates); } else { $event['recurrence']['EXDATE'] = $exdates; } } break; case 'RDATE': if (!empty($value)) { $rdates = array_map(function($_) { return is_array($_) ? $_[0] : $_; }, self::convert_datetime($prop, true)); if (!empty($event['recurrence']['RDATE'])) { $event['recurrence']['RDATE'] = array_merge($event['recurrence']['RDATE'], $rdates); } else { $event['recurrence']['RDATE'] = $rdates; } } break; case 'RECURRENCE-ID': $event['recurrence_date'] = self::convert_datetime($prop); if ($prop->offsetGet('RANGE') == 'THISANDFUTURE' || $prop->offsetGet('THISANDFUTURE') !== null) { $event['thisandfuture'] = true; } break; case 'RELATED-TO': $reltype = $prop->offsetGet('RELTYPE'); if ($reltype == 'PARENT' || $reltype === null) { $event['parent_id'] = $value; } break; case 'SEQUENCE': $event['sequence'] = intval($value); break; case 'PERCENT-COMPLETE': $event['complete'] = intval($value); break; case 'LOCATION': case 'DESCRIPTION': case 'URL': case 'COMMENT': $event[strtolower($prop->name)] = self::convert_string($prop); break; case 'CATEGORY': case 'CATEGORIES': if (!empty($event['categories'])) { $event['categories'] = array_merge((array) $event['categories'], $prop->getParts()); } else { $event['categories'] = $prop->getParts(); } break; case 'X-MICROSOFT-CDO-BUSYSTATUS': if ($value == 'OOF') { $event['free_busy'] = 'outofoffice'; } else if (in_array($value, array('FREE', 'BUSY', 'TENTATIVE'))) { $event['free_busy'] = strtolower($value); } break; case 'ATTENDEE': case 'ORGANIZER': $params = array('RSVP' => false); foreach ($prop->parameters() as $pname => $pvalue) { switch ($pname) { case 'RSVP': $params[$pname] = strtolower($pvalue) == 'true'; break; case 'CN': $params[$pname] = self::unescape($pvalue); break; default: $params[$pname] = strval($pvalue); break; } } $attendee = self::map_keys($params, array_flip($this->attendee_keymap)); $attendee['email'] = preg_replace('!^mailto:!i', '', $value); if ($prop->name == 'ORGANIZER') { $attendee['role'] = 'ORGANIZER'; $attendee['status'] = 'ACCEPTED'; $event['organizer'] = $attendee; if (array_key_exists('schedule-agent', $attendee)) { $schedule_agent = $attendee['schedule-agent']; } } else if (empty($event['organizer']) || $attendee['email'] != $event['organizer']['email']) { $event['attendees'][] = $attendee; } break; case 'ATTACH': $params = self::parameters_array($prop); if (substr($value, 0, 4) == 'http' && !strpos($value, ':attachment:')) { $event['links'][] = $value; } else if (!empty($params['VALUE']) && strtoupper($params['VALUE']) == 'BINARY') { $attachment = self::map_keys($params, [ 'FMTTYPE' => 'mimetype', 'X-LABEL' => 'name', 'X-APPLE-FILENAME' => 'name', 'X-SIZE' => 'size' ]); $attachment['data'] = $value ?? null; $attachment['size'] = $attachment['size'] ?? strlen($value); $attachment['id'] = md5(($attachment['mimetype'] ?? 'application/octet-stream') . ($attachment['name'] ?? 'noname')); $event['attachments'][] = $attachment; } break; default: if (substr($prop->name, 0, 2) == 'X-') { $event['x-custom'][] = [$prop->name, strval($value)]; } break; } } // check DURATION property if no end date is set if (empty($event['end']) && $ve->DURATION) { try { $duration = new DateInterval(strval($ve->DURATION)); $end = clone $event['start']; $end->add($duration); $event['end'] = $end; } catch (\Exception $e) { trigger_error(strval($e), E_USER_WARNING); } } // validate event dates if ($event['_type'] == 'event') { $event['allday'] = !empty($event['start']->_dateonly); // events may lack the DTEND property, set it to DTSTART (RFC5545 3.6.1) if (empty($event['end'])) { $event['end'] = clone $event['start']; } // shift end-date by one day (except Thunderbird) else if ($event['allday'] && is_object($event['end'])) { $event['end']->sub(new \DateInterval('PT23H')); } // sanity-check and fix end date if (!empty($event['end']) && $event['end'] < $event['start']) { $event['end'] = clone $event['start']; } } // make organizer part of the attendees list for compatibility reasons if (!empty($event['organizer']) && is_array($event['attendees']) && $event['_type'] == 'event') { array_unshift($event['attendees'], $event['organizer']); } // find alarms foreach ($ve->select('VALARM') as $valarm) { $action = 'DISPLAY'; $trigger = null; $alarm = array(); foreach ($valarm->children() as $prop) { $value = strval($prop); switch ($prop->name) { case 'TRIGGER': foreach ($prop->parameters as $param) { if ($param->name == 'VALUE' && $param->getValue() == 'DATE-TIME') { $trigger = '@' . $prop->getDateTime()->format('U'); $alarm['trigger'] = $prop->getDateTime(); } else if ($param->name == 'RELATED') { $alarm['related'] = $param->getValue(); } } if (!$trigger && ($values = libcalendaring::parse_alarm_value($value))) { $trigger = $values[2]; } if (empty($alarm['trigger'])) { $alarm['trigger'] = rtrim(preg_replace('/([A-Z])0[WDHMS]/', '\\1', $value), 'T'); // if all 0-values have been stripped, assume 'at time' if ($alarm['trigger'] == 'P') { $alarm['trigger'] = 'PT0S'; } } break; case 'ACTION': $action = $alarm['action'] = strtoupper($value); break; case 'SUMMARY': case 'DESCRIPTION': case 'DURATION': $alarm[strtolower($prop->name)] = self::convert_string($prop); break; case 'REPEAT': $alarm['repeat'] = intval($value); break; case 'ATTENDEE': $alarm['attendees'][] = preg_replace('!^mailto:!i', '', $value); break; case 'ATTACH': $params = self::parameters_array($prop); if (strlen($value) && (preg_match('/^[a-z]+:/', $value) || strtoupper($params['VALUE']) == 'URI')) { // we only support URI-type of attachments here $alarm['uri'] = $value; } break; } } if ($action != 'NONE') { // store first alarm in legacy property if ($trigger && empty($event['alarms'])) { $event['alarms'] = $trigger . ':' . $action; } if (!empty($alarm['trigger'])) { $event['valarms'][] = $alarm; } } } // assign current timezone to event start/end if (!empty($event['start']) && $event['start'] instanceof DateTimeInterface) { $this->_apply_timezone($event['start']); } else { unset($event['start']); } if (!empty($event['end']) && $event['end'] instanceof DateTimeInterface) { $this->_apply_timezone($event['end']); } else { unset($event['end']); } // some iTip CANCEL messages only contain the start date if (empty($event['end']) && !empty($event['start']) && $this->method == 'CANCEL') { $event['end'] = clone $event['start']; } // T2531: Remember SCHEDULE-AGENT in custom property to properly // support event updates via CalDAV when SCHEDULE-AGENT=CLIENT is used if (isset($schedule_agent)) { $event['x-custom'][] = array('SCHEDULE-AGENT', $schedule_agent); } // minimal validation if (empty($event['uid']) || ($event['_type'] == 'event' && empty($event['start']) != empty($event['end']))) { throw new VObject\ParseException('Object validation failed: missing mandatory object properties'); } return $event; } /** * Apply user timezone to DateTime object */ private function _apply_timezone(&$date) { if (empty($this->timezone)) { return; } // For date-only we'll keep the date and time intact if (!empty($date->_dateonly)) { $dt = new libcalendaring_datetime(null, $this->timezone); $dt->setDate($date->format('Y'), $date->format('n'), $date->format('j')); $dt->setTime($date->format('G'), $date->format('i'), 0); $date = $dt; } else { $date->setTimezone($this->timezone); } } /** * Parse the given vfreebusy component into an array representation */ private function _parse_freebusy($ve) { $this->freebusy = ['_type' => 'freebusy', 'periods' => []]; $seen = []; foreach ($ve->children() as $prop) { if (!($prop instanceof VObject\Property)) continue; $value = strval($prop); switch ($prop->name) { case 'CREATED': case 'LAST-MODIFIED': case 'DTSTAMP': case 'DTSTART': case 'DTEND': $propmap = [ 'DTSTART' => 'start', 'DTEND' => 'end', 'CREATED' => 'created', 'LAST-MODIFIED' => 'changed', 'DTSTAMP' => 'changed' ]; $this->freebusy[$propmap[$prop->name]] = self::convert_datetime($prop); break; case 'ORGANIZER': $this->freebusy['organizer'] = preg_replace('!^mailto:!i', '', $value); break; case 'FREEBUSY': // The freebusy component can hold more than 1 value, separated by commas. $periods = explode(',', $value); $fbtype = strval($prop['FBTYPE']) ?: 'BUSY'; // skip dupes if (!empty($seen[$value.':'.$fbtype])) { break; } $seen[$value.':'.$fbtype] = true; foreach ($periods as $period) { // Every period is formatted as [start]/[end]. The start is an // absolute UTC time, the end may be an absolute UTC time, or // duration (relative) value. list($busyStart, $busyEnd) = explode('/', $period); $busyStart = DateTimeParser::parse($busyStart); $busyEnd = DateTimeParser::parse($busyEnd); if ($busyEnd instanceof \DateInterval) { $tmp = clone $busyStart; $tmp->add($busyEnd); $busyEnd = $tmp; } if ($busyEnd && $busyEnd > $busyStart) $this->freebusy['periods'][] = [$busyStart, $busyEnd, $fbtype]; } break; case 'COMMENT': $this->freebusy['comment'] = $value; } } return $this->freebusy; } /** * */ public static function convert_string($prop) { return strval($prop); } /** * */ public static function unescape($prop) { return str_replace('\,', ',', strval($prop)); } /** * Helper method to correctly interpret an all-day date value */ public static function convert_datetime($prop, $as_array = false) { if (empty($prop)) { return $as_array ? [] : null; } if ($prop instanceof VObject\Property\ICalendar\DateTime) { if (count($prop->getDateTimes()) > 1) { $dt = []; $dateonly = !$prop->hasTime(); foreach ($prop->getDateTimes() as $item) { $item = self::toDateTime($item); $item->_dateonly = $dateonly; $dt[] = $item; } } else { $dt = self::toDateTime($prop->getDateTime()); if (!$prop->hasTime()) { $dt->_dateonly = true; } } } else if ($prop instanceof VObject\Property\ICalendar\Period) { $dt = []; foreach ($prop->getParts() as $val) { try { list($start, $end) = explode('/', $val); $start = DateTimeParser::parseDateTime($start); // This is a duration value. if ($end[0] === 'P') { $dur = DateTimeParser::parseDuration($end); $end = clone $start; $end->add($dur); } else { $end = DateTimeParser::parseDateTime($end); } $dt[] = [self::toDateTime($start), self::toDateTime($end)]; } catch (Exception $e) { // ignore single date parse errors } } } else if ($prop instanceof \DateTimeInterface) { $dt = self::toDateTime($prop); } // force return value to array if requested if ($as_array && !is_array($dt)) { $dt = empty($dt) ? [] : [$dt]; } return $dt; } /** * Create a Sabre\VObject\Property instance from a PHP DateTime object * * @param object VObject\Document parent node to create property for * @param string Property name * @param object DateTime * @param boolean Set as UTC date * @param boolean Set as VALUE=DATE property */ public function datetime_prop($cal, $name, $dt, $utc = false, $dateonly = null, $set_type = false) { if ($utc) { $dt->setTimeZone(new \DateTimeZone('UTC')); $is_utc = true; } else { $is_utc = ($tz = $dt->getTimezone()) && in_array($tz->getName(), array('UTC','GMT','Z')); } $is_dateonly = $dateonly === null ? !empty($dt->_dateonly) : (bool) $dateonly; $vdt = $cal->createProperty($name, $dt, null, $is_dateonly ? 'DATE' : 'DATE-TIME'); if ($is_dateonly) { $vdt['VALUE'] = 'DATE'; } else if ($set_type) { $vdt['VALUE'] = 'DATE-TIME'; } // register timezone for VTIMEZONE block if (!$is_utc && !$dateonly && $tz && ($tzname = $tz->getName())) { $ts = $dt->format('U'); if (!empty($this->vtimezones[$tzname])) { $this->vtimezones[$tzname][0] = min($this->vtimezones[$tzname][0], $ts); $this->vtimezones[$tzname][1] = max($this->vtimezones[$tzname][1], $ts); } else { $this->vtimezones[$tzname] = array($ts, $ts); } } return $vdt; } /** * Copy values from one hash array to another using a key-map */ public static function map_keys($values, $map) { $out = array(); foreach ($map as $from => $to) { if (isset($values[$from])) $out[$to] = is_array($values[$from]) ? join(',', $values[$from]) : $values[$from]; } return $out; } /** * */ private static function parameters_array($prop) { $params = array(); foreach ($prop->parameters() as $name => $value) { $params[strtoupper($name)] = strval($value); } return $params; } /** * Export events to iCalendar format * * @param array Events as array * @param string VCalendar method to advertise * @param boolean Directly send data to stdout instead of returning * @param callable Callback function to fetch attachment contents, false if no attachment export * @param boolean Add VTIMEZONE block with timezone definitions for the included events * @return string Events in iCalendar format (http://tools.ietf.org/html/rfc5545) */ public function export($objects, $method = null, $write = false, $get_attachment = false, $with_timezones = true) { $this->method = $method; // encapsulate in VCALENDAR container $vcal = new VObject\Component\VCalendar(); $vcal->VERSION = '2.0'; $vcal->PRODID = $this->prodid; $vcal->CALSCALE = 'GREGORIAN'; if (!empty($method)) { $vcal->METHOD = $method; } // write vcalendar header if ($write) { echo preg_replace('/END:VCALENDAR[\r\n]*$/m', '', $vcal->serialize()); } foreach ($objects as $object) { $this->_to_ical($object, !$write?$vcal:false, $get_attachment); } // include timezone information if ($with_timezones || !empty($method)) { foreach ($this->vtimezones as $tzid => $range) { $vt = self::get_vtimezone($tzid, $range[0], $range[1], $vcal); if (empty($vt)) { continue; // no timezone information found } if ($write) { echo $vt->serialize(); } else { $vcal->add($vt); } } } if ($write) { echo "END:VCALENDAR\r\n"; return true; } else { return $vcal->serialize(); } } /** * Converts internal event representation to Sabre component * * @param array Event * @param callable Callback function to fetch attachment contents, false if no attachment export * * @return Sabre\VObject\Component\VEvent Sabre component */ public function toSabreComponent($object, $get_attachment = false) { $vcal = new VObject\Component\VCalendar(); $this->_to_ical($object, $vcal, $get_attachment); return $vcal->getBaseComponent(); } /** * Build a valid iCal format block from the given event * * @param array Hash array with event/task properties from libkolab * @param object VCalendar object to append event to or false for directly sending data to stdout * @param callable Callback function to fetch attachment contents, false if no attachment export * @param object RECURRENCE-ID property when serializing a recurrence exception */ private function _to_ical($event, $vcal, $get_attachment, $recurrence_id = null) { $type = !empty($event['_type']) ? $event['_type'] : 'event'; $cal = $vcal ?: new VObject\Component\VCalendar(); $ve = $cal->create($this->type_component_map[$type]); $ve->UID = $event['uid']; // set DTSTAMP according to RFC 5545, 3.8.7.2. $dtstamp = !empty($event['changed']) && empty($this->method) ? $event['changed'] : new DateTime('now', new \DateTimeZone('UTC')); $ve->DTSTAMP = $this->datetime_prop($cal, 'DTSTAMP', $dtstamp, true); // all-day events end the next day if (!empty($event['allday']) && !empty($event['end'])) { $event['end'] = self::toDateTime($event['end']); $event['end']->add(new \DateInterval('P1D')); $event['end']->_dateonly = true; } if (!empty($event['created'])) { $ve->add($this->datetime_prop($cal, 'CREATED', $event['created'], true)); } if (!empty($event['changed'])) { $ve->add($this->datetime_prop($cal, 'LAST-MODIFIED', $event['changed'], true)); } if (!empty($event['start'])) { $ve->add($this->datetime_prop($cal, 'DTSTART', $event['start'], false, !empty($event['allday']))); } if (!empty($event['end'])) { $ve->add($this->datetime_prop($cal, 'DTEND', $event['end'], false, !empty($event['allday']))); } if (!empty($event['due'])) { $ve->add($this->datetime_prop($cal, 'DUE', $event['due'], false)); } // we're exporting a recurrence instance only if (!$recurrence_id && !empty($event['recurrence_date']) && $event['recurrence_date'] instanceof DateTimeInterface) { $recurrence_id = $this->datetime_prop($cal, 'RECURRENCE-ID', $event['recurrence_date'], false, !empty($event['allday'])); if (!empty($event['thisandfuture'])) { $recurrence_id->add('RANGE', 'THISANDFUTURE'); } } if ($recurrence_id) { $ve->add($recurrence_id); } if (!empty($event['title'])) { $ve->add('SUMMARY', $event['title']); } if (!empty($event['location'])) { $ve->add($this->is_apple() ? new vobject_location_property($cal, 'LOCATION', $event['location']) : $cal->create('LOCATION', $event['location'])); } if (!empty($event['description'])) { $ve->add('DESCRIPTION', strtr($event['description'], array("\r\n" => "\n", "\r" => "\n"))); // normalize line endings } if (isset($event['sequence'])) { $ve->add('SEQUENCE', $event['sequence']); } if (!empty($event['recurrence']) && !$recurrence_id) { $exdates = $rdates = null; if (isset($event['recurrence']['EXDATE'])) { $exdates = $event['recurrence']['EXDATE']; unset($event['recurrence']['EXDATE']); // don't serialize EXDATEs into RRULE value } if (isset($event['recurrence']['RDATE'])) { $rdates = $event['recurrence']['RDATE']; unset($event['recurrence']['RDATE']); // don't serialize RDATEs into RRULE value } if (!empty($event['recurrence']['FREQ'])) { $ve->add('RRULE', libcalendaring::to_rrule($event['recurrence'], !empty($event['allday']))); } // add EXDATEs each one per line (for Thunderbird Lightning) if (is_array($exdates)) { foreach ($exdates as $exdate) { if ($exdate instanceof DateTimeInterface) { $ve->add($this->datetime_prop($cal, 'EXDATE', $exdate)); } } } // add RDATEs if (is_array($rdates)) { foreach ($rdates as $rdate) { $ve->add($this->datetime_prop($cal, 'RDATE', $rdate)); } } } if (!empty($event['categories'])) { $cat = $cal->create('CATEGORIES'); $cat->setParts((array)$event['categories']); $ve->add($cat); } if (!empty($event['free_busy'])) { $ve->add('TRANSP', $event['free_busy'] == 'free' ? 'TRANSPARENT' : 'OPAQUE'); // for Outlook clients we provide the X-MICROSOFT-CDO-BUSYSTATUS property if (stripos($this->agent, 'outlook') !== false) { $ve->add('X-MICROSOFT-CDO-BUSYSTATUS', $event['free_busy'] == 'outofoffice' ? 'OOF' : strtoupper($event['free_busy'])); } } if (!empty($event['priority'])) { $ve->add('PRIORITY', $event['priority']); } if (!empty($event['cancelled'])) { $ve->add('STATUS', 'CANCELLED'); } else if (!empty($event['free_busy']) && $event['free_busy'] == 'tentative') { $ve->add('STATUS', 'TENTATIVE'); } else if (!empty($event['complete']) && $event['complete'] == 100) { $ve->add('STATUS', 'COMPLETED'); } else if (!empty($event['status'])) { $ve->add('STATUS', $event['status']); } if (!empty($event['complete'])) { $ve->add('PERCENT-COMPLETE', intval($event['complete'])); } // Apple iCal and BusyCal required the COMPLETED date to be set in order to consider a task complete if ( (!empty($event['status']) && $event['status'] == 'COMPLETED') || (!empty($event['complete']) && $event['complete'] == 100) ) { $completed = !empty($event['changed']) ? $event['changed'] : new DateTime('now - 1 hour'); $ve->add($this->datetime_prop($cal, 'COMPLETED', $completed, true)); } if (!empty($event['valarms'])) { foreach ($event['valarms'] as $alarm) { $va = $cal->createComponent('VALARM'); $va->action = $alarm['action']; if ($alarm['trigger'] instanceof DateTimeInterface) { $va->add($this->datetime_prop($cal, 'TRIGGER', $alarm['trigger'], true, null, true)); } else { $alarm_props = array(); if (!empty($alarm['related']) && strtoupper($alarm['related']) == 'END') { $alarm_props['RELATED'] = 'END'; } $va->add('TRIGGER', $alarm['trigger'], $alarm_props); } if (!empty($alarm['action']) && $alarm['action'] == 'EMAIL') { if (!empty($alarm['attendees'])) { foreach ((array) $alarm['attendees'] as $attendee) { $va->add('ATTENDEE', 'mailto:' . $attendee); } } } if (!empty($alarm['description'])) { $va->add('DESCRIPTION', $alarm['description']); } if (!empty($alarm['summary'])) { $va->add('SUMMARY', $alarm['summary']); } if (!empty($alarm['duration'])) { $va->add('DURATION', $alarm['duration']); $va->add('REPEAT', !empty($alarm['repeat']) ? intval($alarm['repeat']) : 0); } if (!empty($alarm['uri'])) { $va->add('ATTACH', $alarm['uri'], array('VALUE' => 'URI')); } $ve->add($va); } } // legacy support else if (!empty($event['alarms'])) { $va = $cal->createComponent('VALARM'); list($trigger, $va->action) = explode(':', $event['alarms']); $val = libcalendaring::parse_alarm_value($trigger); if (!empty($val[3])) { $va->add('TRIGGER', $val[3]); } else if ($val[0] instanceof DateTimeInterface) { $va->add($this->datetime_prop($cal, 'TRIGGER', $val[0], true, null, true)); } $ve->add($va); } // Find SCHEDULE-AGENT if (!empty($event['x-custom'])) { foreach ((array) $event['x-custom'] as $prop) { if ($prop[0] === 'SCHEDULE-AGENT') { $schedule_agent = $prop[1]; } } } if (!empty($event['attendees'])) { foreach ((array) $event['attendees'] as $attendee) { if ($attendee['role'] == 'ORGANIZER') { if (empty($event['organizer'])) $event['organizer'] = $attendee; } else if (!empty($attendee['email'])) { if (isset($attendee['rsvp'])) { $attendee['rsvp'] = $attendee['rsvp'] ? 'TRUE' : null; } $mailto = $attendee['email']; $attendee = array_filter(self::map_keys($attendee, $this->attendee_keymap)); if (isset($schedule_agent) && !isset($attendee['SCHEDULE-AGENT'])) { $attendee['SCHEDULE-AGENT'] = $schedule_agent; } $ve->add('ATTENDEE', 'mailto:' . $mailto, $attendee); } } } if (!empty($event['organizer'])) { $organizer = array_filter(self::map_keys($event['organizer'], $this->organizer_keymap)); if (isset($schedule_agent) && !isset($organizer['SCHEDULE-AGENT'])) { $organizer['SCHEDULE-AGENT'] = $schedule_agent; } $ve->add('ORGANIZER', 'mailto:' . $event['organizer']['email'], $organizer); } if (!empty($event['url'])) { foreach ((array) $event['url'] as $url) { if (!empty($url)) { $ve->add('URL', $url); } } } if (!empty($event['parent_id'])) { $ve->add('RELATED-TO', $event['parent_id'], array('RELTYPE' => 'PARENT')); } if (!empty($event['comment'])) { $ve->add('COMMENT', $event['comment']); } $memory_limit = parse_bytes(ini_get('memory_limit')); // export attachments if (!empty($event['attachments'])) { foreach ((array) $event['attachments'] as $idx => $attach) { // check available memory and skip attachment export if we can't buffer it // @todo: use rcube_utils::mem_check() if (is_callable($get_attachment) && $memory_limit > 0 && ($memory_used = function_exists('memory_get_usage') ? memory_get_usage() : 16*1024*1024) && !empty($attach['size']) && $memory_used + $attach['size'] * 3 > $memory_limit ) { continue; } // embed attachments using the given callback function if (is_callable($get_attachment) && ($data = call_user_func($get_attachment, $attach['id'] ?? $idx, $event))) { // embed attachments for iCal $ve->add('ATTACH', $data, array_filter([ 'VALUE' => 'BINARY', 'ENCODING' => 'BASE64', 'FMTTYPE' => $attach['mimetype'] ?? null, 'X-LABEL' => $attach['name'] ?? null, 'X-SIZE' => $attach['size'] ?? null, ]) ); unset($data); // attempt to free memory } // list attachments as absolute URIs else if (!empty($this->attach_uri)) { $ve->add('ATTACH', strtr($this->attach_uri, [ '{{id}}' => urlencode($attach['id'] ?? $idx), '{{name}}' => urlencode($attach['name']), '{{mimetype}}' => urlencode($attach['mimetype']), ]), ['FMTTYPE' => $attach['mimetype'], 'VALUE' => 'URI'] ); } } } if (!empty($event['links'])) { foreach ((array) $event['links'] as $uri) { $ve->add('ATTACH', $uri); } } // add custom properties if (!empty($event['x-custom'])) { foreach ((array) $event['x-custom'] as $prop) { $ve->add($prop[0], $prop[1]); } } // append to vcalendar container if ($vcal) { $vcal->add($ve); } else { // serialize and send to stdout echo $ve->serialize(); } // append recurrence exceptions if (!empty($event['recurrence']) && !empty($event['recurrence']['EXCEPTIONS'])) { foreach ($event['recurrence']['EXCEPTIONS'] as $ex) { $exdate = !empty($ex['recurrence_date']) ? $ex['recurrence_date'] : $ex['start']; $recurrence_id = $this->datetime_prop($cal, 'RECURRENCE-ID', $exdate, false, !empty($event['allday'])); if (!empty($ex['thisandfuture'])) { $recurrence_id->add('RANGE', 'THISANDFUTURE'); } $ex['uid'] = $ve->UID; $this->_to_ical($ex, $vcal, $get_attachment, $recurrence_id); } } } /** * Returns a VTIMEZONE component for a Olson timezone identifier * with daylight transitions covering the given date range. * * @param string Timezone ID as used in PHP's Date functions * @param integer Unix timestamp with first date/time in this timezone * @param integer Unix timestap with last date/time in this timezone * @param VObject\Component\VCalendar Optional VCalendar component * * @return mixed A Sabre\VObject\Component object representing a VTIMEZONE definition * or false if no timezone information is available */ public static function get_vtimezone($tzid, $from = 0, $to = 0, $cal = null) { // TODO: Consider using tzurl.org database for better interoperability e.g. with Outlook if (!$from) $from = time(); if (!$to) $to = $from; if (!$cal) $cal = new VObject\Component\VCalendar(); if (is_string($tzid)) { try { $tz = new \DateTimeZone($tzid); } catch (\Exception $e) { return false; } } else if (is_a($tzid, '\\DateTimeZone')) { $tz = $tzid; } if (empty($tz) || !is_a($tz, '\\DateTimeZone')) { return false; } $year = 86400 * 360; $transitions = $tz->getTransitions($from - $year, $to + $year); // Make sure VTIMEZONE contains at least one STANDARD/DAYLIGHT component // when there's only one transition in specified time period (T5626) if (count($transitions) == 1) { // Get more transitions and use OFFSET from the previous to last $more_transitions = $tz->getTransitions(0, $to + $year); if (count($more_transitions) > 1) { $index = count($more_transitions) - 2; $tzfrom = $more_transitions[$index]['offset'] / 3600; } } $vt = $cal->createComponent('VTIMEZONE'); $vt->TZID = $tz->getName(); $std = null; $dst = null; foreach ($transitions as $i => $trans) { $cmp = null; if (!isset($tzfrom)) { $tzfrom = $trans['offset'] / 3600; continue; } if ($trans['isdst']) { $t_dst = $trans['ts']; $dst = $cal->createComponent('DAYLIGHT'); $cmp = $dst; } else { $t_std = $trans['ts']; $std = $cal->createComponent('STANDARD'); $cmp = $std; } if ($cmp) { $dt = new DateTime($trans['time']); $offset = $trans['offset'] / 3600; $cmp->DTSTART = $dt->format('Ymd\THis'); $cmp->TZOFFSETFROM = sprintf('%+03d%02d', floor($tzfrom), ($tzfrom - floor($tzfrom)) * 60); $cmp->TZOFFSETTO = sprintf('%+03d%02d', floor($offset), ($offset - floor($offset)) * 60); if (!empty($trans['abbr'])) { $cmp->TZNAME = $trans['abbr']; } $tzfrom = $offset; $vt->add($cmp); } // we covered the entire date range if ($std && $dst && min($t_std, $t_dst) < $from && max($t_std, $t_dst) > $to) { break; } } // add X-MICROSOFT-CDO-TZID if available $microsoftExchangeMap = array_flip(VObject\TimeZoneUtil::$microsoftExchangeMap); if (array_key_exists($tz->getName(), $microsoftExchangeMap)) { $vt->add('X-MICROSOFT-CDO-TZID', $microsoftExchangeMap[$tz->getName()]); } return $vt; } /** * Convert DateTime into libcalendaring_datetime */ private static function toDateTime($date) { return libcalendaring_datetime::createFromFormat( 'Y-m-d\\TH:i:s', $date->format('Y-m-d\\TH:i:s'), $date->getTimezone() ); } /*** Implement PHP 5 Iterator interface to make foreach work ***/ #[\ReturnTypeWillChange] function current() { return $this->objects[$this->iteratorkey]; } #[\ReturnTypeWillChange] function key() { return $this->iteratorkey; } #[\ReturnTypeWillChange] function next() { $this->iteratorkey++; // read next chunk if we're reading from a file if (empty($this->objects[$this->iteratorkey]) && $this->fp) { $this->_parse_next(true); } return $this->valid(); } #[\ReturnTypeWillChange] function rewind() { $this->iteratorkey = 0; } #[\ReturnTypeWillChange] function valid() { return !empty($this->objects[$this->iteratorkey]); } } /** * Override Sabre\VObject\Property\Text that quotes commas in the location property * because Apple clients treat that property as list. */ class vobject_location_property extends VObject\Property\Text { /** * List of properties that are considered 'structured'. * * @var array */ protected $structuredValues = [ // vCard 'N', 'ADR', 'ORG', 'GENDER', 'LOCATION', // iCalendar 'REQUEST-STATUS', ]; } diff --git a/plugins/libkolab/SQL/mysql.initial.sql b/plugins/libkolab/SQL/mysql.initial.sql index 0936cd8d..f45ccf66 100644 --- a/plugins/libkolab/SQL/mysql.initial.sql +++ b/plugins/libkolab/SQL/mysql.initial.sql @@ -1,215 +1,233 @@ SET FOREIGN_KEY_CHECKS=0; DROP TABLE IF EXISTS `kolab_folders`; CREATE TABLE `kolab_folders` ( `folder_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `resource` VARCHAR(255) BINARY NOT NULL, `type` VARCHAR(32) NOT NULL, `synclock` INT(10) NOT NULL DEFAULT '0', `ctag` VARCHAR(40) DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `objectcount` BIGINT DEFAULT NULL, PRIMARY KEY(`folder_id`), INDEX `resource_type` (`resource`, `type`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache`; DROP TABLE IF EXISTS `kolab_cache_contact`; CREATE TABLE `kolab_cache_contact` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `type` VARCHAR(32) CHARACTER SET ascii NOT NULL, `name` VARCHAR(255) NOT NULL, `firstname` VARCHAR(255) NOT NULL, `surname` VARCHAR(255) NOT NULL, `email` VARCHAR(255) NOT NULL, CONSTRAINT `fk_kolab_cache_contact_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `contact_type` (`folder_id`,`type`), INDEX `contact_uid2msguid` (`folder_id`,`uid`,`msguid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache_event`; CREATE TABLE `kolab_cache_event` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `dtstart` DATETIME, `dtend` DATETIME, CONSTRAINT `fk_kolab_cache_event_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `event_uid2msguid` (`folder_id`,`uid`,`msguid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache_task`; CREATE TABLE `kolab_cache_task` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `dtstart` DATETIME, `dtend` DATETIME, CONSTRAINT `fk_kolab_cache_task_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `task_uid2msguid` (`folder_id`,`uid`,`msguid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache_journal`; CREATE TABLE `kolab_cache_journal` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `dtstart` DATETIME, `dtend` DATETIME, CONSTRAINT `fk_kolab_cache_journal_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `journal_uid2msguid` (`folder_id`,`uid`,`msguid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache_note`; CREATE TABLE `kolab_cache_note` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, CONSTRAINT `fk_kolab_cache_note_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `note_uid2msguid` (`folder_id`,`uid`,`msguid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache_file`; CREATE TABLE `kolab_cache_file` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `filename` varchar(255) DEFAULT NULL, CONSTRAINT `fk_kolab_cache_file_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `folder_filename` (`folder_id`, `filename`), INDEX `file_uid2msguid` (`folder_id`,`uid`,`msguid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache_configuration`; CREATE TABLE `kolab_cache_configuration` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `type` VARCHAR(32) CHARACTER SET ascii NOT NULL, CONSTRAINT `fk_kolab_cache_configuration_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `configuration_type` (`folder_id`,`type`), INDEX `configuration_uid2msguid` (`folder_id`,`uid`,`msguid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache_freebusy`; CREATE TABLE `kolab_cache_freebusy` ( `folder_id` BIGINT UNSIGNED NOT NULL, `msguid` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `dtstart` DATETIME, `dtend` DATETIME, CONSTRAINT `fk_kolab_cache_freebusy_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`msguid`), INDEX `freebusy_uid2msguid` (`folder_id`,`uid`,`msguid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache_dav_contact`; CREATE TABLE `kolab_cache_dav_contact` ( `folder_id` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT NULL, `etag` VARCHAR(128) DEFAULT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `type` VARCHAR(32) CHARACTER SET ascii NOT NULL, `name` VARCHAR(255) NOT NULL, `firstname` VARCHAR(255) NOT NULL, `surname` VARCHAR(255) NOT NULL, `email` VARCHAR(255) NOT NULL, CONSTRAINT `fk_kolab_cache_dav_contact_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`uid`), INDEX `contact_type` (`folder_id`,`type`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; DROP TABLE IF EXISTS `kolab_cache_dav_event`; CREATE TABLE `kolab_cache_dav_event` ( `folder_id` BIGINT UNSIGNED NOT NULL, `uid` VARCHAR(512) NOT NULL, `etag` VARCHAR(128) DEFAULT NULL, `created` DATETIME DEFAULT NULL, `changed` DATETIME DEFAULT NULL, `data` LONGTEXT NOT NULL, `tags` TEXT NOT NULL, `words` TEXT NOT NULL, `dtstart` DATETIME, `dtend` DATETIME, CONSTRAINT `fk_kolab_cache_dav_event_folder` FOREIGN KEY (`folder_id`) REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY(`folder_id`,`uid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +DROP TABLE IF EXISTS `kolab_cache_dav_task`; + +CREATE TABLE `kolab_cache_dav_task` ( + `folder_id` BIGINT UNSIGNED NOT NULL, + `uid` VARCHAR(512) NOT NULL, + `etag` VARCHAR(128) DEFAULT NULL, + `created` DATETIME DEFAULT NULL, + `changed` DATETIME DEFAULT NULL, + `data` LONGTEXT NOT NULL, + `tags` TEXT NOT NULL, + `words` TEXT NOT NULL, + `dtstart` DATETIME, + `dtend` DATETIME, + CONSTRAINT `fk_kolab_cache_dav_task_folder` FOREIGN KEY (`folder_id`) + REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY(`folder_id`,`uid`) +) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + SET FOREIGN_KEY_CHECKS=1; -REPLACE INTO `system` (`name`, `value`) VALUES ('libkolab-version', '2022100500'); +REPLACE INTO `system` (`name`, `value`) VALUES ('libkolab-version', '2022122800'); diff --git a/plugins/libkolab/SQL/mysql/2022122800.sql b/plugins/libkolab/SQL/mysql/2022122800.sql new file mode 100644 index 00000000..42127383 --- /dev/null +++ b/plugins/libkolab/SQL/mysql/2022122800.sql @@ -0,0 +1,17 @@ +DROP TABLE IF EXISTS `kolab_cache_dav_task`; + +CREATE TABLE `kolab_cache_dav_task` ( + `folder_id` BIGINT UNSIGNED NOT NULL, + `uid` VARCHAR(512) NOT NULL, + `etag` VARCHAR(128) DEFAULT NULL, + `created` DATETIME DEFAULT NULL, + `changed` DATETIME DEFAULT NULL, + `data` LONGTEXT NOT NULL, + `tags` TEXT NOT NULL, + `words` TEXT NOT NULL, + `dtstart` DATETIME, + `dtend` DATETIME, + CONSTRAINT `fk_kolab_cache_dav_task_folder` FOREIGN KEY (`folder_id`) + REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY(`folder_id`,`uid`) +) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/plugins/libkolab/lib/kolab_dav_client.php b/plugins/libkolab/lib/kolab_dav_client.php index c5d3fb29..1db25549 100644 --- a/plugins/libkolab/lib/kolab_dav_client.php +++ b/plugins/libkolab/lib/kolab_dav_client.php @@ -1,803 +1,828 @@ <?php /** * A *DAV client. * * @author Aleksander Machniak <machniak@apheleia-it.ch> * * Copyright (C) 2022, Apheleia IT AG <contact@apheleia-it.ch> * * 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 <http://www.gnu.org/licenses/>. */ class kolab_dav_client { public $url; protected $user; protected $password; protected $rc; protected $responseHeaders = []; /** * Object constructor */ public function __construct($url) { $this->rc = rcube::get_instance(); $parsedUrl = parse_url($url); if (!empty($parsedUrl['user']) && !empty($parsedUrl['pass'])) { $this->user = rawurldecode($parsedUrl['user']); $this->password = rawurldecode($parsedUrl['pass']); $url = str_replace(rawurlencode($this->user) . ':' . rawurlencode($this->password) . '@', '', $url); } else { $this->user = $this->rc->user->get_username(); $this->password = $this->rc->decrypt($_SESSION['password']); } $this->url = $url; } /** * Execute HTTP request to a DAV server */ protected function request($path, $method, $body = '', $headers = []) { $rcube = rcube::get_instance(); $debug = (array) $rcube->config->get('dav_debug'); $request_config = [ 'store_body' => true, 'follow_redirects' => true, ]; $this->responseHeaders = []; if ($path && ($rootPath = parse_url($this->url, PHP_URL_PATH)) && strpos($path, $rootPath) === 0) { $path = substr($path, strlen($rootPath)); } try { $request = $this->initRequest($this->url . $path, $method, $request_config); $request->setAuth($this->user, $this->password); if ($body) { $request->setBody($body); $request->setHeader(['Content-Type' => 'application/xml; charset=utf-8']); } if (!empty($headers)) { $request->setHeader($headers); } if ($debug) { rcube::write_log('dav', "C: {$method}: " . (string) $request->getUrl() . "\n" . $this->debugBody($body, $request->getHeaders())); } $response = $request->send(); $body = $response->getBody(); $code = $response->getStatus(); if ($debug) { rcube::write_log('dav', "S: [{$code}]\n" . $this->debugBody($body, $response->getHeader())); } if ($code >= 300) { throw new Exception("DAV Error ($code):\n{$body}"); } $this->responseHeaders = $response->getHeader(); return $this->parseXML($body); } catch (Exception $e) { rcube::raise_error($e, true, false); return false; } } /** * Discover DAV home (root) collection of specified type. * * @param string $component Component to filter by (VEVENT, VTODO, VCARD) * * @return string|false Home collection location or False on error */ public function discover($component = 'VEVENT') { if ($cache = $this->get_cache()) { $cache_key = "discover.{$component}." . md5($this->url); if ($response = $cache->get($cache_key)) { return $response; } } $roots = [ 'VEVENT' => 'calendars', 'VTODO' => 'calendars', 'VCARD' => 'addressbooks', ]; $path = parse_url($this->url, PHP_URL_PATH); $body = '<?xml version="1.0" encoding="utf-8"?>' . '<d:propfind xmlns:d="DAV:">' . '<d:prop>' . '<d:current-user-principal />' . '</d:prop>' . '</d:propfind>'; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $response = $this->request('/' . $roots[$component], 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { return false; } $elements = $response->getElementsByTagName('response'); foreach ($elements as $element) { foreach ($element->getElementsByTagName('current-user-principal') as $prop) { $principal_href = $prop->nodeValue; break; } } if ($path && strpos($principal_href, $path) === 0) { $principal_href = substr($principal_href, strlen($path)); } $homes = [ 'VEVENT' => 'calendar-home-set', 'VTODO' => 'calendar-home-set', 'VCARD' => 'addressbook-home-set', ]; $ns = [ 'VEVENT' => 'caldav', 'VTODO' => 'caldav', 'VCARD' => 'carddav', ]; $body = '<?xml version="1.0" encoding="utf-8"?>' . '<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:' . $ns[$component] . '">' . '<d:prop>' . '<c:' . $homes[$component] . ' />' . '</d:prop>' . '</d:propfind>'; $response = $this->request($principal_href, 'PROPFIND', $body); if (empty($response)) { return false; } $elements = $response->getElementsByTagName('response'); foreach ($elements as $element) { foreach ($element->getElementsByTagName($homes[$component]) as $prop) { $root_href = $prop->nodeValue; break; } } if (!empty($root_href)) { if ($path && strpos($root_href, $path) === 0) { $root_href = substr($root_href, strlen($path)); } } else { // Kolab iRony's calendar root $root_href = '/' . $roots[$component] . '/' . rawurlencode($this->user); } if ($cache) { $cache->set($cache_key, $root_href); } return $root_href; } /** * Get list of folders of specified type. * * @param string $component Component to filter by (VEVENT, VTODO, VCARD) * * @return false|array List of folders' metadata or False on error */ public function listFolders($component = 'VEVENT') { $root_href = $this->discover($component); if ($root_href === false) { return false; } $ns = 'xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"'; $props = ''; if ($component != 'VCARD') { $ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/" xmlns:k="Kolab:"'; $props = '<c:supported-calendar-component-set />' . '<a:calendar-color />' . '<k:alarms />'; } $body = '<?xml version="1.0" encoding="utf-8"?>' . '<d:propfind ' . $ns . '>' . '<d:prop>' . '<d:resourcetype />' . '<d:displayname />' // . '<d:sync-token />' . '<cs:getctag />' . $props . '</d:prop>' . '</d:propfind>'; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $response = $this->request($root_href, 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { return false; } $folders = []; foreach ($response->getElementsByTagName('response') as $element) { $folder = $this->getFolderPropertiesFromResponse($element); - // Note: Addressbooks don't have 'type' specified + // Note: Addressbooks don't have 'types' specified if (($component == 'VCARD' && in_array('addressbook', $folder['resource_type'])) - || $folder['type'] === $component + || in_array($component, (array) $folder['types']) ) { $folders[] = $folder; } } return $folders; } /** * Create a DAV object in a folder * * @param string $location Object location * @param string $content Object content * @param string $component Content type (VEVENT, VTODO, VCARD) * * @return false|string|null ETag string (or NULL) on success, False on error */ public function create($location, $content, $component = 'VEVENT') { $ctype = [ 'VEVENT' => 'text/calendar', 'VTODO' => 'text/calendar', 'VCARD' => 'text/vcard', ]; $headers = ['Content-Type' => $ctype[$component] . '; charset=utf-8']; $response = $this->request($location, 'PUT', $content, $headers); - if ($response !== false) { - // Note: ETag is not always returned, e.g. https://github.com/cyrusimap/cyrus-imapd/issues/2456 - $etag = isset($this->responseHeaders['etag']) ? $this->responseHeaders['etag'] : null; - - if (is_string($etag) && preg_match('|^".*"$|', $etag)) { - $etag = substr($etag, 1, -1); - } - - return $etag; - } - - return false; + return $this->getETagFromResponse($response); } /** * Update a DAV object in a folder * * @param string $location Object location * @param string $content Object content * @param string $component Content type (VEVENT, VTODO, VCARD) * * @return false|string|null ETag string (or NULL) on success, False on error */ public function update($location, $content, $component = 'VEVENT') { return $this->create($location, $content, $component); } /** * Delete a DAV object from a folder * * @param string $location Object location * * @return bool True on success, False on error */ public function delete($location) { $response = $this->request($location, 'DELETE', '', ['Depth' => 1, 'Prefer' => 'return-minimal']); return $response !== false; } + /** + * Move a DAV object + * + * @param string $source Source object location + * @param string $target Target object content + * + * @return false|string|null ETag string (or NULL) on success, False on error + */ + public function move($source, $target) + { + $headers = ['Destination' => $target]; + + $response = $this->request($source, 'MOVE', '', $headers); + + return $this->getETagFromResponse($response); + } + /** * Get folder properties. * * @param string $location Object location * * @return false|array Folder metadata or False on error */ public function folderInfo($location) { $body = '<?xml version="1.0" encoding="utf-8"?>' . '<d:propfind xmlns:d="DAV:">' . '<d:allprop/>' . '</d:propfind>'; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $response = $this->request($location, 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (!empty($response) && ($element = $response->getElementsByTagName('response')) && ($folder = $this->getFolderPropertiesFromResponse($element)) ) { return $folder; } return false; } /** * Create a DAV folder * * @param string $location Object location (relative to the user home) * @param string $component Content type (VEVENT, VTODO, VCARD) * @param array $properties Object content * * @return bool True on success, False on error */ public function folderCreate($location, $component, $properties = []) { // Create the collection $response = $this->request($location, 'MKCOL'); if (empty($response)) { return false; } // Update collection properties return $this->folderUpdate($location, $component, $properties); } /** * Delete a DAV folder * * @param string $location Folder location * * @return bool True on success, False on error */ public function folderDelete($location) { $response = $this->request($location, 'DELETE'); return $response !== false; } /** * Update a DAV folder * * @param string $location Object location * @param string $component Content type (VEVENT, VTODO, VCARD) * @param array $properties Object content * * @return bool True on success, False on error */ public function folderUpdate($location, $component, $properties = []) { $ns = 'xmlns:d="DAV:"'; $props = ''; if ($component == 'VCARD') { $ns .= ' xmlns:c="urn:ietf:params:xml:ns:carddav"'; // Resourcetype property is protected // $props = '<d:resourcetype><d:collection/><c:addressbook/></d:resourcetype>'; } else { $ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav"'; // Resourcetype property is protected // $props = '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'; /* // Note: These are set by Cyrus automatically for calendars . '<c:supported-calendar-component-set>' . '<c:comp name="VEVENT"/>' . '<c:comp name="VTODO"/>' . '<c:comp name="VJOURNAL"/>' . '<c:comp name="VFREEBUSY"/>' . '<c:comp name="VAVAILABILITY"/>' . '</c:supported-calendar-component-set>'; */ } foreach ($properties as $name => $value) { if ($name == 'name') { $props .= '<d:displayname>' . htmlspecialchars($value, ENT_XML1, 'UTF-8') . '</d:displayname>'; } else if ($name == 'color' && strlen($value)) { if ($value[0] != '#') { $value = '#' . $value; } $ns .= ' xmlns:a="http://apple.com/ns/ical/"'; $props .= '<a:calendar-color>' . htmlspecialchars($value, ENT_XML1, 'UTF-8') . '</a:calendar-color>'; } else if ($name == 'alarms') { if (!strpos($ns, 'Kolab:')) { $ns .= ' xmlns:k="Kolab:"'; } $props .= "<k:{$name}>" . ($value ? 'true' : 'false') . "</k:{$name}>"; } } if (empty($props)) { return true; } $body = '<?xml version="1.0" encoding="utf-8"?>' . '<d:propertyupdate ' . $ns . '>' . '<d:set>' . '<d:prop>' . $props . '</d:prop>' . '</d:set>' . '</d:propertyupdate>'; $response = $this->request($location, 'PROPPATCH', $body); // TODO: Should we make sure "200 OK" status is set for all requested properties? return $response !== false; } /** * Fetch DAV objects metadata (ETag, href) a folder * * @param string $location Folder location * @param string $component Object type (VEVENT, VTODO, VCARD) * * @return false|array Objects metadata on success, False on error */ public function getIndex($location, $component = 'VEVENT') { $queries = [ 'VEVENT' => 'calendar-query', 'VTODO' => 'calendar-query', 'VCARD' => 'addressbook-query', ]; $ns = [ 'VEVENT' => 'caldav', 'VTODO' => 'caldav', 'VCARD' => 'carddav', ]; $filter = ''; if ($component != 'VCARD') { $filter = '<c:comp-filter name="VCALENDAR">' . '<c:comp-filter name="' . $component . '" />' . '</c:comp-filter>'; } $body = '<?xml version="1.0" encoding="utf-8"?>' .' <c:' . $queries[$component] . ' xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:' . $ns[$component]. '">' . '<d:prop>' . '<d:getetag />' . '</d:prop>' . ($filter ? "<c:filter>$filter</c:filter>" : '') . '</c:' . $queries[$component] . '>'; $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { return false; } $objects = []; foreach ($response->getElementsByTagName('response') as $element) { $objects[] = $this->getObjectPropertiesFromResponse($element); } return $objects; } /** * Fetch DAV objects data from a folder * * @param string $location Folder location * @param string $component Object type (VEVENT, VTODO, VCARD) * @param array $hrefs List of objects' locations to fetch (empty for all objects) * * @return false|array Objects metadata on success, False on error */ public function getData($location, $component = 'VEVENT', $hrefs = []) { if (empty($hrefs)) { return []; } $body = ''; foreach ($hrefs as $href) { $body .= '<d:href>' . $href . '</d:href>'; } $queries = [ 'VEVENT' => 'calendar-multiget', 'VTODO' => 'calendar-multiget', 'VCARD' => 'addressbook-multiget', ]; $ns = [ 'VEVENT' => 'caldav', 'VTODO' => 'caldav', 'VCARD' => 'carddav', ]; $types = [ 'VEVENT' => 'calendar-data', 'VTODO' => 'calendar-data', 'VCARD' => 'address-data', ]; $body = '<?xml version="1.0" encoding="utf-8"?>' .' <c:' . $queries[$component] . ' xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:' . $ns[$component] . '">' . '<d:prop>' . '<d:getetag />' . '<c:' . $types[$component]. ' />' . '</d:prop>' . $body . '</c:' . $queries[$component] . '>'; $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { return false; } $objects = []; foreach ($response->getElementsByTagName('response') as $element) { $objects[] = $this->getObjectPropertiesFromResponse($element); } return $objects; } /** * Parse XML content */ protected function parseXML($xml) { $doc = new DOMDocument('1.0', 'UTF-8'); if (stripos($xml, '<?xml') === 0) { if (!$doc->loadXML($xml)) { throw new Exception("Failed to parse XML"); } $doc->formatOutput = true; } return $doc; } /** * Parse request/response body for debug purposes */ protected function debugBody($body, $headers) { $head = ''; foreach ($headers as $header_name => $header_value) { $head .= "{$header_name}: {$header_value}\n"; } if (stripos($body, '<?xml') === 0) { $doc = new DOMDocument('1.0', 'UTF-8'); $doc->formatOutput = true; $doc->preserveWhiteSpace = false; if (!$doc->loadXML($body)) { throw new Exception("Failed to parse XML"); } $body = $doc->saveXML(); } return $head . "\n" . rtrim($body); } /** * Extract folder properties from a server 'response' element */ protected function getFolderPropertiesFromResponse(DOMNode $element) { if ($href = $element->getElementsByTagName('href')->item(0)) { $href = $href->nodeValue; /* $path = parse_url($this->url, PHP_URL_PATH); if ($path && strpos($href, $path) === 0) { $href = substr($href, strlen($path)); } */ } if ($color = $element->getElementsByTagName('calendar-color')->item(0)) { if (preg_match('/^#[0-9a-fA-F]{6,8}$/', $color->nodeValue)) { $color = substr($color->nodeValue, 1); } else { $color = null; } } if ($name = $element->getElementsByTagName('displayname')->item(0)) { $name = $name->nodeValue; } if ($ctag = $element->getElementsByTagName('getctag')->item(0)) { $ctag = $ctag->nodeValue; } - $component = null; + $components = []; if ($set_element = $element->getElementsByTagName('supported-calendar-component-set')->item(0)) { - if ($comp_element = $set_element->getElementsByTagName('comp')->item(0)) { - $component = $comp_element->attributes->getNamedItem('name')->nodeValue; + foreach ($set_element->getElementsByTagName('comp') as $comp_element) { + $components[] = $comp_element->attributes->getNamedItem('name')->nodeValue; } } $types = []; if ($type_element = $element->getElementsByTagName('resourcetype')->item(0)) { foreach ($type_element->childNodes as $node) { $_type = explode(':', $node->nodeName); $types[] = count($_type) > 1 ? $_type[1] : $_type[0]; } } $result = [ 'href' => $href, 'name' => $name, 'ctag' => $ctag, 'color' => $color, - 'type' => $component, + 'types' => $components, 'resource_type' => $types, ]; foreach (['alarms'] as $tag) { if ($el = $element->getElementsByTagName($tag)->item(0)) { if (strlen($el->nodeValue) > 0) { $result[$tag] = strtolower($el->nodeValue) === 'true'; } } } return $result; } /** * Extract object properties from a server 'response' element */ protected function getObjectPropertiesFromResponse(DOMNode $element) { $uid = null; if ($href = $element->getElementsByTagName('href')->item(0)) { $href = $href->nodeValue; /* $path = parse_url($this->url, PHP_URL_PATH); if ($path && strpos($href, $path) === 0) { $href = substr($href, strlen($path)); } */ // Extract UID from the URL $href_parts = explode('/', $href); $uid = preg_replace('/\.[a-z]+$/', '', $href_parts[count($href_parts)-1]); } if ($data = $element->getElementsByTagName('calendar-data')->item(0)) { $data = $data->nodeValue; } else if ($data = $element->getElementsByTagName('address-data')->item(0)) { $data = $data->nodeValue; } if ($etag = $element->getElementsByTagName('getetag')->item(0)) { $etag = $etag->nodeValue; if (preg_match('|^".*"$|', $etag)) { $etag = substr($etag, 1, -1); } } return [ 'href' => $href, 'data' => $data, 'etag' => $etag, 'uid' => $uid, ]; } + /** + * Get ETag from a response + */ + protected function getETagFromResponse($response) + { + if ($response !== false) { + // Note: ETag is not always returned, e.g. https://github.com/cyrusimap/cyrus-imapd/issues/2456 + $etag = isset($this->responseHeaders['etag']) ? $this->responseHeaders['etag'] : null; + + if (is_string($etag) && preg_match('|^".*"$|', $etag)) { + $etag = substr($etag, 1, -1); + } + + return $etag; + } + + return false; + } + /** * Initialize HTTP request object */ protected function initRequest($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); } // load HTTP_Request2 (support both composer-installed and system-installed package) if (!class_exists('HTTP_Request2')) { require_once 'HTTP/Request2.php'; } try { $request = new HTTP_Request2(); $request->setConfig($http_config); // proxy User-Agent string $request->setHeader('user-agent', $_SERVER['HTTP_USER_AGENT']); // cleanup $request->setBody(''); $request->setUrl($url); $request->setMethod($method); return $request; } catch (Exception $e) { rcube::raise_error($e, true, true); } } /** * Return caching object if enabled */ protected function get_cache() { $rcube = rcube::get_instance(); if ($cache_type = $rcube->config->get('dav_cache', 'db')) { $cache_ttl = $rcube->config->get('dav_cache_ttl', '10m'); $cache_name = 'DAV'; return $rcube->get_cache($cache_name, $cache_type, $cache_ttl); } } } diff --git a/plugins/libkolab/lib/kolab_format.php b/plugins/libkolab/lib/kolab_format.php index a6dbcbff..5d98fb15 100644 --- a/plugins/libkolab/lib/kolab_format.php +++ b/plugins/libkolab/lib/kolab_format.php @@ -1,797 +1,797 @@ <?php /** * Kolab format model class wrapping libkolabxml bindings * * Abstract base class for different Kolab groupware objects read from/written * to the new Kolab 3 format using the PHP bindings of libkolabxml. * * @version @package_version@ * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2012, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. */ abstract class kolab_format { public static $timezone; public /*abstract*/ $CTYPE; public /*abstract*/ $CTYPEv2; protected /*abstract*/ $objclass; protected /*abstract*/ $read_func; protected /*abstract*/ $write_func; protected $obj; protected $data; protected $xmldata; protected $xmlobject; protected $formaterror; protected $loaded = false; protected $version = '3.0'; const KTYPE_PREFIX = 'application/x-vnd.kolab.'; const PRODUCT_ID = 'Roundcube-libkolab-1.1'; // mapping table for valid PHP timezones not supported by libkolabxml // basically the entire list of ftp://ftp.iana.org/tz/data/backward protected static $timezone_map = array( 'Africa/Asmera' => 'Africa/Asmara', 'Africa/Timbuktu' => 'Africa/Abidjan', 'America/Argentina/ComodRivadavia' => 'America/Argentina/Catamarca', 'America/Atka' => 'America/Adak', 'America/Buenos_Aires' => 'America/Argentina/Buenos_Aires', 'America/Catamarca' => 'America/Argentina/Catamarca', 'America/Coral_Harbour' => 'America/Atikokan', 'America/Cordoba' => 'America/Argentina/Cordoba', 'America/Ensenada' => 'America/Tijuana', 'America/Fort_Wayne' => 'America/Indiana/Indianapolis', 'America/Indianapolis' => 'America/Indiana/Indianapolis', 'America/Jujuy' => 'America/Argentina/Jujuy', 'America/Knox_IN' => 'America/Indiana/Knox', 'America/Louisville' => 'America/Kentucky/Louisville', 'America/Mendoza' => 'America/Argentina/Mendoza', 'America/Porto_Acre' => 'America/Rio_Branco', 'America/Rosario' => 'America/Argentina/Cordoba', 'America/Virgin' => 'America/Port_of_Spain', 'Asia/Ashkhabad' => 'Asia/Ashgabat', 'Asia/Calcutta' => 'Asia/Kolkata', 'Asia/Chungking' => 'Asia/Shanghai', 'Asia/Dacca' => 'Asia/Dhaka', 'Asia/Katmandu' => 'Asia/Kathmandu', 'Asia/Macao' => 'Asia/Macau', 'Asia/Saigon' => 'Asia/Ho_Chi_Minh', 'Asia/Tel_Aviv' => 'Asia/Jerusalem', 'Asia/Thimbu' => 'Asia/Thimphu', 'Asia/Ujung_Pandang' => 'Asia/Makassar', 'Asia/Ulan_Bator' => 'Asia/Ulaanbaatar', 'Atlantic/Faeroe' => 'Atlantic/Faroe', 'Atlantic/Jan_Mayen' => 'Europe/Oslo', 'Australia/ACT' => 'Australia/Sydney', 'Australia/Canberra' => 'Australia/Sydney', 'Australia/LHI' => 'Australia/Lord_Howe', 'Australia/NSW' => 'Australia/Sydney', 'Australia/North' => 'Australia/Darwin', 'Australia/Queensland' => 'Australia/Brisbane', 'Australia/South' => 'Australia/Adelaide', 'Australia/Tasmania' => 'Australia/Hobart', 'Australia/Victoria' => 'Australia/Melbourne', 'Australia/West' => 'Australia/Perth', 'Australia/Yancowinna' => 'Australia/Broken_Hill', 'Brazil/Acre' => 'America/Rio_Branco', 'Brazil/DeNoronha' => 'America/Noronha', 'Brazil/East' => 'America/Sao_Paulo', 'Brazil/West' => 'America/Manaus', 'Canada/Atlantic' => 'America/Halifax', 'Canada/Central' => 'America/Winnipeg', 'Canada/East-Saskatchewan' => 'America/Regina', 'Canada/Eastern' => 'America/Toronto', 'Canada/Mountain' => 'America/Edmonton', 'Canada/Newfoundland' => 'America/St_Johns', 'Canada/Pacific' => 'America/Vancouver', 'Canada/Saskatchewan' => 'America/Regina', 'Canada/Yukon' => 'America/Whitehorse', 'Chile/Continental' => 'America/Santiago', 'Chile/EasterIsland' => 'Pacific/Easter', 'Cuba' => 'America/Havana', 'Egypt' => 'Africa/Cairo', 'Eire' => 'Europe/Dublin', 'Europe/Belfast' => 'Europe/London', 'Europe/Tiraspol' => 'Europe/Chisinau', 'GB' => 'Europe/London', 'GB-Eire' => 'Europe/London', 'Greenwich' => 'Etc/GMT', 'Hongkong' => 'Asia/Hong_Kong', 'Iceland' => 'Atlantic/Reykjavik', 'Iran' => 'Asia/Tehran', 'Israel' => 'Asia/Jerusalem', 'Jamaica' => 'America/Jamaica', 'Japan' => 'Asia/Tokyo', 'Kwajalein' => 'Pacific/Kwajalein', 'Libya' => 'Africa/Tripoli', 'Mexico/BajaNorte' => 'America/Tijuana', 'Mexico/BajaSur' => 'America/Mazatlan', 'Mexico/General' => 'America/Mexico_City', 'NZ' => 'Pacific/Auckland', 'NZ-CHAT' => 'Pacific/Chatham', 'Navajo' => 'America/Denver', 'PRC' => 'Asia/Shanghai', 'Pacific/Ponape' => 'Pacific/Pohnpei', 'Pacific/Samoa' => 'Pacific/Pago_Pago', 'Pacific/Truk' => 'Pacific/Chuuk', 'Pacific/Yap' => 'Pacific/Chuuk', 'Poland' => 'Europe/Warsaw', 'Portugal' => 'Europe/Lisbon', 'ROC' => 'Asia/Taipei', 'ROK' => 'Asia/Seoul', 'Singapore' => 'Asia/Singapore', 'Turkey' => 'Europe/Istanbul', 'UCT' => 'Etc/UCT', 'US/Alaska' => 'America/Anchorage', 'US/Aleutian' => 'America/Adak', 'US/Arizona' => 'America/Phoenix', 'US/Central' => 'America/Chicago', 'US/East-Indiana' => 'America/Indiana/Indianapolis', 'US/Eastern' => 'America/New_York', 'US/Hawaii' => 'Pacific/Honolulu', 'US/Indiana-Starke' => 'America/Indiana/Knox', 'US/Michigan' => 'America/Detroit', 'US/Mountain' => 'America/Denver', 'US/Pacific' => 'America/Los_Angeles', 'US/Samoa' => 'Pacific/Pago_Pago', 'Universal' => 'Etc/UTC', 'W-SU' => 'Europe/Moscow', 'Zulu' => 'Etc/UTC', ); /** * Factory method to instantiate a kolab_format object of the given type and version * * @param string Object type to instantiate * @param float Format version * @param string Cached xml data to initialize with * @return object kolab_format */ public static function factory($type, $version = '3.0', $xmldata = null) { if (!isset(self::$timezone)) self::$timezone = new DateTimeZone('UTC'); if (!self::supports($version)) return PEAR::raiseError("No support for Kolab format version " . $version); $type = preg_replace('/configuration\.[a-z._]+$/', 'configuration', $type); $suffix = preg_replace('/[^a-z]+/', '', $type); $classname = 'kolab_format_' . $suffix; if (class_exists($classname)) return new $classname($xmldata, $version); return PEAR::raiseError("Failed to load Kolab Format wrapper for type " . $type); } /** * Determine support for the given format version * * @param float Format version to check * @return boolean True if supported, False otherwise */ public static function supports($version) { if ($version == '2.0') return class_exists('kolabobject'); // default is version 3 return class_exists('kolabformat'); } /** * Convert the given date/time value into a cDateTime object * * @param mixed Date/Time value either as unix timestamp, date string or PHP DateTime object * @param DateTimeZone The timezone the date/time is in. Use global default if Null, local time if False * @param boolean True of the given date has no time component * @param DateTimeZone The timezone to convert the date to before converting to cDateTime * * @return cDateTime The libkolabxml date/time object */ public static function get_datetime($datetime, $tz = null, $dateonly = false, $dest_tz = null) { // use timezone information from datetime or global setting if (!$tz && $tz !== false) { if ($datetime instanceof DateTimeInterface) $tz = $datetime->getTimezone(); if (!$tz) $tz = self::$timezone; } $result = new cDateTime(); try { // got a unix timestamp (in UTC) if (is_numeric($datetime)) { $datetime = new libcalendaring_datetime('@'.$datetime, new DateTimeZone('UTC')); if ($tz) $datetime->setTimezone($tz); } else if (is_string($datetime) && strlen($datetime)) { $datetime = $tz ? new libcalendaring_datetime($datetime, $tz) : new libcalendaring_datetime($datetime); } else if ($datetime instanceof DateTimeInterface) { $datetime = clone $datetime; } } catch (Exception $e) {} if ($datetime instanceof DateTimeInterface) { if ($dest_tz instanceof DateTimeZone && $dest_tz !== $datetime->getTimezone()) { $datetime->setTimezone($dest_tz); $tz = $dest_tz; } $result->setDate($datetime->format('Y'), $datetime->format('n'), $datetime->format('j')); if ($dateonly) { // Dates should be always in local time only return $result; } $result->setTime($datetime->format('G'), $datetime->format('i'), $datetime->format('s')); // libkolabxml throws errors on some deprecated timezone names $utc_aliases = array('UTC', 'GMT', '+00:00', 'Z', 'Etc/GMT', 'Etc/UTC'); if ($tz && in_array($tz->getName(), $utc_aliases)) { $result->setUTC(true); } else if ($tz !== false) { $tzid = $tz->getName(); if (array_key_exists($tzid, self::$timezone_map)) $tzid = self::$timezone_map[$tzid]; $result->setTimezone($tzid); } } return $result; } /** * Convert the given cDateTime into a PHP DateTime object * * @param cDateTime The libkolabxml datetime object * @param DateTimeZone The timezone to convert the date to * * @return libcalendaring_datetime PHP datetime instance */ public static function php_datetime($cdt, $dest_tz = null) { if (!is_object($cdt) || !$cdt->isValid()) { return null; } $d = new libcalendaring_datetime(null, self::$timezone); if ($dest_tz) { $d->setTimezone($dest_tz); } else { try { if ($tzs = $cdt->timezone()) { $tz = new DateTimeZone($tzs); $d->setTimezone($tz); } else if ($cdt->isUTC()) { $d->setTimezone(new DateTimeZone('UTC')); } } catch (Exception $e) { } } $d->setDate($cdt->year(), $cdt->month(), $cdt->day()); if ($cdt->isDateOnly()) { $d->_dateonly = true; $d->setTime(12, 0, 0); // set time to noon to avoid timezone troubles } else { $d->setTime($cdt->hour(), $cdt->minute(), $cdt->second()); } return $d; } /** * Convert a libkolabxml vector to a PHP array * * @param object vector Object * @return array Indexed array containing vector elements */ public static function vector2array($vec, $max = PHP_INT_MAX) { $arr = array(); for ($i=0; $i < $vec->size() && $i < $max; $i++) $arr[] = $vec->get($i); return $arr; } /** * Build a libkolabxml vector (string) from a PHP array * * @param array Array with vector elements * @return object vectors */ public static function array2vector($arr) { $vec = new vectors; foreach ((array)$arr as $val) { if (strlen($val)) $vec->push($val); } return $vec; } /** * Parse the X-Kolab-Type header from MIME messages and return the object type in short form * * @param string X-Kolab-Type header value * @return string Kolab object type (contact,event,task,note,etc.) */ public static function mime2object_type($x_kolab_type) { return preg_replace( array('/dictionary.[a-z.]+$/', '/contact.distlist$/'), array( 'dictionary', 'distribution-list'), substr($x_kolab_type, strlen(self::KTYPE_PREFIX)) ); } /** * Default constructor of all kolab_format_* objects */ public function __construct($xmldata = null, $version = null) { $this->obj = new $this->objclass; $this->xmldata = $xmldata; if ($version) $this->version = $version; // use libkolab module if available if (class_exists('kolabobject')) $this->xmlobject = new XMLObject(); } /** * Check for format errors after calling kolabformat::write*() * * @return boolean True if there were errors, False if OK */ protected function format_errors() { $ret = $log = false; switch (kolabformat::error()) { case kolabformat::NoError: $ret = false; break; case kolabformat::Warning: $ret = false; $uid = is_object($this->obj) ? $this->obj->uid() : $this->data['uid']; $log = "Warning @ $uid"; break; default: $ret = true; $log = "Error"; } if ($log && !isset($this->formaterror)) { rcube::raise_error(array( 'code' => 660, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "kolabformat $log: " . kolabformat::errorMessage(), ), true); $this->formaterror = $ret; } return $ret; } /** * Save the last generated UID to the object properties. * Should be called after kolabformat::writeXXXX(); */ protected function update_uid() { // get generated UID if (!$this->data['uid']) { if ($this->xmlobject) { $this->data['uid'] = $this->xmlobject->getSerializedUID(); } if (empty($this->data['uid'])) { $this->data['uid'] = kolabformat::getSerializedUID(); } $this->obj->setUid($this->data['uid']); } } /** * Initialize libkolabxml object with cached xml data */ protected function init() { if (!$this->loaded) { if ($this->xmldata) { $this->load($this->xmldata); $this->xmldata = null; } $this->loaded = true; } } /** * Get constant value for libkolab's version parameter * * @param float Version value to convert * @return int Constant value of either kolabobject::KolabV2 or kolabobject::KolabV3 or false if kolabobject module isn't available */ protected function libversion($v = null) { if (class_exists('kolabobject')) { $version = $v ?: $this->version; if ($version <= '2.0') return kolabobject::KolabV2; else return kolabobject::KolabV3; } return false; } /** * Determine the correct libkolab(xml) wrapper function for the given call * depending on the available PHP modules */ protected function libfunc($func) { if (is_array($func) || strpos($func, '::')) return $func; else if (class_exists('kolabobject')) return array($this->xmlobject, $func); else return 'kolabformat::' . $func; } /** * Direct getter for object properties */ public function __get($var) { return $this->data[$var]; } /** * Load Kolab object data from the given XML block * * @param string XML data * @return boolean True on success, False on failure */ public function load($xml) { $this->formaterror = null; $read_func = $this->libfunc($this->read_func); if (is_array($read_func)) $r = call_user_func($read_func, $xml, $this->libversion()); else $r = call_user_func($read_func, $xml, false); if (is_resource($r)) $this->obj = new $this->objclass($r); else if (is_a($r, $this->objclass)) $this->obj = $r; $this->loaded = !$this->format_errors(); } /** * Write object data to XML format * * @param float Format version to write * @return string XML data */ public function write($version = null) { $this->formaterror = null; $this->init(); $write_func = $this->libfunc($this->write_func); if (is_array($write_func)) $this->xmldata = call_user_func($write_func, $this->obj, $this->libversion($version), self::PRODUCT_ID); else $this->xmldata = call_user_func($write_func, $this->obj, self::PRODUCT_ID); if (!$this->format_errors()) $this->update_uid(); else $this->xmldata = null; return $this->xmldata; } /** * Set properties to the kolabformat object * * @param array Object data as hash array */ public function set(&$object) { $this->init(); if (!empty($object['uid'])) $this->obj->setUid($object['uid']); // set some automatic values if missing if (method_exists($this->obj, 'setCreated')) { // Always set created date to workaround libkolabxml (>1.1.4) bug $created = $object['created'] ?: new DateTime('now'); $created->setTimezone(new DateTimeZone('UTC')); // must be UTC $this->obj->setCreated(self::get_datetime($created)); $object['created'] = $created; } $object['changed'] = new DateTime('now', new DateTimeZone('UTC')); $this->obj->setLastModified(self::get_datetime($object['changed'])); // Save custom properties of the given object if (isset($object['x-custom']) && method_exists($this->obj, 'setCustomProperties')) { $vcustom = new vectorcs; foreach ((array)$object['x-custom'] as $cp) { if (is_array($cp)) $vcustom->push(new CustomProperty($cp[0], $cp[1])); } $this->obj->setCustomProperties($vcustom); } // load custom properties from XML for caching (#2238) if method exists (#3125) else if (method_exists($this->obj, 'customProperties')) { $object['x-custom'] = array(); $vcustom = $this->obj->customProperties(); for ($i=0; $i < $vcustom->size(); $i++) { $cp = $vcustom->get($i); $object['x-custom'][] = array($cp->identifier, $cp->value); } } } /** * Convert the Kolab object into a hash array data structure * * @param array Additional data for merge * * @return array Kolab object data as hash array */ public function to_array($data = array()) { $this->init(); // read object properties into local data object $object = array( 'uid' => $this->obj->uid(), 'changed' => self::php_datetime($this->obj->lastModified()), ); // not all container support the created property if (method_exists($this->obj, 'created')) { $object['created'] = self::php_datetime($this->obj->created()); } // read custom properties if (method_exists($this->obj, 'customProperties')) { $vcustom = $this->obj->customProperties(); for ($i=0; $i < $vcustom->size(); $i++) { $cp = $vcustom->get($i); $object['x-custom'][] = array($cp->identifier, $cp->value); } } // merge with additional data, e.g. attachments from the message if ($data) { foreach ($data as $idx => $value) { if (is_array($value)) { $object[$idx] = array_merge((array)$object[$idx], $value); } else { $object[$idx] = $value; } } } return $object; } /** * Object validation method to be implemented by derived classes */ abstract public function is_valid(); /** * Callback for kolab_storage_cache to get object specific tags to cache * * @return array List of tags to save in cache */ public function get_tags() { return array(); } /** * Callback for kolab_storage_cache to get words to index for fulltext search * * @return array List of words to save in cache */ public function get_words() { return array(); } /** * Utility function to extract object attachment data * * @param array Hash array reference to append attachment data into */ public function get_attachments(&$object, $all = false) { $this->init(); // handle attachments $vattach = $this->obj->attachments(); for ($i=0; $i < $vattach->size(); $i++) { $attach = $vattach->get($i); // skip cid: attachments which are mime message parts handled by kolab_storage_folder if (substr($attach->uri(), 0, 4) != 'cid:' && $attach->label()) { $name = $attach->label(); $key = $name . (isset($object['_attachments'][$name]) ? '.'.$i : ''); $content = $attach->data(); $object['_attachments'][$key] = array( 'id' => 'i:'.$i, 'name' => $name, 'mimetype' => $attach->mimetype(), 'size' => strlen($content), 'content' => $content, ); } else if ($all && substr($attach->uri(), 0, 4) == 'cid:') { $key = $attach->uri(); $object['_attachments'][$key] = array( 'id' => $key, 'name' => $attach->label(), 'mimetype' => $attach->mimetype(), ); } else if (in_array(substr($attach->uri(), 0, 4), array('http','imap'))) { $object['links'][] = $attach->uri(); } } } /** * Utility function to set attachment properties to the kolabformat object * * @param array Object data as hash array * @param boolean True to always overwrite attachment information */ protected function set_attachments($object, $write = true) { // save attachments $vattach = new vectorattachment; foreach ((array) $object['_attachments'] as $cid => $attr) { if (empty($attr)) continue; $attach = new Attachment; $attach->setLabel((string)$attr['name']); $attach->setUri('cid:' . $cid, $attr['mimetype'] ?: 'application/octet-stream'); if ($attach->isValid()) { $vattach->push($attach); $write = true; } else { rcube::raise_error(array( 'code' => 660, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Invalid attributes for attachment $cid: " . var_export($attr, true), ), true); } } foreach ((array) $object['links'] as $link) { $attach = new Attachment; $attach->setUri($link, 'unknown'); $vattach->push($attach); $write = true; } if ($write) { $this->obj->setAttachments($vattach); } } /** * Unified way of updating/deleting attachments of edited object * * @param array $object Kolab object data * @param array $old Old version of Kolab object */ public static function merge_attachments(&$object, $old) { $object['_attachments'] = isset($old['_attachments']) && is_array($old['_attachments']) ? $old['_attachments'] : []; // delete existing attachment(s) if (!empty($object['deleted_attachments'])) { foreach ($object['_attachments'] as $idx => $att) { if ($object['deleted_attachments'] === true || in_array($att['id'], $object['deleted_attachments'])) { $object['_attachments'][$idx] = false; } } } // in kolab_storage attachments are indexed by content-id - foreach ((array) $object['attachments'] as $attachment) { + foreach ((array) ($object['attachments'] ?? []) as $attachment) { $key = null; // Roundcube ID has nothing to do with the storage ID, remove it // for uploaded/new attachments // FIXME: Roundcube uses 'data', kolab_format uses 'content' if (!empty($attachment['content']) || !empty($attachment['path']) || !empty($attachment['data'])) { unset($attachment['id']); } if (!empty($attachment['id'])) { foreach ((array) $object['_attachments'] as $cid => $att) { if ($att && $attachment['id'] == $att['id']) { $key = $cid; } } } else { // find attachment by name, so we can update it if exists // and make sure there are no duplicates foreach ($object['_attachments'] as $cid => $att) { if ($att && $attachment['name'] == $att['name']) { $key = $cid; } } } if ($key && $attachment['_deleted']) { $object['_attachments'][$key] = false; } // replace existing entry else if ($key) { $object['_attachments'][$key] = $attachment; } // append as new attachment else { $object['_attachments'][] = $attachment; } } unset($object['attachments']); unset($object['deleted_attachments']); } } diff --git a/plugins/libkolab/lib/kolab_storage_cache.php b/plugins/libkolab/lib/kolab_storage_cache.php index e38c4190..859249f1 100644 --- a/plugins/libkolab/lib/kolab_storage_cache.php +++ b/plugins/libkolab/lib/kolab_storage_cache.php @@ -1,1466 +1,1465 @@ <?php /** * Kolab storage cache class providing a local caching layer for Kolab groupware objects. * * @version @package_version@ * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2012-2013, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. */ class kolab_storage_cache { const DB_DATE_FORMAT = 'Y-m-d H:i:s'; const MAX_RECORDS = 500; protected $db; protected $imap; protected $folder; protected $uid2msg; protected $objects; protected $metadata = array(); protected $folder_id; protected $resource_uri; protected $enabled = true; protected $synched = false; protected $synclock = false; protected $ready = false; protected $cache_table; protected $folders_table; protected $max_sql_packet; protected $max_sync_lock_time = 600; protected $extra_cols = array(); protected $data_props = array(); protected $order_by = null; protected $limit = null; protected $error = 0; protected $server_timezone; protected $sync_start; /** * Factory constructor */ public static function factory(kolab_storage_folder $storage_folder) { $subclass = 'kolab_storage_cache_' . $storage_folder->type; if (class_exists($subclass)) { return new $subclass($storage_folder); } else { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "No kolab_storage_cache class found for folder '$storage_folder->name' of type '$storage_folder->type'" ), true); return new kolab_storage_cache($storage_folder); } } /** * Default constructor */ public function __construct(kolab_storage_folder $storage_folder = null) { $rcmail = rcube::get_instance(); $this->db = $rcmail->get_dbh(); $this->imap = $rcmail->get_storage(); $this->enabled = $rcmail->config->get('kolab_cache', false); $this->folders_table = $this->db->table_name('kolab_folders'); $this->server_timezone = new DateTimeZone(date_default_timezone_get()); if ($this->enabled) { // always read folder cache and lock state from DB master $this->db->set_table_dsn('kolab_folders', 'w'); // remove sync-lock on script termination $rcmail->add_shutdown_function(array($this, '_sync_unlock')); } - if ($storage_folder) + if ($storage_folder) { $this->set_folder($storage_folder); + } } /** * Direct access to cache by folder_id * (only for internal use) */ public function select_by_id($folder_id) { $query = $this->db->query("SELECT * FROM `{$this->folders_table}` WHERE `folder_id` = ?", $folder_id); if ($sql_arr = $this->db->fetch_assoc($query)) { $this->metadata = $sql_arr; $this->folder_id = $sql_arr['folder_id']; $this->folder = new StdClass; $this->folder->type = $sql_arr['type']; $this->resource_uri = $sql_arr['resource']; $this->cache_table = $this->db->table_name('kolab_cache_' . $sql_arr['type']); $this->ready = true; } } /** * Connect cache with a storage folder * * @param kolab_storage_folder The storage folder instance to connect with */ public function set_folder(kolab_storage_folder $storage_folder) { $this->folder = $storage_folder; if (empty($this->folder->name) || !$this->folder->valid) { $this->ready = false; return; } // compose fully qualified ressource uri for this instance $this->resource_uri = $this->folder->get_resource_uri(); $this->cache_table = $this->db->table_name('kolab_cache_' . $this->folder->type); $this->ready = $this->enabled && !empty($this->folder->type); $this->folder_id = null; } /** * Returns true if this cache supports query by type */ public function has_type_col() { return in_array('type', $this->extra_cols); } /** * Getter for the numeric ID used in cache tables */ public function get_folder_id() { $this->_read_folder_data(); return $this->folder_id; } /** * Returns code of last error * * @return int Error code */ public function get_error() { return $this->error; } /** * Synchronize local cache data with remote */ public function synchronize() { // only sync once per request cycle if ($this->synched) return; if (!$this->ready) { // kolab cache is disabled, synchronize IMAP mailbox cache only $this->imap_mode(true); $this->imap->folder_sync($this->folder->name); $this->imap_mode(false); } else { $this->sync_start = time(); // read cached folder metadata $this->_read_folder_data(); // Read folder data from IMAP $ctag = $this->folder->get_ctag(); // Validate current ctag list($uidvalidity, $highestmodseq, $uidnext) = explode('-', $ctag); if (empty($uidvalidity) || empty($highestmodseq)) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to sync the kolab cache (Invalid ctag)" ), true); } // check cache status ($this->metadata is set in _read_folder_data()) else if ( empty($this->metadata['ctag']) || empty($this->metadata['changed']) || $this->metadata['ctag'] !== $ctag ) { // lock synchronization for this folder or wait if locked $this->_sync_lock(); // Run a full-sync (initial sync or continue the aborted sync) if (empty($this->metadata['changed']) || empty($this->metadata['ctag'])) { $result = $this->synchronize_full(); } // Synchronize only the changes since last sync else { $result = $this->synchronize_update($ctag); } // update ctag value (will be written to database in _sync_unlock()) if ($result) { $this->metadata['ctag'] = $ctag; $this->metadata['changed'] = date(self::DB_DATE_FORMAT, time()); } // remove lock $this->_sync_unlock(); } } $this->check_error(); $this->synched = time(); } /** * Perform full cache synchronization */ protected function synchronize_full() { // get effective time limit we have for synchronization (~70% of the execution time) $time_limit = $this->_max_sync_lock_time() * 0.7; if (time() - $this->sync_start > $time_limit) { return false; } // disable messages cache if configured to do so $this->imap_mode(true); // synchronize IMAP mailbox cache, does nothing if messages cache is disabled $this->imap->folder_sync($this->folder->name); // compare IMAP index with object cache index $imap_index = $this->imap->index($this->folder->name, null, null, true, true); $this->imap_mode(false); if ($imap_index->is_error()) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to sync the kolab cache (SEARCH failed)" ), true); return false; } // determine objects to fetch or to invalidate $imap_index = $imap_index->get(); $del_index = array(); $old_index = $this->current_index($del_index); // Fetch objects and store in DB $result = $this->synchronize_fetch($imap_index, $old_index, $del_index); if ($result) { // Remove redundant entries from IMAP and cache $rem_index = array_intersect($del_index, $imap_index); $del_index = array_merge(array_unique($del_index), array_diff($old_index, $imap_index)); $this->synchronize_delete($rem_index, $del_index); } return $result; } /** * Perform partial cache synchronization, based on QRESYNC */ protected function synchronize_update() { if (!$this->imap->get_capability('QRESYNC')) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to sync the kolab cache (no QRESYNC capability)" ), true); return $this->synchronize_full(); } // Handle the previous ctag list($uidvalidity, $highestmodseq, $uidnext) = explode('-', $this->metadata['ctag']); if (empty($uidvalidity) || empty($highestmodseq)) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to sync the kolab cache (Invalid old ctag)" ), true); return false; } // Enable QRESYNC $res = $this->imap->conn->enable('QRESYNC'); if ($res === false) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to sync the kolab cache (failed to enable QRESYNC/CONDSTORE)" ), true); return false; } $mbox_data = $this->imap->folder_data($this->folder->name); if (empty($mbox_data)) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to sync the kolab cache (failed to get folder state)" ), true); return false; } // Check UIDVALIDITY if ($uidvalidity != $mbox_data['UIDVALIDITY']) { return $this->synchronize_full(); } // QRESYNC not supported on specified mailbox if (!empty($mbox_data['NOMODSEQ']) || empty($mbox_data['HIGHESTMODSEQ'])) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to sync the kolab cache (QRESYNC not supported on the folder)" ), true); return $this->synchronize_full(); } // Get modified flags and vanished messages // UID FETCH 1:* (FLAGS) (CHANGEDSINCE 0123456789 VANISHED) $result = $this->imap->conn->fetch( $this->folder->name, '1:*', true, array('FLAGS'), $highestmodseq, true ); $removed = array(); $modified = array(); $existing = $this->current_index($removed); if (!empty($result)) { foreach ($result as $msg) { $uid = $msg->uid; // Message marked as deleted if (!empty($msg->flags['DELETED'])) { $removed[] = $uid; continue; } // Flags changed or new $modified[] = $uid; } } $new = array_diff($modified, $existing, $removed); $result = true; if (!empty($new)) { $result = $this->synchronize_fetch($new, $existing, $removed); if (!$result) { return false; } } // VANISHED found? $mbox_data = $this->imap->folder_data($this->folder->name); // Removed vanished messages from the database $vanished = (array) rcube_imap_generic::uncompressMessageSet($mbox_data['VANISHED']); // Remove redundant entries from IMAP and DB $vanished = array_merge($removed, array_intersect($vanished, $existing)); $this->synchronize_delete($removed, $vanished); return $result; } /** * Fetch objects from IMAP and save into the database */ protected function synchronize_fetch($new_index, &$old_index, &$del_index) { // get effective time limit we have for synchronization (~70% of the execution time) $time_limit = $this->_max_sync_lock_time() * 0.7; if (time() - $this->sync_start > $time_limit) { return false; } $i = 0; $aborted = false; // fetch new objects from imap foreach (array_diff($new_index, $old_index) as $msguid) { // Note: We'll store only objects matching the folder type // anything else will be silently ignored if ($object = $this->folder->read_object($msguid)) { // Deduplication: remove older objects with the same UID // Here we do not resolve conflicts, we just make sure // the most recent version of the object will be used if ($old_msguid = $old_index[$object['uid']]) { if ($old_msguid < $msguid) { $del_index[] = $old_msguid; } else { $del_index[] = $msguid; continue; } } $old_index[$object['uid']] = $msguid; $this->_extended_insert($msguid, $object); // check time limit and abort sync if running too long if (++$i % 50 == 0 && time() - $this->sync_start > $time_limit) { $aborted = true; break; } } } $this->_extended_insert(0, null); return $aborted === false; } /** * Remove specified objects from the database and IMAP */ protected function synchronize_delete($imap_delete, $db_delete) { if (!empty($imap_delete)) { $this->imap_mode(true); $this->imap->delete_message($imap_delete, $this->folder->name); $this->imap_mode(false); } if (!empty($db_delete)) { $quoted_ids = join(',', array_map(array($this->db, 'quote'), $db_delete)); $this->db->query( "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `msguid` IN ($quoted_ids)", $this->folder_id ); } } /** * Return current use->msguid index */ protected function current_index(&$duplicates = array()) { // read cache index $sql_result = $this->db->query( "SELECT `msguid`, `uid` FROM `{$this->cache_table}` WHERE `folder_id` = ?" . " ORDER BY `msguid` DESC", $this->folder_id ); $index = $del_index = array(); while ($sql_arr = $this->db->fetch_assoc($sql_result)) { // Mark all duplicates for removal (note sorting order above) // Duplicates here should not happen, but they do sometimes if (isset($index[$sql_arr['uid']])) { $duplicates[] = $sql_arr['msguid']; } else { $index[$sql_arr['uid']] = $sql_arr['msguid']; } } return $index; } /** * Read a single entry from cache or from IMAP directly * * @param string Related IMAP message UID * @param string Object type to read * @param string IMAP folder name the entry relates to * @param array Hash array with object properties or null if not found */ public function get($msguid, $type = null, $foldername = null) { // delegate to another cache instance if ($foldername && $foldername != $this->folder->name) { $success = false; if ($targetfolder = kolab_storage::get_folder($foldername)) { $success = $targetfolder->cache->get($msguid, $type); $this->error = $targetfolder->cache->get_error(); } return $success; } // load object if not in memory if (!isset($this->objects[$msguid])) { if ($this->ready) { $this->_read_folder_data(); $sql_result = $this->db->query( "SELECT * FROM `{$this->cache_table}` ". "WHERE `folder_id` = ? AND `msguid` = ?", $this->folder_id, $msguid ); if ($sql_arr = $this->db->fetch_assoc($sql_result)) { $this->objects = array($msguid => $this->_unserialize($sql_arr)); // store only this object in memory (#2827) } } // fetch from IMAP if not present in cache if (empty($this->objects[$msguid])) { if ($object = $this->folder->read_object($msguid, $type ?: '*', $foldername)) { $this->objects = array($msguid => $object); $this->set($msguid, $object); } } } $this->check_error(); return $this->objects[$msguid]; } /** * Getter for a single Kolab object identified by its UID * * @param string $uid Object UID * * @return array The Kolab object represented as hash array */ public function get_by_uid($uid) { $old_order_by = $this->order_by; $old_limit = $this->limit; // set order to make sure we get most recent object version // set limit to skip count query $this->order_by = '`msguid` DESC'; $this->limit = array(1, 0); $list = $this->select(array(array('uid', '=', $uid))); // set the order/limit back to defined value $this->order_by = $old_order_by; $this->limit = $old_limit; if (!empty($list) && !empty($list[0])) { return $list[0]; } } /** * Insert/Update a cache entry * * @param string Related IMAP message UID * @param mixed Hash array with object properties to save or false to delete the cache entry * @param string IMAP folder name the entry relates to */ public function set($msguid, $object, $foldername = null) { if (!$msguid) { return; } // delegate to another cache instance if ($foldername && $foldername != $this->folder->name) { if ($targetfolder = kolab_storage::get_folder($foldername)) { $targetfolder->cache->set($msguid, $object); $this->error = $targetfolder->cache->get_error(); } return; } // remove old entry if ($this->ready) { $this->_read_folder_data(); $this->db->query("DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `msguid` = ?", $this->folder_id, $msguid); } if ($object) { // insert new object data... $this->save($msguid, $object); } else { // ...or set in-memory cache to false $this->objects[$msguid] = $object; } $this->check_error(); } /** * Insert (or update) a cache entry * * @param int Related IMAP message UID * @param mixed Hash array with object properties to save or false to delete the cache entry * @param int Optional old message UID (for update) */ public function save($msguid, $object, $olduid = null) { // write to cache if ($this->ready) { $this->_read_folder_data(); $sql_data = $this->_serialize($object); $sql_data['folder_id'] = $this->folder_id; $sql_data['msguid'] = $msguid; $sql_data['uid'] = $object['uid']; $args = array(); $cols = array('folder_id', 'msguid', 'uid', 'changed', 'data', 'tags', 'words'); $cols = array_merge($cols, $this->extra_cols); foreach ($cols as $idx => $col) { $cols[$idx] = $this->db->quote_identifier($col); $args[] = $sql_data[$col]; } if ($olduid) { foreach ($cols as $idx => $col) { $cols[$idx] = "$col = ?"; } $query = "UPDATE `{$this->cache_table}` SET " . implode(', ', $cols) . " WHERE `folder_id` = ? AND `msguid` = ?"; $args[] = $this->folder_id; $args[] = $olduid; } else { $query = "INSERT INTO `{$this->cache_table}` (`created`, " . implode(', ', $cols) . ") VALUES (" . $this->db->now() . str_repeat(', ?', count($cols)) . ")"; } $result = $this->db->query($query, $args); if (!$this->db->affected_rows($result)) { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'message' => "Failed to write to kolab cache" ), true); } } // keep a copy in memory for fast access $this->objects = array($msguid => $object); $this->uid2msg = array($object['uid'] => $msguid); $this->check_error(); } /** * Move an existing cache entry to a new resource * * @param string Entry's IMAP message UID * @param string Entry's Object UID * @param kolab_storage_folder Target storage folder instance * @param string Target entry's IMAP message UID */ public function move($msguid, $uid, $target, $new_msguid = null) { if ($this->ready && $target) { // clear cached uid mapping and force new lookup unset($target->cache->uid2msg[$uid]); // resolve new message UID in target folder if (!$new_msguid) { $new_msguid = $target->cache->uid2msguid($uid); } if ($new_msguid) { $this->_read_folder_data(); $this->db->query( "UPDATE `{$this->cache_table}` SET `folder_id` = ?, `msguid` = ? ". "WHERE `folder_id` = ? AND `msguid` = ?", $target->cache->get_folder_id(), $new_msguid, $this->folder_id, $msguid ); $result = $this->db->affected_rows(); } } if (empty($result)) { // just clear cache entry $this->set($msguid, false); } unset($this->uid2msg[$uid]); $this->check_error(); } /** * Remove all objects from local cache */ public function purge() { if (!$this->ready) { return true; } $this->_read_folder_data(); $result = $this->db->query( "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ?", $this->folder_id ); return $this->db->affected_rows($result); } /** * Update resource URI for existing cache entries * * @param string Target IMAP folder to move it to */ public function rename($new_folder) { if (!$this->ready) { return; } if ($target = kolab_storage::get_folder($new_folder)) { // resolve new message UID in target folder $this->db->query( "UPDATE `{$this->folders_table}` SET `resource` = ? ". "WHERE `resource` = ?", $target->get_resource_uri(), $this->resource_uri ); $this->check_error(); } else { $this->error = kolab_storage::ERROR_IMAP_CONN; } } /** * Select Kolab objects filtered by the given query * * @param array Pseudo-SQL query as list of filter parameter triplets * triplet: array('<colname>', '<comparator>', '<value>') * @param boolean Set true to only return UIDs instead of complete objects * @param boolean Use fast mode to fetch only minimal set of information * (no xml fetching and parsing, etc.) * * @return array List of Kolab data objects (each represented as hash array) or UIDs */ public function select($query = array(), $uids = false, $fast = false) { $result = $uids ? array() : new kolab_storage_dataset($this); $count = null; // read from local cache DB (assume it to be synchronized) if ($this->ready) { $this->_read_folder_data(); // fetch full object data on one query if a small result set is expected $fetchall = !$uids && ($this->limit ? $this->limit[0] : ($count = $this->count($query))) < self::MAX_RECORDS; // skip SELECT if we know it will return nothing if ($count === 0) { return $result; } $sql_query = "SELECT " . ($fetchall ? '*' : "`msguid` AS `_msguid`, `uid`") . " FROM `{$this->cache_table}` WHERE `folder_id` = ?" . $this->_sql_where($query) . (!empty($this->order_by) ? " ORDER BY " . $this->order_by : ''); $sql_result = $this->limit ? $this->db->limitquery($sql_query, $this->limit[1], $this->limit[0], $this->folder_id) : $this->db->query($sql_query, $this->folder_id); if ($this->db->is_error($sql_result)) { if ($uids) { return null; } $result->set_error(true); return $result; } while ($sql_arr = $this->db->fetch_assoc($sql_result)) { if ($fast) { $sql_arr['fast-mode'] = true; } if ($uids) { $this->uid2msg[$sql_arr['uid']] = $sql_arr['_msguid']; $result[] = $sql_arr['uid']; } else if ($fetchall && ($object = $this->_unserialize($sql_arr))) { $result[] = $object; } else if (!$fetchall) { // only add msguid to dataset index $result[] = $sql_arr; } } } // use IMAP else { $filter = $this->_query2assoc($query); $this->imap_mode(true); if ($filter['type']) { $search = 'UNDELETED HEADER X-Kolab-Type ' . kolab_format::KTYPE_PREFIX . $filter['type']; $index = $this->imap->search_once($this->folder->name, $search); } else { $index = $this->imap->index($this->folder->name, null, null, true, true); } $this->imap_mode(false); if ($index->is_error()) { $this->check_error(); if ($uids) { return null; } $result->set_error(true); return $result; } $index = $index->get(); $result = $uids ? $index : $this->_fetch($index, $filter['type']); // TODO: post-filter result according to query } // We don't want to cache big results in-memory, however // if we select only one object here, there's a big chance we will need it later if (!$uids && count($result) == 1) { if ($msguid = $result[0]['_msguid']) { $this->uid2msg[$result[0]['uid']] = $msguid; $this->objects = array($msguid => $result[0]); } } $this->check_error(); return $result; } /** * Get number of objects mathing the given query * * @param array $query Pseudo-SQL query as list of filter parameter triplets * @return integer The number of objects of the given type */ public function count($query = array()) { // read from local cache DB (assume it to be synchronized) if ($this->ready) { $this->_read_folder_data(); $sql_result = $this->db->query( "SELECT COUNT(*) AS `numrows` FROM `{$this->cache_table}` ". "WHERE `folder_id` = ?" . $this->_sql_where($query), $this->folder_id ); if ($this->db->is_error($sql_result)) { return null; } $sql_arr = $this->db->fetch_assoc($sql_result); $count = intval($sql_arr['numrows']); } // use IMAP else { $filter = $this->_query2assoc($query); $this->imap_mode(true); if ($filter['type']) { $search = 'UNDELETED HEADER X-Kolab-Type ' . kolab_format::KTYPE_PREFIX . $filter['type']; $index = $this->imap->search_once($this->folder->name, $search); } else { $index = $this->imap->index($this->folder->name, null, null, true, true); } $this->imap_mode(false); if ($index->is_error()) { $this->check_error(); return null; } // TODO: post-filter result according to query $count = $index->count(); } $this->check_error(); return $count; } /** * Define ORDER BY clause for cache queries */ public function set_order_by($sortcols) { if (!empty($sortcols)) { $sortcols = array_map(function($v) { $v = trim($v); if (strpos($v, ' ')) { list($column, $order) = explode(' ', $v, 2); return "`{$column}` {$order}"; } return "`{$v}`"; }, (array) $sortcols); $this->order_by = join(', ', $sortcols); } else { $this->order_by = null; } } /** * Define LIMIT clause for cache queries */ public function set_limit($length, $offset = 0) { $this->limit = array($length, $offset); } /** * Helper method to compose a valid SQL query from pseudo filter triplets */ protected function _sql_where($query) { $sql_where = ''; foreach ((array) $query as $param) { if (is_array($param[0])) { $subq = array(); foreach ($param[0] as $q) { $subq[] = preg_replace('/^\s*AND\s+/i', '', $this->_sql_where(array($q))); } if (!empty($subq)) { $sql_where .= ' AND (' . implode($param[1] == 'OR' ? ' OR ' : ' AND ', $subq) . ')'; } continue; } else if ($param[1] == '=' && is_array($param[2])) { $qvalue = '(' . join(',', array_map(array($this->db, 'quote'), $param[2])) . ')'; $param[1] = 'IN'; } else if ($param[1] == '~' || $param[1] == 'LIKE' || $param[1] == '!~' || $param[1] == '!LIKE') { $not = ($param[1] == '!~' || $param[1] == '!LIKE') ? 'NOT ' : ''; $param[1] = $not . 'LIKE'; $qvalue = $this->db->quote('%'.preg_replace('/(^\^|\$$)/', ' ', $param[2]).'%'); } else if ($param[1] == '~*' || $param[1] == '!~*') { $not = $param[1][1] == '!' ? 'NOT ' : ''; $param[1] = $not . 'LIKE'; $qvalue = $this->db->quote(preg_replace('/(^\^|\$$)/', ' ', $param[2]).'%'); } else if ($param[0] == 'tags') { $param[1] = ($param[1] == '!=' ? 'NOT ' : '' ) . 'LIKE'; $qvalue = $this->db->quote('% '.$param[2].' %'); } else { $qvalue = $this->db->quote($param[2]); } $sql_where .= sprintf(' AND %s %s %s', $this->db->quote_identifier($param[0]), $param[1], $qvalue ); } return $sql_where; } /** * Helper method to convert the given pseudo-query triplets into * an associative filter array with 'equals' values only */ protected function _query2assoc($query) { // extract object type from query parameter $filter = array(); foreach ($query as $param) { if ($param[1] == '=') $filter[$param[0]] = $param[2]; } return $filter; } /** * Fetch messages from IMAP * * @param array List of message UIDs to fetch * @param string Requested object type or * for all * @param string IMAP folder to read from * @return array List of parsed Kolab objects */ protected function _fetch($index, $type = null, $folder = null) { $results = new kolab_storage_dataset($this); foreach ((array)$index as $msguid) { if ($object = $this->folder->read_object($msguid, $type, $folder)) { $results[] = $object; $this->set($msguid, $object); } } return $results; } /** * Helper method to convert the given Kolab object into a dataset to be written to cache */ protected function _serialize($object) { $data = array(); $sql_data = array('changed' => null, 'tags' => '', 'words' => ''); if ($object['changed']) { $sql_data['changed'] = date(self::DB_DATE_FORMAT, is_object($object['changed']) ? $object['changed']->format('U') : $object['changed']); } if ($object['_formatobj']) { $xml = (string) $object['_formatobj']->write(3.0); $data['_size'] = strlen($xml); $sql_data['tags'] = ' ' . join(' ', $object['_formatobj']->get_tags()) . ' '; // pad with spaces for strict/prefix search $sql_data['words'] = ' ' . join(' ', $object['_formatobj']->get_words()) . ' '; } // Store only minimal set of object properties foreach ($this->data_props as $prop) { if (isset($object[$prop])) { $data[$prop] = $object[$prop]; if ($data[$prop] instanceof DateTimeInterface) { $data[$prop] = array( 'cl' => 'DateTime', 'dt' => $data[$prop]->format('Y-m-d H:i:s'), 'tz' => $data[$prop]->getTimezone()->getName(), ); } } } $sql_data['data'] = json_encode(rcube_charset::clean($data)); return $sql_data; } /** * Helper method to turn stored cache data into a valid storage object */ protected function _unserialize($sql_arr) { if ($sql_arr['fast-mode'] && !empty($sql_arr['data']) && ($object = json_decode($sql_arr['data'], true))) { $object['uid'] = $sql_arr['uid']; foreach ($this->data_props as $prop) { if (isset($object[$prop]) && is_array($object[$prop]) && $object[$prop]['cl'] == 'DateTime') { $object[$prop] = new DateTime($object[$prop]['dt'], new DateTimeZone($object[$prop]['tz'])); } else if (!isset($object[$prop]) && isset($sql_arr[$prop])) { $object[$prop] = $sql_arr[$prop]; } } if ($sql_arr['created'] && empty($object['created'])) { $object['created'] = new DateTime($sql_arr['created']); } if ($sql_arr['changed'] && empty($object['changed'])) { $object['changed'] = new DateTime($sql_arr['changed']); } $object['_type'] = $sql_arr['type'] ?: $this->folder->type; $object['_msguid'] = $sql_arr['msguid']; $object['_mailbox'] = $this->folder->name; } // Fetch object xml else { // FIXME: Because old cache solution allowed storing objects that // do not match folder type we may end up with invalid objects. // 2nd argument of read_object() here makes sure they are still // usable. However, not allowing them here might be also an intended // solution in future. $object = $this->folder->read_object($sql_arr['msguid'], '*'); } return $object; } /** * Write records into cache using extended inserts to reduce the number of queries to be executed * * @param int Message UID. Set 0 to commit buffered inserts * @param array Kolab object to cache */ protected function _extended_insert($msguid, $object) { static $buffer = ''; $line = ''; $cols = array('folder_id', 'msguid', 'uid', 'created', 'changed', 'data', 'tags', 'words'); if ($this->extra_cols) { $cols = array_merge($cols, $this->extra_cols); } if ($object) { $sql_data = $this->_serialize($object); // Skip multi-folder insert for all databases but MySQL // In Oracle we can't put long data inline, others we don't support yet if (strpos($this->db->db_provider, 'mysql') !== 0) { $extra_args = array(); $params = array($this->folder_id, $msguid, $object['uid'], $sql_data['changed'], $sql_data['data'], $sql_data['tags'], $sql_data['words']); foreach ($this->extra_cols as $col) { $params[] = $sql_data[$col]; $extra_args[] = '?'; } $cols = implode(', ', array_map(function($n) { return "`{$n}`"; }, $cols)); $extra_args = count($extra_args) ? ', ' . implode(', ', $extra_args) : ''; $result = $this->db->query( "INSERT INTO `{$this->cache_table}` ($cols)" . " VALUES (?, ?, ?, " . $this->db->now() . ", ?, ?, ?, ?$extra_args)", $params ); if (!$this->db->affected_rows($result)) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to write to kolab cache" ), true); } return; } $values = array( $this->db->quote($this->folder_id), $this->db->quote($msguid), $this->db->quote($object['uid']), $this->db->now(), $this->db->quote($sql_data['changed']), $this->db->quote($sql_data['data']), $this->db->quote($sql_data['tags']), $this->db->quote($sql_data['words']), ); foreach ($this->extra_cols as $col) { $values[] = $this->db->quote($sql_data[$col]); } $line = '(' . join(',', $values) . ')'; } if ($buffer && (!$msguid || (strlen($buffer) + strlen($line) > $this->max_sql_packet()))) { $columns = implode(', ', array_map(function($n) { return "`{$n}`"; }, $cols)); $update = implode(', ', array_map(function($i) { return "`{$i}` = VALUES(`{$i}`)"; }, array_slice($cols, 2))); $result = $this->db->query( "INSERT INTO `{$this->cache_table}` ($columns) VALUES $buffer" . " ON DUPLICATE KEY UPDATE $update" ); if (!$this->db->affected_rows($result)) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to write to kolab cache" ), true); } $buffer = ''; } $buffer .= ($buffer ? ',' : '') . $line; } /** * Returns max_allowed_packet from mysql config */ protected function max_sql_packet() { if (!$this->max_sql_packet) { // mysql limit or max 4 MB $value = $this->db->get_variable('max_allowed_packet', 1048500); $this->max_sql_packet = min($value, 4*1024*1024) - 2000; } return $this->max_sql_packet; } /** * Read this folder's ID and cache metadata */ protected function _read_folder_data() { // already done if (!empty($this->folder_id) || !$this->ready) return; $sql_arr = $this->db->fetch_assoc($this->db->query( "SELECT `folder_id`, `synclock`, `ctag`, `changed`" . " FROM `{$this->folders_table}` WHERE `resource` = ?", $this->resource_uri )); if ($sql_arr) { $this->metadata = $sql_arr; $this->folder_id = $sql_arr['folder_id']; } else { $this->db->query("INSERT INTO `{$this->folders_table}` (`resource`, `type`)" . " VALUES (?, ?)", $this->resource_uri, $this->folder->type); $this->folder_id = $this->db->insert_id('kolab_folders'); $this->metadata = array(); } } /** * Check lock record for this folder and wait if locked or set lock */ protected function _sync_lock() { if (!$this->ready) return; $this->_read_folder_data(); // abort if database is not set-up if ($this->db->is_error()) { $this->check_error(); $this->ready = false; return; } $read_query = "SELECT `synclock`, `ctag` FROM `{$this->folders_table}` WHERE `folder_id` = ?"; $write_query = "UPDATE `{$this->folders_table}` SET `synclock` = ? WHERE `folder_id` = ? AND `synclock` = ?"; - $max_lock_time = $this->_max_sync_lock_time(); - $sync_lock = intval($this->metadata['synclock'] ?? 0); // wait if locked (expire locks after 10 minutes) ... // ... or if setting lock fails (another process meanwhile set it) while ( - ($sync_lock + $max_lock_time > time()) || - (($res = $this->db->query($write_query, time(), $this->folder_id, $sync_lock)) + (intval($this->metadata['synclock'] ?? 0) + $max_lock_time > time()) || + (($res = $this->db->query($write_query, time(), $this->folder_id, intval($this->metadata['synclock'] ?? 0))) && !($affected = $this->db->affected_rows($res)) ) ) { usleep(500000); $this->metadata = $this->db->fetch_assoc($this->db->query($read_query, $this->folder_id)); } $this->synclock = $affected > 0; } /** * Remove lock for this folder */ public function _sync_unlock() { if (!$this->ready || !$this->synclock) return; $this->db->query( "UPDATE `{$this->folders_table}` SET `synclock` = 0, `ctag` = ?, `changed` = ? WHERE `folder_id` = ?", $this->metadata['ctag'], $this->metadata['changed'], $this->folder_id ); $this->synclock = false; } protected function _max_sync_lock_time() { $limit = get_offset_sec(ini_get('max_execution_time')); if ($limit <= 0 || $limit > $this->max_sync_lock_time) { $limit = $this->max_sync_lock_time; } return $limit; } /** * Check IMAP connection error state */ protected function check_error() { if (($err_code = $this->imap->get_error_code()) < 0) { $this->error = kolab_storage::ERROR_IMAP_CONN; if (($res_code = $this->imap->get_response_code()) !== 0 && in_array($res_code, array(rcube_storage::NOPERM, rcube_storage::READONLY))) { $this->error = kolab_storage::ERROR_NO_PERMISSION; } } else if ($this->db->is_error()) { $this->error = kolab_storage::ERROR_CACHE_DB; } } /** * Resolve an object UID into an IMAP message UID * * @param string Kolab object UID * @param boolean Include deleted objects * @return int The resolved IMAP message UID */ public function uid2msguid($uid, $deleted = false) { // query local database if available if (!isset($this->uid2msg[$uid]) && $this->ready) { $this->_read_folder_data(); $sql_result = $this->db->query( "SELECT `msguid` FROM `{$this->cache_table}` ". "WHERE `folder_id` = ? AND `uid` = ? ORDER BY `msguid` DESC", $this->folder_id, $uid ); if ($sql_arr = $this->db->fetch_assoc($sql_result)) { $this->uid2msg[$uid] = $sql_arr['msguid']; } } if (!isset($this->uid2msg[$uid])) { // use IMAP SEARCH to get the right message $index = $this->imap->search_once($this->folder->name, ($deleted ? '' : 'UNDELETED ') . 'HEADER SUBJECT ' . rcube_imap_generic::escape($uid)); $results = $index->get(); $this->uid2msg[$uid] = end($results); } return $this->uid2msg[$uid]; } /** * Getter for protected member variables */ public function __get($name) { if ($name == 'folder_id') { $this->_read_folder_data(); } return $this->$name; } /** * Set Roundcube storage options and bypass messages/indexes cache. * * We use skip_deleted and threading settings specific to Kolab, * we have to change these global settings only temporarily. * Roundcube cache duplicates information already stored in kolab_cache, * that's why we can disable it for better performance. * * @param bool $force True to start Kolab mode, False to stop it. */ public function imap_mode($force = false) { // remember current IMAP settings if ($force) { $this->imap_options = array( 'skip_deleted' => $this->imap->get_option('skip_deleted'), 'threading' => $this->imap->get_threading(), ); } // re-set IMAP settings $this->imap->set_threading($force ? false : $this->imap_options['threading']); $this->imap->set_options(array( 'skip_deleted' => $force ? true : $this->imap_options['skip_deleted'], )); // if kolab cache is disabled do nothing if (!$this->enabled) { return; } static $messages_cache, $cache_bypass; if ($messages_cache === null) { $rcmail = rcube::get_instance(); $messages_cache = (bool) $rcmail->config->get('messages_cache'); $cache_bypass = (int) $rcmail->config->get('kolab_messages_cache_bypass'); } if ($messages_cache) { // handle recurrent (multilevel) bypass() calls if ($force) { $this->cache_bypassed += 1; if ($this->cache_bypassed > 1) { return; } } else { $this->cache_bypassed -= 1; if ($this->cache_bypassed > 0) { return; } } switch ($cache_bypass) { case 2: // Disable messages and index cache completely $this->imap->set_messages_caching(!$force); break; case 3: case 1: // We'll disable messages cache, but keep index cache (1) or vice-versa (3) // Default mode is both (MODE_INDEX | MODE_MESSAGE) $mode = $cache_bypass == 3 ? rcube_imap_cache::MODE_MESSAGE : rcube_imap_cache::MODE_INDEX; if (!$force) { $mode |= $cache_bypass == 3 ? rcube_imap_cache::MODE_INDEX : rcube_imap_cache::MODE_MESSAGE; } $this->imap->set_messages_caching(true, $mode); } } } /** * Converts DateTime or unix timestamp into sql date format * using server timezone. */ protected function _convert_datetime($datetime) { if (is_object($datetime)) { $dt = clone $datetime; $dt->setTimeZone($this->server_timezone); return $dt->format(self::DB_DATE_FORMAT); } else if ($datetime) { return date(self::DB_DATE_FORMAT, $datetime); } } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache.php b/plugins/libkolab/lib/kolab_storage_dav_cache.php index 0845b9ac..68a97708 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_cache.php +++ b/plugins/libkolab/lib/kolab_storage_dav_cache.php @@ -1,710 +1,745 @@ <?php /** * Kolab storage cache class providing a local caching layer for Kolab groupware objects. * * @author Aleksander Machniak <machniak@apheleia-it.ch> * * Copyright (C) 2012-2022, Apheleia IT AG <contact@apheleia-it.ch> * * 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 <http://www.gnu.org/licenses/>. */ class kolab_storage_dav_cache extends kolab_storage_cache { /** * Factory constructor */ public static function factory(kolab_storage_folder $storage_folder) { $subclass = 'kolab_storage_dav_cache_' . $storage_folder->type; if (class_exists($subclass)) { return new $subclass($storage_folder); } rcube::raise_error( ['code' => 900, 'message' => "No {$subclass} class found for folder '{$storage_folder->name}'"], true ); return new kolab_storage_dav_cache($storage_folder); } /** * Connect cache with a storage folder * * @param kolab_storage_folder The storage folder instance to connect with */ public function set_folder(kolab_storage_folder $storage_folder) { $this->folder = $storage_folder; if (!$this->folder->valid) { $this->ready = false; return; } // compose fully qualified ressource uri for this instance $this->resource_uri = $this->folder->get_resource_uri(); $this->cache_table = $this->db->table_name('kolab_cache_dav_' . $this->folder->type); $this->ready = true; } /** * Synchronize local cache data with remote */ public function synchronize() { // only sync once per request cycle if ($this->synched) { return; } $this->sync_start = time(); // read cached folder metadata $this->_read_folder_data(); $ctag = $this->folder->get_ctag(); // check cache status ($this->metadata is set in _read_folder_data()) if ( empty($this->metadata['ctag']) || empty($this->metadata['changed']) || $this->metadata['ctag'] !== $ctag ) { // lock synchronization for this folder and wait if already locked $this->_sync_lock(); $result = $this->synchronize_worker(); // update ctag value (will be written to database in _sync_unlock()) if ($result) { $this->metadata['ctag'] = $ctag; $this->metadata['changed'] = date(self::DB_DATE_FORMAT, time()); } // remove lock $this->_sync_unlock(); } $this->synched = time(); } /** * Perform cache synchronization */ protected function synchronize_worker() { // get effective time limit we have for synchronization (~70% of the execution time) $time_limit = $this->_max_sync_lock_time() * 0.7; if (time() - $this->sync_start > $time_limit) { return false; } // TODO: Implement synchronization with use of WebDAV-Sync (RFC 6578) // Get the objects from the DAV server $dav_index = $this->folder->dav->getIndex($this->folder->href, $this->folder->get_dav_type()); if (!is_array($dav_index)) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to sync the kolab cache for {$this->folder->href}" ], true); return false; } // WARNING: For now we assume object's href is <calendar-href>/<uid>.ics, // which would mean there are no duplicates (objects with the same uid). // With DAV protocol we can't get UID without fetching the whole object. // Also the folder_id + uid is a unique index in the database. // In the future we maybe should store the href in database. // Determine objects to fetch or delete $new_index = []; $update_index = []; $old_index = $this->folder_index(); // uid -> etag $chunk_size = 20; // max numer of objects per DAV request foreach ($dav_index as $object) { $uid = $object['uid']; if (isset($old_index[$uid])) { $old_etag = $old_index[$uid]; $old_index[$uid] = null; if ($old_etag === $object['etag']) { // the object didn't change continue; } $update_index[$uid] = $object['href']; } else { $new_index[$uid] = $object['href']; } } + $i = 0; + // Fetch new objects and store in DB if (!empty($new_index)) { - $i = 0; foreach (array_chunk($new_index, $chunk_size, true) as $chunk) { $objects = $this->folder->dav->getData($this->folder->href, $this->folder->get_dav_type(), $chunk); if (!is_array($objects)) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to sync the kolab cache for {$this->folder->href}" ], true); return false; } foreach ($objects as $dav_object) { if ($object = $this->folder->from_dav($dav_object)) { $object['_raw'] = $dav_object['data']; $this->_extended_insert(false, $object); unset($object['_raw']); } } $this->_extended_insert(true, null); // check time limit and abort sync if running too long if (++$i % 25 == 0 && time() - $this->sync_start > $time_limit) { return false; } } } // Fetch updated objects and store in DB if (!empty($update_index)) { foreach (array_chunk($update_index, $chunk_size, true) as $chunk) { $objects = $this->folder->dav->getData($this->folder->href, $this->folder->get_dav_type(), $chunk); if (!is_array($objects)) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to sync the kolab cache for {$this->folder->href}" ], true); return false; } foreach ($objects as $dav_object) { if ($object = $this->folder->from_dav($dav_object)) { $object['_raw'] = $dav_object['data']; $this->save($object, $object['uid']); unset($object['_raw']); } } // check time limit and abort sync if running too long if (++$i % 25 == 0 && time() - $this->sync_start > $time_limit) { return false; } } } // Remove deleted objects $old_index = array_filter($old_index); if (!empty($old_index)) { $quoted_uids = join(',', array_map(array($this->db, 'quote'), $old_index)); $this->db->query( "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` IN ($quoted_uids)", $this->folder_id ); } return true; } /** * Return current folder index (uid -> etag) */ public function folder_index() { $this->_read_folder_data(); // read cache index $sql_result = $this->db->query( "SELECT `uid`, `etag` FROM `{$this->cache_table}` WHERE `folder_id` = ?", $this->folder_id ); $index = []; while ($sql_arr = $this->db->fetch_assoc($sql_result)) { $index[$sql_arr['uid']] = $sql_arr['etag']; } return $index; } /** * Read a single entry from cache or from server directly * * @param string Object UID * @param string Object type to read * @param string Unused (kept for compat. with the parent class) * * @return null|array An array of objects, NULL if not found */ public function get($uid, $type = null, $unused = null) { if ($this->ready) { $this->_read_folder_data(); $sql_result = $this->db->query( "SELECT * FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` = ?", $this->folder_id, $uid ); if ($sql_arr = $this->db->fetch_assoc($sql_result)) { $object = $this->_unserialize($sql_arr); } } // fetch from DAV if not present in cache if (empty($object)) { if ($object = $this->folder->read_object($uid, $type ?: '*')) { $this->save($object); } } return $object ?: null; } /** * Read multiple entries from the server directly * * @param array Object UIDs * * @return false|array An array of objects, False on error */ public function multiget($uids) { return $this->folder->read_objects($uids); } /** * Insert/Update a cache entry * * @param string Object UID * @param array|false Hash array with object properties to save or false to delete the cache entry * @param string Unused (kept for compat. with the parent class) */ public function set($uid, $object, $unused = null) { // remove old entry if ($this->ready) { $this->_read_folder_data(); $this->db->query( "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` = ?", $this->folder_id, $uid ); } if ($object) { $this->save($object); } } /** * Insert (or update) a cache entry * * @param mixed Hash array with object properties to save or false to delete the cache entry * @param string Optional old message UID (for update) * @param string Unused (kept for compat. with the parent class) */ public function save($object, $olduid = null, $unused = null) { // write to cache if ($this->ready) { $this->_read_folder_data(); $sql_data = $this->_serialize($object); $sql_data['folder_id'] = $this->folder_id; $sql_data['uid'] = rcube_charset::clean($object['uid']); $sql_data['etag'] = rcube_charset::clean($object['etag']); $args = []; $cols = ['folder_id', 'uid', 'etag', 'changed', 'data', 'tags', 'words']; $cols = array_merge($cols, $this->extra_cols); foreach ($cols as $idx => $col) { $cols[$idx] = $this->db->quote_identifier($col); $args[] = $sql_data[$col]; } if ($olduid) { foreach ($cols as $idx => $col) { $cols[$idx] = "$col = ?"; } $query = "UPDATE `{$this->cache_table}` SET " . implode(', ', $cols) . " WHERE `folder_id` = ? AND `uid` = ?"; $args[] = $this->folder_id; $args[] = $olduid; } else { $query = "INSERT INTO `{$this->cache_table}` (`created`, " . implode(', ', $cols) . ") VALUES (" . $this->db->now() . str_repeat(', ?', count($cols)) . ")"; } $result = $this->db->query($query, $args); if (!$this->db->affected_rows($result)) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to write to kolab cache" ], true); } } } /** * Move an existing cache entry to a new resource * * @param string Entry's UID * @param kolab_storage_folder Target storage folder instance * @param string Unused (kept for compat. with the parent class) * @param string Unused (kept for compat. with the parent class) */ public function move($uid, $target, $unused1 = null, $unused2 = null) { // TODO } /** * Update resource URI for existing folder * * @param string Target DAV folder to move it to */ public function rename($new_folder) { // TODO } /** * Select Kolab objects filtered by the given query * * @param array Pseudo-SQL query as list of filter parameter triplets * triplet: ['<colname>', '<comparator>', '<value>'] * @param bool Set true to only return UIDs instead of complete objects * @param bool Use fast mode to fetch only minimal set of information * (no xml fetching and parsing, etc.) * * @return array|null|kolab_storage_dataset List of Kolab data objects (each represented as hash array) or UIDs */ public function select($query = [], $uids = false, $fast = false) { $result = $uids ? [] : new kolab_storage_dataset($this); $count = null; $this->_read_folder_data(); // fetch full object data on one query if a small result set is expected $fetchall = !$uids && ($this->limit ? $this->limit[0] : ($count = $this->count($query))) < self::MAX_RECORDS; // skip SELECT if we know it will return nothing if ($count === 0) { return $result; } $sql_query = "SELECT " . ($fetchall ? '*' : "`uid`") . " FROM `{$this->cache_table}` WHERE `folder_id` = ?" . $this->_sql_where($query) . (!empty($this->order_by) ? " ORDER BY " . $this->order_by : ''); $sql_result = $this->limit ? $this->db->limitquery($sql_query, $this->limit[1], $this->limit[0], $this->folder_id) : $this->db->query($sql_query, $this->folder_id); if ($this->db->is_error($sql_result)) { if ($uids) { return null; } $result->set_error(true); return $result; } while ($sql_arr = $this->db->fetch_assoc($sql_result)) { if ($uids) { $result[] = $sql_arr['uid']; } else if (!$fetchall) { $result[] = $sql_arr; } else if (($object = $this->_unserialize($sql_arr, true, $fast))) { $result[] = $object; } else { $result[] = $sql_arr['uid']; } } return $result; } /** * Get number of objects mathing the given query * * @param array $query Pseudo-SQL query as list of filter parameter triplets * * @return int The number of objects of the given type */ public function count($query = []) { // read from local cache DB (assume it to be synchronized) $this->_read_folder_data(); $sql_result = $this->db->query( "SELECT COUNT(*) AS `numrows` FROM `{$this->cache_table}` ". "WHERE `folder_id` = ?" . $this->_sql_where($query), $this->folder_id ); if ($this->db->is_error($sql_result)) { return null; } $sql_arr = $this->db->fetch_assoc($sql_result); $count = intval($sql_arr['numrows']); return $count; } /** * Getter for a single Kolab object identified by its UID * * @param string $uid Object UID * * @return array|null The Kolab object represented as hash array */ public function get_by_uid($uid) { $old_limit = $this->limit; // set limit to skip count query $this->limit = [1, 0]; $list = $this->select([['uid', '=', $uid]]); // set the limit back to defined value $this->limit = $old_limit; if (!empty($list) && !empty($list[0])) { return $list[0]; } } /** * Write records into cache using extended inserts to reduce the number of queries to be executed * * @param bool Set to false to commit buffered insert, true to force an insert * @param array Kolab object to cache */ protected function _extended_insert($force, $object) { static $buffer = ''; $line = ''; $cols = ['folder_id', 'uid', 'etag', 'created', 'changed', 'data', 'tags', 'words']; if ($this->extra_cols) { $cols = array_merge($cols, $this->extra_cols); } if ($object) { $sql_data = $this->_serialize($object); // Skip multi-folder insert for all databases but MySQL // In Oracle we can't put long data inline, others we don't support yet if (strpos($this->db->db_provider, 'mysql') !== 0) { $extra_args = []; $params = [ $this->folder_id, rcube_charset::clean($object['uid']), rcube_charset::clean($object['etag']), $sql_data['changed'], $sql_data['data'], $sql_data['tags'], $sql_data['words'] ]; foreach ($this->extra_cols as $col) { $params[] = $sql_data[$col]; $extra_args[] = '?'; } $cols = implode(', ', array_map(function($n) { return "`{$n}`"; }, $cols)); $extra_args = count($extra_args) ? ', ' . implode(', ', $extra_args) : ''; $result = $this->db->query( "INSERT INTO `{$this->cache_table}` ($cols)" . " VALUES (?, ?, ?, " . $this->db->now() . ", ?, ?, ?, ?$extra_args)", $params ); if (!$this->db->affected_rows($result)) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to write to kolab cache" ), true); } return; } $values = array( $this->db->quote($this->folder_id), $this->db->quote(rcube_charset::clean($object['uid'])), $this->db->quote(rcube_charset::clean($object['etag'])), !empty($sql_data['created']) ? $this->db->quote($sql_data['created']) : $this->db->now(), $this->db->quote($sql_data['changed']), $this->db->quote($sql_data['data']), $this->db->quote($sql_data['tags']), $this->db->quote($sql_data['words']), ); foreach ($this->extra_cols as $col) { $values[] = $this->db->quote($sql_data[$col]); } $line = '(' . join(',', $values) . ')'; } if ($buffer && ($force || (strlen($buffer) + strlen($line) > $this->max_sql_packet()))) { $columns = implode(', ', array_map(function($n) { return "`{$n}`"; }, $cols)); $update = implode(', ', array_map(function($i) { return "`{$i}` = VALUES(`{$i}`)"; }, array_slice($cols, 2))); $result = $this->db->query( "INSERT INTO `{$this->cache_table}` ($columns) VALUES $buffer" . " ON DUPLICATE KEY UPDATE $update" ); if (!$this->db->affected_rows($result)) { rcube::raise_error(array( 'code' => 900, 'message' => "Failed to write to kolab cache" ), true); } $buffer = ''; } $buffer .= ($buffer ? ',' : '') . $line; } /** * Helper method to convert the given Kolab object into a dataset to be written to cache */ protected function _serialize($object) { static $threshold; if ($threshold === null) { $rcube = rcube::get_instance(); $threshold = parse_bytes(rcube::get_instance()->config->get('dav_cache_threshold', 0)); } $data = []; $sql_data = ['created' => date(self::DB_DATE_FORMAT), 'changed' => null, 'tags' => '', 'words' => '']; if (!empty($object['changed'])) { $sql_data['changed'] = self::_convert_datetime($object['changed']); } if (!empty($object['created'])) { $sql_data['created'] = self::_convert_datetime($object['created']); } // Store only minimal set of object properties foreach ($this->data_props as $prop) { if (isset($object[$prop])) { $data[$prop] = $object[$prop]; if ($data[$prop] instanceof DateTimeInterface) { $data[$prop] = array( 'cl' => 'DateTime', 'dt' => $data[$prop]->format('Y-m-d H:i:s'), 'tz' => $data[$prop]->getTimezone()->getName(), ); } } } if (!empty($object['_raw']) && $threshold > 0 && strlen($object['_raw']) <= $threshold) { $data['_raw'] = $object['_raw']; } $sql_data['data'] = json_encode(rcube_charset::clean($data)); return $sql_data; } /** * Helper method to turn stored cache data into a valid storage object */ protected function _unserialize($sql_arr, $noread = false, $fast_mode = false) { $init = function(&$object) use ($sql_arr) { if ($sql_arr['created'] && empty($object['created'])) { $object['created'] = new DateTime($sql_arr['created'], $this->server_timezone); } if ($sql_arr['changed'] && empty($object['changed'])) { $object['changed'] = new DateTime($sql_arr['changed'], $this->server_timezone); } $object['_type'] = !empty($sql_arr['type']) ? $sql_arr['type'] : $this->folder->type; $object['uid'] = $sql_arr['uid']; $object['etag'] = $sql_arr['etag']; }; if (!empty($sql_arr['data']) && ($object = json_decode($sql_arr['data'], true))) { foreach ($this->data_props as $prop) { if (isset($object[$prop]) && is_array($object[$prop]) && isset($object[$prop]['cl']) && $object[$prop]['cl'] == 'DateTime' ) { $object[$prop] = new DateTime($object[$prop]['dt'], new DateTimeZone($object[$prop]['tz'])); } else if (!isset($object[$prop]) && isset($sql_arr[$prop])) { $object[$prop] = $sql_arr[$prop]; } } $init($object); } if (!empty($fast_mode) && !empty($object)) { unset($object['_raw']); } else if ($noread) { // We have the raw content already, parse it if (!empty($object['_raw'])) { $object['data'] = $object['_raw']; if ($object = $this->folder->from_dav($object)) { $init($object); return $object; } } return null; } else { // Fetch a complete object from the server $object = $this->folder->read_object($sql_arr['uid'], '*'); } return $object; } + + /** + * Read this folder's ID and cache metadata + */ + protected function _read_folder_data() + { + // already done + if (!empty($this->folder_id) || !$this->ready) { + return; + } + + // Different than in Kolab XML-based storage, in *DAV folders can + // contain different types of data, e.g. Calendar can store events and tasks. + // Therefore we both `resource` and `type` in WHERE. + + $sql_arr = $this->db->fetch_assoc($this->db->query( + "SELECT `folder_id`, `synclock`, `ctag`, `changed` FROM `{$this->folders_table}`" + . " WHERE `resource` = ? AND `type` = ?", + $this->resource_uri, + $this->folder->type + )); + + if ($sql_arr) { + $this->folder_id = $sql_arr['folder_id']; + $this->metadata = $sql_arr; + } + else { + $this->db->query("INSERT INTO `{$this->folders_table}` (`resource`, `type`)" + . " VALUES (?, ?)", $this->resource_uri, $this->folder->type); + + $this->folder_id = $this->db->insert_id('kolab_folders'); + $this->metadata = []; + } + } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache_task.php b/plugins/libkolab/lib/kolab_storage_dav_cache_task.php new file mode 100644 index 00000000..729998a9 --- /dev/null +++ b/plugins/libkolab/lib/kolab_storage_dav_cache_task.php @@ -0,0 +1,112 @@ +<?php + +/** + * Kolab storage cache class for task objects + * + * @author Aleksander Machniak <machniak@apheleia-it.ch> + * + * Copyright (C) 2013-2022 Apheleia IT AG <contact@apheleia-it.ch> + * + * 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 <http://www.gnu.org/licenses/>. + */ + +class kolab_storage_dav_cache_task extends kolab_storage_dav_cache +{ + protected $extra_cols = ['dtstart','dtend']; + protected $data_props = ['categories', 'status', 'complete', 'start', 'due']; + protected $fulltext_cols = ['title', 'description', 'categories']; + + /** + * Helper method to convert the given Kolab object into a dataset to be written to cache + * + * @override + */ + protected function _serialize($object) + { + $sql_data = parent::_serialize($object); + + $sql_data['dtstart'] = !empty($object['start']) ? $this->_convert_datetime($object['start']) : null; + $sql_data['dtend'] = !empty($object['due']) ? $this->_convert_datetime($object['due']) : null; + + $sql_data['tags'] = ' ' . join(' ', $this->get_tags($object)) . ' '; // pad with spaces for strict/prefix search + $sql_data['words'] = ' ' . join(' ', $this->get_words($object)) . ' '; + + return $sql_data; + } + + /** + * Callback to get words to index for fulltext search + * + * @return array List of words to save in cache + */ + public function get_words($object = []) + { + $data = ''; + + foreach ($this->fulltext_cols as $colname) { + list($col, $field) = strpos($colname, ':') ? explode(':', $colname) : [$colname, null]; + + if (empty($object[$col])) { + continue; + } + + if ($field) { + $a = []; + foreach ((array) $object[$col] as $attr) { + if (!empty($attr[$field])) { + $a[] = $attr[$field]; + } + } + $val = join(' ', $a); + } + else { + $val = is_array($object[$col]) ? join(' ', $object[$col]) : $object[$col]; + } + + if (is_string($val) && strlen($val)) { + $data .= $val . ' '; + } + } + + $words = rcube_utils::normalize_string($data, true); + + return array_unique($words); + } + + /** + * Callback to get object specific tags to cache + * + * @return array List of tags to save in cache + */ + public function get_tags($object) + { + $tags = []; + + if ((isset($object['status']) && $object['status'] == 'COMPLETED') + || (isset($object['complete']) && $object['complete'] == 100 && empty($object['status'])) + ) { + $tags[] = 'x-complete'; + } + + if (!empty($object['priority']) && $object['priority'] == 1) { + $tags[] = 'x-flagged'; + } + + if (!empty($object['parent_id'])) { + $tags[] = 'x-parent:' . $object['parent_id']; + } + + return array_unique($tags); + } +} diff --git a/plugins/libkolab/lib/kolab_storage_dav_folder.php b/plugins/libkolab/lib/kolab_storage_dav_folder.php index fd41de65..6877bdc5 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_folder.php +++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php @@ -1,747 +1,759 @@ <?php /** * A class representing a DAV folder object. * * @author Aleksander Machniak <machniak@apheleia-it.ch> * * Copyright (C) 2014-2022, Apheleia IT AG <contact@apheleia-it.ch> * * 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 <http://www.gnu.org/licenses/>. */ + +#[AllowDynamicProperties] class kolab_storage_dav_folder extends kolab_storage_folder { public $dav; public $href; public $attributes; /** * Object constructor */ public function __construct($dav, $attributes, $type = '') { $this->attributes = $attributes; $this->href = $this->attributes['href']; $this->id = kolab_storage_dav::folder_id($dav->url, $this->href); $this->dav = $dav; $this->valid = true; list($this->type, $suffix) = strpos($type, '.') ? explode('.', $type) : [$type, '']; $this->default = $suffix == 'default'; $this->subtype = $this->default ? '' : $suffix; // Init cache $this->cache = kolab_storage_dav_cache::factory($this); } /** * Returns the owner of the folder. * * @param bool Return a fully qualified owner name (i.e. including domain for shared folders) * * @return string The owner of this folder. */ public function get_owner($fully_qualified = false) { // return cached value if (isset($this->owner)) { return $this->owner; } $rcube = rcube::get_instance(); $this->owner = $rcube->get_user_name(); $this->valid = true; // TODO: Support shared folders return $this->owner; } /** * Get a folder Etag identifier */ public function get_ctag() { return $this->attributes['ctag']; } /** * Getter for the name of the namespace to which the folder belongs * * @return string Name of the namespace (personal, other, shared) */ public function get_namespace() { // TODO: Support shared folders return 'personal'; } /** * Get the display name value of this folder * * @return string Folder name */ public function get_name() { return kolab_storage_dav::object_name($this->attributes['name']); } /** * Getter for the top-end folder name (not the entire path) * * @return string Name of this folder */ public function get_foldername() { return $this->attributes['name']; } public function get_folder_info() { return []; // todo ? } /** * Getter for parent folder path * * @return string Full path to parent folder */ public function get_parent() { // TODO return ''; } /** * Compose a unique resource URI for this folder */ public function get_resource_uri() { if (!empty($this->resource_uri)) { return $this->resource_uri; } // compose fully qualified resource uri for this instance $host = preg_replace('|^https?://|', 'dav://' . urlencode($this->get_owner(true)) . '@', $this->dav->url); $path = $this->href[0] == '/' ? $this->href : "/{$this->href}"; $host_path = parse_url($host, PHP_URL_PATH); if ($host_path && strpos($path, $host_path) === 0) { $path = substr($path, strlen($host_path)); } $this->resource_uri = unslashify($host) . $path; return $this->resource_uri; } /** * Getter for the Cyrus mailbox identifier corresponding to this folder * (e.g. user/john.doe/Calendar/Personal@example.org) * * @return string Mailbox ID */ public function get_mailbox_id() { // TODO: This is used with Bonnie related features return ''; } /** * Get the color value stored in metadata * * @param string Default color value to return if not set * * @return mixed Color value from the folder metadata or $default if not set */ public function get_color($default = null) { return !empty($this->attributes['color']) ? $this->attributes['color'] : $default; } /** * Get ACL information for this folder * * @return string Permissions as string */ public function get_myrights() { // TODO return ''; } /** * Helper method to extract folder UID * * @return string Folder's UID */ public function get_uid() { // TODO ??? return ''; } /** * Check activation status of this folder * * @return bool True if enabled, false if not */ public function is_active() { return true; // Unused } /** * Change activation status of this folder * * @param bool The desired subscription status: true = active, false = not active * * @return bool True on success, false on error */ public function activate($active) { return true; // Unused } /** * Check subscription status of this folder * * @return bool True if subscribed, false if not */ public function is_subscribed() { return true; // TODO } /** * Change subscription status of this folder * * @param bool The desired subscription status: true = subscribed, false = not subscribed * * @return True on success, false on error */ public function subscribe($subscribed) { return true; // TODO } /** * Delete the specified object from this folder. * * @param array|string $object The Kolab object to delete or object UID * @param bool $expunge Should the folder be expunged? * * @return bool True if successful, false on error */ public function delete($object, $expunge = true) { if (!$this->valid) { return false; } $uid = is_array($object) ? $object['uid'] : $object; $success = $this->dav->delete($this->object_location($uid)); if ($success) { $this->cache->set($uid, false); } return $success; } /** * Delete all objects in a folder. * * Note: This method is used by kolab_addressbook plugin only * * @return bool True if successful, false on error */ public function delete_all() { if (!$this->valid) { return false; } // TODO: Maybe just deleting and re-creating a folder would be // better, but probably might not always work (ACL) $this->cache->synchronize(); foreach (array_keys($this->cache->folder_index()) as $uid) { $this->dav->delete($this->object_location($uid)); } $this->cache->purge(); return true; } /** * Restore a previously deleted object * * @param string $uid Object UID * * @return mixed Message UID on success, false on error */ public function undelete($uid) { if (!$this->valid) { return false; } // TODO return false; } /** * Move a Kolab object message to another IMAP folder * - * @param string Object UID - * @param string IMAP folder to move object to + * @param string Object UID + * @param kolab_storage_dav_folder Target folder to move object into * * @return bool True on success, false on failure */ public function move($uid, $target_folder) { if (!$this->valid) { return false; } - // TODO + $source = $this->object_location($uid); + $target = $target_folder->object_location($uid); - return false; + $success = $this->dav->move($source, $target) !== false; + + if ($success) { + $this->cache->set($uid, false); + } + + return $success; } /** * Save an object in this folder. * * @param array $object The array that holds the data of the object. * @param string $type The type of the kolab object. * @param string $uid The UID of the old object if it existed before * * @return mixed False on error or object UID on success */ public function save(&$object, $type = null, $uid = null) { if (!$this->valid || empty($object)) { return false; } if (!$type) { $type = $this->type; } $result = false; if (empty($uid)) { if (empty($object['created'])) { $object['created'] = new DateTime('now'); } } else { $object['changed'] = new DateTime('now'); } // generate and save object message if ($content = $this->to_dav($object)) { $method = $uid ? 'update' : 'create'; $dav_type = $this->get_dav_type(); $result = $this->dav->{$method}($this->object_location($object['uid']), $content, $dav_type); // Note: $result can be NULL if the request was successful, but ETag wasn't returned if ($result !== false) { // insert/update object in the cache $object['etag'] = $result; $object['_raw'] = $content; $this->cache->save($object, $uid); $result = true; unset($object['_raw']); } } return $result; } /** * Fetch the object the DAV server and convert to internal format * * @param string The object UID to fetch * @param string The object type expected (use wildcard '*' to accept all types) * @param string Unused (kept for compat. with the parent class) * * @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found */ public function read_object($uid, $type = null, $folder = null) { if (!$this->valid) { return false; } $href = $this->object_location($uid); $objects = $this->dav->getData($this->href, $this->get_dav_type(), [$href]); if (!is_array($objects) || count($objects) != 1) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to fetch {$href}" ], true); return false; } return $this->from_dav($objects[0]); } /** * Fetch multiple objects from the DAV server and convert to internal format * * @param array The object UIDs to fetch * * @return mixed Hash array representing the Kolab objects */ public function read_objects($uids) { if (!$this->valid) { return false; } if (empty($uids)) { return []; } foreach ($uids as $uid) { $hrefs[] = $this->object_location($uid); } $objects = $this->dav->getData($this->href, $this->get_dav_type(), $hrefs); if (!is_array($objects)) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to fetch {$href}" ], true); return false; } $objects = array_map([$this, 'from_dav'], $objects); foreach ($uids as $idx => $uid) { foreach ($objects as $oidx => $object) { if ($object && $object['uid'] == $uid) { $uids[$idx] = $object; unset($objects[$oidx]); continue 2; } } $uids[$idx] = false; } return $uids; } /** * Convert DAV object into PHP array * * @param array Object data in kolab_dav_client::fetchData() format * * @return array|false Object properties, False on error */ public function from_dav($object) { if (empty($object ) || empty($object['data'])) { return false; } - if ($this->type == 'event') { + if ($this->type == 'event' || $this->type == 'task') { $ical = libcalendaring::get_ical(); - $events = $ical->import($object['data']); + $objects = $ical->import($object['data']); - if (!count($events) || empty($events[0]['uid'])) { + if (!count($objects) || empty($objects[0]['uid'])) { return false; } - $result = $events[0]; + $result = $objects[0]; $result['_attachments'] = $result['attachments'] ?? []; unset($result['attachments']); } else if ($this->type == 'contact') { if (stripos($object['data'], 'BEGIN:VCARD') !== 0) { return false; } // vCard properties not supported by rcube_vcard $map = [ 'uid' => 'UID', 'kind' => 'KIND', 'member' => 'MEMBER', 'x-kind' => 'X-ADDRESSBOOKSERVER-KIND', 'x-member' => 'X-ADDRESSBOOKSERVER-MEMBER', ]; // TODO: We should probably use Sabre/Vobject to parse the vCard $vcard = new rcube_vcard($object['data'], RCUBE_CHARSET, false, $map); if (!empty($vcard->displayname) || !empty($vcard->surname) || !empty($vcard->firstname) || !empty($vcard->email)) { $result = $vcard->get_assoc(); // Contact groups if (!empty($result['x-kind']) && implode($result['x-kind']) == 'group') { $result['_type'] = 'group'; $members = isset($result['x-member']) ? $result['x-member'] : []; unset($result['x-kind'], $result['x-member']); } else if (!empty($result['kind']) && implode($result['kind']) == 'group') { $result['_type'] = 'group'; $members = isset($result['member']) ? $result['member'] : []; unset($result['kind'], $result['member']); } if (isset($members)) { $result['member'] = []; foreach ($members as $member) { if (strpos($member, 'urn:uuid:') === 0) { $result['member'][] = ['uid' => substr($member, 9)]; } else if (strpos($member, 'mailto:') === 0) { $member = reset(rcube_mime::decode_address_list(urldecode(substr($member, 7)))); if (!empty($member['mailto'])) { $result['member'][] = ['email' => $member['mailto'], 'name' => $member['name']]; } } } } if (!empty($result['uid'])) { $result['uid'] = preg_replace('/^urn:uuid:/', '', implode($result['uid'])); } } else { return false; } } $result['etag'] = $object['etag']; $result['href'] = !empty($object['href']) ? $object['href'] : null; $result['uid'] = !empty($object['uid']) ? $object['uid'] : $result['uid']; return $result; } /** * Convert Kolab object into DAV format (iCalendar) */ public function to_dav($object) { $result = ''; - if ($this->type == 'event') { + if ($this->type == 'event' || $this->type == 'task') { $ical = libcalendaring::get_ical(); + if (!empty($object['exceptions'])) { $object['recurrence']['EXCEPTIONS'] = $object['exceptions']; } + $object['_type'] = $this->type; + // pre-process attachments if (isset($object['_attachments']) && is_array($object['_attachments'])) { foreach ($object['_attachments'] as $key => $attachment) { if ($attachment === false) { // Deleted attachment unset($object['_attachments'][$key]); continue; } // make sure size is set if (!isset($attachment['size'])) { if (!empty($attachment['data'])) { if (is_resource($attachment['data'])) { // this need to be a seekable resource, otherwise // fstat() fails and we're unable to determine size // here nor in rcube_imap_generic before IMAP APPEND $stat = fstat($attachment['data']); $attachment['size'] = $stat ? $stat['size'] : 0; } else { $attachment['size'] = strlen($attachment['data']); } } else if (!empty($attachment['path'])) { $attachment['size'] = filesize($attachment['path']); } $object['_attachments'][$key] = $attachment; } } } $object['attachments'] = $object['_attachments'] ?? []; unset($object['_attachments']); $result = $ical->export([$object], null, false, [$this, 'get_attachment']); } else if ($this->type == 'contact') { // copy values into vcard object // TODO: We should probably use Sabre/Vobject to create the vCard // vCard properties not supported by rcube_vcard $map = ['uid' => 'UID', 'kind' => 'KIND']; $vcard = new rcube_vcard('', RCUBE_CHARSET, false, $map); if ((!empty($object['_type']) && $object['_type'] == 'group') || (!empty($object['type']) && $object['type'] == 'group') ) { $object['kind'] = 'group'; } foreach ($object as $key => $values) { list($field, $section) = rcube_utils::explode(':', $key); // avoid casting DateTime objects to array if (is_object($values) && $values instanceof DateTimeInterface) { $values = [$values]; } foreach ((array) $values as $value) { if (isset($value)) { $vcard->set($field, $value, $section); } } } $result = $vcard->export(false); if (!empty($object['kind']) && $object['kind'] == 'group') { $members = ''; foreach ((array) $object['member'] as $member) { $value = null; if (!empty($member['uid'])) { $value = 'urn:uuid:' . $member['uid']; } else if (!empty($member['email']) && !empty($member['name'])) { $value = 'mailto:' . urlencode(sprintf('"%s" <%s>', addcslashes($member['name'], '"'), $member['email'])); } else if (!empty($member['email'])) { $value = 'mailto:' . $member['email']; } if ($value) { $members .= "MEMBER:{$value}\r\n"; } } if ($members) { $result = preg_replace('/\r\nEND:VCARD/', "\r\n{$members}END:VCARD", $result); } /** Version 4.0 of the vCard format requires Cyrus >= 3.6.0, we'll use Version 3.0 for now $result = preg_replace('/\r\nVERSION:3\.0\r\n/', "\r\nVERSION:4.0\r\n", $result); $result = preg_replace('/\r\nN:[^\r]+/', '', $result); $result = preg_replace('/\r\nUID:([^\r]+)/', "\r\nUID:urn:uuid:\\1", $result); */ $result = preg_replace('/\r\nMEMBER:([^\r]+)/', "\r\nX-ADDRESSBOOKSERVER-MEMBER:\\1", $result); $result = preg_replace('/\r\nKIND:([^\r]+)/', "\r\nX-ADDRESSBOOKSERVER-KIND:\\1", $result); } } if ($result) { // The content must be UTF-8, otherwise if we try to fetch the object // from server XML parsing would fail. $result = rcube_charset::clean($result); } return $result; } - protected function object_location($uid) + public function object_location($uid) { return unslashify($this->href) . '/' . urlencode($uid) . '.' . $this->get_dav_ext(); } /** * Get a folder DAV content type */ public function get_dav_type() { return kolab_storage_dav::get_dav_type($this->type); } /** * Get body of an attachment */ public function get_attachment($id, $event, $unused1 = null, $unused2 = false, $unused3 = null, $unused4 = false) { // Note: 'attachments' is defined when saving the data into the DAV server // '_attachments' is defined after fetching the object from the DAV server if (is_int($id) && isset($event['attachments'][$id])) { $attachment = $event['attachments'][$id]; } else if (is_int($id) && isset($event['_attachments'][$id])) { $attachment = $event['_attachments'][$id]; } else if (is_string($id) && !empty($event['attachments'])) { foreach ($event['attachments'] as $att) { if (!empty($att['id']) && $att['id'] === $id) { $attachment = $att; } } } else if (is_string($id) && !empty($event['_attachments'])) { foreach ($event['_attachments'] as $att) { if (!empty($att['id']) && $att['id'] === $id) { $attachment = $att; } } } if (empty($attachment)) { return false; } if (!empty($attachment['path'])) { return file_get_contents($attachment['path']); } return $attachment['data'] ?? null; } /** * Get a DAV file extension for specified Kolab type */ public function get_dav_ext() { $types = [ 'event' => 'ics', 'task' => 'ics', 'contact' => 'vcf', ]; return $types[$this->type]; } /** * Return folder name as string representation of this object * * @return string Folder display name */ public function __toString() { return $this->attributes['name']; } } diff --git a/plugins/tasklist/README b/plugins/tasklist/README index 172a952c..29e07f27 100644 --- a/plugins/tasklist/README +++ b/plugins/tasklist/README @@ -1,71 +1,71 @@ A task management module for Roundcube -------------------------------------- -This plugin currently supports a local database as well as a Kolab groupware +This plugin currently supports a local database, CalDAV server or a Kolab groupware server as backends for tasklists and todo items storage. REQUIREMENTS ------------ Some functions are shared with other plugins and therefore being moved to library plugins. Thus in order to run the tasklist plugin, you also need the following plugins installed: * kolab/libcalendaring [1] * kolab/libkolab [1] INSTALLATION ------------ For a manual installation of the plugin (and its dependencies), execute the following steps. This will set it up with the database backend driver. 1. Get the source from git $ cd /tmp $ git clone https://git.kolab.org/diffusion/RPK/roundcubemail-plugins-kolab.git $ cd /<path-to-roundcube>/plugins $ cp -r /tmp/roundcubemail-plugins-kolab/plugins/tasklist . $ cp -r /tmp/roundcubemail-plugins-kolab/plugins/libcalendaring . $ cp -r /tmp/roundcubemail-plugins-kolab/plugins/libkolab . 2. Create tasklist plugin configuration $ cd tasklist/ $ cp config.inc.php.dist config.inc.php $ edit config.inc.php 3. Initialize the tasklist database tables $ cd ../../ $ bin/initdb.sh --dir=plugins/tasklist/drivers/database/SQL -4. Build css styles for the Elastic skin +4. Build css styles for the Elastic skin (if needed) $ lessc --relative-urls -x plugins/libkolab/skins/elastic/libkolab.less > plugins/libkolab/skins/elastic/libkolab.min.css 5. Enable the tasklist plugin $ edit config/config.inc.php Add 'tasklist' to the list of active plugins: $config['plugins'] = array( (...) 'tasklist', ); IMPORTANT --------- This plugin doesn't work with the Classic skin of Roundcube because no templates are available for that skin. Use Roundcube `skins_allowed` option to limit skins available to the user or remove incompatible skins from the skins folder. [1] https://git.kolab.org/diffusion/RPK/ diff --git a/plugins/tasklist/config.inc.php.dist b/plugins/tasklist/config.inc.php.dist index 399344cd..b04d96b7 100644 --- a/plugins/tasklist/config.inc.php.dist +++ b/plugins/tasklist/config.inc.php.dist @@ -1,11 +1,14 @@ <?php -// backend type (database, kolab) +// backend type (database, kolab, caldav) $config['tasklist_driver'] = 'kolab'; +// CalDAV server location (required when tasklist_driver = caldav) +$config['tasklist_caldav_server'] = "http://localhost"; + // default sorting order of tasks listing (auto, datetime, startdatetime, flagged, complete, changed) $config['tasklist_sort_col'] = ''; // default sorting order for tasks listing (asc or desc) $config['tasklist_sort_order'] = 'asc'; diff --git a/plugins/tasklist/drivers/caldav/tasklist_caldav_driver.php b/plugins/tasklist/drivers/caldav/tasklist_caldav_driver.php new file mode 100644 index 00000000..7172224c --- /dev/null +++ b/plugins/tasklist/drivers/caldav/tasklist_caldav_driver.php @@ -0,0 +1,1627 @@ +<?php + +/** + * CalDAV driver for the Tasklist plugin + * + * @author Aleksander Machniak <machniak@apheleia-it.ch> + * + * Copyright (C) 2012-2022, Apheleia IT AG <contact@apheleia-it.ch> + * + * 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 <http://www.gnu.org/licenses/>. + */ + +class tasklist_caldav_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 = ['DISPLAY','AUDIO']; + public $search_more_results; + + private $rc; + private $plugin; + private $storage; + private $lists; + private $folders = []; + private $tasks = []; + private $tags = []; + private $bonnie_api = false; + + + /** + * Default constructor + */ + public function __construct($plugin) + { + $this->rc = $plugin->rc; + $this->plugin = $plugin; + + // Initialize the CalDAV storage + $url = $this->rc->config->get('tasklist_caldav_server', 'http://localhost'); + $this->storage = new kolab_storage_dav($url); + + // get configuration for the Bonnie API + // $this->bonnie_api = libkolab::get_bonnie_api(); + + // $this->plugin->register_action('folder-acl', [$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 = $this->storage->get_folders('task'); + $this->lists = $this->folders = []; + + $prefs = $this->rc->config->get('kolab_tasklists', []); + + foreach ($folders as $folder) { + $tasklist = $this->folder_props($folder, $prefs); + + $this->lists[$tasklist['id']] = $tasklist; + $this->folders[$tasklist['id']] = $folder; +// $this->folders[$folder->name] = $folder; + } + + return $this->lists; + } + + /** + * Derive list properties from the given kolab_storage_folder object + */ + protected function folder_props($folder, $prefs) + { + if ($folder->get_namespace() == 'personal') { + $norename = false; + $editable = true; + $rights = 'lrswikxtea'; + $alarms = true; + } + else { + $alarms = false; + $rights = 'lr'; + $editable = false; + if ($myrights = $folder->get_myrights()) { + $rights = $myrights; + if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) { + $editable = strpos($rights, 'i') !== false; + } + } + $info = $folder->get_folder_info(); + $norename = $readonly || $info['norename'] || $info['protected']; + } + + $list_id = $folder->id; + + return [ + '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' => !isset($prefs[$list_id]['active']) || !empty($prefs[$list_id]['active']), + 'owner' => $folder->get_owner(), + 'parentfolder' => $folder->get_parent(), + 'default' => $folder->default, + 'virtual' => !empty($folder->virtual), + 'children' => true, // TODO: determine if that folder indeed has child folders + // 'subscribed' => (bool) $folder->is_subscribed(), + 'removable' => !$folder->default, + 'subtype' => $folder->subtype, + 'group' => $folder->default ? 'default' : $folder->get_namespace(), + 'class' => trim($folder->get_namespace() . ($folder->default ? ' default' : '')), + 'caldavuid' => '', // $folder->get_uid(), + 'history' => !empty($this->bonnie_api), + ]; + } + + /** + * Get a list of available task lists from this source + * + * @param int Bitmask defining filter criterias. + * See FILTER_* constants for possible values. + */ + public function get_lists($filter = 0, &$tree = null) + { + $this->_read_lists(); + + $folders = $this->filter_folders($filter); + + $prefs = $this->rc->config->get('kolab_tasklists', []); + $lists = []; + + foreach ($folders as $folder) { + $parent_id = null; + $list_id = $folder->id; + $fullname = $folder->get_name(); + $listname = $folder->get_foldername(); + + // special handling for virtual folders + if ($folder instanceof kolab_storage_folder_user) { + $lists[$list_id] = [ + '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 (!empty($folder->virtual)) { + $lists[$list_id] = [ + 'id' => $list_id, + 'name' => $fullname, + 'listname' => $listname, + 'virtual' => true, + 'editable' => false, + 'rights' => 'l', + 'group' => $folder->get_namespace(), + 'class' => 'folder', + 'parent' => $parent_id, + ]; + } + else { + if (empty($this->lists[$list_id])) { + $this->lists[$list_id] = $this->folder_props($folder, $prefs); + $this->folders[$list_id] = $folder; + } + + // $this->lists[$list_id]['parent'] = $parent_id; + $lists[$list_id] = $this->lists[$list_id]; + } + } + + return $lists; + } + + /** + * Get list of folders according to specified filters + * + * @param int Bitmask defining restrictions. See FILTER_* constants for possible values. + * + * @return array List of task folders + */ + protected function filter_folders($filter) + { + $this->_read_lists(); + + $folders = []; + foreach ($this->lists as $id => $list) { + if (!empty($this->folders[$id])) { + $folder = $this->folders[$id]; + + if ($folder->get_namespace() == 'personal') { + $folder->editable = true; + } + else if ($rights = $folder->get_myrights()) { + if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) { + $folder->editable = strpos($rights, 'i') !== false; + } + } + + $folders[] = $folder; + } + } + + $plugin = $this->rc->plugins->exec_hook('tasklist_list_filter', [ + 'list' => $folders, + 'filter' => $filter, + 'tasklists' => $folders, + ]); + + if ($plugin['abort'] || !$filter) { + return $plugin['tasklists'] ?? []; + } + + $personal = $filter & self::FILTER_PERSONAL; + $shared = $filter & self::FILTER_SHARED; + + $tasklists = []; + foreach ($folders as $folder) { + if (($filter & self::FILTER_WRITEABLE) && !$folder->editable) { + continue; + } +/* + if (($filter & self::FILTER_INSERTABLE) && !$folder->insert) { + continue; + } + if (($filter & self::FILTER_ACTIVE) && !$folder->is_active()) { + continue; + } + if (($filter & self::FILTER_PRIVATE) && $folder->subtype != 'private') { + continue; + } + if (($filter & self::FILTER_CONFIDENTIAL) && $folder->subtype != 'confidential') { + continue; + } +*/ + if ($personal || $shared) { + $ns = $folder->get_namespace(); + if (!(($personal && $ns == 'personal') || ($shared && $ns == 'shared'))) { + continue; + } + } + + $tasklists[$folder->id] = $folder; + } + + return $tasklists; + } + + /** + * Get the kolab_calendar instance for the given calendar ID + * + * @param string List identifier (encoded imap folder name) + * + * @return ?kolab_storage_folder Object nor null if list doesn't exist + */ + protected function get_folder($id) + { + $this->_read_lists(); + + return $this->folders[$id] ?? null; + } + + + /** + * 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'; + + $id = $this->storage->folder_update($prop); + + if ($id === false) { + return false; + } + + $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', []); + + if (isset($prop['showalarms'])) { + $prefs['kolab_tasklists'][$id]['showalarms'] = $prop['showalarms'] ? true : false; + } + + if (isset($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 = $this->get_folder($id); + $prop += $this->folder_props($folder, []); + } + + 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 bool True on success, Fales on failure + */ + public function edit_list(&$prop) + { + if (!empty($prop['id'])) { + $id = $prop['id']; + $prop['type'] = 'task'; + + if ($this->storage->folder_update($prop) !== false) { + $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', []); + + if (isset($prop['showalarms'])) { + $prefs['kolab_tasklists'][$id]['showalarms'] = $prop['showalarms'] ? true : false; + } + + if (isset($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 true; + } + } + + 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 bool True on success, Fales on failure + */ + public function subscribe_list($prop) + { + if (!empty($prop['id'])) { + $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', []); + + if (isset($prop['permanent'])) { + $prefs['kolab_tasklists'][$prop['id']]['permanent'] = intval($prop['permanent']); + } + + if (isset($prop['active'])) { + $prefs['kolab_tasklists'][$prop['id']]['active'] = intval($prop['active']); + } + + $this->rc->user->save_prefs($prefs); + + return true; + } + + return false; + } + + /** + * Delete the given list with all its contents + * + * @param array Hash array with list properties + * id: list Identifier + * + * @return bool True on success, Fales on failure + */ + public function delete_list($prop) + { + if (!empty($prop['id'])) { + if ($this->storage->folder_delete($prop['id'], 'task')) { + // remove folder from user prefs + $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', []); + if (isset($prefs['kolab_tasklists'][$prop['id']])) { + unset($prefs['kolab_tasklists'][$prop['id']]); + $this->rc->user->save_prefs($prefs); + } + + return true; + } + } + + 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) + { +/* + $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(); +*/ + return []; + } + + /** + * Get a list of tags to assign tasks to + * + * @return array List of tags + */ + public function get_tags() + { + return []; + } + + /** + * Get number of tasks matching the given filter + * + * @param array List of lists to count tasks of + * + * @return array Hash array with counts grouped by status (all|flagged|completed|today|tomorrow|nodate) + */ + public function count_tasks($lists = null) + { + if (empty($lists)) { + $lists = $this->_read_lists(); + $lists = array_keys($lists); + } + else if (is_string($lists)) { + $lists = explode(',', $lists); + } + + $today_date = new DateTime('now', $this->plugin->timezone); + $today = $today_date->format('Y-m-d'); + $tomorrow_date = new DateTime('now + 1 day', $this->plugin->timezone); + $tomorrow = $tomorrow_date->format('Y-m-d'); + + $counts = ['all' => 0, 'today' => 0, 'tomorrow' => 0, 'later' => 0, 'overdue' => 0]; + + foreach ($lists as $list_id) { + if (!$folder = $this->get_folder($list_id)) { + continue; + } + + foreach ($folder->select([['tags', '!~', 'x-complete']], true) as $record) { + $rec = $this->_to_rcube_task($record, $list_id, false); + + if ($this->is_complete($rec)) { // don't count complete tasks + continue; + } + + $counts['all']++; + if (empty($rec['date'])) { + $counts['later']++; + } + else if ($rec['date'] == $today) { + $counts['today']++; + } + else if ($rec['date'] == $tomorrow) { + $counts['tomorrow']++; + } + else if ($rec['date'] < $today) { + $counts['overdue']++; + } + else if ($rec['date'] > $tomorrow) { + $counts['later']++; + } + } + } + + return $counts; + } + + /** + * Get all task records matching the given filter + * + * @param array Hash array with filter criterias: + * - mask: Bitmask representing the filter selection (check against tasklist::FILTER_MASK_* constants) + * - from: Date range start as string (Y-m-d) + * - to: Date range end as string (Y-m-d) + * - search: Search query string + * - uid: Task UIDs + * @param array List of lists to get tasks from + * + * @return array List of tasks records matchin the criteria + */ + public function list_tasks($filter, $lists = null) + { + if (empty($lists)) { + $lists = $this->_read_lists(); + $lists = array_keys($lists); + } + else if (is_string($lists)) { + $lists = explode(',', $lists); + } + + $results = []; + + // query Kolab storage cache + $query = []; + if (isset($filter['mask']) && ($filter['mask'] & tasklist::FILTER_MASK_COMPLETE)) { + $query[] = ['tags', '~', 'x-complete']; + } + else if (empty($filter['since'])) { + $query[] = ['tags', '!~', 'x-complete']; + } + + // full text search (only works with cache enabled) + if (!empty($filter['search'])) { + $search = mb_strtolower($filter['search']); + foreach (rcube_utils::normalize_string($search, true) as $word) { + $query[] = ['words', '~', $word]; + } + } + + if (!empty($filter['since'])) { + $query[] = ['changed', '>=', $filter['since']]; + } + + if (!empty($filter['uid'])) { + $query[] = ['uid', '=', (array) $filter['uid']]; + } + + foreach ($lists as $list_id) { + if (!$folder = $this->get_folder($list_id)) { + continue; + } + + foreach ($folder->select($query) as $record) { + // TODO: post-filter tasks returned from storage + $record['list_id'] = $list_id; + $results[] = $record; + } + } + + foreach (array_keys($results) as $idx) { + $results[$idx] = $this->_to_rcube_task($results[$idx], $results[$idx]['list_id']); + } + + return $results; + } + + /** + * Return data of a specific task + * + * @param mixed Hash array with task properties or task UID + * @param int Bitmask defining filter criterias for folders. + * See FILTER_* constants for possible values. + * + * @return array Hash array with task properties or false if not found + */ + public function get_task($prop, $filter = 0) + { + $this->_parse_id($prop); + + $id = $prop['uid']; + $list_id = $prop['list']; + $folders = $list_id ? [$list_id => $this->get_folder($list_id)] : $this->get_lists($filter); + + // find task in the available folders + foreach ($folders as $list_id => $folder) { + if (is_array($folder)) { + $folder = $this->folders[$list_id]; + } + if (is_numeric($list_id) || !$folder) { + continue; + } + if (empty($this->tasks[$id]) && ($object = $folder->get_object($id))) { + $this->tasks[$id] = $this->_to_rcube_task($object, $list_id); + break; + } + } + + return $this->tasks[$id] ?? false; + } + + /** + * Get all decendents of the given task record + * + * @param mixed Hash array with task properties or task UID + * @param bool 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 = ['uid' => $task['uid'], 'list' => $task['list']]; + } + else { + $this->_parse_id($prop); + } + + $childs = []; + $list_id = $prop['list']; + $task_ids = [$prop['uid']]; + $folder = $this->get_folder($list_id); + + // query for childs (recursively) + while ($folder && !empty($task_ids)) { + $query_ids = []; + foreach ($task_ids as $task_id) { + $query = [['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 bool 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 int $time Current time (unix timestamp) + * @param mixed $lists 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 []; + } + + if ($lists && is_string($lists)) { + $lists = explode(',', $lists); + } + + $time = $slot + $interval; + + $candidates = []; + $query = [ + ['tags', '=', 'x-has-alarms'], + ['tags', '!=', 'x-complete'], + ]; + + $this->_read_lists(); + + foreach ($this->lists as $lid => $list) { + // skip lists with alarms disabled + if (empty($list['showalarms']) || ($lists && !in_array($lid, $lists))) { + continue; + } + + $folder = $this->get_folder($lid); + + foreach ($folder->select($query) as $record) { + if ((empty($record['valarms']) && empty($record['alarms'])) + || $record['status'] == 'COMPLETED' + || $record['complete'] == 100 + ) { + // don't trust the 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 && !empty($alarm['time']) && $alarm['time'] <= $time && in_array($alarm['action'], $this->alarm_types)) { + $id = $alarm['id']; // use alarm-id as primary identifier + $candidates[$id] = [ + 'id' => $id, + 'title' => $task['title'] ?? null, + 'date' => $task['date'] ?? null, + 'time' => $task['time'], + 'notifyat' => $alarm['time'], + 'action' => $alarm['action'] ?? null, + ]; + } + } + } + + // get alarm information stored in local database + if (!empty($candidates)) { + $alarm_ids = array_map([$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 = []; + foreach ($candidates as $id => $task) { + // skip dismissed + if (!empty($dbdata[$id]['dismissed'])) { + continue; + } + + // snooze function may have shifted alarm time + $notifyat = !empty($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 $id Task identifier + * @param int $snooze 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 $id 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; + } + + /** + * 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 = !empty($prop['_fromlist']) ? $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 = []; + } + + // split 'id' into list + uid + if (!empty($id)) { + if (strpos($id, ':')) { + list($list, $uid) = explode(':', $id, 2); + $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 = [ + 'id' => $id_prefix . $record['uid'], + 'uid' => $record['uid'], + 'title' => $record['title'] ?? '', +// 'location' => $record['location'], + 'description' => $record['description'] ?? '', + 'flagged' => !empty($record['priority']) && $record['priority'] == 1, + 'complete' => floatval(($record['complete'] ?? 0) / 100), + 'status' => $record['status'] ?? null, + 'parent_id' => !empty($record['parent_id']) ? $id_prefix . $record['parent_id'] : null, + 'recurrence' => $record['recurrence'] ?? [], + 'attendees' => $record['attendees'] ?? [], + 'organizer' => $record['organizer'] ?? null, + 'sequence' => $record['sequence'] ?? null, + 'list' => $list_id, + 'links' => [], // $record['links'], + 'tags' => [], // $record['tags'], + ]; + + // convert from DateTime to internal date format + if (isset($record['due']) && $record['due'] instanceof DateTimeInterface) { + $due = $this->plugin->lib->adjust_timezone($record['due']); + $task['date'] = $due->format('Y-m-d'); + if (empty($record['due']->_dateonly)) { + $task['time'] = $due->format('H:i'); + } + } + // convert from DateTime to internal date format + if (isset($record['start']) && $record['start'] instanceof DateTimeInterface) { + $start = $this->plugin->lib->adjust_timezone($record['start']); + $task['startdate'] = $start->format('Y-m-d'); + if (empty($record['start']->_dateonly)) { + $task['starttime'] = $start->format('H:i'); + } + } + if (isset($record['changed']) && $record['changed'] instanceof DateTimeInterface) { + $task['changed'] = $record['changed']; + } + if (isset($record['created']) && $record['created'] instanceof DateTimeInterface) { + $task['created'] = $record['created']; + } + + if (isset($record['valarms'])) { + $task['valarms'] = $record['valarms']; + } + else if (isset($record['alarms'])) { + $task['alarms'] = $record['alarms']; + } + + if (!empty($task['attendees'])) { + foreach ((array) $task['attendees'] as $i => $attendee) { + if (isset($attendee['delegated-from']) && is_array($attendee['delegated-from'])) { + $task['attendees'][$i]['delegated-from'] = join(', ', $attendee['delegated-from']); + } + if (isset($attendee['delegated-to']) && 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 (empty($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 = []) + { + $object = $task; + $id_prefix = $task['list'] . ':'; + + $toDT = function($date) { + // Convert DateTime into libcalendaring_datetime + return libcalendaring_datetime::createFromFormat( + 'Y-m-d\\TH:i:s', + $date->format('Y-m-d\\TH:i:s'), + $date->getTimezone() + ); + }; + + if (!empty($task['date'])) { + $object['due'] = $toDT(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'] = $toDT(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 (!empty($task['flagged'])) { + $object['priority'] = 1; + } + else { + $object['priority'] = isset($old['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) && !empty($old['recurrence'])) { + $object['recurrence'] = $old['recurrence']; + } + + unset($task['attachments']); + kolab_format::merge_attachments($object, $old); + + // allow sequence increments if I'm the organizer + if ($this->plugin->is_organizer($object) && empty($object['_method'])) { + unset($object['sequence']); + } + else if (isset($old['sequence']) && empty($object['_method'])) { + $object['sequence'] = $old['sequence']; + } + + unset($object['tempid'], $object['raw'], $object['list'], $object['flagged'], $object['tags'], $object['created']); + + return $object; + } + + /** + * Add a single task to the database + * + * @param array Hash array with task properties (see header of tasklist_driver.php) + * + * @return mixed New task ID on success, False on error + */ + public function create_task($task) + { + return $this->edit_task($task); + } + + /** + * Update a task entry with the given data + * + * @param array Hash array with task properties (see header of tasklist_driver.php) + * + * @return bool True on success, False on error + */ + public function edit_task($task) + { + $this->_parse_id($task); + + if (empty($task['list']) || !($folder = $this->get_folder($task['list']))) { + rcube::raise_error([ + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Invalid list identifer to save task: " . print_r($task['list'], true) + ], + true, false); + + return false; + } + + // moved from another folder + if (!empty($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 (!empty($task['id'])) { + $old = $folder->get_object($task['uid']); + if (!$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, $task['list']); + } + } + + // generate new task object from RC input + $object = $this->_from_rcube_task($task, $old ?? null); + + $object['created'] = $old['created'] ?? null; + + $saved = $folder->save($object, 'task', !empty($old) ? $task['uid'] : null); + + if (!$saved) { + rcube::raise_error([ + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error saving task object to Kolab server" + ], + true, false); + + return false; + } + + $task = $this->_to_rcube_task($object, $task['list']); + $this->tasks[$task['uid']] = $task; + + return true; + } + + /** + * Move a single task to another list + * + * @param array $task Hash array with task properties + * + * @return bool True on success, False on error + * @see tasklist_driver::move_task() + */ + public function move_task($task) + { + $this->_parse_id($task); + + if (empty($task['list']) || !($folder = $this->get_folder($task['list']))) { + return false; + } + + // execute move command + if (!empty($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 bool Remove record irreversible (mark as deleted otherwise, if supported by the backend) + * + * @return bool True on success, False on error + */ + public function delete_task($task, $force = true) + { + $this->_parse_id($task); + + if (empty($task['list']) || !($folder = $this->get_folder($task['list']))) { + return false; + } + + $status = $folder->delete($task['uid'], $force); + + 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 (!empty($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) { + if (!empty($att['data'])) { + // This way we don't have to call get_attachment_body() again + $att['body'] = &$att['data']; + } + + 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($id, $task); + } + + 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) + { + return []; +/* + $config = kolab_storage_config::get_instance(); + $result = $config->get_message_relations($headers, $folder, 'task'); + + foreach ($result as $idx => $rec) { + $result[$idx] = $this->_to_rcube_task($rec, kolab_storage::folder_id($rec['_mailbox'])); + } + + return $result; +*/ + } + + /** + * + */ + public function tasklist_edit_form($action, $list, $fieldprop) + { + $this->_read_lists(); + + if (!empty($list['id']) && ($list = $this->lists[$list['id']])) { + $folder_name = $this->get_folder($list['id'])->name; + } + else { + $folder_name = ''; + } + + $hidden_fields[] = ['name' => 'oldname', 'value' => $folder_name]; + + // folder name (default field) + $input_name = new html_inputfield(['name' => 'name', 'id' => 'taskedit-tasklistname', 'size' => 20]); + $fieldprop['name']['value'] = $input_name->show($list['editname'] ?? ''); + + // General tab + $form = [ + 'properties' => [ + 'name' => $this->rc->gettext('properties'), + 'fields' => [], + ] + ]; + + foreach (['name', 'showalarms'] as $f) { + $form['properties']['fields'][$f] = $fieldprop[$f]; + } + + return kolab_utils::folder_form($form, $folder_name, 'tasklist', $hidden_fields, true); + } +} diff --git a/plugins/tasklist/drivers/tasklist_driver.php b/plugins/tasklist/drivers/tasklist_driver.php index 11c8578e..e10891ff 100644 --- a/plugins/tasklist/drivers/tasklist_driver.php +++ b/plugins/tasklist/drivers/tasklist_driver.php @@ -1,472 +1,473 @@ <?php /** * Driver interface for the Tasklist plugin * * @version @package_version@ * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2012, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. */ /** * Struct of an internal task object how it is passed from/to the driver classes: * * $task = array( * 'id' => 'Task ID used for editing', // must be unique for the current user * 'parent_id' => 'ID of parent task', // null if top-level task * 'uid' => 'Unique identifier of this task', * 'list' => 'Task list identifier to add the task to or where the task is stored', * 'changed' => <DateTime>, // Last modification date/time of the record * 'title' => 'Event title/summary', * 'description' => 'Event description', * 'tags' => array(), // List of tags for this task * 'date' => 'Due date', // as string of format YYYY-MM-DD or null if no date is set * 'time' => 'Due time', // as string of format hh::ii or null if no due time is set * 'startdate' => 'Start date' // Delay start of the task until that date * 'starttime' => 'Start time' // ...and time * 'categories' => 'Task category', * 'flagged' => 'Boolean value whether this record is flagged', * 'complete' => 'Float value representing the completeness state (range 0..1)', * 'status' => 'Task status string according to (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED) RFC 2445', * 'valarms' => array( // List of reminders (new format), each represented as a hash array: * array( * 'trigger' => '-PT90M', // ISO 8601 period string prefixed with '+' or '-', or DateTime object * 'action' => 'DISPLAY|EMAIL|AUDIO', * 'duration' => 'PT15M', // ISO 8601 period string * 'repeat' => 0, // number of repetitions * 'description' => '', // text to display for DISPLAY actions * 'summary' => '', // message text for EMAIL actions * 'attendees' => array(), // list of email addresses to receive alarm messages * ), * ), * 'recurrence' => array( // Recurrence definition according to iCalendar (RFC 2445) specification as list of key-value pairs * 'FREQ' => 'DAILY|WEEKLY|MONTHLY|YEARLY', * 'INTERVAL' => 1...n, * 'UNTIL' => DateTime, * 'COUNT' => 1..n, // number of times * 'RDATE' => array(), // complete list of DateTime objects denoting individual repeat dates * ), * '_fromlist' => 'List identifier where the task was stored before', * ); */ /** * Driver interface for the Tasklist plugin */ abstract class tasklist_driver { // features supported by the backend public $alarms = false; public $attachments = false; public $attendees = false; public $undelete = false; // task undelete action public $sortable = false; - public $alarm_types = array('DISPLAY'); + public $alarm_types = ['DISPLAY']; public $alarm_absolute = true; public $last_error; const FILTER_ALL = 0; const FILTER_WRITEABLE = 1; const FILTER_INSERTABLE = 2; const FILTER_ACTIVE = 4; const FILTER_PERSONAL = 8; const FILTER_PRIVATE = 16; const FILTER_CONFIDENTIAL = 32; const FILTER_SHARED = 64; /** * Get a list of available task lists from this source * @param integer Bitmask defining filter criterias. * See FILTER_* constants for possible values. */ abstract function get_lists($filter = 0); /** * 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 */ abstract function create_list(&$prop); /** * 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 */ abstract function edit_list(&$prop); /** * 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 * @return boolean True on success, Fales on failure */ abstract function subscribe_list($prop); /** * 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 */ abstract function delete_list($prop); /** * 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 */ abstract function search_lists($query, $source); /** * 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) */ abstract function count_tasks($lists = null); /** * Get all task records matching the given filter * * @param array Hash array with filter criterias: * - mask: Bitmask representing the filter selection (check against tasklist::FILTER_MASK_* constants) * - from: Date range start as string (Y-m-d) * - to: Date range end as string (Y-m-d) * - search: Search query string * @param array List of lists to get tasks from * @return array List of tasks records matchin the criteria */ abstract function list_tasks($filter, $lists = null); /** * Get a list of tags to assign tasks to * * @return array List of tags */ abstract function get_tags(); /** * 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 * id: Task identifier * uid: Unique identifier of this task * date: Task due date * time: Task due time * title: Task title/summary */ abstract function pending_alarms($time, $lists = null); /** * (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 */ abstract function dismiss_alarm($id, $snooze = 0); /** * Remove alarm dismissal or snooze state * * @param string Task identifier */ abstract public function clear_alarms($id); /** * Return data of a specific task * * @param mixed Hash array with task properties or task UID * @param integer Bitmask defining filter criterias for folders. * See FILTER_* constants for possible values. * * @return array Hash array with task properties or false if not found */ abstract public function get_task($prop, $filter = 0); /** * Get 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 */ abstract public function get_childs($prop, $recursive = false); /** * Add a single task to the database * * @param array Hash array with task properties (see header of this file) * @return mixed New event ID on success, False on error */ abstract function create_task($prop); /** * Update an task entry with the given data * * @param array Hash array with task properties (see header of this file) * @return boolean True on success, False on error */ abstract function edit_task($prop); /** * Move a single task to another list * * @param array Hash array with task properties: * id: Task identifier * list: New list identifier to move to * _fromlist: Previous list identifier * @return boolean True on success, False on error */ abstract function move_task($prop); /** * Remove a single task from the database * * @param array Hash array with task properties: * id: Task identifier * list: Tasklist identifer * @param boolean Remove record irreversible (mark as deleted otherwise, if supported by the backend) * @return boolean True on success, False on error */ abstract function delete_task($prop, $force = true); /** * 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) { 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 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) { } /** * Build a struct representing the given message reference * * @param object|string $uri_or_headers rcube_message_header instance holding the message headers * or an URI from a stored link referencing a mail message. * @param string $folder IMAP folder the message resides in * * @return array An struct referencing the given IMAP message */ public function get_message_reference($uri_or_headers, $folder = null) { // to be implemented by the derived classes return false; } /** * Find tasks assigned to a specified message * * @param object $message rcube_message_header instance * @param string $folder IMAP folder the message resides in * * @param array List of linked task objects */ public function get_message_related_tasks($headers, $folder) { // to be implemented by the derived classes - return array(); + return []; } /** * Helper method to determine whether the given task is considered "complete" * * @param array $task Hash array with event properties * @return boolean True if complete, False otherwiese */ public function is_complete($task) { - return ($task['complete'] >= 1.0 && empty($task['status'])) || $task['status'] === 'COMPLETED'; + return (isset($task['complete']) && $task['complete'] >= 1.0 && empty($task['status'])) + || (!empty($task['status']) && $task['status'] === 'COMPLETED'); } /** * Provide a list of revisions for the given task * * @param array $task Hash array with task properties: * id: Task identifier * list: List identifier * * @return array List of changes, each as a hash array: * rev: Revision number * type: Type of the change (create, update, move, delete) * date: Change date * user: The user who executed the change * ip: Client IP * mailbox: Destination list for 'move' type */ public function get_task_changelog($task) { return false; } /** * Get a list of property changes beteen two revisions of a task object * * @param array $task Hash array with task properties: * id: Task identifier * list: List identifier * @param mixed $rev1 Old Revision * @param mixed $rev2 New Revision * * @return array List of property changes, each as a hash array: * property: Revision number * old: Old property value * new: Updated property value */ public function get_task_diff($task, $rev1, $rev2) { return false; } /** * Return full data of a specific revision of an event * * @param mixed $task UID string or hash array with task properties: * id: Task identifier * list: List identifier * @param mixed $rev Revision number * * @return array Task object as hash array * @see self::get_task() */ public function get_task_revison($task, $rev) { 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: * id: Task identifier * list: List identifier * @param mixed $rev Revision number * * @return boolean True on success, False on failure */ public function restore_task_revision($task, $rev) { return false; } /** * Build the edit/create form for lists. * This gives the drivers the opportunity to add more list properties * * @param string The action called this form * @param array Tasklist properties * @param array List with form fields to be rendered * * @return string HTML content of the form */ public function tasklist_edit_form($action, $list, $formfields) { - $table = new html_table(array('cols' => 2, 'class' => 'propform')); + $table = new html_table(['cols' => 2, 'class' => 'propform']); foreach ($formfields as $col => $colprop) { $label = !empty($colprop['label']) ? $colprop['label'] : $rcmail->gettext("$domain.$col"); $table->add('title', html::label($colprop['id'], rcube::Q($label))); $table->add(null, $colprop['value']); } return $table->show(); } /** * Compose an URL for CalDAV access to the given list (if configured) */ public function tasklist_caldav_url($list) { $rcmail = rcube::get_instance(); - if (!empty($list['caldavuid']) && ($template = $rcmail->config->get('calendar_caldav_url', null))) { - return strtr($template, array( + if (!empty($list['caldavuid']) && ($template = $rcmail->config->get('calendar_caldav_url'))) { + return strtr($template, [ '%h' => $_SERVER['HTTP_HOST'], '%u' => urlencode($rcmail->get_user_name()), '%i' => urlencode($list['caldavuid']), '%n' => urlencode($list['editname']), - )); + ]); } return null; } /** * Handler for user_delete plugin hook * * @param array Hash array with hook arguments * @return array Return arguments for plugin hooks */ public function user_delete($args) { // TO BE OVERRIDDEN return $args; } } diff --git a/plugins/tasklist/skins/elastic/templates/taskedit.html b/plugins/tasklist/skins/elastic/templates/taskedit.html index d36b30e5..2619d703 100644 --- a/plugins/tasklist/skins/elastic/templates/taskedit.html +++ b/plugins/tasklist/skins/elastic/templates/taskedit.html @@ -1,127 +1,129 @@ <div id="taskedit" class="<roundcube:exp expression="env:framed ? '' : 'hidden '">formcontent" data-nodialog="true" data-notabs="true"> <form id="taskeditform" class="tabbed" action="#" method="post" enctype="multipart/form-data"> <!-- basic info --> <fieldset id="taskedit-panel-main"> <legend><roundcube:label name="tasklist.tabsummary" /></legend> <div class="form-group"> <label for="taskedit-title"><roundcube:label name="tasklist.title" /></label> <input type="text" class="form-control" name="title" id="taskedit-title" size="60" /> </div> <div class="form-group"> <label for="taskedit-description"><roundcube:label name="tasklist.description" /></label> <textarea name="description" id="taskedit-description" class="form-control" rows="5" cols="60"></textarea> </div> <div class="form-group"> <label for="tagedit-input"><roundcube:label name="tasklist.tags" /></label> <roundcube:object name="plugin.tags_editline" id="taskedit-tagline" class="tagedit" tabindex="0" /> </div> <div class="form-group row"> <label for="taskedit-startdate" class="col-sm-2 col-form-label"><roundcube:label name="tasklist.start" /></label> <div class="col-sm-10 datetime"> <input type="text" name="startdate" size="10" id="taskedit-startdate" class="form-control datepicker" /> <input type="text" name="starttime" size="6" id="taskedit-starttime" class="form-control" aria-label="<roundcube:label name='tasklist.starttime' />" /> <a href="#nodate" class="edit-nodate" rel="#taskedit-startdate,#taskedit-starttime"><roundcube:label name="tasklist.nodate" /></a> </div> </div> <div class="form-group row"> <label for="taskedit-date" class="col-sm-2 col-form-label"><roundcube:label name="tasklist.datetime" /></label> <div class="col-sm-10 datetime"> <input type="text" name="date" size="10" id="taskedit-date" class="form-control datepicker" /> <input type="text" name="time" size="6" id="taskedit-time" class="form-control" aria-label="<roundcube:label name='tasklist.duetime' />" /> <a href="#nodate" class="edit-nodate" rel="#taskedit-date,#taskedit-time"><roundcube:label name="tasklist.nodate" /></a> </div> </div> <div class="form-group row" id="taskedit-alarms"> <label for="edit-alarm-item" class="col-sm-2 col-form-label"><roundcube:label name="tasklist.alarms" /></label> <div class="col-sm-10 alarms-input"> <div class="edit-alarm-item input-group first"> <roundcube:object name="plugin.alarm_select" id="edit-alarm-item" /> <span class="edit-alarm-buttons input-group-append"> <a href="#add" class="icon btn button add add-alarm input-group-text"><span class="inner"><roundcube:label name="libcalendaring.addalarm" /></span></a> <a href="#delete" class="icon btn button delete delete-alarm input-group-text"><span class="inner"><roundcube:label name="libcalendaring.removealarm" /></span></a> </span> </div> </div> </div> <div class="form-group row"> <label for="taskedit-completeness" class="col-sm-2 col-form-label"><roundcube:label name="tasklist.complete" /></label> <div class="col-sm-10 input-percent-slider"> <input type="text" name="title" id="taskedit-completeness" size="3" autocomplete="off" class="form-control" /> <span class="label">%</span><div id="taskedit-completeness-slider"></div> </div> </div> <div class="form-group row"> <label for="taskedit-status" class="col-sm-2 col-form-label"><roundcube:label name="tasklist.status" /></label> <div class="col-sm-10"> <roundcube:object name="plugin.status_select" id="taskedit-status" class="form-control" /> </div> </div> <div class="form-group row" id="tasklist-select"> <label for="taskedit-tasklist" class="col-sm-2 col-form-label"><roundcube:label name="tasklist.list" /></label> <div class="col-sm-10"> <roundcube:object name="plugin.tasklist_select" id="taskedit-tasklist" class="form-control" /> </div> </div> <div class="form-group row" id="taskedit-links"> <label class="col-sm-2 col-form-label"><roundcube:label name="tasklist.links" /></label> <div class="col-sm-10"> <div class="task-text"></div> </div> </div> </fieldset> <!-- recurrence settings --> <fieldset id="taskedit-panel-recurrence"> <legend><roundcube:label name="tasklist.tabrecurrence" /></legend> <div class="form-group row"> <roundcube:object name="plugin.recurrence_form" part="frequency" /> </div> <div class="recurrence-form" id="recurrence-form-daily"> <roundcube:object name="plugin.recurrence_form" part="daily" class="form-group row" /> </div> <div class="recurrence-form" id="recurrence-form-weekly"> <roundcube:object name="plugin.recurrence_form" part="weekly" class="form-group row" /> </div> <div class="recurrence-form" id="recurrence-form-monthly"> <roundcube:object name="plugin.recurrence_form" part="monthly" class="form-group row" /> </div> <div class="recurrence-form" id="recurrence-form-yearly"> <roundcube:object name="plugin.recurrence_form" part="yearly" class="form-group row" /> </div> <div class="recurrence-form" id="recurrence-form-until"> <roundcube:object name="plugin.recurrence_form" part="until" class="form-group row" /> </div> <div class="recurrence-form" id="recurrence-form-rdate"> <roundcube:object name="plugin.recurrence_form" part="rdate" class="form-group row" /> </div> </fieldset> <!-- attendees list (assignments) --> <fieldset id="taskedit-panel-attendees" data-navlink-class="nav-icon attendees"> <legend><roundcube:label name="tasklist.tabassignments" /></legend> <div class="form-group row" id="taskedit-organizer"> <label for="edit-identities-list" class="col-form-label col-sm-2"><roundcube:label name="tasklist.roleorganizer" /></label> - <roundcube:object name="plugin.identity_select" id="edit-identities-list" class="col-sm-10 form-control" /> + <span class="col-sm-10"> + <roundcube:object name="plugin.identity_select" id="edit-identities-list" /> + </span> </div> <h3 id="aria-label-attendeestable" class="voice"><roundcube:label name="tasklist.arialabeleventassignments" /></h3> <roundcube:object name="plugin.attendees_list" id="edit-attendees-table" class="edit-attendees-table no-img table table-sm" coltitle="attendee" aria-labelledby="aria-label-attendeestable" /> <roundcube:object name="plugin.attendees_form" id="edit-attendees-form" /> </fieldset> <!-- attachments list (with upload form) --> <fieldset id="taskedit-panel-attachments" data-navlink-class="nav-icon attachments"> <legend><roundcube:label name="tasklist.tabattachments" /></legend> <div id="taskedit-attachments-droparea" class="file-upload"> <h3 id="aria-label-attachmentuploadform" class="voice"><roundcube:label name="arialabelattachmentuploadform" /></h3> <div id="taskedit-attachments-form" class="upload-form" role="region" aria-labelledby="aria-label-attachmentuploadform"> <roundcube:object name="plugin.attachments_form" mode="hint" /> <button type="button" class="btn btn-secondary attach" onclick="rcmail.upload_input('taskedit-attachment-form'); return false"><roundcube:label name="addattachment" /></button> </div> <div id="taskedit-attachments"> <roundcube:object name="plugin.attachments_list" id="taskedit-attachment-list" class="attachmentslist" /> </div> </div> <roundcube:object name="plugin.filedroparea" id="taskedit-attachments-droparea" /> </fieldset> </form> <roundcube:object name="plugin.edit_attendees_notify" id="edit-attendees-notify" class="dialog-message" style="display:none" /> <roundcube:object name="plugin.attachments_form" id="taskedit-attachment-form" mode="smart" /> </div> diff --git a/plugins/tasklist/tasklist.js b/plugins/tasklist/tasklist.js index 831aad1e..9b065260 100644 --- a/plugins/tasklist/tasklist.js +++ b/plugins/tasklist/tasklist.js @@ -1,3467 +1,3467 @@ /** * Client scripts for the Tasklist plugin * * @author Thomas Bruederli <bruederli@kolabsys.com> * * @licstart The following is the entire license notice for the * JavaScript code in this file. * * Copyright (C) 2012-2015, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. * * @licend The above is the entire license notice * for the JavaScript code in this file. */ function rcube_tasklist_ui(settings) { // extend base class rcube_libcalendaring.call(this, settings); /* constants */ var FILTER_MASK_ALL = 0; var FILTER_MASK_TODAY = 1; var FILTER_MASK_TOMORROW = 2; var FILTER_MASK_WEEK = 4; var FILTER_MASK_LATER = 8; var FILTER_MASK_NODATE = 16; var FILTER_MASK_OVERDUE = 32; var FILTER_MASK_FLAGGED = 64; var FILTER_MASK_COMPLETE = 128; var FILTER_MASK_ASSIGNED = 256; var FILTER_MASK_MYTASKS = 512; var filter_masks = { all: FILTER_MASK_ALL, today: FILTER_MASK_TODAY, tomorrow: FILTER_MASK_TOMORROW, week: FILTER_MASK_WEEK, later: FILTER_MASK_LATER, nodate: FILTER_MASK_NODATE, overdue: FILTER_MASK_OVERDUE, flagged: FILTER_MASK_FLAGGED, complete: FILTER_MASK_COMPLETE, assigned: FILTER_MASK_ASSIGNED, mytasks: FILTER_MASK_MYTASKS }; /* private vars */ var filtermask = FILTER_MASK_ALL; var loadstate = { filter:-1, lists:'', search:null }; var idcount = 0; var focusview = false; var focusview_lists = []; var saving_lock; var ui_loading; var taskcounts = {}; var listindex = []; var listdata = {}; var tagsfilter = []; var draghelper; var search_request; var search_query; var completeness_slider; var task_draghelper; var task_drag_active = false; var list_scroll_top = 0; var scroll_delay = 400; var scroll_step = 5; var scroll_speed = 20; var scroll_sensitivity = 40; var scroll_timer; var tasklists_widget; var focused_task; var focused_subclass; var task_attendees = []; var attendees_list; var me = this; var extended_datepicker_settings; /* public members */ this.tasklists = rcmail.env.tasklists; this.selected_task = null; this.selected_list = null; /* public methods */ this.init = init; this.edit_task = task_edit_dialog; this.print_tasks = print_tasks; this.delete_task = delete_task; this.add_childtask = add_childtask; this.quicksearch = quicksearch; this.reset_search = reset_search; this.expand_collapse = expand_collapse; this.list_delete = list_delete; this.list_remove = list_remove; this.list_showurl = list_showurl; this.list_edit_dialog = list_edit_dialog; this.list_set_sort_and_order = list_set_sort_and_order; this.get_setting = get_setting; this.unlock_saving = unlock_saving; /* imports */ var Q = this.quote_html; var text2html = this.text2html; var render_message_links = this.render_message_links; /** * initialize the tasks UI */ function init() { if (rcmail.env.action == 'print' && rcmail.task == 'tasks') { filtermask = rcmail.env.filtermask; data_ready({data: rcmail.env.tasks}); return; } if (rcmail.env.action == 'dialog-ui') { task_edit_dialog(null, 'new', rcmail.env.task_prop); rcmail.addEventListener('plugin.unlock_saving', function(status) { unlock_saving(); if (status) { var rc = window.parent.rcmail, win = rc.env.contentframe ? rc.get_frame_window(rc.env.contentframe) : window.parent; if (win) { win.location.reload(); } window.parent.kolab_task_dialog_element.dialog('destroy'); } }); return; } // initialize task list selectors for (var id in me.tasklists) { if (settings.selected_list && me.tasklists[settings.selected_list] && !me.tasklists[settings.selected_list].active) { me.tasklists[settings.selected_list].active = true; me.selected_list = settings.selected_list; $(rcmail.gui_objects.tasklistslist).find("input[value='"+settings.selected_list+"']").prop('checked', true); } if (me.tasklists[id].editable && (!me.selected_list || me.tasklists[id]['default'] || (me.tasklists[id].active && !me.tasklists[me.selected_list].active))) { me.selected_list = id; } } if (rcmail.env.source && me.tasklists[rcmail.env.source]) me.selected_list = rcmail.env.source; // initialize treelist widget that controls the tasklists list var widget_class = window.kolab_folderlist || rcube_treelist_widget; tasklists_widget = new widget_class(rcmail.gui_objects.tasklistslist, { id_prefix: 'rcmlitasklist', selectable: true, save_state: true, keyboard: false, searchbox: '#tasklistsearch', search_action: 'tasks/tasklist', search_sources: [ 'folders', 'users' ], search_title: rcmail.gettext('listsearchresults','tasklist') }); tasklists_widget.addEventListener('select', function(node) { var id = $(this).data('id'); rcmail.enable_command('list-edit', me.has_permission(me.tasklists[node.id], 'wa')); rcmail.enable_command('list-delete', me.has_permission(me.tasklists[node.id], 'xa')); rcmail.enable_command('list-import', me.has_permission(me.tasklists[node.id], 'i')); rcmail.enable_command('list-remove', me.tasklists[node.id] && me.tasklists[node.id].removable); rcmail.enable_command('list-showurl', me.tasklists[node.id] && !!me.tasklists[node.id].caldavurl); me.selected_list = node.id; rcmail.update_state({source: node.id}); rcmail.triggerEvent('show-list', {title: me.tasklists[node.id].name}); }); tasklists_widget.addEventListener('subscribe', function(p) { var list; if ((list = me.tasklists[p.id])) { list.subscribed = p.subscribed || false; rcmail.http_post('tasklist', { action:'subscribe', l:{ id:p.id, active:list.active?1:0, permanent:list.subscribed?1:0 } }); } }); tasklists_widget.addEventListener('remove', function(p) { if (me.tasklists[p.id] && me.tasklists[p.id].removable) { list_remove(p.id); } }); tasklists_widget.addEventListener('insert-item', function(p) { var list = p.data; if (list && list.id && !list.virtual) { me.tasklists[list.id] = list; var prop = { id:p.id, active:list.active?1:0 }; if (list.subscribed) prop.permanent = 1; rcmail.http_post('tasklist', { action:'subscribe', l:prop }); list_tasks(); $(p.item).data('type', 'tasklist'); } }); tasklists_widget.addEventListener('search-complete', function(data) { if (data.length) rcmail.display_message(rcmail.gettext('nrtasklistsfound','tasklist').replace('$nr', data.length), 'voice'); else rcmail.display_message(rcmail.gettext('notasklistsfound','tasklist'), 'notice'); }); // Make Elastic checkboxes pretty if (window.UI && UI.pretty_checkbox) { $(rcmail.gui_objects.tasklistslist).find('input[type=checkbox]').each(function() { UI.pretty_checkbox(this); }); tasklists_widget.addEventListener('add-item', function(prop) { UI.pretty_checkbox($(prop.li).find('input')); }); } // init (delegate) event handler on tasklist checkboxes tasklists_widget.container.on('click', 'input[type=checkbox]', function(e) { var list, id = this.value; if ((list = me.tasklists[id])) { list.active = this.checked; fetch_counts(); if (!this.checked) remove_tasks(id); else list_tasks(null); rcmail.http_post('tasklist', { action:'subscribe', l:{ id:id, active:list.active?1:0 } }); // disable focusview if (!this.checked && focusview && $.inArray(id, focusview_lists) >= 0) { set_focusview(null); } // adjust checked state of original list item if (tasklists_widget.is_search()) { tasklists_widget.container.find('input[value="'+id+'"]').prop('checked', this.checked); } } e.stopPropagation(); }) .on('keypress', 'input[type=checkbox]', function(e) { // select tasklist on <Enter> if (e.keyCode == 13) { tasklists_widget.select(this.value); return rcube_event.cancel(e); } }) .find('li:not(.virtual)').data('type', 'tasklist'); // handler for clicks on quickview buttons tasklists_widget.container.on('click', '.quickview', function(e){ var id = $(this).closest('li').attr('id').replace(/^rcmlitasklist/, ''); if (tasklists_widget.is_search()) id = id.replace(/--xsR$/, ''); if (!rcube_event.is_keyboard(e) && this.blur) this.blur(); set_focusview(id, e.shiftKey || e.metaKey || e.ctrlKey); e.stopPropagation(); return false; }); // register dbl-click handler to open calendar edit dialog tasklists_widget.container.on('dblclick', ':not(.virtual) > .tasklist', function(e){ var id = $(this).closest('li').attr('id').replace(/^rcmlitasklist/, ''); if (tasklists_widget.is_search()) id = id.replace(/--xsR$/, ''); list_edit_dialog(id); }); if (me.selected_list) { rcmail.enable_command('addtask', true); tasklists_widget.select(me.selected_list); } // register server callbacks rcmail.addEventListener('plugin.data_ready', data_ready); rcmail.addEventListener('plugin.update_task', update_taskitem); rcmail.addEventListener('plugin.refresh_tasks', function(p) { update_taskitem(p, true); }); rcmail.addEventListener('plugin.update_counts', update_counts); rcmail.addEventListener('plugin.insert_tasklist', insert_list); rcmail.addEventListener('plugin.update_tasklist', update_list); rcmail.addEventListener('plugin.destroy_tasklist', destroy_list); rcmail.addEventListener('plugin.unlock_saving', unlock_saving); - rcmail.addEventListener('plugin.refresh_tagcloud', function() { update_tagcloud(); }); + rcmail.addEventListener('plugin.refresh_tagcloud', function() { update_taglist(); }); rcmail.addEventListener('requestrefresh', before_refresh); rcmail.addEventListener('plugin.reload_data', function(){ list_tasks(null, true); setTimeout(fetch_counts, 200); }); rcmail.addEventListener('plugin.import_success', function(p){ rctasks.import_success(p); }); rcmail.addEventListener('plugin.import_error', function(p){ rctasks.import_error(p); }); rcmail.addEventListener('plugin.task_render_changelog', task_render_changelog); rcmail.addEventListener('plugin.task_show_diff', task_show_diff); rcmail.addEventListener('plugin.task_show_revision', function(data){ task_show_dialog(null, data, true); }); rcmail.addEventListener('plugin.close_history_dialog', close_history_dialog); rcmail.register_command('list-sort', list_set_sort, true); rcmail.register_command('list-order', list_set_order, (settings.sort_col || 'auto') != 'auto'); rcmail.register_command('task-history', task_history_dialog, false); $('#taskviewsortmenu .by-' + (settings.sort_col || 'auto')).attr('aria-checked', 'true').addClass('selected'); $('#taskviewsortmenu .sortorder.' + (settings.sort_order || 'asc')).attr('aria-checked', 'true').addClass('selected'); // start loading tasks fetch_counts(); list_tasks(settings.selected_filter); // register event handlers for UI elements $('#taskselector a').click(function(e) { if (!$(this).parent().hasClass('inactive')) { var selector = this.href.replace(/^.*#/, ''), mask = filter_masks[selector], shift = e.shiftKey || e.ctrlKey || e.metaKey; if (!shift) filtermask = mask; // reset selection on regular clicks else if (filtermask & mask) filtermask -= mask; else filtermask |= mask; list_tasks(); } return false; }); // quick-add a task $(rcmail.gui_objects.quickaddform).submit(function(e){ if (saving_lock) return false; var tasktext = this.elements.text.value, rec = { id:-(++idcount), title:tasktext, readonly:true, mask:0, complete:0 }; if (tasktext && tasktext.length) { save_task({ tempid:rec.id, raw:tasktext, list:me.selected_list }, 'new'); $('#listmessagebox').hide(); } // clear form this.reset(); return false; }).find('input[type=text]').placeholder(rcmail.gettext('createnewtask','tasklist')); // click-handler on task list items (delegate) $(rcmail.gui_objects.resultlist).on('click', function(e) { var item = $(e.target), className = e.target.className; if (item.hasClass('childtoggle')) { item = item.parent().find('.taskhead'); className = 'childtoggle'; } else { if (!item.hasClass('taskhead')) item = item.closest('div.taskhead'); className = String(className).split(' ')[0]; } // ignore if (!item.length) return false; var id = item.data('id'), li = item.parent(), rec = listdata[id]; switch (className) { case 'childtoggle': rec.collapsed = !rec.collapsed; li.children('.childtasks:first').toggle().attr('aria-hidden', rec.collapsed ? 'true' : 'false'); $(e.target).toggleClass('collapsed').html('').append($('<span class="inner">').text(rec.collapsed ? '▶' : '▼')); rcmail.http_post('tasks/task', { action:'collapse', t:{ id:rec.id, list:rec.list }, collapsed:rec.collapsed?1:0 }); if (e.shiftKey) // expand/collapse all childs li.children('.childtasks:first .childtoggle.'+(rec.collapsed?'expanded':'collapsed')).click(); break; case 'complete': if (rcmail.busy) return false; save_task_confirm(rec, 'edit', { _status_before:rec.status + '', status:e.target.checked ? 'COMPLETED' : (rec.complete > 0 ? 'IN-PROCESS' : 'NEEDS-ACTION') }); item.toggleClass('complete'); return true; case 'flagged': if (rcmail.busy) return false; rec.flagged = rec.flagged ? 0 : 1; item.toggleClass('flagged').find('.flagged:first').attr('aria-checked', (rec.flagged ? 'true' : 'false')); save_task(rec, 'edit'); break; case 'date': if (rcmail.busy) return false; var link = $(e.target).html(''), input = $('<input type="text" size="10" />').appendTo(link).val(rec.date || ''); input.datepicker($.extend({ onClose: function(dateText, inst) { if (dateText != (rec.date || '')) { save_task_confirm(rec, 'edit', { date:dateText }); } input.datepicker('destroy').remove(); link.html(dateText || rcmail.gettext('nodate','tasklist')); } }, extended_datepicker_settings) ) .datepicker('setDate', rec.date) .datepicker('show'); break; case 'delete': delete_task(id); break; case 'actions': var pos, ref = $(e.target), menu = $('#taskitemmenu'); if (menu.is(':visible') && menu.data('refid') == id) { rcmail.command('menu-close', 'taskitemmenu'); } else { rcmail.enable_command('task-history', me.tasklists[rec.list] && !!me.tasklists[rec.list].history); rcmail.command('menu-open', { menu: 'taskitemmenu', show: true }, e.target, e); menu.data('refid', id); me.selected_task = rec; } e.bubble = false; break; case 'extlink': return true; default: if (e.target.nodeName != 'INPUT') { task_show_dialog(id); $(rcmail.gui_objects.resultlist).find('.selected').removeClass('selected'); $(item).addClass('selected'); } break; } return false; }) .on('dblclick', '.taskhead, .childtoggle', function(e){ var id, rec, item = $(e.target); if (!item.hasClass('taskhead')) item = item.closest('div.taskhead'); if (!rcmail.busy && item.length && (id = item.data('id')) && (rec = listdata[id])) { var list = rec.list && me.tasklists[rec.list] ? me.tasklists[rec.list] : {}; if (rec.readonly || !list.editable) task_show_dialog(id); else task_edit_dialog(id, 'edit'); clearSelection(); } $(rcmail.gui_objects.resultlist).find('.selected').removeClass('selected'); $(item).addClass('selected'); }) .on('keydown', '.taskhead', function(e) { if (e.target.nodeName == 'INPUT' && e.target.type == 'text') return true; var inc = 1; switch (e.keyCode) { case 13: // Enter $(e.target).trigger('click', { pointerType:'keyboard' }); return rcube_event.cancel(e); case 38: // Up arrow key inc = -1; case 40: // Down arrow key if ($(e.target).hasClass('actions')) { // unfold actions menu $(e.target).trigger('click', { pointerType:'keyboard' }); return rcube_event.cancel(e); } // focus next/prev task item var x = 0, target = this, items = $(rcmail.gui_objects.resultlist).find('.taskhead:visible'); items.each(function(i, item) { if (item === target) { x = i; return false; } }); items.get(x + inc).focus(); return rcube_event.cancel(e); case 37: // Left arrow key case 39: // Right arrow key $(this).parent().children('.childtoggle:visible').first().trigger('click', { pointerType:'keyboard' }); break; } }) .on('focusin', '.taskhead', function(e){ if (rcube_event.is_keyboard(e)) { var item = $(e.target); if (!item.hasClass('taskhead')) item = item.closest('div.taskhead'); var id = item.data('id'); if (id && listdata[id]) { focused_task = id; focused_subclass = item.get(0) !== e.target ? e.target.className : null; } } $(item).addClass('focused'); }) .on('focusout', '.taskhead', function(e){ var item = $(e.target); if (focused_task && item.data('id') == focused_task) { focused_task = focused_subclass = null; } $(item).removeClass('focused'); }); /** * */ function task_rsvp(response, delegate) { if (me.selected_task && me.selected_task.attendees && response) { // bring up delegation dialog if (response == 'delegated' && !delegate) { rcube_libcalendaring.itip_delegate_dialog(function(data) { $('#reply-comment-task-rsvp').val(data.comment); data.rsvp = data.rsvp ? 1 : ''; task_rsvp('delegated', data); }); return; } // update attendee status for (var data, i=0; i < me.selected_task.attendees.length; i++) { data = me.selected_task.attendees[i]; if (settings.identity.emails.indexOf(';'+String(data.email).toLowerCase()) >= 0) { data.status = response.toUpperCase(); if (data.status == 'DELEGATED') { data['delegated-to'] = delegate.to; } else { delete data.rsvp; // unset RSVP flag if (data['delegated-to']) { delete data['delegated-to']; if (data.role == 'NON-PARTICIPANT' && data.status != 'DECLINED') { data.role = 'REQ-PARTICIPANT'; } } } } } // submit status change to server saving_lock = rcmail.set_busy(true, 'tasklist.savingdata'); rcmail.http_post('tasks/task', { action: 'rsvp', t: $.extend({}, me.selected_task, (delegate || {})), filter: filtermask, status: response, noreply: $('#noreply-task-rsvp:checked').length ? 1 : 0, comment: $('#reply-comment-task-rsvp').val() }); task_show_dialog(me.selected_task.id); } } // init RSVP widget $('#task-rsvp input.button').click(function(e) { task_rsvp($(this).attr('rel')) }); // register click handler for message links $('#task-links, #taskedit-links').on('click', 'li a.messagelink', function(e) { rcmail.open_window(this.href); return false; }); // register click handler for message delete buttons $('#taskedit-links').on('click', 'li a.delete', function(e) { remove_link(e.target); return false; }); // extended datepicker settings var extended_datepicker_settings = $.extend({ showButtonPanel: true, beforeShow: function(input, inst) { setTimeout(function(){ $(input).datepicker('widget').find('button.ui-datepicker-close') .html(rcmail.gettext('nodate','tasklist')) .attr('onclick', '') .unbind('click') .bind('click', function(e){ $(input).datepicker('setDate', null).datepicker('hide'); }); }, 1); } }, me.datepicker_settings); rcmail.addEventListener('kolab-tags-search', filter_tasks) .addEventListener('kolab-tags-drop-data', function(e) { return listdata[e.id]; }) .addEventListener('kolab-tags-drop', function(e) { var rec = listdata[e.id]; if (rec && rec.id && e.tag) { if (!rec.tags) rec.tags = []; rec.tags.push(e.tag); save_task(rec, 'edit'); } }); // Create simple list widget replacement for Elastic skin, // as we do not use list nor treelist widget for tasks list rcmail.tasklist = { _find_sibling: function(dir) { if (me.selected_task && me.selected_task.id) { var n = false, target = $('li[rel="' + me.selected_task.id + '"] > .taskhead', rcmail.gui_objects.resultlist)[0], items = $(rcmail.gui_objects.resultlist).find('.taskhead'); items.each(function(i, item) { if (item === target) { n = i; return false; } }); if (n !== false) { return items[n + dir]; } } }, get_single_selection: function() { if (me.selected_task) { return me.selected_task.id; } }, get_node: function(uid) { if (me.selected_task && me.selected_task.id) { return {collapsed: true}; } }, expand: function() { if (me.selected_task && me.selected_task.id) { var parent = $('li[rel="' + me.selected_task.id + '"]', rcmail.gui_objects.resultlist).parent('.childtasks')[0]; if (parent) { $(parent).parent().children('.childtoggle.collapsed').click(); } } }, get_next: function() { return rcmail.tasklist._find_sibling(1); }, get_prev: function() { return rcmail.tasklist._find_sibling(-1); }, select: function(node) { $(node).click(); } }; rcmail.triggerEvent('tasklist-init'); } /** * initialize task edit form elements */ function init_taskedit() { $('#taskedit:not([data-notabs])').tabs({ activate: function(event, ui) { // reset autocompletion on tab change (#3389) rcmail.ksearch_blur(); } }); $('#taskedit li.nav-item a').on('click', function() { // reset autocompletion on tab change (#3389) rcmail.ksearch_blur(); }); var completeness_slider_change = function(e, ui) { var v = completeness_slider.slider('value'); if (v >= 98) v = 100; if (v <= 2) v = 0; $('#taskedit-completeness').val(v); }; completeness_slider = $('#taskedit-completeness-slider').slider({ range: 'min', animate: 'fast', slide: completeness_slider_change, change: completeness_slider_change }); $('#taskedit-completeness').change(function(e){ completeness_slider.slider('value', parseInt(this.value)) }); // register events on alarms and recurrence fields me.init_alarms_edit('#taskedit-alarms'); me.init_recurrence_edit('#eventedit'); $('#taskedit-date, #taskedit-startdate').datepicker(me.datepicker_settings); $('a.edit-nodate').click(function(){ var sel = $(this).attr('rel'); if (sel) $(sel).val(''); return false; }); // init attendees autocompletion var ac_props; // parallel autocompletion if (rcmail.env.autocomplete_threads > 0) { ac_props = { threads: rcmail.env.autocomplete_threads, sources: rcmail.env.autocomplete_sources }; } rcmail.init_address_input_events($('#edit-attendee-name'), ac_props); rcmail.addEventListener('autocomplete_insert', function(e) { var cutype, success = false; if (e.field.name == 'participant') { cutype = e.data && e.data.type == 'group' && e.result_type == 'person' ? 'GROUP' : 'INDIVIDUAL'; success = add_attendees(e.insert, { role:'REQ-PARTICIPANT', status:'NEEDS-ACTION', cutype: cutype }); } if (e.field && success) { e.field.value = ''; } }); $('#edit-attendee-add').click(function() { var input = $('#edit-attendee-name'); rcmail.ksearch_blur(); if (add_attendees(input.val(), { role:'REQ-PARTICIPANT', status:'NEEDS-ACTION', cutype:'INDIVIDUAL' })) { input.val(''); } }); // handle change of "send invitations" checkbox $('#edit-attendees-invite').change(function() { $('#edit-attendees-donotify,input.edit-attendee-reply').prop('checked', this.checked); // hide/show comment field $('#taskeditform .attendees-commentbox')[this.checked ? 'show' : 'hide'](); }); // delegate change task to "send invitations" checkbox $('#edit-attendees-donotify').change(function() { $('#edit-attendees-invite').click(); return false; }); // configure drop-down menu on time input fields based on jquery UI autocomplete $('#taskedit-starttime, #taskedit-time').each(function() { me.init_time_autocomplete(this, {container: '#taskedit'}); }); } /** * Request counts from the server */ function fetch_counts() { var active = active_lists(); if (active.length) rcmail.http_request('counts', { lists:active.join(',') }); else update_counts({}); } /** * List tasks matching the given selector */ function list_tasks(sel, force) { if (rcmail.busy) return; if (sel && filter_masks[sel] !== undefined) { filtermask = filter_masks[sel]; } var active = active_lists(), basefilter = filtermask & FILTER_MASK_COMPLETE ? FILTER_MASK_COMPLETE : FILTER_MASK_ALL, reload = force || active.join(',') != loadstate.lists || basefilter != loadstate.filter || loadstate.search != search_query; if (active.length && reload) { ui_loading = rcmail.set_busy(true, 'loading'); rcmail.http_request('fetch', { filter:basefilter, lists:active.join(','), q:search_query }, true); } else if (reload) data_ready({ data:[], lists:'', filter:basefilter, search:search_query }); else render_tasklist(); $('#taskselector li.selected').removeClass('selected').attr('aria-checked', 'false'); // select all active selectors if (filtermask > 0) { $.each(filter_masks, function(sel, mask) { if (filtermask & mask) $('#taskselector li.'+sel).addClass('selected').attr('aria-checked', 'true'); }); } else $('#taskselector li.all').addClass('selected').attr('aria-checked', 'true'); } /** * Filter tasks by tag */ function filter_tasks(tags) { tagsfilter = tags; list_tasks(); } /** * Remove all tasks of the given list from the UI */ function remove_tasks(list_id) { // remove all tasks of the given list from index var newindex = $.grep(listindex, function(id, i){ return listdata[id] && listdata[id].list != list_id; }); listindex = newindex; render_tasklist(); // avoid reloading me.tasklists[list_id].active = false; loadstate.lists = active_lists(); } var init_cloned_form = function(form) { // update element IDs after clone $('select,input,label', form).each(function() { if (this.htmlFor) this.htmlFor += '-clone'; else if (this.id) this.id += '-clone'; }); } // open a dialog to upload an .ics file with tasks to be imported this.import_tasks = function(tasklist) { // close show dialog first var buttons = {}, $dialog = $("#tasksimport").clone(true).removeClass('uidialog'), form = $dialog.find('form').get(0); if ($dialog.is(':ui-dialog')) $dialog.dialog('close'); if (tasklist) $('#task-import-list').val(tasklist); init_cloned_form(form); buttons[rcmail.gettext('import', 'tasklist')] = function() { if (form && form.elements._data.value) { rcmail.async_upload_form(form, 'import', function(e) { rcmail.set_busy(false, null, saving_lock); saving_lock = null; $('.ui-dialog-buttonpane button', $dialog.parent()).prop('disabled', false); // display error message if no sophisticated response from server arrived (e.g. iframe load error) if (me.import_succeeded === null) rcmail.display_message(rcmail.get_label('importerror', 'tasklist'), 'error'); }); // display upload indicator (with extended timeout) var timeout = rcmail.env.request_timeout; rcmail.env.request_timeout = 600; me.import_succeeded = null; saving_lock = rcmail.set_busy(true, 'uploading'); $('.ui-dialog-buttonpane button', $dialog.parent()).prop('disabled', true); // restore settings rcmail.env.request_timeout = timeout; } }; buttons[rcmail.gettext('cancel', 'tasklist')] = function() { $(this).dialog('close'); }; // open jquery UI dialog this.import_dialog = rcmail.show_popup_dialog($dialog, rcmail.gettext('tasklist.importtasks'), buttons, { closeOnEscape: false, button_classes: ['import mainaction', 'cancel'] }); }; // callback from server if import succeeded this.import_success = function(p) { this.import_succeeded = true; this.import_dialog.dialog('close'); rcmail.set_busy(false, null, saving_lock); saving_lock = null; rcmail.gui_objects.importform.reset(); if (p.refetch) { list_tasks(null, true); setTimeout(fetch_counts, 200); } }; // callback from server to report errors on import this.import_error = function(p) { this.import_succeeded = false; rcmail.set_busy(false, null, saving_lock); saving_lock = null; rcmail.display_message(p.message || rcmail.get_label('importerror', 'tasklist'), 'error'); }; // open a tasks export dialog this.export_tasks = function() { // close show dialog first var list, $dialog = $("#tasksexport").clone(true).removeClass('uidialog'), form = $dialog.find('form').get(0), buttons = {}; if ($dialog.is(':ui-dialog')) $dialog.dialog('close'); list = $("#task-export-list").val(''); init_cloned_form(form); buttons[rcmail.gettext('export', 'tasklist')] = function() { var data = {}, form_elements = $('select, input', form); // "current view" export, use hidden form to POST task IDs if (list.val() === '') { var cache = {}, tasks = [], inputs = [], postform = $('#tasks-export-form-post'); $.each(listindex || [], function() { var rec = listdata[this]; if (match_filter(rec, cache)) { tasks.push(rec.id); } }); // copy form inputs, there may be controls added by other plugins form_elements.each(function() { if (this.type != 'checkbox' || this.checked) inputs.push($('<input>').attr({type: 'hidden', name: this.name, value: this.value})); }); inputs.push($('<input>').attr({type: 'hidden', name: '_token', value: rcmail.env.request_token})); inputs.push($('<input>').attr({type: 'hidden', name: 'id', value: tasks.join(',')})); if (!postform.length) postform = $('<form>') .attr({style: 'display: none', method: 'POST', action: '?_task=tasks&_action=export'}) .appendTo('body'); postform.html('').append(inputs).submit(); } // otherwise we can use simple GET else { form_elements.each(function() { if (this.type != 'checkbox' || this.checked) data[this.name] = $(this).val(); }); rcmail.goto_url('export', data, false); } $(this).dialog('close'); }; buttons[rcmail.gettext('cancel', 'tasklist')] = function() { $(this).dialog('close'); }; // open jquery UI dialog rcmail.show_popup_dialog($dialog, rcmail.gettext('exporttitle', 'tasklist'), buttons, { button_classes: ['export mainaction', 'cancel'] }); }; /* // download the selected task as iCal this.task_download = function(task) { if (task && task.id) { rcmail.goto_url('export', {source: task.list, id: task.id, attachments: 1}); } }; */ /** * Modify query parameters for refresh requests */ function before_refresh(query) { query.filter = filtermask == FILTER_MASK_COMPLETE ? FILTER_MASK_COMPLETE : FILTER_MASK_ALL; query.lists = active_lists().join(','); if (search_query) query.q = search_query; return query; } /** * Callback if task data from server is ready */ function data_ready(response) { listdata = {}; listindex = []; loadstate.lists = response.lists; loadstate.filter = response.filter; loadstate.search = response.search; for (var id, i=0; i < response.data.length; i++) { id = response.data[i].id; listindex.push(id); listdata[id] = response.data[i]; listdata[id].children = []; // register a forward-pointer to child tasks if (listdata[id].parent_id && listdata[listdata[id].parent_id]) listdata[listdata[id].parent_id].children.push(id); } // sort index before rendering listindex.sort(function(a, b) { return task_cmp(listdata[a], listdata[b]); }); update_taglist(response.tags || []); render_tasklist(); // show selected task dialog if (settings.selected_id) { if (listdata[settings.selected_id]) { task_show_dialog(settings.selected_id); delete settings.selected_id; } // remove _id from window location if (window.history.replaceState) { window.history.replaceState({}, document.title, rcmail.url('', { _list: me.selected_list })); } } rcmail.set_busy(false, 'loading', ui_loading); } /** * */ function render_tasklist() { // clear display var id, rec, count = 0, cache = {}, activetags = {}, msgbox = $('#listmessagebox').hide(), list = $(rcmail.gui_objects.resultlist); selected = $('.taskhead.selected', list).parent().attr('rel'); list.html(''); for (var i=0; i < listindex.length; i++) { id = listindex[i]; rec = listdata[id]; if (match_filter(rec, cache)) { if (rcmail.env.action == 'print') { render_task_printmode(rec); continue; } render_task(rec); count++; // keep a list of tags from all visible tasks for (var t, j=0; rec.tags && j < rec.tags.length; j++) { t = rec.tags[j]; if (typeof activetags[t] == 'undefined') activetags[t] = 0; activetags[t]++; } } } if (rcmail.env.action == 'print') return; fix_tree_toggles(); update_taglist(); if (!count) { msgbox.html(rcmail.gettext('notasksfound','tasklist')).show(); rcmail.display_message(rcmail.gettext('notasksfound','tasklist'), 'voice'); } else if (selected) { // mark back the selected task $('li[rel="' + selected + '"] > .taskhead').addClass('selected'); } } /** * Show/hide child toggle buttons on all visible task items */ function fix_tree_toggles() { $('.taskitem', rcmail.gui_objects.resultlist).each(function(i,elem){ var li = $(elem), rec = listdata[li.attr('rel')], childs = $('.childtasks li', li); $('.childtoggle', li)[(childs.length ? 'show' : 'hide')](); }); } /** * Expand/collapse all task items with childs */ function expand_collapse(expand) { var collapsed = !expand; $('.taskitem .childtasks')[(collapsed ? 'hide' : 'show')](); $('.taskitem .childtoggle') .removeClass(collapsed ? 'expanded' : 'collapsed') .addClass(collapsed ? 'collapsed' : 'expanded') .html('').append($('<span class="inner">').text(collapsed ? '▶' : '▼')); // store new toggle collapse states var ids = []; for (var id in listdata) { if (listdata[id].children && listdata[id].children.length) ids.push(id); } if (ids.length) { rcmail.http_post('tasks/task', { action:'collapse', t:{ id:ids.join(',') }, collapsed:collapsed?1:0 }); } } /** * Display the given counts to each tag and set those inactive which don't * have any matching tasks in the current view. */ function update_taglist(tags) { var counts = {}; $.each(listdata, function(id, rec) { for (var t, j=0; rec && rec.tags && j < rec.tags.length; j++) { t = rec.tags[j]; if (typeof counts[t] == 'undefined') counts[t] = 0; counts[t]++; } }); rcmail.triggerEvent('kolab-tags-counts', {counter: counts}); if (tags && tags.length) { rcmail.triggerEvent('kolab-tags-refresh', {tags: tags}); } } function update_counts(counts) { // got new data if (counts) taskcounts = counts; // iterate over all selector links and update counts $('#taskselector a').each(function(i, elem){ var link = $(elem), f = link.parent().attr('class').replace(/\s\w+/g, ''); if (f != 'all') link.children('span').html('+' + (taskcounts[f] || ''))[(taskcounts[f] ? 'show' : 'hide')](); }); // spacial case: overdue $('#taskselector li.overdue')[(taskcounts.overdue ? 'removeClass' : 'addClass')]('inactive'); } /** * Callback from server to update a single task item */ function update_taskitem(rec, filter) { // handle a list of task records if ($.isArray(rec)) { $.each(rec, function(i,r){ update_taskitem(r, filter); }); return; } var id = rec.id, oldid = rec.tempid || id, oldrec = listdata[oldid], oldindex = $.inArray(oldid, listindex), oldparent = oldrec ? (oldrec._old_parent_id || oldrec.parent_id) : null, list = me.tasklists[rec.list]; if (!id || !list) return; if (oldindex >= 0) listindex[oldindex] = id; else listindex.push(id); listdata[id] = rec; // remove child-pointer from old parent if (oldparent && listdata[oldparent] && oldparent != rec.parent_id) { var oldchilds = listdata[oldparent].children, i = $.inArray(oldid, oldchilds); if (i >= 0) { listdata[oldparent].children = oldchilds.slice(0,i).concat(oldchilds.slice(i+1)); } } // register a forward-pointer to child tasks if (rec.parent_id && listdata[rec.parent_id] && listdata[rec.parent_id].children && $.inArray(id, listdata[rec.parent_id].children) < 0) listdata[rec.parent_id].children.push(id); // restore pointers to my children if (!listdata[id].children) { listdata[id].children = []; for (var pid in listdata) { if (listdata[pid].parent_id == id) listdata[id].children.push(pid); } } // copy _depth property from old rec or derive from parent if (rec.parent_id && listdata[rec.parent_id]) { rec._depth = (listdata[rec.parent_id]._depth || 0) + 1; } else if (oldrec) { rec._depth = oldrec._depth || 0; } if (list.active || rec.tempid) { if (!filter || match_filter(rec, {})) render_task(rec, oldid); } else { $('li[rel="'+id+'"]', rcmail.gui_objects.resultlist).remove(); } update_taglist(rec.tags || []); fix_tree_toggles(); // refresh currently displayed task details dialog if ($('#taskshow').is(':visible') && me.selected_task && me.selected_task.id == rec.id) { task_show_dialog(rec.id); } } /** * Submit the given (changed) task record to the server */ function save_task(rec, action) { // show confirmation dialog when status of an assigned task has changed if (rec._status_before !== undefined && me.is_attendee(rec)) return save_task_confirm(rec, action); if (!rcmail.busy) { saving_lock = rcmail.set_busy(true, 'tasklist.savingdata'); rcmail.http_post('tasks/task', { action:action, t:rec, filter:filtermask }); return true; } return false; } /** * Display confirm dialog when modifying/deleting a task record */ var save_task_confirm = function(rec, action, updates) { var data = $.extend({}, rec, updates || {}), notify = false, partstat = false, html = '', do_confirm = settings.itip_notify & 2; // task has attendees, ask whether to notify them if (me.has_attendees(rec) && me.is_organizer(rec)) { notify = true; if (do_confirm) { html = rcmail.gettext('changeconfirmnotifications', 'tasklist'); } else { data._notify = settings.itip_notify; } } // ask whether to change my partstat and notify organizer else if (data._status_before !== undefined && data.status && data._status_before != data.status && me.is_attendee(rec)) { partstat = true; if (do_confirm) { html = rcmail.gettext('partstatupdatenotification', 'tasklist'); } else if (settings.itip_notify & 1) { data._reportpartstat = data.status == 'CANCELLED' ? 'DECLINED' : data.status; } } // remove to avoid endless recursion delete data._status_before; // show dialog if (html) { var $dialog = $('<div>').html(html); var buttons = []; buttons.push({ text: rcmail.gettext('saveandnotify', 'tasklist'), 'class': 'mainaction save notify', click: function() { if (notify) data._notify = 1; if (partstat) data._reportpartstat = data.status == 'CANCELLED' ? 'DECLINED' : data.status; save_task(data, action); $(this).dialog('close'); } }); buttons.push({ text: rcmail.gettext('save', 'tasklist'), 'class': 'save', click: function() { save_task(data, action); $(this).dialog('close'); } }); buttons.push({ text: rcmail.gettext('cancel', 'tasklist'), 'class': 'cancel', click: function() { $(this).dialog('close'); if (updates) render_task(rec, rec.id); // restore previous state } }); $dialog.dialog({ modal: true, width: 460, closeOnEscapeType: false, dialogClass: 'warning no-close', title: rcmail.gettext('changetaskconfirm', 'tasklist'), buttons: buttons, open: function() { setTimeout(function(){ $dialog.parent().find('button:not(.ui-dialog-titlebar-close)').first().focus(); }, 5); }, close: function(){ $dialog.dialog('destroy').remove(); } }).addClass('task-update-confirm').show(); return true; } // do update return save_task(data, action); } /** * Remove saving lock and free the UI for new input */ function unlock_saving() { if (saving_lock) { rcmail.set_busy(false, null, saving_lock); saving_lock = null; // Elastic if (!$('.selected', rcmail.gui_objects.resultlist).length) { $('#taskedit').parents('.watermark').removeClass('formcontainer'); rcmail.triggerEvent('show-list', {force: true}); } } } /** * Render the given task into the tasks list */ function render_task(rec, replace) { var label_id = rcmail.html_identifier(rec.id) + '-title'; var div = $('<div>').addClass('taskhead').html( '<div class="progressbar"><div class="progressvalue" style="width:' + (rec.complete * 100) + '%"></div></div>' + '<input type="checkbox" name="completed[]" value="1" class="complete" aria-label="' + rcmail.gettext('complete','tasklist') + '" ' + (is_complete(rec) ? 'checked="checked" ' : '') + '/>' + '<span class="flagged" role="checkbox" tabindex="0" aria-checked="' + (rec.flagged ? 'true' : 'false') + '" aria-label="' + rcmail.gettext('flagged','tasklist') + '"></span>' + '<span class="title" id="' + label_id + '">' + text2html(Q(rec.title)) + '</span>' + '<span class="tags"></span>' + '<span class="date">' + Q(rec.date || rcmail.gettext('nodate','tasklist')) + '</span>' + '<a href="#" class="actions" aria-haspopup="true" aria-expanded="false">' + rcmail.gettext('taskactions','tasklist') + '</a>' ) .attr('tabindex', '0') .attr('aria-labelledby', label_id) .data('id', rec.id); // Make the task draggable, but not in the Elastic skin on touch devices, to fix scrolling if (!window.UI || !UI.is_touch || !window.UI.is_touch()) { div.draggable({ revert: 'invalid', addClasses: false, cursorAt: { left:-10, top:12 }, helper: task_draggable_helper, appendTo: 'body', start: task_draggable_start, stop: task_draggable_stop, drag: task_draggable_move, revertDuration: 300 }); } if (window.kolab_tags_text_block) { var tags = rec.tags || []; if (tags.length) { window.kolab_tags_text_block(tags, $('.tags', div)); } } if (is_complete(rec)) div.addClass('complete'); if (rec.flagged) div.addClass('flagged'); if (!rec.date) div.addClass('nodate'); if ((rec.mask & FILTER_MASK_OVERDUE)) div.addClass('overdue'); var li, inplace = false, parent = rec.parent_id ? $('li[rel="'+rec.parent_id+'"] > ul.childtasks', rcmail.gui_objects.resultlist) : null; if (replace && (li = $('li[rel="'+replace+'"]', rcmail.gui_objects.resultlist)) && li.length) { li.children('div.taskhead').first().replaceWith(div); li.attr('rel', rec.id); inplace = true; } else { li = $('<li role="treeitem">') .attr('rel', rec.id) .addClass('taskitem') .append((rec.collapsed ? '<span class="childtoggle collapsed"><span class="inner">▶' : '<span class="childtoggle expanded"><span class="inner">▼') + '</span></span>') .append(div) .append('<ul class="childtasks" role="group" style="' + (rec.collapsed ? 'display:none' : '') + '" aria-hidden="' + (rec.collapsed ? 'true' : 'false') +'"></ul>'); if (!parent || !parent.length) li.appendTo(rcmail.gui_objects.resultlist); } if (!inplace && parent && parent.length) li.appendTo(parent); rcmail.triggerEvent('tasks-insertrow', {div: div}); if (replace) { resort_task(rec, li, true); // TODO: remove the item after a while if it doesn't match the current filter anymore } // re-set focus to taskhead element after DOM update if (focused_task == rec.id) { focus_task(li); } } /** * Render the given task into the tasks list (in print mode) */ function render_task_printmode(rec) { var label_id = rcmail.html_identifier(rec.id) + '-title', div = $('<div>').addClass('taskhead') .append($('<span class="title">').attr('id', label_id).text(rec.title)), parent = rec.parent_id ? $('li[rel="'+rec.parent_id+'"] > ul.childtasks', rcmail.gui_objects.resultlist) : null, li = $('<li role="treeitem">').attr('rel', rec.id).addClass('taskitem') .append(div) .append('<ul class="childtasks" role="group"></ul>'); if (rec.description) div.append($('<span class="description">').text(rec.description)); /* if (is_complete(rec)) div.addClass('complete'); if (rec.flagged) div.addClass('flagged'); if (!rec.date) div.addClass('nodate'); if (rec.mask & FILTER_MASK_OVERDUE) div.addClass('overdue'); */ if (!parent || !parent.length) li.appendTo(rcmail.gui_objects.resultlist); else li.appendTo(parent); } /** * Move the given task item to the right place in the list */ function resort_task(rec, li, animated) { var dir = 0, index, slice, cmp, next_li, next_id, next_rec, insert_after, past_myself; // animated moving var insert_animated = function(li, before, after) { if (before && li.next().get(0) == before.get(0)) return; // nothing to do else if (after && li.prev().get(0) == after.get(0)) return; // nothing to do var speed = 300; li.slideUp(speed, function(){ if (before) li.insertBefore(before); else if (after) li.insertAfter(after); li.slideDown(speed, function(){ if (focused_task == rec.id) { focus_task(li); } }); }); } // remove from list index var oldlist = listindex.join('%%%'); var oldindex = $.inArray(rec.id, listindex); if (oldindex >= 0) { slice = listindex.slice(0,oldindex); listindex = slice.concat(listindex.slice(oldindex+1)); } // find the right place to insert the task item li.parent().children('.taskitem').each(function(i, elem){ next_li = $(elem); next_id = next_li.attr('rel'); next_rec = listdata[next_id]; if (next_id == rec.id) { past_myself = true; return 1; // continue } cmp = next_rec ? task_cmp(rec, next_rec) : 0; if (cmp > 0 || (cmp == 0 && !past_myself)) { insert_after = next_li; return 1; // continue; } else if (next_li && cmp < 0) { if (animated) insert_animated(li, next_li); else li.insertBefore(next_li); index = $.inArray(next_id, listindex); return false; // break } }); if (insert_after) { if (animated) insert_animated(li, null, insert_after); else li.insertAfter(insert_after); next_id = insert_after.attr('rel'); index = $.inArray(next_id, listindex); } // insert into list index if (next_id && index >= 0) { slice = listindex.slice(0,index); slice.push(rec.id); listindex = slice.concat(listindex.slice(index)); } else { // restore old list index listindex = oldlist.split('%%%'); } } /** * Compare function of two task records. * (used for sorting) */ function task_cmp(a, b) { // sort by hierarchy level first if ((a._depth || 0) != (b._depth || 0)) return a._depth - b._depth; var p, alt, inv = 1, c = is_complete(a) - is_complete(b), d = c; // completed tasks always move to the end if (c != 0) return c; // custom sorting if (settings.sort_col && settings.sort_col != 'auto') { alt = settings.sort_col == 'datetime' || settings.sort_col == 'startdatetime' ? 99999999999 : 0 d = (a[settings.sort_col]||alt) - (b[settings.sort_col]||alt); inv = settings.sort_order == 'desc' ? -1 : 1; } // default sorting (auto) else { if (!d) d = (b._hasdate-0) - (a._hasdate-0); if (!d) d = (a.datetime||99999999999) - (b.datetime||99999999999); } // fall-back to created/changed date if (!d) d = (a.created||0) - (b.created||0); if (!d) d = (a.changed||0) - (b.changed||0); return d * inv; } /** * Set focus on the given task item after DOM update */ function focus_task(li) { var selector = '.taskhead'; if (focused_subclass) selector += ' .' + focused_subclass li.find(selector).focus(); } /** * Determine whether the given task should be displayed as "complete" */ function is_complete(rec) { return ((rec.complete == 1.0 && !rec.status) || rec.status === 'COMPLETED') ? 1 : 0; } /** * */ function get_all_childs(id) { var cid, childs = []; for (var i=0; listdata[id].children && i < listdata[id].children.length; i++) { cid = listdata[id].children[i]; childs.push(cid); childs = childs.concat(get_all_childs(cid)); } return childs; } /* Helper functions for drag & drop functionality */ function task_draggable_helper(e) { if (!task_draghelper) task_draghelper = $('<div id="rcmdraglayer" class="taskitem-draghelper">'); var title = $(e.target).parents('li').first().find('.title:first').text(); task_draghelper.html(Q(title) || '✔'); return task_draghelper; } function task_draggable_start(event, ui) { var opts = { hoverClass: 'droptarget', accept: task_droppable_accept, drop: task_draggable_dropped, tolerance: 'pointer', addClasses: false }; $('.taskhead, #rootdroppable').droppable(opts); tasklists_widget.droppable(opts); $(this).parent().addClass('dragging'); $('#rootdroppable').show(); // enable auto-scrolling of list container var container = $(rcmail.gui_objects.resultlist); if (container.height() > container.parent().height()) { task_drag_active = true; list_scroll_top = container.parent().scrollTop(); } } function task_draggable_move(event, ui) { var scroll = 0, mouse = rcube_event.get_mouse_pos(event), container = $(rcmail.gui_objects.resultlist); mouse.y -= container.parent().offset().top; if (mouse.y < scroll_sensitivity && list_scroll_top > 0) { scroll = -1; // up } else if (mouse.y > container.parent().height() - scroll_sensitivity) { scroll = 1; // down } if (task_drag_active && scroll != 0) { if (!scroll_timer) scroll_timer = window.setTimeout(function(){ tasklist_drag_scroll(container, scroll); }, scroll_delay); } else if (scroll_timer) { window.clearTimeout(scroll_timer); scroll_timer = null; } } function task_draggable_stop(event, ui) { $(this).parent().removeClass('dragging'); $('#rootdroppable').hide(); task_drag_active = false; } function task_droppable_accept(draggable) { if (rcmail.busy) return false; var drag_id = draggable.data('id'), drop_id = $(this).data('id'), drag_rec = listdata[drag_id] || {}, drop_rec = listdata[drop_id]; // drop target is another list if (drag_rec && $(this).data('type') == 'tasklist') { var drop_list = me.tasklists[drop_id], from_list = me.tasklists[drag_rec.list]; return !drag_rec.parent_id && drop_id != drag_rec.list && drop_list && drop_list.editable && from_list && from_list.editable; } if (drop_rec && drop_rec.list != drag_rec.list) return false; if (drop_id == drag_rec.parent_id) return false; while (drop_rec && drop_rec.parent_id) { if (drop_rec.parent_id == drag_id) return false; drop_rec = listdata[drop_rec.parent_id]; } return true; } function task_draggable_dropped(event, ui) { var drop_id = $(this).data('id'), task_id = ui.draggable.data('id'), rec = listdata[task_id], parent, li; // dropped on another list -> move if ($(this).data('type') == 'tasklist') { if (rec) { save_task({ id:rec.id, list:drop_id, _fromlist:rec.list }, 'move'); rec.list = drop_id; } } // dropped on a new parent task or root else { parent = drop_id ? $('li[rel="'+drop_id+'"] > ul.childtasks', rcmail.gui_objects.resultlist) : $(rcmail.gui_objects.resultlist) if (rec && parent.length) { // submit changes to server rec._old_parent_id = rec.parent_id; rec.parent_id = drop_id || 0; save_task(rec, 'edit'); li = ui.draggable.parent(); li.slideUp(300, function(){ li.appendTo(parent); resort_task(rec, li); li.slideDown(300); fix_tree_toggles(); }); } } } /** * Scroll list container in the given direction */ function tasklist_drag_scroll(container, dir) { if (!task_drag_active) return; var old_top = list_scroll_top; container.parent().get(0).scrollTop += scroll_step * dir; list_scroll_top = container.parent().scrollTop(); scroll_timer = null; if (list_scroll_top != old_top) scroll_timer = window.setTimeout(function(){ tasklist_drag_scroll(container, dir); }, scroll_speed); } // check if the current user is the organizer this.is_organizer = function(task, email) { if (!email) email = task.organizer ? task.organizer.email : null; if (email) return settings.identity.emails.indexOf(';'+email) >= 0; return true; }; // add the given list of participants var add_attendees = function(names, params) { names = explode_quoted_string(names.replace(/,\s*$/, ''), ','); // parse name/email pairs var i, item, email, name, success = false; for (i=0; i < names.length; i++) { email = name = ''; item = $.trim(names[i]); if (!item.length) { continue; } // address in brackets without name (do nothing) else if (item.match(/^<[^@]+@[^>]+>$/)) { email = item.replace(/[<>]/g, ''); } // address without brackets and without name (add brackets) else if (rcube_check_email(item)) { email = item; } // address with name else if (item.match(/([^\s<@]+@[^>]+)>*$/)) { email = RegExp.$1; name = item.replace(email, '').replace(/^["\s<>]+/, '').replace(/["\s<>]+$/, ''); } if (email) { add_attendee($.extend({ email:email, name:name }, params)); success = true; } else { rcmail.alert_dialog(rcmail.gettext('noemailwarning')); } } return success; }; // add the given attendee to the list var add_attendee = function(data, readonly, before) { if (!me.selected_task) return false; // check for dupes... var exists = false; $.each(task_attendees, function(i, v) { exists |= (v.email == data.email); }); if (exists) return false; var dispname = Q(data.name || data.email); dispname = '<span' + ((data.email && data.email != dispname) ? ' title="' + Q(data.email) + '"' : '') + '>' + dispname + '</span>'; // delete icon var icon = rcmail.env.deleteicon ? '<img src="' + rcmail.env.deleteicon + '" alt="" />' : '<span class="inner">' + Q(rcmail.gettext('delete')) + '</span>'; var dellink = '<a href="#delete" class="iconlink icon button delete deletelink" title="' + Q(rcmail.gettext('delete')) + '">' + icon + '</a>'; var tooltip, status = (data.status || '').toLowerCase(), status_label = rcmail.gettext('status' + status, 'libcalendaring'); // send invitation checkbox var invbox = '<input type="checkbox" class="edit-attendee-reply" value="' + Q(data.email) +'" title="' + Q(rcmail.gettext('tasklist.sendinvitations')) + '" ' + (!data.noreply && settings.itip_notify & 1 ? 'checked="checked" ' : '') + '/>'; if (data['delegated-to']) tooltip = rcmail.gettext('libcalendaring.delegatedto') + ' ' + data['delegated-to']; else if (data['delegated-from']) tooltip = rcmail.gettext('libcalendaring.delegatedfrom') + ' ' + data['delegated-from']; else if (status) tooltip = status_label; // add expand button for groups if (data.cutype == 'GROUP') { dispname += ' <a href="#expand" data-email="' + Q(data.email) + '" class="iconbutton add icon button expandlink" title="' + rcmail.gettext('expandattendeegroup','libcalendaring') + '">' + '<span class="inner">' + rcmail.gettext('expandattendeegroup','libcalendaring') + '</span></a>'; } var elastic = $(attendees_list).parents('.no-img').length > 0; var html = '<td class="name"><span class="attendee-name">' + dispname + '</span></td>' + '<td class="confirmstate"><span class="attendee ' + status + '" title="' + Q(tooltip) + '">' + Q(status && !elastic ? status_label : '') + '</span></td>' + (data.cutype != 'RESOURCE' ? '<td class="invite">' + (readonly || !invbox ? '' : invbox) + '</td>' : '') + '<td class="options">' + (readonly ? '' : dellink) + '</td>'; var tr = $('<tr>') .addClass(String(data.role).toLowerCase()) .html(html); if (before) tr.insertBefore(before) else tr.appendTo(attendees_list); tr.find('a.deletelink').click({ id:(data.email || data.name) }, function(e) { remove_attendee(this, e.data.id); return false; }); tr.find('a.expandlink').click(data, function(e) { me.expand_attendee_group(e, add_attendee, remove_attendee); return false; }); tr.find('input.edit-attendee-reply').click(function() { var enabled = $('#edit-attendees-invite:checked').length || $('input.edit-attendee-reply:checked').length; $('#taskeditform .attendees-commentbox')[enabled ? 'show' : 'hide'](); }); // Make Elastic checkboxes pretty if (window.UI && UI.pretty_checkbox) $(tr).find('input[type=checkbox]').each(function() { UI.pretty_checkbox(this); }); task_attendees.push(data); return true; }; // event handler for clicks on an attendee link var task_attendee_click = function(e) { var mailto = this.href.substr(7); rcmail.command('compose', mailto); return false; }; // remove an attendee from the list var remove_attendee = function(elem, id) { $(elem).closest('tr').remove(); task_attendees = $.grep(task_attendees, function(data) { return (data.name != id && data.email != id) }); }; /** * Show task details in a dialog */ function task_show_dialog(id, data, temp) { var rec, list, elastic = false, $dialog = $('#taskshow'); if ($dialog.data('nodialog')) { elastic = true; $('#taskedit').addClass('hidden').parent().addClass('watermark'); // Elastic } else { $dialog.filter(':ui-dialog').dialog('close'); } rcmail.enable_command('edit-task', 'delete-task', 'save-task', 'task-history', false); // remove status-* classes $dialog.removeClass(function(i, oldclass) { var oldies = String(oldclass).split(' '); return $.grep(oldies, function(cls) { return cls.indexOf('status-') === 0 }).join(' '); }); if (!(rec = (data || listdata[id])) || (rcmail.menu_stack && rcmail.menu_stack.length > 0)) return; me.selected_task = rec; list = rec.list && me.tasklists[rec.list] ? me.tasklists[rec.list] : {}; // hide nav buttons on mobile (elastic) $('.content-frame-navigation > .buttons > .disabled').hide(); // for Elastic rcmail.triggerEvent('show-content', { mode: 'info', title: rcmail.gettext('taskdetails', 'tasklist'), obj: $('#taskshow').parent(), scrollElement: $('#taskshow') }); var status = rcmail.gettext('status-' + String(rec.status).toLowerCase(),'tasklist'); // fill dialog data $('#task-parent-title').html(Q(rec.parent_title || '')+' »').css('display', rec.parent_title ? 'block' : 'none'); $('#task-title').html(text2html(Q(rec.title || ''))); $('#task-description').html(text2html(rec.description || '', 300, 6))[(rec.description ? 'show' : 'hide')](); $('#task-date')[(rec.date ? 'show' : 'hide')]().find('.task-text').text(rec.date || rcmail.gettext('nodate','tasklist')); $('#task-time').text(rec.time || ''); $('#task-start')[(rec.startdate ? 'show' : 'hide')]().find('.task-text').text(rec.startdate || ''); $('#task-starttime').text(rec.starttime || ''); $('#task-alarm')[(rec.alarms_text ? 'show' : 'hide')]().children('.task-text').html(Q(rec.alarms_text)); $('#task-completeness .task-text').html(((rec.complete || 0) * 100) + '%'); $('#task-status')[(rec.status ? 'show' : 'hide')]().children('.task-text').text(status); $('#task-list .task-text').html(Q(me.tasklists[rec.list] ? me.tasklists[rec.list].name : '')); $('#task-attendees, #task-organizer, #task-created-changed, #task-created, #task-changed, #task-rsvp, #task-rsvp-comment').hide(); $('#event-status-badge > span').text(status); // tags var taglist = $('#task-tags').hide().children('.task-text').empty(); if (window.kolab_tags_text_block) { var tags = $.uniqueStrings((rec.tags || []).concat(get_inherited_tags(rec))); if (tags.length) { window.kolab_tags_text_block(tags, taglist); $('#task-tags').show(); } } if (rec.status) { $dialog.addClass('status-' + String(rec.status).toLowerCase()); } if (rec.flagged) { $dialog.addClass('status-flagged'); } if (rec.recurrence && rec.recurrence_text) { $('#task-recurrence').show().children('.task-text').html(Q(rec.recurrence_text)); } else { $('#task-recurrence').hide(); } if (rec.created || rec.changed) { $('.task-created', $dialog).text(rec.created_ || rcmail.gettext('unknown','tasklist')); $('.task-changed', $dialog).text(rec.changed_ || rcmail.gettext('unknown','tasklist')); $('#task-created-changed, #task-created, #task-changed').show(); } // build attachments list $('#task-attachments').hide(); if ($.isArray(rec.attachments)) { task_show_attachments(rec.attachments || [], $('#task-attachments').children('.task-text'), rec); if (rec.attachments.length > 0) { $('#task-attachments').show(); } } // build attachments list $('#task-links').hide(); if ($.isArray(rec.links) && rec.links.length) { render_message_links(rec.links || [], $('#task-links').children('.task-text'), false, 'tasklist'); $('#task-links').show(); } // list task attendees if (list.attendees && rec.attendees) { /* // sort resources to the end rec.attendees.sort(function(a,b) { var j = a.cutype == 'RESOURCE' ? 1 : 0, k = b.cutype == 'RESOURCE' ? 1 : 0; return (j - k); }); */ var j, data, rsvp = false, mystatus = null, line, morelink, html = '', overflow = '', organizer = me.is_organizer(rec); for (j=0; j < rec.attendees.length; j++) { data = rec.attendees[j]; if (data.email && settings.identity.emails.indexOf(';'+data.email) >= 0) { mystatus = data.status.toLowerCase(); if (data.status == 'NEEDS-ACTION' || data.status == 'TENTATIVE' || data.rsvp) rsvp = mystatus; } line = rcube_libcalendaring.attendee_html(data); if (morelink) overflow += line; else html += line; // stop listing attendees if (j == 7 && rec.attendees.length >= 7) { morelink = $('<a href="#more" class="morelink"></a>').html(rcmail.gettext('andnmore', 'tasklist').replace('$nr', rec.attendees.length - j - 1)); } } if (html) { $('#task-attendees').show() .children('.task-text') .html(html) .find('a.mailtolink').click(task_attendee_click); // display all attendees in a popup when clicking the "more" link if (morelink) { $('#task-attendees .task-text').append(morelink); morelink.click(function(e) { rcmail.show_popup_dialog( '<div id="all-task-attendees" class="task-attendees">' + html + overflow + '</div>', rcmail.gettext('tabattendees', 'tasklist'), null, {width: 450, modal: false} ); $('#all-task-attendees a.mailtolink').click(task_attendee_click); return false; }); } } /* if (mystatus && !rsvp) { $('#task-partstat').show().children('.changersvp') .removeClass('accepted tentative declined delegated needs-action') .addClass(mystatus) .children('.task-text') .html(rcmail.gettext('status' + mystatus, 'libcalendaring')); } */ var show_rsvp = !temp && rsvp && list.editable && !me.is_organizer(rec) && rec.status != 'CANCELLED'; $('#task-rsvp')[(show_rsvp ? 'show' : 'hide')](); $('#task-rsvp .rsvp-buttons input').prop('disabled', false).filter('input[rel='+mystatus+']').prop('disabled', true); if (show_rsvp && rec.comment) { $('#task-rsvp-comment').show().children('.task-text').html(Q(rec.comment)); } $('#task-rsvp a.reply-comment-toggle').show(); $('#task-rsvp .itip-reply-comment textarea').hide().val(''); if (rec.organizer && !organizer) { $('#task-organizer').show().children('.task-text').html(rcube_libcalendaring.attendee_html($.extend(rec.organizer, { role:'ORGANIZER' }))); } } // define dialog buttons var buttons = []; if (list.editable && !rec.readonly) { rcmail.enable_command('edit-task', 'add-child-task', true); buttons.push({ text: rcmail.gettext('edit','tasklist'), click: function() { task_edit_dialog(me.selected_task.id, 'edit'); }, disabled: rcmail.busy }); } if (me.has_permission(list, 'td') && !rec.readonly) { rcmail.enable_command('delete-task', true); buttons.push({ text: rcmail.gettext('delete','tasklist'), 'class': 'delete', click: function() { if (delete_task(me.selected_task.id)) $dialog.dialog('close'); }, disabled: rcmail.busy }); } rcmail.enable_command('task-history', !!list.history); if (!elastic) { // open jquery UI dialog $dialog.dialog({ modal: false, resizable: true, closeOnEscape: true, title: rcmail.gettext('taskdetails', 'tasklist'), open: function() { $dialog.parent().find('button:not(.ui-dialog-titlebar-close)').first().focus(); }, close: function() { $dialog.dialog('destroy').appendTo(document.body); $('.libcal-rsvp-replymode').hide(); }, dragStart: function() { $('.libcal-rsvp-replymode').hide(); }, resizeStart: function() { $('.libcal-rsvp-replymode').hide(); }, buttons: buttons, minWidth: 500, width: 580 }).show(); // set dialog size according to content me.dialog_resize($dialog.get(0), $dialog.height(), 580); } // Elastic $dialog.removeClass('hidden').parents('.watermark').addClass('formcontainer'); $('#taskshow').parent().trigger('loaded'); // hide nav buttons on mobile (elastic) $('.content-frame-navigation > .buttons > :not(.disabled)').show(); } /** * */ function task_history_dialog() { var dialog, rec = me.selected_task; if (!rec || !rec.id || !window.libkolab_audittrail) { return false; } // render dialog $dialog = libkolab_audittrail.object_history_dialog({ module: 'tasklist', container: '#taskhistory', title: rcmail.gettext('objectchangelog','tasklist') + ' - ' + rec.title, // callback function for list actions listfunc: function(action, rev) { var rec = $dialog.data('rec'); saving_lock = rcmail.set_busy(true, 'loading', saving_lock); rcmail.http_post('task', { action: action, t: { id: rec.id, list:rec.list, rev: rev } }, saving_lock); }, // callback function for comparing two object revisions comparefunc: function(rev1, rev2) { var rec = $dialog.data('rec'); saving_lock = rcmail.set_busy(true, 'loading', saving_lock); rcmail.http_post('task', { action:'diff', t: { id: rec.id, list: rec.list, rev1: rev1, rev2: rev2 } }, saving_lock); } }); $dialog.data('rec', rec); // fetch changelog data saving_lock = rcmail.set_busy(true, 'loading', saving_lock); rcmail.http_post('task', { action: 'changelog', t: { id: rec.id, list: rec.list } }, saving_lock); } /** * */ function task_render_changelog(data) { var $dialog = $('#taskhistory'), rec = $dialog.data('rec'); if (data === false || !data.length || !rec) { // display 'unavailable' message $('<div class="notfound-message dialog-message warning">' + rcmail.gettext('objectchangelognotavailable','tasklist') + '</div>') .insertBefore($dialog.find('.changelog-table').hide()); return; } data.module = 'tasklist'; libkolab_audittrail.render_changelog(data, rec, me.tasklists[rec.list]); // set dialog size according to content me.dialog_resize($dialog.get(0), $dialog.height(), 600); } /** * */ function task_show_diff(data) { var rec = me.selected_task, $dialog = $("#taskdiff"); $dialog.find('div.form-section, h2.task-title-new').hide().data('set', false).find('.index').html(''); $dialog.find('div.form-section.clone').remove(); // always show event title and date $('.task-title', $dialog).text(rec.title).removeClass('task-text-old').show(); // show each property change $.each(data.changes, function(i, change) { var prop = change.property, r2, html = false, row = $('div.task-' + prop, $dialog).first(); // special case: title if (prop == 'title') { $('.task-title', $dialog).addClass('task-text-old').text(change['old'] || '--'); $('.task-title-new', $dialog).text(change['new'] || '--').show(); } // no display container for this property if (!row.length) { return true; } // clone row if already exists if (row.data('set')) { r2 = row.clone().addClass('clone').insertAfter(row); row = r2; } // render description text if (prop == 'description') { if (!change.diff_ && change['old']) change.old_ = text2html(change['old']); if (!change.diff_ && change['new']) change.new_ = text2html(change['new']); html = true; } // format attendees struct else if (prop == 'attendees') { if (change['old']) change.old_ = rcube_libcalendaring.attendee_html(change['old']); if (change['new']) change.new_ = rcube_libcalendaring.attendee_html($.extend({}, change['old'] || {}, change['new'])); html = true; } // localize status else if (prop == 'status') { if (change['old']) change.old_ = rcmail.gettext('status-'+String(change['old']).toLowerCase(), 'tasklist'); if (change['new']) change.new_ = rcmail.gettext('status-'+String(change['new']).toLowerCase(), 'tasklist'); } // format attachments struct if (prop == 'attachments') { if (change['old']) task_show_attachments([change['old']], row.children('.task-text-old'), rec, false); else row.children('.task-text-old').text('--'); if (change['new']) task_show_attachments([$.extend({}, change['old'] || {}, change['new'])], row.children('.task-text-new'), rec, false); else row.children('.task-text-new').text('--'); // remove click handler in diff view $('.attachmentslist li a', row).unbind('click').removeAttr('href'); } else if (change.diff_) { row.children('.task-text-diff').html(change.diff_); row.children('.task-text-old, .task-text-new').hide(); } else { if (!html) { // escape HTML characters change.old_ = Q(change.old_ || change['old'] || '--') change.new_ = Q(change.new_ || change['new'] || '--') } row.children('.task-text-old').html(change.old_ || change['old'] || '--').show(); row.children('.task-text-new').html(change.new_ || change['new'] || '--').show(); } // display index number if (typeof change.index != 'undefined') { row.find('.index').html('(' + change.index + ')'); } row.show().data('set', true); }); // open jquery UI dialog $dialog.dialog({ modal: false, resizable: true, closeOnEscape: true, title: rcmail.gettext('objectdiff','tasklist').replace('$rev1', data.rev1).replace('$rev2', data.rev2) + ' - ' + rec.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: 320, width: 450 }).show(); // set dialog size according to content me.dialog_resize($dialog.get(0), $dialog.height(), 400); } // close the event history dialog function close_history_dialog() { $('#taskhistory, #taskdiff').each(function(i, elem) { var $dialog = $(elem); if ($dialog.is(':ui-dialog')) $dialog.dialog('close'); }); }; /** * Opens the dialog to edit a task */ function task_edit_dialog(id, action, presets) { var elastic = false, infodialog = $('#taskshow'); if (infodialog.data('nodialog') || $('#taskedit').data('nodialog')) { elastic = true; infodialog.addClass('hidden').parent().addClass('watermark'); // Elastic } else { infodialog.filter(':ui-dialog').dialog('close'); } rcmail.enable_command('edit-task', 'delete-task', 'add-child-task', 'save-task', 'task-history', false); var selected_list, rec = listdata[id] || presets, $dialog = $('<div>'), editform = $('#taskedit'), readonly = rec && rec.readonly, list = rec.list && me.tasklists[rec.list] ? me.tasklists[rec.list] : (me.selected_list ? me.tasklists[me.selected_list] : { editable: action == 'new', rights: action == 'new' ? 'rwitd' : 'r' }); if (rcmail.busy || !me.has_permission(list, 'i') || (action == 'edit' && (!rec || rec.readonly))) return false; // hide nav buttons on mobile (elastic) $('.content-frame-navigation > .buttons > .disabled').hide(); if (action == 'new') { $(rcmail.gui_objects.resultlist).find('.selected').removeClass('selected'); } // for Elastic rcmail.triggerEvent('show-content', { mode: rec.uid ? 'edit' : 'add', title: rcmail.gettext(rec.uid ? 'edittask' : 'newtask', 'tasklist'), obj: $('#taskedit').parent(), scrollElement: $('#taskedit') }); me.selected_task = $.extend({ valarms:[] }, rec); // clone task object rec = me.selected_task; // assign temporary id if (!me.selected_task.id) me.selected_task.id = -(++idcount); // reset dialog first $('#taskeditform').get(0).reset(); // allow other plugins to do actions when task form is opened rcmail.triggerEvent('tasklist-task-init', {o: rec}); // fill form data var title = $('#taskedit-title').val(rec.title || ''); var description = $('#taskedit-description').val(rec.description || ''); var recdate = $('#taskedit-date').val(rec.date || ''); var rectime = $('#taskedit-time').val(rec.time || ''); var recstartdate = $('#taskedit-startdate').val(rec.startdate || ''); var recstarttime = $('#taskedit-starttime').val(rec.starttime || ''); var complete = $('#taskedit-completeness').val((rec.complete || 0) * 100); completeness_slider.slider('value', complete.val()); var taskstatus = $('#taskedit-status').val(rec.status || ''); var tasklist = $('#taskedit-tasklist').prop('disabled', rec.parent_id ? true : false); var notify = $('#edit-attendees-donotify').get(0); var invite = $('#edit-attendees-invite').get(0); var comment = $('#edit-attendees-comment'); var taglist; invite.checked = settings.itip_notify & 1 > 0; notify.checked = me.has_attendees(rec) && invite.checked; // set tasklist selection according to permissions tasklist.find('option').each(function(i, opt) { var l = me.tasklists[opt.value] || {}, writable = l.editable || (action == 'new' && me.has_permission(l, 'i')); $(opt).prop('disabled', !writable); if (!selected_list && writable) selected_list = opt.value; }); tasklist.val(rec.list || me.selected_list || selected_list); // tag-edit line var tagline = $('#taskedit-tagline'); if (window.kolab_tags_input) { tagline.parent().show(); taglist = kolab_tags_input(tagline, rec.tags, readonly); } else { tagline.parent().hide(); } // set alarm(s) me.set_alarms_edit('#taskedit-alarms', action != 'new' && rec.valarms ? rec.valarms : []); if ($.isArray(rec.links) && rec.links.length) { render_message_links(rec.links, $('#taskedit-links .task-text'), true, 'tasklist'); $('#taskedit-links').show(); } else { $('#taskedit-links').hide(); } // set recurrence me.set_recurrence_edit(rec); // init attendees tab var organizer = !rec.attendees || me.is_organizer(rec), allow_invitations = organizer || (rec.owner && rec.owner == 'anonymous') || settings.invite_shared; task_attendees = []; attendees_list = $('#edit-attendees-table > tbody').html(''); $('#edit-attendees-notify')[(allow_invitations && me.has_attendees(rec) && (settings.itip_notify & 2) ? 'show' : 'hide')](); $('#edit-localchanges-warning')[(me.has_attendees(rec) && !(allow_invitations || (rec.owner && me.is_organizer(rec, rec.owner))) ? 'show' : 'hide')](); // attendees (aka assignees) if (list.attendees) { var j, data, reply_selected = 0; if (rec.attendees) { for (j=0; j < rec.attendees.length; j++) { data = rec.attendees[j]; add_attendee(data, !allow_invitations); if (allow_invitations && !data.noreply) { reply_selected++; } } } // make sure comment box is visible if at least one attendee has reply enabled // or global "send invitations" checkbox is checked $('#taskeditform .attendees-commentbox')[(reply_selected || invite.checked ? 'show' : 'hide')](); // select the correct organizer identity var identity_id = 0; $.each(settings.identities, function(i,v) { if (!rec.organizer || v == rec.organizer.email) { identity_id = i; return false; } }); $('#edit-tab-attendees').show(); // Larry $('a[href="#taskedit-panel-attendees"]').parent().show(); // Elastic $('#edit-attendees-form')[(allow_invitations?'show':'hide')](); $('#edit-identities-list').val(identity_id); $('#taskedit-organizer')[(organizer ? 'show' : 'hide')](); } else { $('#edit-tab-attendees').hide(); // Larry $('a[href="#taskedit-panel-attendees"]').parent().hide(); // Elastic } // attachments rcmail.enable_command('remove-attachment', 'upload-file', list.editable); me.selected_task.deleted_attachments = []; // we're sharing some code for uploads handling with app.js rcmail.env.attachments = []; rcmail.env.compose_id = me.selected_task.id; // for rcmail.async_upload_form() if ($.isArray(rec.attachments)) { task_show_attachments(rec.attachments, $('#taskedit-attachments'), rec, true); } else { $('#taskedit-attachments > ul').empty(); } // show/hide tabs according to calendar's feature support $('#taskedit-tab-attachments')[(list.attachments||rec.attachments?'show':'hide')](); // Larry $('a[href="#taskedit-panel-attachments"]').parent()[(list.attachments||rec.attachments?'show':'hide')](); // Elastic // activate the first tab $('#taskedit:not([data-notabs])').tabs('option', 'active', 0); // Larry if ($('#taskedit').data('notabs')) $('#taskedit li.nav-item:first-child a').tab('show'); // Elastic // define dialog buttons var buttons = [], save_func = function() { var data = me.selected_task; data._status_before = me.selected_task.status + ''; // copy form field contents into task object to save $.each({ title:title, description:description, date:recdate, time:rectime, startdate:recstartdate, starttime:recstarttime, status:taskstatus }, function(key,input){ data[key] = input.val(); }); data.list = tasklist.find('option:selected').val(); data.tags = taglist ? kolab_tags_input_value(taglist) : []; data.attachments = []; data.attendees = task_attendees; data.valarms = me.serialize_alarms('#taskedit-alarms'); data.recurrence = me.serialize_recurrence(rectime.val()); // do some basic input validation if (!data.title || !data.title.length) { title.focus(); return false; } else if (data.startdate && data.date) { var startdate = $.datepicker.parseDate(me.datepicker_settings.dateFormat, data.startdate, me.datepicker_settings); var duedate = $.datepicker.parseDate(me.datepicker_settings.dateFormat, data.date, me.datepicker_settings); if (startdate > duedate) { rcmail.alert_dialog(rcmail.gettext('invalidstartduedates', 'tasklist')); return false; } else if ((data.time == '') != (data.starttime == '')) { rcmail.alert_dialog(rcmail.gettext('invalidstartduetimes', 'tasklist')); return false; } } else if (data.recurrence && !data.startdate && !data.date) { rcmail.alert_dialog(rcmail.gettext('recurrencerequiresdate', 'tasklist')); return false; } // uploaded attachments list for (var i in rcmail.env.attachments) { if (i.match(/^rcmfile(.+)/)) data.attachments.push(RegExp.$1); } // task assigned to a new list if (data.list && listdata[id] && data.list != listdata[id].list) { data._fromlist = list.id; } data.complete = complete.val() / 100; if (isNaN(data.complete)) data.complete = null; else if (data.complete == 1.0 && rec.status === '') data.status = 'COMPLETED'; if (!data.list && list.id) data.list = list.id; if (!data.tags.length) data.tags = ''; if (organizer) { data._identity = $('#edit-identities-list option:selected').val(); delete data.organizer; } // per-attendee notification suppression var need_invitation = false; if (allow_invitations) { $.each(data.attendees, function (i, v) { if (v.role != 'ORGANIZER') { if ($('input.edit-attendee-reply[value="' + v.email + '"]').prop('checked')) { need_invitation = true; delete data.attendees[i]['noreply']; } else if (settings.itip_notify > 0) { data.attendees[i].noreply = 1; } } }); } // tell server to send notifications if ((me.has_attendees(data) || (rec.id && me.has_attendees(rec))) && allow_invitations && (notify.checked || invite.checked || need_invitation)) { data._notify = settings.itip_notify; data._comment = comment.val(); } else if (data._notify) { delete data._notify; } if (save_task(data, action) && !elastic) $dialog.dialog('close'); }; rcmail.enable_command('save-task', true); rcmail.env.task_save_func = save_func; buttons.push({ text: rcmail.gettext('save', 'tasklist'), 'class': 'mainaction', click: save_func }); if (action != 'new') { rcmail.enable_command('delete-task', true); buttons.push({ text: rcmail.gettext('delete', 'tasklist'), 'class': 'delete', click: function() { if (delete_task(rec.id)) $dialog.dialog('close'); } }); } buttons.push({ text: rcmail.gettext('cancel', 'tasklist'), click: function() { $dialog.dialog('close'); } }); // open jquery UI dialog if (!elastic) { $dialog.dialog({ modal: true, resizable: (!bw.ie6 && !bw.ie7), // disable for performance reasons closeOnEscape: false, title: rcmail.gettext((action == 'edit' ? 'edittask' : 'newtask'), 'tasklist'), close: function() { rcmail.ksearch_blur(); editform.hide().appendTo(document.body); $dialog.dialog('destroy').remove(); }, buttons: buttons, minHeight: 460, minWidth: 500, width: 580 }).append(editform.show()); // adding form content AFTERWARDS massively speeds up opening on IE // set dialog size according to content me.dialog_resize($dialog.get(0), $dialog.height(), 580); } // Elastic editform.removeClass('hidden').parents('.watermark').addClass('formcontainer'); $('#taskedit').parent().trigger('loaded'); title.select(); // show nav buttons on mobile (elastic) $('.content-frame-navigation > .buttons > :not(.disabled)').show(); } /** * Open a task attachment either in a browser window for inline view or download it */ function load_attachment(data) { var rec = data.record; // can't open temp attachments if (!rec.id || rec.id < 0) return false; var query = {_id: data.attachment.id, _t: rec.recurrence_id || rec.id, _list: rec.list}; if (rec.rev) query._rev = rec.rev; libkolab.load_attachment(query, data.attachment); }; /** * Build task attachments list */ function task_show_attachments(list, container, task, edit) { - libkolab.list_attachments(list, container, edit, task, - function(id) { remove_attachment(id); }, - function(data) { load_attachment(data); } - ); + libkolab.list_attachments(list, container, edit, task, + function(id) { remove_attachment(id); }, + function(data) { load_attachment(data); } + ); }; /** * */ function remove_attachment(id) { me.selected_task.deleted_attachments.push(id); } /** * */ function remove_link(elem) { var $elem = $(elem), uri = $elem.attr('data-uri'); // remove the link item matching the given uri me.selected_task.links = $.grep(me.selected_task.links, function(link) { return link.uri != uri; }); // remove UI list item $elem.hide().closest('li').addClass('deleted'); } /** * */ function add_childtask(id) { if (rcmail.busy) return false; var rec = listdata[id]; task_edit_dialog(null, 'new', { parent_id:id, list:rec.list }); } /** * Delete the given task */ function delete_task(id) { var rec = listdata[id]; if (!rec || rec.readonly || rcmail.busy) return false; var html, buttons = [], $dialog = $('<div>'); // Subfunction to submit the delete command after confirm var _delete_task = function(id, mode) { var rec = listdata[id], li = $('li[rel="'+id+'"]', rcmail.gui_objects.resultlist).hide(), decline = $dialog.find('input.confirm-attendees-decline:checked').length, notify = $dialog.find('input.confirm-attendees-notify:checked').length; saving_lock = rcmail.set_busy(true, 'tasklist.savingdata'); rcmail.http_post('task', { action:'delete', t:{ id:rec.id, list:rec.list, _decline:decline, _notify:notify }, mode:mode, filter:filtermask }); // move childs to parent/root if (mode != 1 && rec.children !== undefined) { var parent_node = rec.parent_id ? $('li[rel="'+rec.parent_id+'"] > .childtasks', rcmail.gui_objects.resultlist) : null; if (!parent_node || !parent_node.length) parent_node = rcmail.gui_objects.resultlist; $.each(rec.children, function(i,cid) { var child = listdata[cid]; child.parent_id = rec.parent_id; resort_task(child, $('li[rel="'+cid+'"]').appendTo(parent_node), true); }); } li.remove(); delete listdata[id]; } if (rec.children && rec.children.length) { html = rcmail.gettext('deleteparenttasktconfirm','tasklist'); buttons.push({ text: rcmail.gettext('deletethisonly','tasklist'), 'class': 'delete', click: function() { _delete_task(id, 0); $(this).dialog('close'); } }); buttons.push({ text: rcmail.gettext('deletewithchilds','tasklist'), 'class': 'delete', click: function() { _delete_task(id, 1); $(this).dialog('close'); } }); } else { html = rcmail.gettext('deletetasktconfirm','tasklist'); buttons.push({ text: rcmail.gettext('delete','tasklist'), 'class': 'delete', click: function() { _delete_task(id, 0); $(this).dialog('close'); } }); } if (me.is_attendee(rec)) { html += '<div class="dialog-message">' + '<label><input class="confirm-attendees-decline pretty-checkbox" type="checkbox" checked="checked" value="1" name="_decline" /> ' + rcmail.gettext('itipdeclinetask', 'tasklist') + '</label></div>'; } else if (me.has_attendees(rec) && me.is_organizer(rec)) { html += '<div class="dialog-message">' + '<label><input class="confirm-attendees-notify pretty-checkbox" type="checkbox" checked="checked" value="1" name="_notify" /> ' + rcmail.gettext('sendcancellation', 'tasklist') + '</label></div>'; } buttons.push({ text: rcmail.gettext('cancel', 'tasklist'), 'class': 'cancel', click: function() { $(this).dialog('close'); } }); $dialog.html(html); $dialog.dialog({ modal: true, width: 520, dialogClass: 'warning no-close', title: rcmail.gettext('deletetask', 'tasklist'), buttons: buttons, close: function(){ $dialog.dialog('destroy').hide(); } }).addClass('tasklist-confirm').show(); return true; } /** * Check if the given task matches the current filtermask and tag selection */ function match_filter(rec, cache, recursive) { if (!rec) return false; // return cached result if (typeof cache[rec.id] != 'undefined' && recursive != 2) { return cache[rec.id]; } var match = !filtermask || (filtermask & rec.mask) == filtermask; // in focusview mode, only tasks from the selected list are allowed if (focusview) match = $.inArray(rec.list, focusview_lists) >= 0 && match; if (match && tagsfilter.length) { match = rec.tags && rec.tags.length; var alltags = get_inherited_tags(rec).concat(rec.tags || []); for (var i=0; match && i < tagsfilter.length; i++) { if ($.inArray(tagsfilter[i], alltags) < 0) match = false; } } // check if a child task matches the tags if (!match && (recursive||0) < 2 && rec.children && rec.children.length) { for (var j=0; !match && j < rec.children.length; j++) { match = match_filter(listdata[rec.children[j]], cache, 1); } } // walk up the task tree and check if a parent task matches var parent_id; if (!match && !recursive && (parent_id = rec.parent_id)) { while (!match && parent_id && listdata[parent_id]) { match = match_filter(listdata[parent_id], cache, 2); parent_id = listdata[parent_id].parent_id; } } if (recursive != 1) { cache[rec.id] = match; } return match; } /** * */ function get_inherited_tags(rec) { var parent_id, itags = []; if ((parent_id = rec.parent_id)) { while (parent_id && listdata[parent_id]) { itags = itags.concat(listdata[parent_id].tags || []); parent_id = listdata[parent_id].parent_id; } } return $.uniqueStrings(itags); } /** * Change tasks list sorting */ function list_set_sort(col) { list_set_sort_and_order(col, settings.sort_order); } /** * Change tasks list sort order */ function list_set_order(order) { list_set_sort_and_order(settings.sort_col, order); } /** * Change tasks list sorting and/or order */ function list_set_sort_and_order(col, order) { var col_change = settings.sort_col != col, ord_change = settings.sort_order != order; if (col_change || ord_change) { settings.sort_col = col; settings.sort_order = order; // re-sort list index and re-render list listindex.sort(function(a, b) { return task_cmp(listdata[a], listdata[b]); }); render_tasklist(); rcmail.enable_command('list-order', settings.sort_col != 'auto'); if (col_change) rcmail.save_pref({ name: 'tasklist_sort_col', value: (col == 'auto' ? '' : col) }); if (ord_change) rcmail.save_pref({ name: 'tasklist_sort_order', value: order }); $('#taskviewsortmenu .sortcol').attr('aria-checked', 'false').removeClass('selected') .filter('.by-' + col).attr('aria-checked', 'true').addClass('selected'); $('#taskviewsortmenu .sortorder').removeClass('selected') .filter('[aria-checked=true]').addClass('selected'); } } function get_setting(name) { return settings[name]; } /** * Dialog for tasks list creation/update */ function list_edit_dialog(id) { var list = me.tasklists[id] || {name: '', editable: true, rights: 'riwta', showalarms: true}, title = rcmail.gettext((list.id ? 'editlist' : 'createlist'), 'tasklist'), params = {action: (list.id ? 'form-edit' : 'form-new'), l: { id:list.id }, _framed: 1}, $dialog = $('<iframe>').attr('src', rcmail.url('tasklist', params)).on('load', function() { $(this).contents().find('#taskedit-showalarms') .prop('checked', list.showalarms); $(this).contents().find('#taskedit-tasklistname') .prop('disabled', !me.has_permission(list, 'a') || list.norename) .val(list.editname || list.name) .select(); }), save_func = function() { var data, form = $dialog.contents().find('#tasklisteditform'), name = form.find('#taskedit-tasklistname'); // form is not loaded if (!form || !form.length) return false; // do some input validation if (!name.val() || name.val().length < 2) { rcmail.alert_dialog(rcmail.gettext('invalidlistproperties', 'tasklist'), function() { name.select(); }); return false; } // post data to server data = form.serializeJSON(); if (list.id) data.id = list.id; saving_lock = rcmail.set_busy(true, 'tasklist.savingdata'); rcmail.http_post('tasklist', {action: (list.id ? 'edit' : 'new'), l: data}); return true; }; rcmail.simple_dialog($dialog, title, save_func, { width: 600, height: 400 }); } /** * */ function list_delete(id) { var list = me.tasklists[id]; if (list && !list.norename) { rcmail.confirm_dialog(rcmail.gettext(list.children ? 'deletelistconfirmrecursive' : 'deletelistconfirm', 'tasklist'), 'delete', function() { saving_lock = rcmail.set_busy(true, 'tasklist.savingdata'); rcmail.http_post('tasklist', { action:'delete', l:{ id:list.id } }); }); } return false; } /** * */ function list_remove(id) { var list = me.tasklists[id]; if (list && list.removable) { destroy_list(list); rcmail.http_post('tasklist', { action:'subscribe', l:{ id:list.id, active:0, permanent:0, recursive:1 } }); } } /** * Callback from server to finally remove the given list */ function destroy_list(prop) { var li, delete_ids = [], list = me.tasklists[prop.id]; // find sub-lists if (list && list.children) { for (var child_id in me.tasklists) { if (String(child_id).indexOf(prop.id) == 0) delete_ids.push(child_id); } } else { delete_ids.push(prop.id); } // delete all subfolders in the list for (var i=0; i < delete_ids.length; i++) { id = delete_ids[i]; list = me.tasklists[id]; tasklists_widget.remove(id); if (list) { list.active = false; // delete me.tasklists[prop.id]; unlock_saving(); remove_tasks(list.id); } $("#taskedit-tasklist option[value='"+id+"']").remove(); } } /** * */ function insert_list(prop) { if (prop._reload) { rcmail.redirect(rcmail.url('')); return; } tasklists_widget.insert({ id: prop.id, classes: [ prop.group || '' ], virtual: prop.virtual, html: prop.html }, prop.parent || null, prop.group); // flag as tasklist for drag & drop var li = $(tasklists_widget.get_item(prop.id)).data('type', 'tasklist'); delete prop.html; me.tasklists[prop.id] = prop; // append to list selector in task edit dialog, too (#2985) $('<option>').attr('value', prop.id).html(Q(prop.name)).appendTo('#taskedit-tasklist'); // Elastic skin if (window.UI && UI.pretty_checkbox) { UI.pretty_checkbox($(li).find('input')); } } /** * */ function update_list(prop) { var id = prop.oldid || prop.id, li = tasklists_widget.get_item(id); if (prop._reload) { rcmail.redirect(rcmail.url('')); return; } if (me.tasklists[id] && li) { delete me.tasklists[id]; me.tasklists[prop.id] = prop; $(li).find('input').first().val(prop.id); $(li).find('.listname').first().html(Q(prop.name)); tasklists_widget.update(id, {id: prop.id, html: $(li).children().first()}); } } /** * Display tasklist CalDAV URL dialog */ function list_showurl(id) { var list = me.tasklists[id]; if (list && list.caldavurl) { var dialog = $('<div>').addClass('showurldialog').append('<p>'+rcmail.gettext('caldavurldescription', 'tasklist')+'</p>'), textbox = $('<textarea>').addClass('urlbox form-control').css('width', '100%') .attr({rows: 3, readonly: true}).appendTo(dialog); rcmail.simple_dialog(dialog, 'tasklist.showcaldavurl', null, { open: function() { textbox.val(list.caldavurl).select(); }, cancel_button: 'close' }); } } /** * Execute search */ function quicksearch() { var q; if (rcmail.gui_objects.qsearchbox && (q = rcmail.gui_objects.qsearchbox.value)) { var id = 'search-'+q; var resources = []; for (var rid in me.tasklists) { if (me.tasklists[rid].active) { resources.push(rid); } } id += '@'+resources.join(','); // ignore if query didn't change if (search_request == id) return; search_request = id; search_query = q; list_tasks(); } else // empty search input equals reset this.reset_search(); } /** * Reset search and get back to normal listing */ function reset_search() { $(rcmail.gui_objects.qsearchbox).val(''); if (search_request) { search_request = search_query = null; list_tasks(); } } /** * Method to show the tasks print page. */ function print_tasks() { var param = {}, active = active_lists(); if (active.length) { param = {filter: filtermask, lists: active.join(','), q: search_query}; rcmail.open_window(rcmail.url('print', param), true, true); } }; /**** Utility functions ****/ // same as str.split(delimiter) but it ignores delimiters within quoted strings var explode_quoted_string = function(str, delimiter) { var result = [], strlen = str.length, q, p, i, chr, last; for (q = p = i = 0; i < strlen; i++) { chr = str.charAt(i); if (chr == '"' && last != '\\') { q = !q; } else if (!q && chr == delimiter) { result.push(str.substring(p, i)); p = i + 1; } last = chr; } result.push(str.substr(p)); return result; }; /** * Clear any text selection * (text is probably selected when double-clicking somewhere) */ function clearSelection() { if (document.selection && document.selection.empty) { document.selection.empty() ; } else if (window.getSelection) { var sel = window.getSelection(); if (sel && sel.removeAllRanges) sel.removeAllRanges(); } } /** * Check whether the event target is a descentand of the given element */ function target_overlaps(target, elem) { while (target.parentNode) { if (target.parentNode == elem) return true; target = target.parentNode; } return false; } /** * */ function active_lists() { var active = []; for (var id in me.tasklists) { if (me.tasklists[id].active) active.push(id); } return active; } /** * Enable/disable focusview mode for the given list */ function set_focusview(id, shift) { var in_focus = $.inArray(id, focusview_lists) >= 0, li = $(tasklists_widget.get_item(id)).find('.tasklist').first(); // remove list from focusview if (in_focus && shift && id !== null) { focusview_lists = $.grep(focusview_lists, function(list_id) { return list_id != id; }); } else { if (!shift || id === null) { focusview_lists = []; // uncheck all active focusview icons tasklists_widget.container.find('div.focusview') .removeClass('focusview') .find('a.quickview').attr('aria-checked', 'false'); } if (!in_focus && id !== null) { focusview_lists.push(id) } } focusview = focusview_lists.length > 0; // activate list if necessary if (focusview && !me.tasklists[id].active) { li.find('input[type=checkbox]').get(0).checked = true; me.tasklists[id].active = true; fetch_counts(); } // update list list_tasks(null); if (focusview) { li[in_focus ? 'removeClass' : 'addClass']('focusview') .find('a.quickview').attr('aria-checked', in_focus ? 'false' : 'true'); $('body').addClass('quickview-active'); } else { $('body').removeClass('quickview-active'); } } // init dialog by default init_taskedit(); } // equivalent to $.unique() but working on arrays of strings jQuery.uniqueStrings = (function() { return function(arr) { var hash = {}, out = []; for (var i = 0; i < arr.length; i++) { hash[arr[i]] = 0; } for (var val in hash) { out.push(val); } return out; }; })(); /** * Tasks list options dialog */ function tasklist_options_menu(p) { var content = $('#listoptions-menu'), width = content.width() + 25, dialog = content.clone(true); // set form values $('#options-sortcol', dialog).val(rctasks.get_setting('sort_col') || 'auto'); $('#options-ord', dialog).val(rctasks.get_setting('sort_order') || 'asc'); // Fix id/for attributes $('select', dialog).each(function() { this.id = this.id + '-clone'; }); $('label', dialog).each(function() { $(this).attr('for', $(this).attr('for') + '-clone'); }); var save_func = function(e) { if (rcube_event.is_keyboard(e.originalEvent)) { $('#listmenulink').focus(); } var col = $('#options-sortcol-clone', dialog).val(), ord = $('#options-ord-clone', dialog).val(); rctasks.list_set_sort_and_order(col, ord); return true; }; dialog = rcmail.simple_dialog(dialog, rcmail.gettext('listoptionstitle'), save_func, { closeOnEscape: true, minWidth: 400, width: width }); }; /* tasklist plugin UI initialization */ var rctasks; window.rcmail && rcmail.addEventListener('init', function(evt) { rctasks = new rcube_tasklist_ui($.extend(rcmail.env.tasklist_settings, rcmail.env.libcal_settings)); // register button commands rcmail.register_command('newtask', function(){ rctasks.edit_task(null, 'new', {}); }, true); rcmail.register_command('print', function(){ rctasks.print_tasks(); }, true); rcmail.register_command('import', function(){ rctasks.import_tasks(rctasks.selected_list); }, true); rcmail.register_command('edit-task', function(){ rctasks.edit_task(rctasks.selected_task.id, 'edit'); }); rcmail.register_command('delete-task', function(){ rctasks.delete_task(rctasks.selected_task.id); }); rcmail.register_command('add-child-task', function(){ rctasks.add_childtask(rctasks.selected_task.id); }); rcmail.register_command('save-task', function(){ rcmail.env.task_save_func(); }); rcmail.register_command('list-create', function(){ rctasks.list_edit_dialog(null); }, true); rcmail.register_command('list-edit', function(){ rctasks.list_edit_dialog(rctasks.selected_list); }, false); rcmail.register_command('list-delete', function(){ rctasks.list_delete(rctasks.selected_list); }, false); rcmail.register_command('list-remove', function(){ rctasks.list_remove(rctasks.selected_list); }, false); rcmail.register_command('list-showurl', function(){ rctasks.list_showurl(rctasks.selected_list); }, false); rcmail.register_command('export', function(){ rctasks.export_tasks(); }, true); rcmail.register_command('search', function(){ rctasks.quicksearch(); }, true); rcmail.register_command('reset-search', function(){ rctasks.reset_search(); }, true); rcmail.register_command('expand-all', function(){ rctasks.expand_collapse(true); }, true); rcmail.register_command('collapse-all', function(){ rctasks.expand_collapse(false); }, true); rctasks.init(); }); diff --git a/plugins/tasklist/tasklist.php b/plugins/tasklist/tasklist.php index 8dfcba47..c38f4f7d 100644 --- a/plugins/tasklist/tasklist.php +++ b/plugins/tasklist/tasklist.php @@ -1,2480 +1,2501 @@ <?php /** * Tasks plugin for Roundcube webmail * * @version @package_version@ * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2012, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. */ +#[AllowDynamicProperties] class tasklist extends rcube_plugin { const FILTER_MASK_TODAY = 1; const FILTER_MASK_TOMORROW = 2; const FILTER_MASK_WEEK = 4; const FILTER_MASK_LATER = 8; const FILTER_MASK_NODATE = 16; const FILTER_MASK_OVERDUE = 32; const FILTER_MASK_FLAGGED = 64; const FILTER_MASK_COMPLETE = 128; const FILTER_MASK_ASSIGNED = 256; const FILTER_MASK_MYTASKS = 512; const SESSION_KEY = 'tasklist_temp'; public static $filter_masks = array( 'today' => self::FILTER_MASK_TODAY, 'tomorrow' => self::FILTER_MASK_TOMORROW, 'week' => self::FILTER_MASK_WEEK, 'later' => self::FILTER_MASK_LATER, 'nodate' => self::FILTER_MASK_NODATE, 'overdue' => self::FILTER_MASK_OVERDUE, 'flagged' => self::FILTER_MASK_FLAGGED, 'complete' => self::FILTER_MASK_COMPLETE, 'assigned' => self::FILTER_MASK_ASSIGNED, 'mytasks' => self::FILTER_MASK_MYTASKS, ); public $task = '?(?!login|logout).*'; public $allowed_prefs = array('tasklist_sort_col','tasklist_sort_order'); public $rc; public $lib; public $timezone; public $ui; public $home; // declare public to be used in other classes // These are handled by __get() // public $driver; // public $itip; // public $ical; private $collapsed_tasks = array(); private $message_tasks = array(); private $task_titles = array(); /** * Plugin initialization. */ function init() { $this->require_plugin('libcalendaring'); $this->require_plugin('libkolab'); $this->rc = rcube::get_instance(); $this->lib = libcalendaring::get_instance(); $this->register_task('tasks', 'tasklist'); // load plugin configuration $this->load_config(); $this->timezone = $this->lib->timezone; // proceed initialization in startup hook $this->add_hook('startup', array($this, 'startup')); $this->add_hook('user_delete', array($this, 'user_delete')); } /** * Startup hook */ public function startup($args) { // the tasks module can be enabled/disabled by the kolab_auth plugin if ($this->rc->config->get('tasklist_disabled', false) || !$this->rc->config->get('tasklist_enabled', true)) return; // load localizations $this->add_texts('localization/', $args['task'] == 'tasks' && (!$args['action'] || $args['action'] == 'print')); $this->rc->load_language($_SESSION['language'], array('tasks.tasks' => $this->gettext('navtitle'))); // add label for task title if ($args['task'] == 'tasks' && $args['action'] != 'save-pref') { $this->load_driver(); // register calendar actions $this->register_action('index', array($this, 'tasklist_view')); $this->register_action('task', array($this, 'task_action')); $this->register_action('tasklist', array($this, 'tasklist_action')); $this->register_action('counts', array($this, 'fetch_counts')); $this->register_action('fetch', array($this, 'fetch_tasks')); $this->register_action('print', array($this, 'print_tasks')); $this->register_action('dialog-ui', array($this, 'mail_message2task')); $this->register_action('get-attachment', array($this, 'attachment_get')); $this->register_action('upload', array($this, 'attachment_upload')); $this->register_action('import', array($this, 'import_tasks')); $this->register_action('export', array($this, 'export_tasks')); $this->register_action('mailimportitip', array($this, 'mail_import_itip')); $this->register_action('mailimportattach', array($this, 'mail_import_attachment')); $this->register_action('itip-status', array($this, 'task_itip_status')); $this->register_action('itip-remove', array($this, 'task_itip_remove')); $this->register_action('itip-decline-reply', array($this, 'mail_itip_decline_reply')); $this->register_action('itip-delegate', array($this, 'mail_itip_delegate')); $this->add_hook('refresh', array($this, 'refresh')); $this->collapsed_tasks = array_filter(explode(',', $this->rc->config->get('tasklist_collapsed_tasks', ''))); } else if ($args['task'] == 'mail') { if ($args['action'] == 'show' || $args['action'] == 'preview') { if ($this->rc->config->get('tasklist_mail_embed', true)) { $this->add_hook('message_load', array($this, 'mail_message_load')); } $this->add_hook('template_object_messagebody', array($this, 'mail_messagebody_html')); } // add 'Create event' item to message menu if ($this->api->output->type == 'html' && (empty($_GET['_rel']) || $_GET['_rel'] != 'task')) { $this->api->add_content(html::tag('li', array('role' => 'menuitem'), $this->api->output->button(array( 'command' => 'tasklist-create-from-mail', 'label' => 'tasklist.createfrommail', 'type' => 'link', 'classact' => 'icon taskaddlink active', 'class' => 'icon taskaddlink disabled', 'innerclass' => 'icon taskadd', ))), 'messagemenu'); $this->api->output->add_label('tasklist.createfrommail'); } } if (!$this->rc->output->ajax_call && empty($this->rc->output->env['framed'])) { $this->load_ui(); $this->ui->init(); } // add hooks for alarms handling $this->add_hook('pending_alarms', array($this, 'pending_alarms')); $this->add_hook('dismiss_alarms', array($this, 'dismiss_alarms')); } /** * */ private function load_ui() { if (!$this->ui) { require_once($this->home . '/tasklist_ui.php'); $this->ui = new tasklist_ui($this); } } /** * Helper method to load the backend driver according to local config */ private function load_driver() { if (!empty($this->driver)) { return; } $driver_name = $this->rc->config->get('tasklist_driver', 'database'); $driver_class = 'tasklist_' . $driver_name . '_driver'; require_once($this->home . '/drivers/tasklist_driver.php'); require_once($this->home . '/drivers/' . $driver_name . '/' . $driver_class . '.php'); $this->driver = new $driver_class($this); $this->rc->output->set_env('tasklist_driver', $driver_name); } /** * Dispatcher for task-related actions initiated by the client */ public function task_action() { $filter = intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)); $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $rec = rcube_utils::get_input_value('t', rcube_utils::INPUT_POST, true); $oldrec = $rec; - $success = $refresh = $got_msg = false; + $success = $got_msg = false; + $refresh = []; // force notify if hidden + active $itip_send_option = (int)$this->rc->config->get('calendar_itip_send_option', 3); if ($itip_send_option === 1 && empty($rec['_reportpartstat'])) { $rec['_notify'] = 1; } switch ($action) { case 'new': $oldrec = null; $rec = $this->prepare_task($rec); $rec['uid'] = $this->generate_uid(); $temp_id = !empty($rec['tempid']) ? $rec['tempid'] : null; if ($success = $this->driver->create_task($rec)) { $refresh = $this->driver->get_task($rec); if ($temp_id) $refresh['tempid'] = $temp_id; $this->cleanup_task($rec); } break; case 'complete': $complete = intval(rcube_utils::get_input_value('complete', rcube_utils::INPUT_POST)); if (!($rec = $this->driver->get_task($rec))) { break; } $oldrec = $rec; $rec['status'] = $complete ? 'COMPLETED' : ($rec['complete'] > 0 ? 'IN-PROCESS' : 'NEEDS-ACTION'); // sent itip notifications if enabled (no user interaction here) if (($itip_send_option & 1)) { if ($this->is_attendee($rec)) { $rec['_reportpartstat'] = $rec['status']; } else if ($this->is_organizer($rec)) { $rec['_notify'] = 1; } } case 'edit': $oldrec = $this->driver->get_task($rec); $rec = $this->prepare_task($rec); $clone = $this->handle_recurrence($rec, $this->driver->get_task($rec)); if ($success = $this->driver->edit_task($rec)) { $new_task = $this->driver->get_task($rec); $new_task['tempid'] = $rec['id']; $refresh[] = $new_task; $this->cleanup_task($rec); // add clone from recurring task if ($clone && $this->driver->create_task($clone)) { $new_clone = $this->driver->get_task($clone); $new_clone['tempid'] = $clone['id']; $refresh[] = $new_clone; $this->driver->clear_alarms($rec['id']); } // move all childs if list assignment was changed if (!empty($rec['_fromlist']) && !empty($rec['list']) && $rec['_fromlist'] != $rec['list']) { foreach ($this->driver->get_childs(array('id' => $rec['id'], 'list' => $rec['_fromlist']), true) as $cid) { $child = array('id' => $cid, 'list' => $rec['list'], '_fromlist' => $rec['_fromlist']); if ($this->driver->move_task($child)) { $r = $this->driver->get_task($child); if ((bool)($filter & self::FILTER_MASK_COMPLETE) == $this->driver->is_complete($r)) { $r['tempid'] = $cid; $refresh[] = $r; } } } } } break; case 'move': foreach ((array)$rec['id'] as $id) { $r = $rec; $r['id'] = $id; - if ($this->driver->move_task($r)) { - $new_task = $this->driver->get_task($r); + if ($this->driver->move_task($r) && ($new_task = $this->driver->get_task($r))) { $new_task['tempid'] = $id; $refresh[] = $new_task; $success = true; // move all childs, too foreach ($this->driver->get_childs(array('id' => $id, 'list' => $rec['_fromlist']), true) as $cid) { $child = $rec; $child['id'] = $cid; if ($this->driver->move_task($child)) { $r = $this->driver->get_task($child); if ((bool)($filter & self::FILTER_MASK_COMPLETE) == $this->driver->is_complete($r)) { $r['tempid'] = $cid; $refresh[] = $r; } } } } } break; case 'delete': $mode = intval(rcube_utils::get_input_value('mode', rcube_utils::INPUT_POST)); $oldrec = $this->driver->get_task($rec); if ($success = $this->driver->delete_task($rec, false)) { // delete/modify all childs foreach ($this->driver->get_childs($rec, $mode) as $cid) { $child = array('id' => $cid, 'list' => $rec['list']); if ($mode == 1) { // delete all childs if ($this->driver->delete_task($child, false)) { if ($this->driver->undelete) $_SESSION['tasklist_undelete'][$rec['id']][] = $cid; } else $success = false; } else { $child['parent_id'] = strval($oldrec['parent_id']); $this->driver->edit_task($child); } } // update parent task to adjust list of children if (!empty($oldrec['parent_id'])) { $parent = array('id' => $oldrec['parent_id'], 'list' => $rec['list']); - if ($parent = $this->driver->get_task()) { + if ($parent = $this->driver->get_task($parent)) { $refresh[] = $parent; } } } if (!$success) $this->rc->output->command('plugin.reload_data'); break; case 'undelete': if ($success = $this->driver->undelete_task($rec)) { $refresh[] = $this->driver->get_task($rec); foreach ((array)$_SESSION['tasklist_undelete'][$rec['id']] as $cid) { if ($this->driver->undelete_task($rec)) { $refresh[] = $this->driver->get_task($rec); } } } break; case 'collapse': foreach (explode(',', $rec['id']) as $rec_id) { if (intval(rcube_utils::get_input_value('collapsed', rcube_utils::INPUT_GPC))) { $this->collapsed_tasks[] = $rec_id; } else { $i = array_search($rec_id, $this->collapsed_tasks); if ($i !== false) unset($this->collapsed_tasks[$i]); } } $this->rc->user->save_prefs(array('tasklist_collapsed_tasks' => join(',', array_unique($this->collapsed_tasks)))); return; // avoid further actions case 'rsvp': $status = rcube_utils::get_input_value('status', rcube_utils::INPUT_GPC); $noreply = intval(rcube_utils::get_input_value('noreply', rcube_utils::INPUT_GPC)) || $status == 'needs-action'; $task = $this->driver->get_task($rec); $task['attendees'] = $rec['attendees']; $task['_type'] = 'task'; // send invitation to delegatee + add it as attendee if ($status == 'delegated' && $rec['to']) { $itip = $this->load_itip(); if ($itip->delegate_to($task, $rec['to'], (bool)$rec['rsvp'])) { $this->rc->output->show_message('tasklist.itipsendsuccess', 'confirmation'); $refresh[] = $task; $noreply = false; } } $rec = $task; if ($success = $this->driver->edit_task($rec)) { if (!$noreply) { // let the reply clause further down send the iTip message $rec['_reportpartstat'] = $status; } } break; case 'changelog': $data = $this->driver->get_task_changelog($rec); if (is_array($data) && !empty($data)) { $lib = $this->lib; $dtformat = $this->rc->config->get('date_format') . ' ' . $this->rc->config->get('time_format'); array_walk($data, function(&$change) use ($lib, $dtformat) { if ($change['date']) { $dt = $lib->adjust_timezone($change['date']); if ($dt instanceof DateTime) { $change['date'] = $this->rc->format_date($dt, $dtformat, false); } } }); $this->rc->output->command('plugin.task_render_changelog', $data); } else { $this->rc->output->command('plugin.task_render_changelog', false); } $got_msg = true; break; case 'diff': $data = $this->driver->get_task_diff($rec, $rec['rev1'], $rec['rev2']); if (is_array($data)) { // convert some properties, similar to self::_client_event() $lib = $this->lib; $date_format = $this->rc->config->get('date_format', 'Y-m-d'); $time_format = $this->rc->config->get('time_format', 'H:i'); array_walk($data['changes'], function(&$change, $i) use ($lib, $date_format, $time_format) { // convert date cols if (in_array($change['property'], array('date','start','created','changed'))) { if (!empty($change['old'])) { $dtformat = strlen($change['old']) == 10 ? $date_format : $date_format . ' ' . $time_format; $change['old_'] = $lib->adjust_timezone($change['old'], strlen($change['old']) == 10)->format($dtformat); } if (!empty($change['new'])) { $dtformat = strlen($change['new']) == 10 ? $date_format : $date_format . ' ' . $time_format; $change['new_'] = $lib->adjust_timezone($change['new'], strlen($change['new']) == 10)->format($dtformat); } } // create textual representation for alarms and recurrence if ($change['property'] == 'alarms') { if (is_array($change['old'])) $change['old_'] = libcalendaring::alarm_text($change['old']); if (is_array($change['new'])) $change['new_'] = libcalendaring::alarm_text(array_merge((array)$change['old'], $change['new'])); } if ($change['property'] == 'recurrence') { if (is_array($change['old'])) $change['old_'] = $lib->recurrence_text($change['old']); if (is_array($change['new'])) $change['new_'] = $lib->recurrence_text(array_merge((array)$change['old'], $change['new'])); } if ($change['property'] == 'complete') { $change['old_'] = intval($change['old']) . '%'; $change['new_'] = intval($change['new']) . '%'; } if ($change['property'] == 'attachments') { if (is_array($change['old'])) $change['old']['classname'] = rcube_utils::file2class($change['old']['mimetype'], $change['old']['name']); if (is_array($change['new'])) { $change['new'] = array_merge((array)$change['old'], $change['new']); $change['new']['classname'] = rcube_utils::file2class($change['new']['mimetype'], $change['new']['name']); } } // resolve parent_id to the refered task title for display if ($change['property'] == 'parent_id') { $change['property'] = 'parent-title'; if (!empty($change['old']) && ($old_parent = $this->driver->get_task(array('id' => $change['old'], 'list' => $rec['list'])))) { $change['old_'] = $old_parent['title']; } if (!empty($change['new']) && ($new_parent = $this->driver->get_task(array('id' => $change['new'], 'list' => $rec['list'])))) { $change['new_'] = $new_parent['title']; } } // compute a nice diff of description texts if ($change['property'] == 'description') { $change['diff_'] = libkolab::html_diff($change['old'], $change['new']); } }); $this->rc->output->command('plugin.task_show_diff', $data); } else { $this->rc->output->command('display_message', $this->gettext('objectdiffnotavailable'), 'error'); } $got_msg = true; break; case 'show': if ($rec = $this->driver->get_task_revison($rec, $rec['rev'])) { $this->encode_task($rec); $rec['readonly'] = 1; $this->rc->output->command('plugin.task_show_revision', $rec); } else { $this->rc->output->command('display_message', $this->gettext('objectnotfound'), 'error'); } $got_msg = true; break; case 'restore': if ($success = $this->driver->restore_task_revision($rec, $rec['rev'])) { $refresh = $this->driver->get_task($rec); $this->rc->output->command('display_message', $this->gettext(array('name' => 'objectrestoresuccess', 'vars' => array('rev' => $rec['rev']))), 'confirmation'); $this->rc->output->command('plugin.close_history_dialog'); } else { $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error'); } $got_msg = true; break; } if ($success) { $this->rc->output->show_message('successfullysaved', 'confirmation'); $this->update_counts($oldrec, $refresh); } else if (!$got_msg) { $this->rc->output->show_message('tasklist.errorsaving', 'error'); } // send out notifications if ($success && !empty($rec['_notify']) && ($rec['attendees'] || $oldrec['attendees'])) { // make sure we have the complete record $task = $action == 'delete' ? $oldrec : $this->driver->get_task($rec); // only notify if data really changed (TODO: do diff check on client already) if (!$oldrec || $action == 'delete' || self::task_diff($task, $oldrec)) { $sent = $this->notify_attendees($task, $oldrec, $action, $rec['_comment']); if ($sent > 0) $this->rc->output->show_message('tasklist.itipsendsuccess', 'confirmation'); else if ($sent < 0) $this->rc->output->show_message('tasklist.errornotifying', 'error'); } } if ($success && !empty($rec['_reportpartstat']) && $rec['_reportpartstat'] != 'NEEDS-ACTION') { // get the full record after update if (!$task) { $task = $this->driver->get_task($rec); } // send iTip REPLY with the updated partstat if ($task['organizer'] && ($idx = $this->is_attendee($task)) !== false) { $sender = $task['attendees'][$idx]; $status = strtolower($sender['status']); if (!empty($_POST['comment'])) $task['comment'] = rcube_utils::get_input_value('comment', rcube_utils::INPUT_POST); $itip = $this->load_itip(); $itip->set_sender_email($sender['email']); - if ($itip->send_itip_message($this->to_libcal($task), 'REPLY', $task['organizer'], 'itipsubject' . $status, 'itipmailbody' . $status)) + if ($itip->send_itip_message($this->to_libcal($task), 'REPLY', $task['organizer'], 'itipsubject' . $status, 'itipmailbody' . $status)) { $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $task['organizer']['name'] ?: $task['organizer']['email']))), 'confirmation'); - else + } + else { $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); + } } } // unlock client $this->rc->output->command('plugin.unlock_saving', $success); - if ($refresh) { + if (!empty($refresh)) { if (!empty($refresh['id'])) { $this->encode_task($refresh); } else if (is_array($refresh)) { - foreach ($refresh as $i => $r) + foreach ($refresh as $i => $r) { $this->encode_task($refresh[$i]); + } } $this->rc->output->command('plugin.update_task', $refresh); } else if ($success && ($action == 'delete' || $action == 'undelete')) { $this->rc->output->command('plugin.refresh_tagcloud'); } } /** * Load iTIP functions */ private function load_itip() { if (empty($this->itip)) { require_once __DIR__ . '/../libcalendaring/lib/libcalendaring_itip.php'; $this->itip = new libcalendaring_itip($this, 'tasklist'); $this->itip->set_rsvp_actions(array('accepted','declined','delegated')); $this->itip->set_rsvp_status(array('accepted','tentative','declined','delegated','in-process','completed')); } return $this->itip; } /** * repares new/edited task properties before save */ private function prepare_task($rec) { // try to be smart and extract date from raw input if (!empty($rec['raw'])) { foreach (array('today','tomorrow','sunday','monday','tuesday','wednesday','thursday','friday','saturday','sun','mon','tue','wed','thu','fri','sat') as $word) { $locwords[] = '/^' . preg_quote(mb_strtolower($this->gettext($word))) . '\b/i'; $normwords[] = $word; $datewords[] = $word; } foreach (array('jan','feb','mar','apr','may','jun','jul','aug','sep','oct','now','dec') as $month) { $locwords[] = '/(' . preg_quote(mb_strtolower($this->gettext('long'.$month))) . '|' . preg_quote(mb_strtolower($this->gettext($month))) . ')\b/i'; $normwords[] = $month; $datewords[] = $month; } foreach (array('on','this','next','at') as $word) { $fillwords[] = preg_quote(mb_strtolower($this->gettext($word))); $fillwords[] = $word; } $raw = trim($rec['raw']); $date_str = ''; // translate localized keywords $raw = preg_replace('/^(' . join('|', $fillwords) . ')\s*/i', '', $raw); $raw = preg_replace($locwords, $normwords, $raw); // find date pattern $date_pattern = '!^(\d+[./-]\s*)?((?:\d+[./-])|' . join('|', $datewords) . ')\.?(\s+\d{4})?[:;,]?\s+!i'; if (preg_match($date_pattern, $raw, $m)) { $date_str .= $m[1] . $m[2] . $m[3]; $raw = preg_replace(array($date_pattern, '/^(' . join('|', $fillwords) . ')\s*/i'), '', $raw); // add year to date string if ($m[1] && !$m[3]) $date_str .= date('Y'); } // find time pattern $time_pattern = '/^(\d+([:.]\d+)?(\s*[hapm.]+)?),?\s+/i'; if (preg_match($time_pattern, $raw, $m)) { $has_time = true; $date_str .= ($date_str ? ' ' : 'today ') . $m[1]; $raw = preg_replace($time_pattern, '', $raw); } // yes, raw input matched a (valid) date if (strlen($date_str) && strtotime($date_str) && ($date = new DateTime($date_str, $this->timezone))) { $rec['date'] = $date->format('Y-m-d'); if ($has_time) $rec['time'] = $date->format('H:i'); $rec['title'] = $raw; } else $rec['title'] = $rec['raw']; } // normalize input from client if (isset($rec['complete'])) { $rec['complete'] = floatval($rec['complete']); if ($rec['complete'] > 1) $rec['complete'] /= 100; } if (isset($rec['flagged'])) $rec['flagged'] = intval($rec['flagged']); // fix for garbage input if ($rec['description'] == 'null') $rec['description'] = ''; foreach ($rec as $key => $val) { if ($val === 'null') $rec[$key] = null; } if (!empty($rec['date'])) { $this->normalize_dates($rec, 'date', 'time'); } if (!empty($rec['startdate'])) { $this->normalize_dates($rec, 'startdate', 'starttime'); } // convert tags to array, filter out empty entries if (isset($rec['tags']) && !is_array($rec['tags'])) { $rec['tags'] = array_filter((array)$rec['tags']); } // convert the submitted alarm values if (!empty($rec['valarms'])) { $valarms = array(); foreach (libcalendaring::from_client_alarms($rec['valarms']) as $alarm) { // alarms can only work with a date (either task start, due or absolute alarm date) if (is_a($alarm['trigger'], 'DateTime') || $rec['date'] || $rec['startdate']) $valarms[] = $alarm; } $rec['valarms'] = $valarms; } // convert the submitted recurrence settings - if (is_array($rec['recurrence'])) { + if (isset($rec['recurrence']) && is_array($rec['recurrence'])) { $refdate = null; if (!empty($rec['date'])) { - $refdate = new DateTime($rec['date'] . ' ' . $rec['time'], $this->timezone); + $refdate = new DateTime($rec['date'] . ' ' . ($rec['time'] ?? ''), $this->timezone); } else if (!empty($rec['startdate'])) { - $refdate = new DateTime($rec['startdate'] . ' ' . $rec['starttime'], $this->timezone); + $refdate = new DateTime($rec['startdate'] . ' ' . ($rec['starttime'] ?? ''), $this->timezone); } if ($refdate) { $rec['recurrence'] = $this->lib->from_client_recurrence($rec['recurrence'], $refdate); // translate count into an absolute end date. // why? because when shifting completed tasks to the next occurrence, // the initial start date to count from gets lost. if (!empty($rec['recurrence']['COUNT'])) { $engine = libcalendaring::get_recurrence(); $engine->init($rec['recurrence'], $refdate); if ($until = $engine->end()) { $rec['recurrence']['UNTIL'] = $until; unset($rec['recurrence']['COUNT']); } } } else { // recurrence requires a reference date $rec['recurrence'] = ''; } } $handler = new kolab_attachments_handler(); $rec['attachments'] = $handler->attachments_set(self::SESSION_KEY, $rec['id'], $rec['attachments'] ?? []); // convert link references into simple URIs if (array_key_exists('links', $rec)) { $rec['links'] = array_map(function($link) { return is_array($link) ? $link['uri'] : strval($link); }, (array)$rec['links']); } // convert invalid data if (isset($rec['attendees']) && !is_array($rec['attendees'])) { $rec['attendees'] = array(); } if (!empty($rec['attendees'])) { foreach ((array) $rec['attendees'] as $i => $attendee) { - if (is_string($attendee['rsvp'])) { + if (isset($attendee['rsvp']) && is_string($attendee['rsvp'])) { $rec['attendees'][$i]['rsvp'] = $attendee['rsvp'] == 'true' || $attendee['rsvp'] == '1'; } } } // copy the task status to my attendee partstat if (!empty($rec['_reportpartstat'])) { if (($idx = $this->is_attendee($rec)) !== false) { if (!($rec['_reportpartstat'] == 'NEEDS-ACTION' && $rec['attendees'][$idx]['status'] == 'ACCEPTED')) $rec['attendees'][$idx]['status'] = $rec['_reportpartstat']; else unset($rec['_reportpartstat']); } } // set organizer from identity selector if ((isset($rec['_identity']) || (!empty($rec['attendees']) && empty($rec['organizer']))) && ($identity = $this->rc->user->get_identity($rec['_identity']))) { $rec['organizer'] = array('name' => $identity['name'], 'email' => $identity['email']); } if (is_numeric($rec['id']) && $rec['id'] < 0) unset($rec['id']); return $rec; } /** * Utility method to convert a tasks date/time values into a normalized format */ private function normalize_dates(&$rec, $date_key, $time_key) { try { // parse date from user format (#2801) $date_format = $this->rc->config->get(empty($rec[$time_key]) ? 'date_format' : 'date_long', 'Y-m-d'); - $date = DateTime::createFromFormat($date_format, trim($rec[$date_key] . ' ' . $rec[$time_key]), $this->timezone); + $date = DateTime::createFromFormat($date_format, trim(($rec[$date_key] ?? '') . ' ' . ($rec[$time_key] ?? '')), $this->timezone); // fall back to default strtotime logic if (empty($date)) { - $date = new DateTime($rec[$date_key] . ' ' . $rec[$time_key], $this->timezone); + $date = new DateTime(($rec[$date_key] ?? '') . ' ' . ($rec[$time_key] ?? ''), $this->timezone); } $rec[$date_key] = $date->format('Y-m-d'); - if (!empty($rec[$time_key])) + if (!empty($rec[$time_key])) { $rec[$time_key] = $date->format('H:i'); + } return true; } catch (Exception $e) { $rec[$date_key] = $rec[$time_key] = null; } return false; } /** * Releases some resources after successful save */ private function cleanup_task(&$rec) { $handler = new kolab_attachments_handler(); $handler->attachments_cleanup(self::SESSION_KEY); } /** * When flagging a recurring task as complete, * clone it and shift dates to the next occurrence */ private function handle_recurrence(&$rec, $old) { $clone = null; - if ($this->driver->is_complete($rec) && $old && !$this->driver->is_complete($old) && is_array($rec['recurrence'])) { + if ($this->driver->is_complete($rec) && $old && !$this->driver->is_complete($old) && !empty($rec['recurrence'])) { $engine = libcalendaring::get_recurrence(); $rrule = $rec['recurrence']; $updates = array(); // compute the next occurrence of date attributes foreach (array('date'=>'time', 'startdate'=>'starttime') as $date_key => $time_key) { if (empty($rec[$date_key])) continue; - $date = new DateTime($rec[$date_key] . ' ' . $rec[$time_key], $this->timezone); + $date = new DateTime($rec[$date_key] . ' ' . ($rec[$time_key] ?? ''), $this->timezone); $engine->init($rrule, $date); if ($next = $engine->next_start()) { $updates[$date_key] = $next->format('Y-m-d'); - if (!empty($rec[$time_key])) + if (!empty($rec[$time_key])) { $updates[$time_key] = $next->format('H:i'); + } } } // shift absolute alarm dates if (!empty($updates) && is_array($rec['valarms'])) { $updates['valarms'] = array(); unset($rrule['UNTIL'], $rrule['COUNT']); // make recurrence rule unlimited foreach ($rec['valarms'] as $i => $alarm) { if ($alarm['trigger'] instanceof DateTime) { $engine->init($rrule, $alarm['trigger']); if ($next = $engine->next_start()) { $alarm['trigger'] = $next; } } $updates['valarms'][$i] = $alarm; } } if (!empty($updates)) { // clone task to save a completed copy $clone = $rec; $clone['uid'] = $this->generate_uid(); $clone['parent_id'] = $rec['id']; unset($clone['id'], $clone['recurrence'], $clone['attachments']); // update the task but unset completed flag $rec = array_merge($rec, $updates); $rec['complete'] = $old['complete']; $rec['status'] = $old['status']; } } return $clone; } /** * Send out an invitation/notification to all task attendees */ private function notify_attendees($task, $old, $action = 'edit', $comment = null) { if ($action == 'delete' || ($task['status'] == 'CANCELLED' && $old['status'] != $task['status'])) { $task['cancelled'] = true; $is_cancelled = true; } $itip = $this->load_itip(); $emails = $this->lib->get_user_emails(); $itip_notify = (int)$this->rc->config->get('calendar_itip_send_option', 3); // add comment to the iTip attachment $task['comment'] = $comment; // needed to generate VTODO instead of VEVENT entry $task['_type'] = 'task'; // compose multipart message using PEAR:Mail_Mime $method = $action == 'delete' ? 'CANCEL' : 'REQUEST'; $object = $this->to_libcal($task); $message = $itip->compose_itip_message($object, $method, $task['sequence'] > $old['sequence']); // list existing attendees from the $old task $old_attendees = array(); foreach ((array)$old['attendees'] as $attendee) { $old_attendees[] = $attendee['email']; } // send to every attendee $sent = 0; $current = array(); foreach ((array)$task['attendees'] as $attendee) { $current[] = strtolower($attendee['email']); // skip myself for obvious reasons if (!$attendee['email'] || in_array(strtolower($attendee['email']), $emails)) { continue; } // skip if notification is disabled for this attendee if ($attendee['noreply'] && $itip_notify & 2) { continue; } // skip if this attendee has delegated and set RSVP=FALSE if ($attendee['status'] == 'DELEGATED' && $attendee['rsvp'] === false) { continue; } // which template to use for mail text $is_new = !in_array($attendee['email'], $old_attendees); $is_rsvp = $is_new || $task['sequence'] > $old['sequence']; $bodytext = $is_cancelled ? 'itipcancelmailbody' : ($is_new ? 'invitationmailbody' : 'itipupdatemailbody'); $subject = $is_cancelled ? 'itipcancelsubject' : ($is_new ? 'invitationsubject' : ($task['title'] ? 'itipupdatesubject' : 'itipupdatesubjectempty')); // finally send the message if ($itip->send_itip_message($object, $method, $attendee, $subject, $bodytext, $message, $is_rsvp)) $sent++; else $sent = -100; } // send CANCEL message to removed attendees foreach ((array)$old['attendees'] as $attendee) { if (!$attendee['email'] || in_array(strtolower($attendee['email']), $current)) { continue; } $vtodo = $this->to_libcal($old); $vtodo['cancelled'] = $is_cancelled; $vtodo['attendees'] = array($attendee); $vtodo['comment'] = $comment; if ($itip->send_itip_message($vtodo, 'CANCEL', $attendee, 'itipcancelsubject', 'itipcancelmailbody')) $sent++; else $sent = -100; } return $sent; } /** * Compare two task objects and return differing properties * * @param array Event A * @param array Event B * @return array List of differing task properties */ public static function task_diff($a, $b) { $diff = array(); $ignore = array('changed' => 1, 'attachments' => 1); foreach (array_unique(array_merge(array_keys($a), array_keys($b))) as $key) { if (!$ignore[$key] && $a[$key] != $b[$key]) $diff[] = $key; } // only compare number of attachments if (count($a['attachments']) != count($b['attachments'])) $diff[] = 'attachments'; return $diff; } /** * Dispatcher for tasklist actions initiated by the client */ public function tasklist_action() { $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $list = rcube_utils::get_input_value('l', rcube_utils::INPUT_GPC, true); $success = false; unset($list['_token']); if (isset($list['showalarms'])) { $list['showalarms'] = intval($list['showalarms']); } switch ($action) { case 'form-new': case 'form-edit': $this->load_ui(); echo $this->ui->tasklist_editform($action, $list); exit; case 'new': $list += array('showalarms' => true, 'active' => true, 'editable' => true); if ($insert_id = $this->driver->create_list($list)) { $list['id'] = $insert_id; if (empty($list['_reload'])) { $this->load_ui(); $list['html'] = $this->ui->tasklist_list_item($insert_id, $list, $jsenv); $list += (array)$jsenv[$insert_id]; } $this->rc->output->command('plugin.insert_tasklist', $list); $success = true; } break; case 'edit': if ($newid = $this->driver->edit_list($list)) { $list['oldid'] = $list['id']; $list['id'] = $newid; $this->rc->output->command('plugin.update_tasklist', $list); $success = true; } break; case 'subscribe': $success = $this->driver->subscribe_list($list); break; case 'delete': if (($success = $this->driver->delete_list($list))) $this->rc->output->command('plugin.destroy_tasklist', $list); break; case 'search': $this->load_ui(); $results = array(); $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); foreach ((array)$this->driver->search_lists($query, $source) as $id => $prop) { $editname = $prop['editname']; unset($prop['editname']); // force full name to be displayed $prop['active'] = false; // let the UI generate HTML and CSS representation for this calendar $html = $this->ui->tasklist_list_item($id, $prop, $jsenv); $prop += (array)$jsenv[$id]; $prop['editname'] = $editname; $prop['html'] = $html; $results[] = $prop; } // report more results available if (!empty($this->driver->search_more_results)) { $this->rc->output->show_message('autocompletemore', 'notice'); } $this->rc->output->command('multi_thread_http_response', $results, rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC)); return; } if ($success) { $this->rc->output->show_message('successfullysaved', 'confirmation'); } else { $this->rc->output->show_message('tasklist.errorsaving', 'error'); } $this->rc->output->command('plugin.unlock_saving'); } /** * Get counts for active tasks divided into different selectors */ public function fetch_counts() { if (isset($_REQUEST['lists'])) { $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC); } else { foreach ($this->driver->get_lists() as $list) { if (!empty($list['active'])) { $lists[] = $list['id']; } } } $counts = $this->driver->count_tasks($lists); $this->rc->output->command('plugin.update_counts', $counts); } /** * Adjust the cached counts after changing a task */ public function update_counts($oldrec, $newrec) { // rebuild counts until this function is finally implemented $this->fetch_counts(); // $this->rc->output->command('plugin.update_counts', $counts); } /** * */ public function fetch_tasks() { $mask = intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)); $search = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC); $filter = array('mask' => $mask, 'search' => $search); $data = $this->tasks_data($this->driver->list_tasks($filter, $lists)); $this->rc->output->command('plugin.data_ready', array( 'filter' => $mask, 'lists' => $lists, 'search' => $search, 'data' => $data, 'tags' => $this->driver->get_tags(), )); } /** * Handler for printing calendars */ public function print_tasks() { // Add CSS stylesheets to the page header $skin_path = $this->local_skin_path(); $this->include_stylesheet($skin_path . '/print.css'); $this->include_script('tasklist.js'); $this->rc->output->add_handlers(array( 'plugin.tasklist_print' => array($this, 'print_tasks_list'), )); $this->rc->output->set_pagetitle($this->gettext('print')); $this->rc->output->send('tasklist.print'); } /** * Handler for printing calendars */ public function print_tasks_list($attrib) { $mask = intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)); $search = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC); $filter = array('mask' => $mask, 'search' => $search); $data = $this->tasks_data($this->driver->list_tasks($filter, $lists)); // we'll build the tasks table in javascript on page load // where we have sorting methods, etc. $this->rc->output->set_env('tasks', $data); $this->rc->output->set_env('filtermask', $mask); return $this->ui->tasks_resultview($attrib); } /** * Prepare and sort the given task records to be sent to the client */ private function tasks_data($records) { $data = $this->task_tree = $this->task_titles = array(); foreach ($records as $rec) { if (!empty($rec['parent_id'])) { $this->task_tree[$rec['id']] = $rec['parent_id']; } $this->encode_task($rec); $data[] = $rec; } // assign hierarchy level indicators for later sorting array_walk($data, array($this, 'task_walk_tree')); return $data; } /** * Prepare the given task record before sending it to the client */ private function encode_task(&$rec) { - $rec['mask'] = $this->filter_mask($rec); - $rec['flagged'] = intval($rec['flagged']); - $rec['complete'] = floatval($rec['complete']); + $rec['mask'] = $this->filter_mask($rec); + $rec['flagged'] = intval($rec['flagged'] ?? 0); + $rec['complete'] = floatval($rec['complete'] ?? 0); - if (is_object($rec['created'])) { + if (!empty($rec['created']) && is_object($rec['created'])) { $rec['created_'] = $this->rc->format_date($rec['created']); $rec['created'] = $rec['created']->format('U'); } - if (is_object($rec['changed'])) { + if (!empty($rec['changed']) && is_object($rec['changed'])) { $rec['changed_'] = $this->rc->format_date($rec['changed']); $rec['changed'] = $rec['changed']->format('U'); } else { $rec['changed'] = null; } - if ($rec['date']) { + if (!empty($rec['date'])) { try { - $date = new DateTime($rec['date'] . ' ' . $rec['time'], $this->timezone); + $date = new DateTime($rec['date'] . ' ' . ($rec['time'] ?? ''), $this->timezone); $rec['datetime'] = intval($date->format('U')); $rec['date'] = $date->format($this->rc->config->get('date_format', 'Y-m-d')); $rec['_hasdate'] = 1; } catch (Exception $e) { $rec['date'] = $rec['datetime'] = null; } } else { $rec['date'] = $rec['datetime'] = null; $rec['_hasdate'] = 0; } - if ($rec['startdate']) { + if (!empty($rec['startdate'])) { try { - $date = new DateTime($rec['startdate'] . ' ' . $rec['starttime'], $this->timezone); + $date = new DateTime($rec['startdate'] . ' ' . ($rec['starttime'] ?? ''), $this->timezone); $rec['startdatetime'] = intval($date->format('U')); $rec['startdate'] = $date->format($this->rc->config->get('date_format', 'Y-m-d')); } catch (Exception $e) { $rec['startdate'] = $rec['startdatetime'] = null; } } if (!empty($rec['valarms'])) { $rec['alarms_text'] = libcalendaring::alarms_text($rec['valarms']); $rec['valarms'] = libcalendaring::to_client_alarms($rec['valarms']); } if (!empty($rec['recurrence'])) { $rec['recurrence_text'] = $this->lib->recurrence_text($rec['recurrence']); - $rec['recurrence'] = $this->lib->to_client_recurrence($rec['recurrence'], $rec['time'] || $rec['starttime']); + $rec['recurrence'] = $this->lib->to_client_recurrence($rec['recurrence'], !empty($rec['time']) || !empty($rec['starttime'])); } if (!empty($rec['attachments'])) { foreach ((array) $rec['attachments'] as $k => $attachment) { $rec['attachments'][$k]['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); + unset($rec['attachments'][$k]['data']); } } // convert link URIs references into structs if (array_key_exists('links', $rec)) { foreach ((array) $rec['links'] as $i => $link) { if (strpos($link, 'imap://') === 0 && ($msgref = $this->driver->get_message_reference($link, 'task'))) { $rec['links'][$i] = $msgref; } } } // Convert HTML description into plain text if ($this->is_html($rec)) { $h2t = new rcube_html2text($rec['description'], false, true, 0); $rec['description'] = $h2t->get_text(); } - if (!is_array($rec['tags'])) - $rec['tags'] = (array)$rec['tags']; + if (!isset($rec['tags']) || !is_array($rec['tags'])) { + $rec['tags'] = (array) ($rec['tags'] ?? ''); + } + sort($rec['tags'], SORT_LOCALE_STRING); - if (in_array($rec['id'], $this->collapsed_tasks)) - $rec['collapsed'] = true; + if (in_array($rec['id'], $this->collapsed_tasks)) { + $rec['collapsed'] = true; + } - if (empty($rec['parent_id'])) + if (empty($rec['parent_id'])) { $rec['parent_id'] = null; + } - $this->task_titles[$rec['id']] = $rec['title']; + $this->task_titles[$rec['id']] = $rec['title'] ?? ''; } /** * Determine whether the given task description is HTML formatted */ private function is_html($task) { // check for opening and closing <html> or <body> tags - return (preg_match('/<(html|body)(\s+[a-z]|>)/', $task['description'], $m) && strpos($task['description'], '</'.$m[1].'>') > 0); + return isset($task['description']) + && preg_match('/<(html|body)(\s+[a-z]|>)/', $task['description'], $m) + && strpos($task['description'], '</' . $m[1] . '>') > 0; } /** * Callback function for array_walk over all tasks. * Sets tree depth and parent titles */ private function task_walk_tree(&$rec) { $rec['_depth'] = 0; $parent_titles = array(); $parent_id = isset($this->task_tree[$rec['id']]) ? $this->task_tree[$rec['id']] : null; while ($parent_id) { $rec['_depth']++; if (isset($this->task_titles[$parent_id])) { array_unshift($parent_titles, $this->task_titles[$parent_id]); } $parent_id = isset($this->task_tree[$parent_id]) ? $this->task_tree[$parent_id] : null; } if (count($parent_titles)) { $rec['parent_title'] = join(' » ', array_filter($parent_titles)); } } /** * Compute the filter mask of the given task * * @param array Hash array with Task record properties * @return int Filter mask */ public function filter_mask($rec) { static $today, $today_date, $tomorrow, $weeklimit; if (!$today) { - $today_date = new DateTime('now', $this->timezone); + $today_date = new libcalendaring_datetime('now', $this->timezone); $today = $today_date->format('Y-m-d'); - $tomorrow_date = new DateTime('now + 1 day', $this->timezone); + $tomorrow_date = new libcalendaring_datetime('now + 1 day', $this->timezone); $tomorrow = $tomorrow_date->format('Y-m-d'); // In Kolab-mode we hide "Next 7 days" filter, which means // "Later" should catch tasks with date after tomorrow (#5353) if ($this->rc->output->get_env('tasklist_driver') == 'kolab') { $weeklimit = $tomorrow; } else { $week_date = new DateTime('now + 7 days', $this->timezone); $weeklimit = $week_date->format('Y-m-d'); } } $mask = 0; - $start = $rec['startdate'] ?: '1900-00-00'; - $duedate = $rec['date'] ?: '3000-00-00'; + $start = !empty($rec['startdate']) ? $rec['startdate'] : '1900-00-00'; + $duedate = !empty($rec['date']) ? $rec['date'] : '3000-00-00'; - if ($rec['flagged']) + if (!empty($rec['flagged'])) { $mask |= self::FILTER_MASK_FLAGGED; - if ($this->driver->is_complete($rec)) + } + if ($this->driver->is_complete($rec)) { $mask |= self::FILTER_MASK_COMPLETE; + } - if (empty($rec['date'])) + if (empty($rec['date'])) { $mask |= self::FILTER_MASK_NODATE; - else if ($rec['date'] < $today) + } + else if ($rec['date'] < $today) { $mask |= self::FILTER_MASK_OVERDUE; + } if (empty($rec['recurrence']) || $duedate < $today || $start > $weeklimit) { - if ($duedate <= $today || ($rec['startdate'] && $start <= $today)) + if ($duedate <= $today || (!empty($rec['startdate']) && $start <= $today)) $mask |= self::FILTER_MASK_TODAY; else if (($start > $today && $start <= $tomorrow) || ($duedate > $today && $duedate <= $tomorrow)) $mask |= self::FILTER_MASK_TOMORROW; else if (($start > $tomorrow && $start <= $weeklimit) || ($duedate > $tomorrow && $duedate <= $weeklimit)) $mask |= self::FILTER_MASK_WEEK; else if ($start > $weeklimit || $duedate > $weeklimit) $mask |= self::FILTER_MASK_LATER; } - else if ($rec['startdate'] || $rec['date']) { - $date = new DateTime($rec['startdate'] ?: $rec['date'], $this->timezone); + else if (!empty($rec['startdate']) || !empty($rec['date'])) { + $date = new libcalendaring_datetime(!empty($rec['startdate']) ? $rec['startdate'] : $rec['date'], $this->timezone); // set safe recurrence start while ($date->format('Y-m-d') >= $today) { switch ($rec['recurrence']['FREQ']) { case 'DAILY': $date = clone $today_date; $date->sub(new DateInterval('P1D')); break; case 'WEEKLY': $date->sub(new DateInterval('P7D')); break; case 'MONTHLY': $date->sub(new DateInterval('P1M')); break; case 'YEARLY': $date->sub(new DateInterval('P1Y')); break; default; break 2; } } $date->_dateonly = true; $engine = libcalendaring::get_recurrence(); $engine->init($rec['recurrence'], $date); // check task occurrences (stop next week) // FIXME: is there a faster way of doing this? while ($date = $engine->next_start()) { $date = $date->format('Y-m-d'); // break iteration asap if ($date > $duedate || ($mask & self::FILTER_MASK_LATER)) { break; } if ($date == $today) { $mask |= self::FILTER_MASK_TODAY; } else if ($date == $tomorrow) { $mask |= self::FILTER_MASK_TOMORROW; } else if ($date > $tomorrow && $date <= $weeklimit) { $mask |= self::FILTER_MASK_WEEK; } else if ($date > $weeklimit) { $mask |= self::FILTER_MASK_LATER; break; } } } // add masks for assigned tasks - if ($this->is_organizer($rec) && !empty($rec['attendees']) && $this->is_attendee($rec) === false) + if ($this->is_organizer($rec) && !empty($rec['attendees']) && $this->is_attendee($rec) === false) { $mask |= self::FILTER_MASK_ASSIGNED; - else if (/*empty($rec['attendees']) ||*/ $this->is_attendee($rec) !== false) + } + else if (/*empty($rec['attendees']) ||*/ $this->is_attendee($rec) !== false) { $mask |= self::FILTER_MASK_MYTASKS; + } return $mask; } /** * Determine whether the current user is an attendee of the given task */ public function is_attendee($task) { $emails = $this->lib->get_user_emails(); - foreach ((array)$task['attendees'] as $i => $attendee) { + foreach ((array) ($task['attendees'] ?? []) as $i => $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { return $i; } } return false; } /** * Determine whether the current user is the organizer of the given task */ public function is_organizer($task) { $emails = $this->lib->get_user_emails(); return (empty($task['organizer']) || in_array(strtolower($task['organizer']['email']), $emails)); } /******* UI functions ********/ /** * Render main view of the tasklist task */ public function tasklist_view() { $this->ui->init(); $this->ui->init_templates(); // set autocompletion env $this->rc->output->set_env('autocomplete_threads', (int)$this->rc->config->get('autocomplete_threads', 0)); $this->rc->output->set_env('autocomplete_max', (int)$this->rc->config->get('autocomplete_max', 15)); $this->rc->output->set_env('autocomplete_min_length', $this->rc->config->get('autocomplete_min_length')); $this->rc->output->add_label('autocompletechars', 'autocompletemore', 'delete', 'close'); $this->rc->output->set_pagetitle($this->gettext('navtitle')); $this->rc->output->send('tasklist.mainview'); } /** * Handler for keep-alive requests * This will check for updated data in active lists and sync them to the client */ public function refresh($attr) { // refresh the entire list every 10th time to also sync deleted items if (rand(0,10) == 10) { $this->rc->output->command('plugin.reload_data'); return; } $filter = array( 'since' => $attr['last'], 'search' => rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC), 'mask' => intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)) & self::FILTER_MASK_COMPLETE, ); $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC);; $updates = $this->driver->list_tasks($filter, $lists); if (!empty($updates)) { $this->rc->output->command('plugin.refresh_tasks', $this->tasks_data($updates), true); // update counts $counts = $this->driver->count_tasks($lists); $this->rc->output->command('plugin.update_counts', $counts); } } /** * Handler for pending_alarms plugin hook triggered by the calendar module on keep-alive requests. * This will check for pending notifications and pass them to the client */ public function pending_alarms($p) { $this->load_driver(); if ($alarms = $this->driver->pending_alarms($p['time'] ?: time())) { foreach ($alarms as $alarm) { // encode alarm object to suit the expectations of the calendaring code if ($alarm['date']) $alarm['start'] = new DateTime($alarm['date'].' '.$alarm['time'], $this->timezone); $alarm['id'] = 'task:' . $alarm['id']; // prefix ID with task: $alarm['allday'] = empty($alarm['time']) ? 1 : 0; $p['alarms'][] = $alarm; } } return $p; } /** * Handler for alarm dismiss hook triggered by the calendar module */ public function dismiss_alarms($p) { $this->load_driver(); foreach ((array)$p['ids'] as $id) { if (strpos($id, 'task:') === 0) $p['success'] |= $this->driver->dismiss_alarm(substr($id, 5), $p['snooze']); } return $p; } /** * Handler for importing .ics files */ function import_tasks() { // Upload progress update if (!empty($_GET['_progress'])) { $this->rc->upload_progress(); } @set_time_limit(0); // process uploaded file if there is no error $err = $_FILES['_data']['error']; if (!$err && $_FILES['_data']['tmp_name']) { $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); $lists = $this->driver->get_lists(); $list = $lists[$source] ?: $this->get_default_tasklist(); $source = $list['id']; // extract zip file if ($_FILES['_data']['type'] == 'application/zip') { $count = 0; if (class_exists('ZipArchive', false)) { $zip = new ZipArchive(); if ($zip->open($_FILES['_data']['tmp_name'])) { $randname = uniqid('zip-' . session_id(), true); $tmpdir = slashify($this->rc->config->get('temp_dir', sys_get_temp_dir())) . $randname; mkdir($tmpdir, 0700); // extract each ical file from the archive and import it for ($i = 0; $i < $zip->numFiles; $i++) { $filename = $zip->getNameIndex($i); if (preg_match('/\.ics$/i', $filename)) { $tmpfile = $tmpdir . '/' . basename($filename); if (copy('zip://' . $_FILES['_data']['tmp_name'] . '#'.$filename, $tmpfile)) { $count += $this->import_from_file($tmpfile, $source, $errors); unlink($tmpfile); } } } rmdir($tmpdir); $zip->close(); } else { $errors = 1; $msg = 'Failed to open zip file.'; } } else { $errors = 1; $msg = 'Zip files are not supported for import.'; } } else { // attempt to import the uploaded file directly $count = $this->import_from_file($_FILES['_data']['tmp_name'], $source, $errors); } if ($count) { $this->rc->output->command('display_message', $this->gettext(array('name' => 'importsuccess', 'vars' => array('nr' => $count))), 'confirmation'); $this->rc->output->command('plugin.import_success', array('source' => $source, 'refetch' => true)); } else if (!$errors) { $this->rc->output->command('display_message', $this->gettext('importnone'), 'notice'); $this->rc->output->command('plugin.import_success', array('source' => $source)); } else { $this->rc->output->command('plugin.import_error', array('message' => $this->gettext('importerror') . ($msg ? ': ' . $msg : ''))); } } else { if ($err == UPLOAD_ERR_INI_SIZE || $err == UPLOAD_ERR_FORM_SIZE) { $msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array( 'size' => $this->rc->show_bytes(parse_bytes(ini_get('upload_max_filesize')))))); } else { $msg = $this->rc->gettext('fileuploaderror'); } $this->rc->output->command('plugin.import_error', array('message' => $msg)); } $this->rc->output->send('iframe'); } /** * Helper function to parse and import a single .ics file */ private function import_from_file($filepath, $source, &$errors) { $user_email = $this->rc->user->get_username(); $ical = $this->get_ical(); $errors = !$ical->fopen($filepath); $count = $i = 0; foreach ($ical as $task) { // keep the browser connection alive on long import jobs if (++$i > 100 && $i % 100 == 0) { echo "<!-- -->"; ob_flush(); } if ($task['_type'] == 'task') { $task['list'] = $source; if ($this->driver->create_task($task)) { $count++; } else { $errors++; } } } return $count; } /** * Construct the ics file for exporting tasks to iCalendar format */ function export_tasks() { $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); $task_id = rcube_utils::get_input_value('id', rcube_utils::INPUT_GPC); $attachments = (bool) rcube_utils::get_input_value('attachments', rcube_utils::INPUT_GPC); $this->load_driver(); $browser = new rcube_browser; $lists = $this->driver->get_lists(); $tasks = array(); $filter = array(); // get message UIDs for filter if ($source && ($list = $lists[$source])) { $filename = html_entity_decode($list['name']) ?: $sorce; $filter = array($source => true); } else if ($task_id) { $filename = 'tasks'; foreach (explode(',', $task_id) as $id) { list($list_id, $task_id) = explode(':', $id, 2); if ($list_id && $task_id) { $filter[$list_id][] = $task_id; } } } // Get tasks foreach ($filter as $list_id => $uids) { $_filter = is_array($uids) ? array('uid' => $uids) : null; $_tasks = $this->driver->list_tasks($_filter, $list_id); if (!empty($_tasks)) { $tasks = array_merge($tasks, $_tasks); } } // Set file name if ($source && count($tasks) == 1) { $filename = $tasks[0]['title'] ?: 'task'; } $filename .= '.ics'; $filename = $browser->ie ? rawurlencode($filename) : addcslashes($filename, '"'); $tasks = array_map(array($this, 'to_libcal'), $tasks); // Give plugins a possibility to implement other output formats or modify the result $plugin = $this->rc->plugins->exec_hook('tasks_export', array( 'result' => $tasks, 'attachments' => $attachments, 'filename' => $filename, 'plugin' => $this, )); if ($plugin['abort']) { exit; } $this->rc->output->nocacheing_headers(); // don't kill the connection if download takes more than 30 sec. @set_time_limit(0); header("Content-Type: text/calendar"); header("Content-Disposition: inline; filename=\"". $plugin['filename'] ."\""); $this->get_ical()->export($plugin['result'], '', true, !empty($plugin['attachments']) ? array($this->driver, 'get_attachment_body') : null); exit; } /******* Attachment handling *******/ /** * Handler for attachments upload */ public function attachment_upload() { $handler = new kolab_attachments_handler(); $handler->attachment_upload(self::SESSION_KEY); } /** * Handler for attachments download/displaying */ public function attachment_get() { $handler = new kolab_attachments_handler(); // show loading page if (!empty($_GET['_preload'])) { return $handler->attachment_loading_page(); } $task = rcube_utils::get_input_value('_t', rcube_utils::INPUT_GPC); $list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC); $id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); $rev = rcube_utils::get_input_value('_rev', rcube_utils::INPUT_GPC); - $task = array('id' => $task, 'list' => $list, 'rev' => $rev); + $task = ['id' => $task, 'list' => $list, 'rev' => $rev]; $attachment = $this->driver->get_attachment($id, $task); // show part page if (!empty($_GET['_frame'])) { $handler->attachment_page($attachment); } // deliver attachment content else if ($attachment) { - $attachment['body'] = $this->driver->get_attachment_body($id, $task); + if (empty($attachment['body'])) { + $attachment['body'] = $this->driver->get_attachment_body($id, $task); + } $handler->attachment_get($attachment); } // if we arrive here, the requested part was not found header('HTTP/1.1 404 Not Found'); exit; } /******* Email related function *******/ public function mail_message2task() { $this->load_ui(); $this->ui->init(); $this->ui->init_templates(); $this->ui->tasklists(); $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_GET); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_GET); $task = array(); $imap = $this->rc->get_storage(); $message = new rcube_message($uid, $mbox); if ($message->headers) { $task['title'] = trim($message->subject); $task['description'] = trim($message->first_text_part()); $task['id'] = -$uid; $this->load_driver(); // add a reference to the email message if ($msgref = $this->driver->get_message_reference($message->headers, $mbox)) { $task['links'] = array($msgref); } // copy mail attachments to task else if (!empty($message->attachments) && $this->driver->attachments) { $handler = new kolab_attachments_handler(); $task['attachments'] = $handler->copy_mail_attachments(self::SESSION_KEY, $task['id'], $message); } $this->rc->output->set_env('task_prop', $task); } else { $this->rc->output->command('display_message', $this->gettext('messageopenerror'), 'error'); } $this->rc->output->send('tasklist.dialog'); } /** * Add UI element to copy task invitations or updates to the tasklist */ public function mail_messagebody_html($p) { // load iCalendar functions (if necessary) if (!empty($this->lib->ical_parts)) { $this->get_ical(); $this->load_itip(); } $html = ''; $has_tasks = false; $ical_objects = $this->lib->get_mail_ical_objects(); // show a box for every task in the file foreach ($ical_objects as $idx => $task) { if ($task['_type'] != 'task') { continue; } $has_tasks = true; // get prepared inline UI for this event object if ($ical_objects->method) { $html .= html::div('tasklist-invitebox invitebox boxinformation', $this->itip->mail_itip_inline_ui( $task, $ical_objects->method, $ical_objects->mime_id . ':' . $idx, 'tasks', rcube_utils::anytodatetime($ical_objects->message_date) ) ); } // limit listing if ($idx >= 3) { break; } } // list linked tasks $links = array(); foreach ($this->message_tasks as $task) { $checkbox = new html_checkbox(array( 'name' => 'completed', 'class' => 'complete pretty-checkbox', 'title' => $this->gettext('complete'), 'data-list' => $task['list'], )); $complete = $this->driver->is_complete($task); $links[] = html::tag('li', 'messagetaskref' . ($complete ? ' complete' : ''), $checkbox->show($complete ? $task['id'] : null, array('value' => $task['id'])) . ' ' . html::a(array( 'href' => $this->rc->url(array( 'task' => 'tasks', 'list' => $task['list'], 'id' => $task['id'], )), 'class' => 'messagetasklink', 'rel' => $task['id'] . '@' . $task['list'], 'target' => '_blank', ), rcube::Q($task['title'])) ); } if (count($links)) { $html .= html::div('messagetasklinks boxinformation', html::tag('ul', 'tasklist', join("\n", $links))); } // prepend iTip/relation boxes to message body if ($html) { $this->load_ui(); $this->ui->init(); $p['content'] = $html . $p['content']; $this->rc->output->add_label('tasklist.savingdata','tasklist.deletetaskconfirm','tasklist.declinedeleteconfirm'); } // add "Save to tasks" button into attachment menu if ($has_tasks) { $this->add_button(array( 'id' => 'attachmentsavetask', 'name' => 'attachmentsavetask', 'type' => 'link', 'wrapper' => 'li', 'command' => 'attachment-save-task', 'class' => 'icon tasklistlink disabled', 'classact' => 'icon tasklistlink active', 'innerclass' => 'icon taskadd', 'label' => 'tasklist.savetotasklist', ), 'attachmentmenu'); } return $p; } /** * Lookup backend storage and find notes associated with the given message */ public function mail_message_load($p) { if (empty($p['object']->headers->others['x-kolab-type'])) { $this->load_driver(); $this->message_tasks = $this->driver->get_message_related_tasks($p['object']->headers, $p['object']->folder); // sort message tasks by completeness and due date $driver = $this->driver; array_walk($this->message_tasks, array($this, 'encode_task')); usort($this->message_tasks, function($a, $b) use ($driver) { $a_complete = intval($driver->is_complete($a)); $b_complete = intval($driver->is_complete($b)); $d = $a_complete - $b_complete; if (!$d) $d = $b['_hasdate'] - $a['_hasdate']; if (!$d) $d = $a['datetime'] - $b['datetime']; return $d; }); } } /** * Load iCalendar functions */ public function get_ical() { if (empty($this->ical)) { $this->ical = libcalendaring::get_ical(); } return $this->ical; } /** * Get properties of the tasklist this user has specified as default */ public function get_default_tasklist($lists = null) { if ($lists === null) { $lists = $this->driver->get_lists(tasklist_driver::FILTER_PERSONAL | tasklist_driver::FILTER_WRITEABLE); } $list = null; foreach ($lists as $l) { if ($l['default']) { $list = $l; } if ($l['editable']) { $first = $l; } } return $list ?: $first; } /** * Import the full payload from a mail message attachment */ public function mail_import_attachment() { $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $mime_id = rcube_utils::get_input_value('_part', rcube_utils::INPUT_POST); $charset = RCUBE_CHARSET; // establish imap connection $imap = $this->rc->get_storage(); $imap->set_folder($mbox); if ($uid && $mime_id) { $part = $imap->get_message_part($uid, $mime_id); // $headers = $imap->get_message_headers($uid); if ($part->ctype_parameters['charset']) { $charset = $part->ctype_parameters['charset']; } if ($part) { $tasks = $this->get_ical()->import($part, $charset); } } $success = $existing = 0; if (!empty($tasks)) { // find writeable tasklist to store task $cal_id = !empty($_REQUEST['_list']) ? rcube_utils::get_input_value('_list', rcube_utils::INPUT_POST) : null; $lists = $this->driver->get_lists(); foreach ($tasks as $task) { // save to tasklist $list = $lists[$cal_id] ?: $this->get_default_tasklist(); if ($list && $list['editable'] && $task['_type'] == 'task') { $task = $this->from_ical($task); $task['list'] = $list['id']; if (!$this->driver->get_task($task['uid'])) { $success += (bool) $this->driver->create_task($task); } else { $existing++; } } } } if ($success) { $this->rc->output->command('display_message', $this->gettext(array( 'name' => 'importsuccess', 'vars' => array('nr' => $success), )), 'confirmation'); } else if ($existing) { $this->rc->output->command('display_message', $this->gettext('importwarningexists'), 'warning'); } else { $this->rc->output->command('display_message', $this->gettext('errorimportingtask'), 'error'); } } /** * Handler for POST request to import an event attached to a mail message */ public function mail_import_itip() { $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $mime_id = rcube_utils::get_input_value('_part', rcube_utils::INPUT_POST); $status = rcube_utils::get_input_value('_status', rcube_utils::INPUT_POST); $comment = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST); $delete = intval(rcube_utils::get_input_value('_del', rcube_utils::INPUT_POST)); $noreply = intval(rcube_utils::get_input_value('_noreply', rcube_utils::INPUT_POST)) || $status == 'needs-action'; $error_msg = $this->gettext('errorimportingtask'); $success = false; if ($status == 'delegated') { $delegates = rcube_mime::decode_address_list(rcube_utils::get_input_value('_to', rcube_utils::INPUT_POST, true), 1, false); $delegate = reset($delegates); if (empty($delegate) || empty($delegate['mailto'])) { $this->rc->output->command('display_message', $this->gettext('libcalendaring.delegateinvalidaddress'), 'error'); return; } } // successfully parsed tasks? if ($task = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'task')) { $task = $this->from_ical($task); // forward iTip request to delegatee if ($delegate) { $rsvpme = rcube_utils::get_input_value('_rsvp', rcube_utils::INPUT_POST); $itip = $this->load_itip(); $task['comment'] = $comment; if ($itip->delegate_to($task, $delegate, !empty($rsvpme))) { $this->rc->output->show_message('tasklist.itipsendsuccess', 'confirmation'); } else { $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); } unset($task['comment']); } $mode = tasklist_driver::FILTER_PERSONAL | tasklist_driver::FILTER_SHARED | tasklist_driver::FILTER_WRITEABLE; // find writeable list to store the task $list_id = !empty($_REQUEST['_folder']) ? rcube_utils::get_input_value('_folder', rcube_utils::INPUT_POST) : null; $lists = $this->driver->get_lists($mode); $list = $lists[$list_id]; $dontsave = ($_REQUEST['_folder'] === '' && $task['_method'] == 'REQUEST'); // select default list except user explicitly selected 'none' if (!$list && !$dontsave) { $list = $this->get_default_tasklist($lists); } $metadata = array( 'uid' => $task['uid'], 'changed' => is_object($task['changed']) ? $task['changed']->format('U') : 0, 'sequence' => intval($task['sequence']), 'fallback' => strtoupper($status), 'method' => $task['_method'], 'task' => 'tasks', ); // update my attendee status according to submitted method if (!empty($status)) { $organizer = $task['organizer']; $emails = $this->lib->get_user_emails(); foreach ($task['attendees'] as $i => $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $metadata['attendee'] = $attendee['email']; $metadata['rsvp'] = $attendee['role'] != 'NON-PARTICIPANT'; $reply_sender = $attendee['email']; $task['attendees'][$i]['status'] = strtoupper($status); if (!in_array($task['attendees'][$i]['status'], array('NEEDS-ACTION','DELEGATED'))) { $task['attendees'][$i]['rsvp'] = false; // unset RSVP attribute } } } // add attendee with this user's default identity if not listed if (!$reply_sender) { $sender_identity = $this->rc->user->list_emails(true); $task['attendees'][] = array( 'name' => $sender_identity['name'], 'email' => $sender_identity['email'], 'role' => 'OPT-PARTICIPANT', 'status' => strtoupper($status), ); $metadata['attendee'] = $sender_identity['email']; } } // save to tasklist if ($list && $list['editable']) { $task['list'] = $list['id']; // check for existing task with the same UID $existing = $this->find_task($task['uid'], $mode); if ($existing) { // only update attendee status if ($task['_method'] == 'REPLY') { // try to identify the attendee using the email sender address $existing_attendee = -1; $existing_attendee_emails = array(); foreach ($existing['attendees'] as $i => $attendee) { $existing_attendee_emails[] = $attendee['email']; if ($task['_sender'] && ($attendee['email'] == $task['_sender'] || $attendee['email'] == $task['_sender_utf'])) { $existing_attendee = $i; } } $task_attendee = null; foreach ($task['attendees'] as $attendee) { if ($task['_sender'] && ($attendee['email'] == $task['_sender'] || $attendee['email'] == $task['_sender_utf'])) { $task_attendee = $attendee; $metadata['fallback'] = $attendee['status']; $metadata['attendee'] = $attendee['email']; $metadata['rsvp'] = $attendee['rsvp'] || $attendee['role'] != 'NON-PARTICIPANT'; if ($attendee['status'] != 'DELEGATED') { break; } } // also copy delegate attendee else if (!empty($attendee['delegated-from']) && (stripos($attendee['delegated-from'], $task['_sender']) !== false || stripos($attendee['delegated-from'], $task['_sender_utf']) !== false) && (!in_array($attendee['email'], $existing_attendee_emails))) { $existing['attendees'][] = $attendee; } } // if delegatee has declined, set delegator's RSVP=True if ($task_attendee && $task_attendee['status'] == 'DECLINED' && $task_attendee['delegated-from']) { foreach ($existing['attendees'] as $i => $attendee) { if ($attendee['email'] == $task_attendee['delegated-from']) { $existing['attendees'][$i]['rsvp'] = true; break; } } } // found matching attendee entry in both existing and new events if ($existing_attendee >= 0 && $task_attendee) { $existing['attendees'][$existing_attendee] = $task_attendee; $success = $this->driver->edit_task($existing); } // update the entire attendees block else if (($task['sequence'] >= $existing['sequence'] || $task['changed'] >= $existing['changed']) && $task_attendee) { $existing['attendees'][] = $task_attendee; $success = $this->driver->edit_task($existing); } else { $error_msg = $this->gettext('newerversionexists'); } } // delete the task when declined else if ($status == 'declined' && $delete) { $deleted = $this->driver->delete_task($existing, true); $success = true; } // import the (newer) task else if ($task['sequence'] >= $existing['sequence'] || $task['changed'] >= $existing['changed']) { $task['id'] = $existing['id']; $task['list'] = $existing['list']; // preserve my participant status for regular updates if (empty($status)) { $this->lib->merge_attendees($task, $existing); } // set status=CANCELLED on CANCEL messages if ($task['_method'] == 'CANCEL') { $task['status'] = 'CANCELLED'; } // update attachments list, allow attachments update only on REQUEST (#5342) if ($task['_method'] == 'REQUEST') { $task['deleted_attachments'] = true; } else { unset($task['attachments']); } // show me as free when declined (#1670) if ($status == 'declined' || $task['status'] == 'CANCELLED') { $task['free_busy'] = 'free'; } $success = $this->driver->edit_task($task); } else if (!empty($status)) { $existing['attendees'] = $task['attendees']; if ($status == 'declined') { // show me as free when declined (#1670) $existing['free_busy'] = 'free'; } $success = $this->driver->edit_event($existing); } else { $error_msg = $this->gettext('newerversionexists'); } } else if (!$existing && ($status != 'declined' || $this->rc->config->get('kolab_invitation_tasklists'))) { $success = $this->driver->create_task($task); } else if ($status == 'declined') { $error_msg = null; } } else if ($status == 'declined' || $dontsave) { $error_msg = null; } else { $error_msg = $this->gettext('nowritetasklistfound'); } } if ($success || $dontsave) { if ($success) { $message = $task['_method'] == 'REPLY' ? 'attendeupdateesuccess' : ($deleted ? 'successremoval' : ($existing ? 'updatedsuccessfully' : 'importedsuccessfully')); $this->rc->output->command('display_message', $this->gettext(array('name' => $message, 'vars' => array('list' => $list['name']))), 'confirmation'); } $metadata['rsvp'] = intval($metadata['rsvp']); $metadata['after_action'] = $this->rc->config->get('calendar_itip_after_action', 0); $this->rc->output->command('plugin.itip_message_processed', $metadata); $error_msg = null; } else if ($error_msg) { $this->rc->output->command('display_message', $error_msg, 'error'); } // send iTip reply if ($task['_method'] == 'REQUEST' && $organizer && !$noreply && !in_array(strtolower($organizer['email']), $emails) && !$error_msg) { $task['comment'] = $comment; $itip = $this->load_itip(); $itip->set_sender_email($reply_sender); if ($itip->send_itip_message($this->to_libcal($task), 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status)) $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ?: $organizer['email']))), 'confirmation'); else $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); } $this->rc->output->send(); } /**** Task invitation plugin hooks ****/ /** * Handler for task/itip-delegate requests */ function mail_itip_delegate() { // forward request to mail_import_itip() with the right status $_POST['_status'] = $_REQUEST['_status'] = 'delegated'; $this->mail_import_itip(); } /** * Find a task in user tasklists */ protected function find_task($task, &$mode) { $this->load_driver(); // We search for writeable folders in personal namespace by default $mode = tasklist_driver::FILTER_WRITEABLE | tasklist_driver::FILTER_PERSONAL; $result = $this->driver->get_task($task, $mode); // ... now check shared folders if not found if (!$result) { $result = $this->driver->get_task($task, tasklist_driver::FILTER_WRITEABLE | tasklist_driver::FILTER_SHARED); if ($result) { $mode |= tasklist_driver::FILTER_SHARED; } } return $result; } /** * Handler for task/itip-status requests */ public function task_itip_status() { $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); // find local copy of the referenced task $existing = $this->find_task($data, $mode); $is_shared = $mode & tasklist_driver::FILTER_SHARED; $itip = $this->load_itip(); $response = $itip->get_itip_status($data, $existing); // get a list of writeable lists to save new tasks to if ((!$existing || $is_shared) && $response['action'] == 'rsvp' || $response['action'] == 'import') { $lists = $this->driver->get_lists($mode); $select = new html_select(array('name' => 'tasklist', 'id' => 'itip-saveto', 'is_escaped' => true, 'class' => 'form-control')); $select->add('--', ''); foreach ($lists as $list) { if ($list['editable']) { $select->add($list['name'], $list['id']); } } } if ($select) { $default_list = $this->get_default_tasklist($lists); $response['select'] = html::span('folder-select', $this->gettext('saveintasklist') . ' ' . $select->show($is_shared ? $existing['list'] : $default_list['id'])); } $this->rc->output->command('plugin.update_itip_object_status', $response); } /** * Handler for task/itip-remove requests */ public function task_itip_remove() { $success = false; $uid = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST); // search for event if only UID is given if ($task = $this->driver->get_task($uid)) { $success = $this->driver->delete_task($task, true); } if ($success) { $this->rc->output->show_message('tasklist.successremoval', 'confirmation'); } else { $this->rc->output->show_message('tasklist.errorsaving', 'error'); } } /******* Utility functions *******/ /** * Generate a unique identifier for an event */ public function generate_uid() { return strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($this->rc->user->get_username()), 0, 16)); } /** * Map task properties for ical exprort using libcalendaring */ public function to_libcal($task) { $object = $task; $object['_type'] = 'task'; $object['categories'] = (array)$task['tags']; // convert to datetime objects if (!empty($task['date'])) { $object['due'] = rcube_utils::anytodatetime($task['date'].' '.$task['time'], $this->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->timezone); if (empty($task['starttime'])) $object['start']->_dateonly = true; unset($object['startdate']); } $object['complete'] = $task['complete'] * 100; if ($task['complete'] == 1.0 && empty($task['complete'])) { $object['status'] = 'COMPLETED'; } if ($task['flagged']) { $object['priority'] = 1; } else if (empty($task['priority'])) { $object['priority'] = 0; } return $object; } /** * Convert task properties from ical parser to the internal format */ public function from_ical($vtodo) { $task = $vtodo; $task['tags'] = array_filter((array)$vtodo['categories']); $task['flagged'] = $vtodo['priority'] == 1; $task['complete'] = floatval($vtodo['complete'] / 100); // convert from DateTime to internal date format if (is_a($vtodo['due'], 'DateTime')) { $due = $this->lib->adjust_timezone($vtodo['due']); $task['date'] = $due->format('Y-m-d'); if (!$vtodo['due']->_dateonly) $task['time'] = $due->format('H:i'); } // convert from DateTime to internal date format if (is_a($vtodo['start'], 'DateTime')) { $start = $this->lib->adjust_timezone($vtodo['start']); $task['startdate'] = $start->format('Y-m-d'); if (!$vtodo['start']->_dateonly) $task['starttime'] = $start->format('H:i'); } if (is_a($vtodo['dtstamp'], 'DateTime')) { $task['changed'] = $vtodo['dtstamp']; } unset($task['categories'], $task['due'], $task['start'], $task['dtstamp']); return $task; } /** * Handler for user_delete plugin hook */ public function user_delete($args) { $this->load_driver(); return $this->driver->user_delete($args); } /** * Magic getter for public access to protected members */ public function __get($name) { switch ($name) { case 'ical': return $this->get_ical(); case 'itip': return $this->load_itip(); case 'driver': $this->load_driver(); return $this->driver; } return null; } } diff --git a/plugins/tasklist/tasklist_ui.php b/plugins/tasklist/tasklist_ui.php index 249cfe7c..32a9caa6 100644 --- a/plugins/tasklist/tasklist_ui.php +++ b/plugins/tasklist/tasklist_ui.php @@ -1,619 +1,623 @@ <?php /** * User Interface class for the Tasklist plugin * * @version @package_version@ * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2012-2015, Kolab Systems AG <contact@kolabsys.com> * * 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 <http://www.gnu.org/licenses/>. */ - +#[AllowDynamicProperties] class tasklist_ui { private $rc; private $plugin; private $ready = false; - private $gui_objects = array(); + private $gui_objects = []; function __construct($plugin) { $this->plugin = $plugin; $this->rc = $plugin->rc; } /** * Calendar UI initialization and requests handlers */ public function init() { if ($this->ready) { return; } // add taskbar button $this->plugin->add_button(array( 'command' => 'tasks', 'class' => 'button-tasklist', 'classsel' => 'button-tasklist button-selected', 'innerclass' => 'button-inner', 'label' => 'tasklist.navtitle', 'type' => 'link' ), 'taskbar'); $this->plugin->include_stylesheet($this->plugin->local_skin_path() . '/tasklist.css'); if ($this->rc->task == 'mail' || $this->rc->task == 'tasks') { $this->plugin->include_script('tasklist_base.js'); // copy config to client $this->rc->output->set_env('tasklist_settings', $this->load_settings()); // initialize attendees autocompletion $this->rc->autocomplete_init(); } $this->ready = true; } /** * */ function load_settings() { - $settings = array(); + $settings = []; $settings['invite_shared'] = (int)$this->rc->config->get('calendar_allow_invite_shared', 0); $settings['itip_notify'] = (int)$this->rc->config->get('calendar_itip_send_option', 3); $settings['sort_col'] = $this->rc->config->get('tasklist_sort_col', ''); $settings['sort_order'] = $this->rc->config->get('tasklist_sort_order', 'asc'); // get user identity to create default attendee foreach ($this->rc->user->list_emails() as $rec) { if (empty($identity)) { $identity = $rec; } $identity['emails'][] = $rec['email']; $settings['identities'][$rec['identity_id']] = $rec['email']; } $identity['emails'][] = $this->rc->user->get_username(); $settings['identity'] = array( 'name' => $identity['name'], 'email' => strtolower($identity['email']), 'emails' => ';' . strtolower(join(';', $identity['emails'])) ); if ($list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC)) { $settings['selected_list'] = $list; } if ($list && ($id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC))) { $settings['selected_id'] = $id; // check if the referenced task is completed $task = $this->plugin->driver->get_task(array('id' => $id, 'list' => $list)); if ($task && $this->plugin->driver->is_complete($task)) { $settings['selected_filter'] = 'complete'; } } else if ($filter = rcube_utils::get_input_value('_filter', rcube_utils::INPUT_GPC)) { $settings['selected_filter'] = $filter; } return $settings; } /** * Render a HTML select box for user identity selection */ - function identity_select($attrib = array()) + function identity_select($attrib = []) { $attrib['name'] = 'identity'; $select = new html_select($attrib); $identities = $this->rc->user->list_emails(); foreach ($identities as $ident) { $select->add(format_email_recipient($ident['email'], $ident['name']), $ident['identity_id']); } return $select->show(null); } /** * Register handler methods for the template engine */ public function init_templates() { $this->plugin->register_handler('plugin.tasklists', array($this, 'tasklists')); $this->plugin->register_handler('plugin.tasklist_select', array($this, 'tasklist_select')); $this->plugin->register_handler('plugin.status_select', array($this, 'status_select')); $this->plugin->register_handler('plugin.searchform', array($this->rc->output, 'search_form')); $this->plugin->register_handler('plugin.quickaddform', array($this, 'quickadd_form')); $this->plugin->register_handler('plugin.tasks', array($this, 'tasks_resultview')); $this->plugin->register_handler('plugin.tags_editline', array($this, 'tags_editline')); $this->plugin->register_handler('plugin.alarm_select', array($this, 'alarm_select')); $this->plugin->register_handler('plugin.recurrence_form', array($this->plugin->lib, 'recurrence_form')); $this->plugin->register_handler('plugin.attendees_list', array($this, 'attendees_list')); $this->plugin->register_handler('plugin.attendees_form', array($this, 'attendees_form')); $this->plugin->register_handler('plugin.identity_select', array($this, 'identity_select')); $this->plugin->register_handler('plugin.edit_attendees_notify', array($this, 'edit_attendees_notify')); $this->plugin->register_handler('plugin.task_rsvp_buttons', array($this->plugin->itip, 'itip_rsvp_buttons')); $this->plugin->register_handler('plugin.object_changelog_table', array('libkolab', 'object_changelog_table')); $this->plugin->register_handler('plugin.tasks_export_form', array($this, 'tasks_export_form')); $this->plugin->register_handler('plugin.tasks_import_form', array($this, 'tasks_import_form')); kolab_attachments_handler::ui(); $this->plugin->include_script('tasklist.js'); $this->plugin->api->include_script('libkolab/libkolab.js'); } /** * */ - public function tasklists($attrib = array()) + public function tasklists($attrib = []) { - $tree = true; - $jsenv = array(); + $tree = true; + $jsenv = []; $lists = $this->plugin->driver->get_lists(0, $tree); if (empty($attrib['id'])) { $attrib['id'] = 'rcmtasklistslist'; } // walk folder tree if (is_object($tree)) { $html = $this->list_tree_html($tree, $lists, $jsenv, $attrib); } else { // fall-back to flat folder listing - $attrib['class'] .= ' flat'; - + $attrib['class'] = ($attrib['class'] ?? '') . ' flat'; $html = ''; - foreach ((array)$lists as $id => $prop) { + + foreach ((array) $lists as $id => $prop) { if (!empty($attrib['activeonly']) && empty($prop['active'])) { continue; } - $html .= html::tag('li', array( + $html .= html::tag('li', [ 'id' => 'rcmlitasklist' . rcube_utils::html_identifier($id), - 'class' => isset($prop['group']) ? $prop['group'] : null, - ), + 'class' => $prop['group'] ?? null, + ], $this->tasklist_list_item($id, $prop, $jsenv, !empty($attrib['activeonly'])) ); } } $this->rc->output->include_script('treelist.js'); $this->rc->output->set_env('source', rcube_utils::get_input_value('source', rcube_utils::INPUT_GET)); $this->rc->output->set_env('tasklists', $jsenv); $this->register_gui_object('tasklistslist', $attrib['id']); return html::tag('ul', $attrib, $html, html::$common_attrib); } /** * Return html for a structured list <ul> for the folder tree */ public function list_tree_html($node, $data, &$jsenv, $attrib) { $out = ''; foreach ($node->children as $folder) { $id = $folder->id; $prop = $data[$id]; $is_collapsed = false; // TODO: determine this somehow? $content = $this->tasklist_list_item($id, $prop, $jsenv, $attrib['activeonly']); if (!empty($folder->children)) { $content .= html::tag('ul', array('style' => ($is_collapsed ? "display:none;" : null)), $this->list_tree_html($folder, $data, $jsenv, $attrib)); } if (strlen($content)) { $out .= html::tag('li', array( 'id' => 'rcmlitasklist' . rcube_utils::html_identifier($id), 'class' => $prop['group'] . ($prop['virtual'] ? ' virtual' : ''), ), $content); } } return $out; } /** * Helper method to build a tasklist item (HTML content and js data) */ public function tasklist_list_item($id, $prop, &$jsenv, $activeonly = false) { // enrich list properties with settings from the driver if (empty($prop['virtual'])) { unset($prop['user_id']); $prop['alarms'] = $this->plugin->driver->alarms; $prop['undelete'] = $this->plugin->driver->undelete; $prop['sortable'] = $this->plugin->driver->sortable; $prop['attachments'] = $this->plugin->driver->attachments; $prop['attendees'] = $this->plugin->driver->attendees; $prop['caldavurl'] = $this->plugin->driver->tasklist_caldav_url($prop); $jsenv[$id] = $prop; } $classes = array('tasklist'); $title = ''; if (!empty($prop['title'])) { $title = $prop['title']; } else if (empty($prop['listname']) || $prop['name'] != $prop['listname'] || strlen($prop['name']) > 25) { html_entity_decode($prop['name'], ENT_COMPAT, RCUBE_CHARSET); } if (!empty($prop['virtual'])) { $classes[] = 'virtual'; } else if (empty($prop['editable'])) { $classes[] = 'readonly'; } if (!empty($prop['subscribed'])) { $classes[] = 'subscribed'; } if (!empty($prop['class'])) { $classes[] = $prop['class']; } if (!$activeonly || $prop['active']) { $label_id = 'tl:' . $id; $chbox = html::tag('input', array( 'type' => 'checkbox', 'name' => '_list[]', 'value' => $id, 'checked' => $prop['active'], 'title' => $this->plugin->gettext('activate'), 'aria-labelledby' => $label_id )); + $actions = ''; + if (!empty($prop['removable'])) { + $actions .= html::a(['href' => '#', 'class' => 'remove', 'title' => $this->plugin->gettext('removelist')], ' '); + } + $actions .= html::a(['href' => '#', 'class' => 'quickview', 'title' => $this->plugin->gettext('focusview'), 'role' => 'checkbox', 'aria-checked' => 'false'], ' '); + if (isset($prop['subscribed'])) { + $action .= html::a(['href' => '#', 'class' => 'subscribed', 'title' => $this->plugin->gettext('tasklistsubscribe'), 'role' => 'checkbox', 'aria-checked' => $prop['subscribed'] ? 'true' : 'false'], ' '); + } + return html::div(join(' ', $classes), - html::a(array('class' => 'listname', 'title' => $title, 'href' => '#', 'id' => $label_id), - !empty($prop['listname']) ? $prop['listname'] : $prop['name']) . - (!empty($prop['virtual']) ? '' : $chbox . html::span('actions', - (!empty($prop['removable']) ? html::a(array('href' => '#', 'class' => 'remove', 'title' => $this->plugin->gettext('removelist')), ' ') : '') - . html::a(array('href' => '#', 'class' => 'quickview', 'title' => $this->plugin->gettext('focusview'), 'role' => 'checkbox', 'aria-checked' => 'false'), ' ') - . (isset($prop['subscribed']) ? html::a(array('href' => '#', 'class' => 'subscribed', 'title' => $this->plugin->gettext('tasklistsubscribe'), 'role' => 'checkbox', 'aria-checked' => $prop['subscribed'] ? 'true' : 'false'), ' ') : '') - ) - ) + html::a(['class' => 'listname', 'title' => $title, 'href' => '#', 'id' => $label_id], + !empty($prop['listname']) ? $prop['listname'] : $prop['name']) + . (!empty($prop['virtual']) ? '' : $chbox . html::span('actions', $actions)) ); } return ''; } /** * Render HTML form for task status selector */ function status_select($attrib = array()) { $attrib['name'] = 'status'; $select = new html_select($attrib); $select->add('---', ''); $select->add($this->plugin->gettext('status-needs-action'), 'NEEDS-ACTION'); $select->add($this->plugin->gettext('status-in-process'), 'IN-PROCESS'); $select->add($this->plugin->gettext('status-completed'), 'COMPLETED'); $select->add($this->plugin->gettext('status-cancelled'), 'CANCELLED'); return $select->show(null); } /** * Render a HTML select box for list selection */ function tasklist_select($attrib = array()) { if (empty($attrib['name'])) { $attrib['name'] = 'list'; } $attrib['is_escaped'] = true; $select = new html_select($attrib); $default = null; if (!empty($attrib['extra'])) { foreach ((array) $attrib['extra'] as $id => $name) { $select->add($name, $id); } } foreach ((array) $this->plugin->driver->get_lists() as $id => $prop) { if (!empty($prop['editable']) || strpos($prop['rights'], 'i') !== false) { $select->add($prop['name'], $id); if (!$default || !empty($prop['default'])) { $default = $id; } } } return $select->show($default); } function tasklist_editform($action, $list = array()) { $this->action = $action; $this->list = $list; $this->rc->output->set_env('pagetitle', $this->plugin->gettext('arialabeltasklistform')); $this->rc->output->add_handler('folderform', array($this, 'tasklistform')); $this->rc->output->send('libkolab.folderform'); } function tasklistform($attrib) { $fields = array( 'name' => array( 'id' => 'taskedit-tasklistname', 'label' => $this->plugin->gettext('listname'), 'value' => html::tag('input', array('id' => 'taskedit-tasklistname', 'name' => 'name', 'type' => 'text', 'class' => 'text', 'size' => 40)), ), /* 'color' => array( 'id' => 'taskedit-color', 'label' => $this->plugin->gettext('color'), 'value' => html::tag('input', array('id' => 'taskedit-color', 'name' => 'color', 'type' => 'text', 'class' => 'text colorpicker', 'size' => 6)), ), */ 'showalarms' => array( 'id' => 'taskedit-showalarms', 'label' => $this->plugin->gettext('showalarms'), 'value' => html::tag('input', array('id' => 'taskedit-showalarms', 'name' => 'showalarms', 'type' => 'checkbox', 'value' => 1)), ), ); return html::tag('form', $attrib + array('action' => "#", 'method' => "post", 'id' => 'tasklisteditform'), $this->plugin->driver->tasklist_edit_form($this->action, $this->list, $fields) ); } /** * Render HTML form for alarm configuration */ function alarm_select($attrib = array()) { $attrib['_type'] = 'task'; return $this->plugin->lib->alarm_select($attrib, $this->plugin->driver->alarm_types, $this->plugin->driver->alarm_absolute); } /** * */ function quickadd_form($attrib) { $attrib += array('action' => $this->rc->url('add'), 'method' => 'post', 'id' => 'quickaddform'); $label = html::label(array('for' => 'quickaddinput', 'class' => 'voice'), $this->plugin->gettext('quickaddinput')); $input = new html_inputfield(array('name' => 'text', 'id' => 'quickaddinput')); $button = html::tag('input', array( 'type' => 'submit', 'value' => '+', 'title' => $this->plugin->gettext('createtask'), 'class' => 'button mainaction' )); $this->register_gui_object('quickaddform', $attrib['id']); return html::tag('form', $attrib, $label . $input->show() . $button); } /** * The result view */ function tasks_resultview($attrib) { $attrib += array('id' => 'rcmtaskslist'); $this->register_gui_object('resultlist', $attrib['id']); unset($attrib['name']); return html::tag('ul', $attrib, ''); } /** * Interactive UI element to add/remove tags */ function tags_editline($attrib) { $attrib += array('id' => 'rcmtasktagsedit'); $this->register_gui_object('edittagline', $attrib['id']); $input = new html_inputfield(array( 'name' => 'tags[]', 'class' => 'tag', 'size' => !empty($attrib['size']) ? $attrib['size'] : null, 'tabindex' => isset($attrib['tabindex']) ? $attrib['tabindex'] : null, )); unset($attrib['tabindex']); return html::div($attrib, $input->show('')); } /** * */ function attendees_list($attrib = array()) { // add "noreply" checkbox to attendees table only $invitations = strpos($attrib['id'], 'attend') !== false; $invite = new html_checkbox(array('value' => 1, 'id' => 'edit-attendees-invite')); $table = new html_table(array('cols' => 4 + intval($invitations), 'border' => 0, 'cellpadding' => 0, 'class' => 'rectable')); // $table->add_header('role', $this->plugin->gettext('role')); $table->add_header('name', $this->plugin->gettext($attrib['coltitle'] ?: 'attendee')); $table->add_header('confirmstate', $this->plugin->gettext('confirmstate')); if ($invitations) { $table->add_header(array('class' => 'invite', 'title' => $this->plugin->gettext('sendinvitations')), $invite->show(1) . html::label('edit-attendees-invite', html::span('inner', $this->plugin->gettext('sendinvitations')))); } $table->add_header('options', ''); // hide invite column if disabled by config $itip_notify = (int)$this->rc->config->get('calendar_itip_send_option', 3); if ($invitations && !($itip_notify & 2)) { $css = sprintf('#%s td.invite, #%s th.invite { display:none !important }', $attrib['id'], $attrib['id']); $this->rc->output->add_footer(html::tag('style', array('type' => 'text/css'), $css)); } return $table->show($attrib); } /** * */ function attendees_form($attrib = array()) { $input = new html_inputfield(array( 'name' => 'participant', 'id' => 'edit-attendee-name', 'size' => !empty($attrib['size']) ? $attrib['size'] : null, 'class' => 'form-control' )); $textarea = new html_textarea(array( 'name' => 'comment', 'id' => 'edit-attendees-comment', 'rows' => 4, 'cols' => 55, 'title' => $this->plugin->gettext('itipcommenttitle'), 'class' => 'form-control' )); return html::div($attrib, html::div('form-searchbar', $input->show() . " " . html::tag('input', array('type' => 'button', 'class' => 'button', 'id' => 'edit-attendee-add', 'value' => $this->plugin->gettext('addattendee'))) // . " " . html::tag('input', array('type' => 'button', 'class' => 'button', 'id' => 'edit-attendee-schedule', 'value' => $this->plugin->gettext('scheduletime').'...')) ) . html::p('attendees-commentbox', html::label('edit-attendees-comment', $this->plugin->gettext('itipcomment')) . $textarea->show()) ); } /** * */ function edit_attendees_notify($attrib = array()) { $checkbox = new html_checkbox(array('name' => '_notify', 'id' => 'edit-attendees-donotify', 'value' => 1, 'class' => 'pretty-checkbox')); return html::div($attrib, html::label(null, $checkbox->show(1) . ' ' . $this->plugin->gettext('sendnotifications'))); } /** * Form for uploading and importing tasks */ function tasks_import_form($attrib = array()) { if (empty($attrib['id'])) { $attrib['id'] = 'rcmImportForm'; } // Get max filesize, enable upload progress bar $max_filesize = $this->rc->upload_init(); $accept = '.ics, text/calendar, text/x-vcalendar, application/ics'; if (class_exists('ZipArchive', false)) { $accept .= ', .zip, application/zip'; } $input = new html_inputfield(array( 'id' => 'importfile', 'type' => 'file', 'name' => '_data', 'size' => !empty($attrib['uploadfieldsize']) ? $attrib['uploadfieldsize'] : null, 'accept' => $accept )); $html = html::div('form-section form-group row', html::label(array('class' => 'col-sm-4 col-form-label', 'for' => 'importfile'), rcube::Q($this->rc->gettext('importfromfile'))) . html::div('col-sm-8', $input->show() . html::div('hint', $this->rc->gettext(array('name' => 'maxuploadsize', 'vars' => array('size' => $max_filesize))))) ); $html .= html::div('form-section form-group row', html::label(array('for' => 'task-import-list', 'class' => 'col-sm-4 col-form-label'), $this->plugin->gettext('list')) . html::div('col-sm-8', $this->tasklist_select(array('name' => 'source', 'id' => 'task-import-list', 'editable' => true))) ); $this->rc->output->add_gui_object('importform', $attrib['id']); $this->rc->output->add_label('import', 'importerror'); return html::tag('p', null, $this->plugin->gettext('importtext')) .html::tag('form', array( 'action' => $this->rc->url(array('task' => 'tasklist', 'action' => 'import')), 'method' => 'post', 'enctype' => 'multipart/form-data', 'id' => $attrib['id'], ), $html ); } /** * Form to select options for exporting tasks */ function tasks_export_form($attrib = array()) { if (empty($attrib['id'])) { $attrib['id'] = 'rcmTaskExportForm'; } $html = html::div('form-section form-group row', html::label(array('for' => 'task-export-list', 'class' => 'col-sm-4 col-form-label'), $this->plugin->gettext('list')) . html::div('col-sm-8', $this->tasklist_select(array( 'name' => 'source', 'id' => 'task-export-list', 'extra' => array('' => '- ' . $this->plugin->gettext('currentview') . ' -'), ))) ); $checkbox = new html_checkbox(array('name' => 'attachments', 'id' => 'task-export-attachments', 'value' => 1, 'class' => 'form-check-input pretty-checkbox')); $html .= html::div('form-section row form-check', html::label(array('for' => 'task-export-attachments', 'class' => 'col-sm-4 col-form-label'), $this->plugin->gettext('exportattachments')) . html::div('col-sm-8', $checkbox->show(1)) ); $this->register_gui_object('exportform', $attrib['id']); return html::tag('form', array( 'action' => $this->rc->url(array('task' => 'tasklist', 'action' => 'export')), 'method' => 'post', 'id' => $attrib['id'] ), $html ); } /** * Wrapper for rcube_output_html::add_gui_object() */ function register_gui_object($name, $id) { $this->gui_objects[$name] = $id; $this->rc->output->add_gui_object($name, $id); } /** * Getter for registered gui objects. * (for manual registration when loading the inline UI) */ function get_gui_objects() { return $this->gui_objects; } }