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"], '&nbsp;');
             }
 
             $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', '&nbsp;')
             . 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'], '&nbsp;')
             )
         );
 
         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" /> &nbsp;
 					<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" /> &nbsp;
 					<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 ? '&#9654;' : '&#9660;'));
                     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 ? '&#9654;' : '&#9660;'));
 
         // 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">&#9654;' : '<span class="childtoggle expanded"><span class="inner">&#9660;') + '</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) || '&#x2714');
 
         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 || '')+' &raquo;').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" />&nbsp;' +
                     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" />&nbsp;' +
                     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') . '&nbsp;' .
                 $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;
     }
 }