diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index ee87251d..14a590ad 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -1,3597 +1,3970 @@ * @author Thomas Bruederli * * Copyright (C) 2010, Lazlo Westerhof * Copyright (C) 2014-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class calendar extends rcube_plugin { - const FREEBUSY_UNKNOWN = 0; - const FREEBUSY_FREE = 1; - const FREEBUSY_BUSY = 2; - const FREEBUSY_TENTATIVE = 3; - const FREEBUSY_OOF = 4; - - const SESSION_KEY = 'calendar_temp'; - - public $task = '?(?!logout).*'; - public $rc; - public $lib; - public $resources_dir; - public $home; // declare public to be used in other classes - public $urlbase; - public $timezone; - public $timezone_offset; - public $gmt_offset; - public $ui; - - public $defaults = array( - 'calendar_default_view' => "agendaWeek", - 'calendar_timeslots' => 2, - 'calendar_work_start' => 6, - 'calendar_work_end' => 18, - 'calendar_agenda_range' => 60, - 'calendar_event_coloring' => 0, - 'calendar_time_indicator' => true, - 'calendar_allow_invite_shared' => false, - 'calendar_itip_send_option' => 3, - 'calendar_itip_after_action' => 0, - ); - -// These are implemented with __get() -// private $ical; -// private $itip; -// private $driver; - - - /** - * Plugin initialization. - */ - function init() - { - $this->rc = rcube::get_instance(); - - $this->register_task('calendar', 'calendar'); - - // load calendar configuration - $this->load_config(); - - // catch iTIP confirmation requests that don're require a valid session - if ($this->rc->action == 'attend' && !empty($_REQUEST['_t'])) { - $this->add_hook('startup', array($this, 'itip_attend_response')); - } - else if ($this->rc->action == 'feed' && !empty($_REQUEST['_cal'])) { - $this->add_hook('startup', array($this, 'ical_feed_export')); - } - else if ($this->rc->task != 'login') { - // default startup routine - $this->add_hook('startup', array($this, 'startup')); - } - - $this->add_hook('user_delete', array($this, 'user_delete')); - } - - /** - * Setup basic plugin environment and UI - */ - protected function setup() - { - $this->require_plugin('libcalendaring'); - $this->require_plugin('libkolab'); - - $this->lib = libcalendaring::get_instance(); - $this->timezone = $this->lib->timezone; - $this->gmt_offset = $this->lib->gmt_offset; - $this->dst_active = $this->lib->dst_active; - $this->timezone_offset = $this->gmt_offset / 3600 - $this->dst_active; - - // load localizations - $this->add_texts('localization/', $this->rc->task == 'calendar' && (!$this->rc->action || $this->rc->action == 'print')); - - require($this->home . '/lib/calendar_ui.php'); - $this->ui = new calendar_ui($this); - } - - /** - * Startup hook - */ - public function startup($args) - { - // the calendar module can be enabled/disabled by the kolab_auth plugin - if ($this->rc->config->get('calendar_disabled', false) || !$this->rc->config->get('calendar_enabled', true)) - return; - - $this->setup(); - - // load Calendar user interface - if (!$this->rc->output->ajax_call && (!$this->rc->output->env['framed'] || $args['action'] == 'preview')) { - $this->ui->init(); - - // settings are required in (almost) every GUI step - if ($args['action'] != 'attend') - $this->rc->output->set_env('calendar_settings', $this->load_settings()); - } - - if ($args['task'] == 'calendar' && $args['action'] != 'save-pref') { - if ($args['action'] != 'upload') { + const FREEBUSY_UNKNOWN = 0; + const FREEBUSY_FREE = 1; + const FREEBUSY_BUSY = 2; + const FREEBUSY_TENTATIVE = 3; + const FREEBUSY_OOF = 4; + + const SESSION_KEY = 'calendar_temp'; + + public $task = '?(?!logout).*'; + public $rc; + public $lib; + public $resources_dir; + public $home; // declare public to be used in other classes + public $urlbase; + public $timezone; + public $timezone_offset; + public $gmt_offset; + public $ui; + + public $defaults = [ + 'calendar_default_view' => "agendaWeek", + 'calendar_timeslots' => 2, + 'calendar_work_start' => 6, + 'calendar_work_end' => 18, + 'calendar_agenda_range' => 60, + 'calendar_show_weekno' => 0, + 'calendar_first_day' => 1, + 'calendar_first_hour' => 6, + 'calendar_time_format' => null, + 'calendar_event_coloring' => 0, + 'calendar_time_indicator' => true, + 'calendar_allow_invite_shared' => false, + 'calendar_itip_send_option' => 3, + 'calendar_itip_after_action' => 0, + ]; + + // These are implemented with __get() + // private $ical; + // private $itip; + // private $driver; + + + /** + * Plugin initialization. + */ + function init() + { + $this->rc = rcube::get_instance(); + + $this->register_task('calendar', 'calendar'); + + // load calendar configuration + $this->load_config(); + + // catch iTIP confirmation requests that don're require a valid session + if ($this->rc->action == 'attend' && !empty($_REQUEST['_t'])) { + $this->add_hook('startup', [$this, 'itip_attend_response']); + } + else if ($this->rc->action == 'feed' && !empty($_REQUEST['_cal'])) { + $this->add_hook('startup', [$this, 'ical_feed_export']); + } + else if ($this->rc->task != 'login') { + // default startup routine + $this->add_hook('startup', [$this, 'startup']); + } + + $this->add_hook('user_delete', [$this, 'user_delete']); + } + + /** + * Setup basic plugin environment and UI + */ + protected function setup() + { + $this->require_plugin('libcalendaring'); + $this->require_plugin('libkolab'); + + $this->lib = libcalendaring::get_instance(); + $this->timezone = $this->lib->timezone; + $this->gmt_offset = $this->lib->gmt_offset; + $this->dst_active = $this->lib->dst_active; + $this->timezone_offset = $this->gmt_offset / 3600 - $this->dst_active; + + // load localizations + $this->add_texts('localization/', $this->rc->task == 'calendar' && (!$this->rc->action || $this->rc->action == 'print')); + + require($this->home . '/lib/calendar_ui.php'); + $this->ui = new calendar_ui($this); + } + + /** + * Startup hook + */ + public function startup($args) + { + // the calendar module can be enabled/disabled by the kolab_auth plugin + if ($this->rc->config->get('calendar_disabled', false) + || !$this->rc->config->get('calendar_enabled', true) + ) { + return; + } + + $this->setup(); + + // load Calendar user interface + if (!$this->rc->output->ajax_call + && (empty($this->rc->output->env['framed']) || $args['action'] == 'preview') + ) { + $this->ui->init(); + + // settings are required in (almost) every GUI step + if ($args['action'] != 'attend') { + $this->rc->output->set_env('calendar_settings', $this->load_settings()); + } + } + + if ($args['task'] == 'calendar' && $args['action'] != 'save-pref') { + if ($args['action'] != 'upload') { + $this->load_driver(); + } + + // register calendar actions + $this->register_action('index', [$this, 'calendar_view']); + $this->register_action('event', [$this, 'event_action']); + $this->register_action('calendar', [$this, 'calendar_action']); + $this->register_action('count', [$this, 'count_events']); + $this->register_action('load_events', [$this, 'load_events']); + $this->register_action('export_events', [$this, 'export_events']); + $this->register_action('import_events', [$this, 'import_events']); + $this->register_action('upload', [$this, 'attachment_upload']); + $this->register_action('get-attachment', [$this, 'attachment_get']); + $this->register_action('freebusy-status', [$this, 'freebusy_status']); + $this->register_action('freebusy-times', [$this, 'freebusy_times']); + $this->register_action('randomdata', [$this, 'generate_randomdata']); + $this->register_action('print', [$this,'print_view']); + $this->register_action('mailimportitip', [$this, 'mail_import_itip']); + $this->register_action('mailimportattach', [$this, 'mail_import_attachment']); + $this->register_action('dialog-ui', [$this, 'mail_message2event']); + $this->register_action('check-recent', [$this, 'check_recent']); + $this->register_action('itip-status', [$this, 'event_itip_status']); + $this->register_action('itip-remove', [$this, 'event_itip_remove']); + $this->register_action('itip-decline-reply', [$this, 'mail_itip_decline_reply']); + $this->register_action('itip-delegate', [$this, 'mail_itip_delegate']); + $this->register_action('resources-list', [$this, 'resources_list']); + $this->register_action('resources-owner', [$this, 'resources_owner']); + $this->register_action('resources-calendar', [$this, 'resources_calendar']); + $this->register_action('resources-autocomplete', [$this, 'resources_autocomplete']); + $this->add_hook('refresh', [$this, 'refresh']); + + // remove undo information... + if (!empty($_SESSION['calendar_event_undo'])) { + $undo = $_SESSION['calendar_event_undo']; + // ...after timeout + $undo_time = $this->rc->config->get('undo_timeout', 0); + if ($undo['ts'] < time() - $undo_time) { + $this->rc->session->remove('calendar_event_undo'); + // @TODO: do EXPUNGE on kolab objects? + } + } + } + else if ($args['task'] == 'settings') { + // add hooks for Calendar settings + $this->add_hook('preferences_sections_list', [$this, 'preferences_sections_list']); + $this->add_hook('preferences_list', [$this, 'preferences_list']); + $this->add_hook('preferences_save', [$this, 'preferences_save']); + } + else if ($args['task'] == 'mail') { + // hooks to catch event invitations on incoming mails + if ($args['action'] == 'show' || $args['action'] == 'preview') { + $this->add_hook('template_object_messagebody', [$this, 'mail_messagebody_html']); + } + + // add 'Create event' item to message menu + if ($this->api->output->type == 'html' && (empty($_GET['_rel']) || $_GET['_rel'] != 'event')) { + $this->api->output->add_label('calendar.createfrommail'); + $this->api->add_content( + html::tag('li', ['role' => 'menuitem'], + $this->api->output->button([ + 'command' => 'calendar-create-from-mail', + 'label' => 'calendar.createfrommail', + 'type' => 'link', + 'classact' => 'icon calendarlink active', + 'class' => 'icon calendarlink disabled', + 'innerclass' => 'icon calendar', + ]) + ), + 'messagemenu' + ); + } + + $this->add_hook('messages_list', [$this, 'mail_messages_list']); + $this->add_hook('message_compose', [$this, 'mail_message_compose']); + } + else if ($args['task'] == 'addressbook') { + if ($this->rc->config->get('calendar_contact_birthdays')) { + $this->add_hook('contact_update', [$this, 'contact_update']); + $this->add_hook('contact_create', [$this, 'contact_update']); + } + } + + // add hooks to display alarms + $this->add_hook('pending_alarms', [$this, 'pending_alarms']); + $this->add_hook('dismiss_alarms', [$this, 'dismiss_alarms']); + } + + /** + * 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('calendar_driver', 'database'); + $driver_class = $driver_name . '_driver'; + + require_once($this->home . '/drivers/calendar_driver.php'); + require_once($this->home . '/drivers/' . $driver_name . '/' . $driver_class . '.php'); + + $this->driver = new $driver_class($this); + + if ($this->driver->undelete) { + $this->driver->undelete = $this->rc->config->get('undo_timeout', 0) > 0; + } + } + + /** + * Load iTIP functions + */ + private function load_itip() + { + if (empty($this->itip)) { + require_once($this->home . '/lib/calendar_itip.php'); + $this->itip = new calendar_itip($this); + + if ($this->rc->config->get('kolab_invitation_calendars')) { + $this->itip->set_rsvp_actions(['accepted','tentative','declined','delegated','needs-action']); + } + } + + return $this->itip; + } + + /** + * Load iCalendar functions + */ + public function get_ical() + { + if (empty($this->ical)) { + $this->ical = libcalendaring::get_ical(); + } + + return $this->ical; + } + + /** + * Get properties of the calendar this user has specified as default + */ + public function get_default_calendar($sensitivity = null, $calendars = null) + { + if ($calendars === null) { + $filter = calendar_driver::FILTER_PERSONAL | calendar_driver::FILTER_WRITEABLE; + $calendars = $this->driver->list_calendars($filter); + } + + $default_id = $this->rc->config->get('calendar_default_calendar'); + $calendar = !empty($calendars[$default_id]) ? $calendars[$default_id] : null; + $first = null; + + if (!$calendar || $sensitivity) { + foreach ($calendars as $cal) { + if ($sensitivity && !empty($cal['subtype']) && $cal['subtype'] == $sensitivity) { + $calendar = $cal; + break; + } + if (!empty($cal['default']) && $cal['editable']) { + $calendar = $cal; + } + if ($cal['editable']) { + $first = $cal; + } + } + } + + return $calendar ?: $first; + } + + /** + * Render the main calendar view from skin template + */ + function calendar_view() + { + $this->rc->output->set_pagetitle($this->gettext('calendar')); + + // Add JS files to the page header + $this->ui->addJS(); + + $this->ui->init_templates(); + $this->rc->output->add_label('lowest','low','normal','high','highest','delete', + 'cancel','uploading','noemailwarning','close' + ); + + // initialize attendees autocompletion + $this->rc->autocomplete_init(); + + $this->rc->output->set_env('timezone', $this->timezone->getName()); + $this->rc->output->set_env('calendar_driver', $this->rc->config->get('calendar_driver'), false); + $this->rc->output->set_env('calendar_resources', (bool)$this->rc->config->get('calendar_resources_driver')); + $this->rc->output->set_env('identities-selector', $this->ui->identity_select([ + 'id' => 'edit-identities-list', + 'aria-label' => $this->gettext('roleorganizer'), + 'class' => 'form-control custom-select', + ])); + + $view = rcube_utils::get_input_value('view', rcube_utils::INPUT_GPC); + if (in_array($view, ['agendaWeek', 'agendaDay', 'month', 'list'])) { + $this->rc->output->set_env('view', $view); + } + + if ($date = rcube_utils::get_input_value('date', rcube_utils::INPUT_GPC)) { + $this->rc->output->set_env('date', $date); + } + + if ($msgref = rcube_utils::get_input_value('itip', rcube_utils::INPUT_GPC)) { + $this->rc->output->set_env('itip_events', $this->itip_events($msgref)); + } + + $this->rc->output->send('calendar.calendar'); + } + + /** + * Handler for preferences_sections_list hook. + * Adds Calendar settings sections into preferences sections list. + * + * @param array Original parameters + * + * @return array Modified parameters + */ + function preferences_sections_list($p) + { + $p['list']['calendar'] = [ + 'id' => 'calendar', + 'section' => $this->gettext('calendar'), + ]; + + return $p; + } + + /** + * Handler for preferences_list hook. + * Adds options blocks into Calendar settings sections in Preferences. + * + * @param array Original parameters + * + * @return array Modified parameters + */ + function preferences_list($p) + { + if ($p['section'] != 'calendar') { + return $p; + } + + $no_override = array_flip((array) $this->rc->config->get('dont_override')); + + $p['blocks']['view']['name'] = $this->gettext('mainoptions'); + + if (!isset($no_override['calendar_default_view'])) { + if (empty($p['current'])) { + $p['blocks']['view']['content'] = true; + return $p; + } + + $field_id = 'rcmfd_default_view'; + $view = $this->rc->config->get('calendar_default_view', $this->defaults['calendar_default_view']); + + $select = new html_select(['name' => '_default_view', 'id' => $field_id]); + $select->add($this->gettext('day'), "agendaDay"); + $select->add($this->gettext('week'), "agendaWeek"); + $select->add($this->gettext('month'), "month"); + $select->add($this->gettext('agenda'), "list"); + + $p['blocks']['view']['options']['default_view'] = [ + 'title' => html::label($field_id, rcube::Q($this->gettext('default_view'))), + 'content' => $select->show($view == 'table' ? 'list' : $view), + ]; + } + + if (!isset($no_override['calendar_timeslots'])) { + if (empty($p['current'])) { + $p['blocks']['view']['content'] = true; + return $p; + } + + $field_id = 'rcmfd_timeslot'; + $choices = ['1', '2', '3', '4', '6']; + $timeslots = $this->rc->config->get('calendar_timeslots', $this->defaults['calendar_timeslots']); + + $select = new html_select(['name' => '_timeslots', 'id' => $field_id]); + $select->add($choices); + + $p['blocks']['view']['options']['timeslots'] = [ + 'title' => html::label($field_id, rcube::Q($this->gettext('timeslots'))), + 'content' => $select->show(strval($timeslots)), + ]; + } + + if (!isset($no_override['calendar_first_day'])) { + if (empty($p['current'])) { + $p['blocks']['view']['content'] = true; + return $p; + } + + $field_id = 'rcmfd_firstday'; + $first_day = $this->rc->config->get('calendar_first_day', $this->defaults['calendar_first_day']); + + $select = new html_select(['name' => '_first_day', 'id' => $field_id]); + $select->add($this->gettext('sunday'), '0'); + $select->add($this->gettext('monday'), '1'); + $select->add($this->gettext('tuesday'), '2'); + $select->add($this->gettext('wednesday'), '3'); + $select->add($this->gettext('thursday'), '4'); + $select->add($this->gettext('friday'), '5'); + $select->add($this->gettext('saturday'), '6'); + + $p['blocks']['view']['options']['first_day'] = [ + 'title' => html::label($field_id, rcube::Q($this->gettext('first_day'))), + 'content' => $select->show(strval($first_day)), + ]; + } + + if (!isset($no_override['calendar_first_hour'])) { + if (empty($p['current'])) { + $p['blocks']['view']['content'] = true; + return $p; + } + + $first_hour = $this->rc->config->get('calendar_first_hour', $this->defaults['calendar_first_hour']); + $time_format = $this->rc->config->get('calendar_time_format', $this->defaults['calendar_time_format']); + $time_format = $this->rc->config->get('time_format', libcalendaring::to_php_date_format($time_format)); + $field_id = 'rcmfd_firsthour'; + + $select_hours = new html_select(['name' => '_first_hour', 'id' => $field_id]); + for ($h = 0; $h < 24; $h++) { + $select_hours->add(date($time_format, mktime($h, 0, 0)), $h); + } + + $p['blocks']['view']['options']['first_hour'] = [ + 'title' => html::label($field_id, rcube::Q($this->gettext('first_hour'))), + 'content' => $select_hours->show($first_hour), + ]; + } + + if (!isset($no_override['calendar_work_start'])) { + if (empty($p['current'])) { + $p['blocks']['view']['content'] = true; + return $p; + } + + $field_id = 'rcmfd_workstart'; + $work_start = $this->rc->config->get('calendar_work_start', $this->defaults['calendar_work_start']); + $work_end = $this->rc->config->get('calendar_work_end', $this->defaults['calendar_work_end']); + + $p['blocks']['view']['options']['workinghours'] = [ + 'title' => html::label($field_id, rcube::Q($this->gettext('workinghours'))), + 'content' => html::div('input-group', + $select_hours->show($work_start, ['name' => '_work_start', 'id' => $field_id]) + . html::span('input-group-append input-group-prepend', html::span('input-group-text',' — ')) + . $select_hours->show($work_end, ['name' => '_work_end', 'id' => $field_id]) + ) + ]; + } + + if (!isset($no_override['calendar_event_coloring'])) { + if (empty($p['current'])) { + $p['blocks']['view']['content'] = true; + return $p; + } + + $field_id = 'rcmfd_coloring'; + $mode = $this->rc->config->get('calendar_event_coloring', $this->defaults['calendar_event_coloring']); + + $select_colors = new html_select(['name' => '_event_coloring', 'id' => $field_id]); + $select_colors->add($this->gettext('coloringmode0'), 0); + $select_colors->add($this->gettext('coloringmode1'), 1); + $select_colors->add($this->gettext('coloringmode2'), 2); + $select_colors->add($this->gettext('coloringmode3'), 3); + + $p['blocks']['view']['options']['eventcolors'] = [ + 'title' => html::label($field_id, rcube::Q($this->gettext('eventcoloring'))), + 'content' => $select_colors->show($mode), + ]; + } + + // loading driver is expensive, don't do it if not needed $this->load_driver(); - } - - // register calendar actions - $this->register_action('index', array($this, 'calendar_view')); - $this->register_action('event', array($this, 'event_action')); - $this->register_action('calendar', array($this, 'calendar_action')); - $this->register_action('count', array($this, 'count_events')); - $this->register_action('load_events', array($this, 'load_events')); - $this->register_action('export_events', array($this, 'export_events')); - $this->register_action('import_events', array($this, 'import_events')); - $this->register_action('upload', array($this, 'attachment_upload')); - $this->register_action('get-attachment', array($this, 'attachment_get')); - $this->register_action('freebusy-status', array($this, 'freebusy_status')); - $this->register_action('freebusy-times', array($this, 'freebusy_times')); - $this->register_action('randomdata', array($this, 'generate_randomdata')); - $this->register_action('print', array($this,'print_view')); - $this->register_action('mailimportitip', array($this, 'mail_import_itip')); - $this->register_action('mailimportattach', array($this, 'mail_import_attachment')); - $this->register_action('dialog-ui', array($this, 'mail_message2event')); - $this->register_action('check-recent', array($this, 'check_recent')); - $this->register_action('itip-status', array($this, 'event_itip_status')); - $this->register_action('itip-remove', array($this, 'event_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->register_action('resources-list', array($this, 'resources_list')); - $this->register_action('resources-owner', array($this, 'resources_owner')); - $this->register_action('resources-calendar', array($this, 'resources_calendar')); - $this->register_action('resources-autocomplete', array($this, 'resources_autocomplete')); - $this->add_hook('refresh', array($this, 'refresh')); - - // remove undo information... - if ($undo = $_SESSION['calendar_event_undo']) { - // ...after timeout - $undo_time = $this->rc->config->get('undo_timeout', 0); - if ($undo['ts'] < time() - $undo_time) { - $this->rc->session->remove('calendar_event_undo'); - // @TODO: do EXPUNGE on kolab objects? - } - } - } - else if ($args['task'] == 'settings') { - // add hooks for Calendar settings - $this->add_hook('preferences_sections_list', array($this, 'preferences_sections_list')); - $this->add_hook('preferences_list', array($this, 'preferences_list')); - $this->add_hook('preferences_save', array($this, 'preferences_save')); - } - else if ($args['task'] == 'mail') { - // hooks to catch event invitations on incoming mails - if ($args['action'] == 'show' || $args['action'] == 'preview') { - $this->add_hook('template_object_messagebody', array($this, 'mail_messagebody_html')); - } - - // add 'Create event' item to message menu - if ($this->api->output->type == 'html' && $_GET['_rel'] != 'event') { - $this->api->add_content(html::tag('li', array('role' => 'menuitem'), - $this->api->output->button(array( - 'command' => 'calendar-create-from-mail', - 'label' => 'calendar.createfrommail', - 'type' => 'link', - 'classact' => 'icon calendarlink active', - 'class' => 'icon calendarlink disabled', - 'innerclass' => 'icon calendar', - ))), - 'messagemenu'); - - $this->api->output->add_label('calendar.createfrommail'); - } - - $this->add_hook('messages_list', array($this, 'mail_messages_list')); - $this->add_hook('message_compose', array($this, 'mail_message_compose')); - } - else if ($args['task'] == 'addressbook') { - if ($this->rc->config->get('calendar_contact_birthdays')) { - $this->add_hook('contact_update', array($this, 'contact_update')); - $this->add_hook('contact_create', array($this, 'contact_update')); - } - } - - // add hooks to display alarms - $this->add_hook('pending_alarms', array($this, 'pending_alarms')); - $this->add_hook('dismiss_alarms', array($this, 'dismiss_alarms')); - } - - /** - * Helper method to load the backend driver according to local config - */ - private function load_driver() - { - if (is_object($this->driver)) - return; - - $driver_name = $this->rc->config->get('calendar_driver', 'database'); - $driver_class = $driver_name . '_driver'; - - require_once($this->home . '/drivers/calendar_driver.php'); - require_once($this->home . '/drivers/' . $driver_name . '/' . $driver_class . '.php'); - - $this->driver = new $driver_class($this); - - if ($this->driver->undelete) - $this->driver->undelete = $this->rc->config->get('undo_timeout', 0) > 0; - } - - /** - * Load iTIP functions - */ - private function load_itip() - { - if (!$this->itip) { - require_once($this->home . '/lib/calendar_itip.php'); - $this->itip = new calendar_itip($this); - - if ($this->rc->config->get('kolab_invitation_calendars')) - $this->itip->set_rsvp_actions(array('accepted','tentative','declined','delegated','needs-action')); - } - - return $this->itip; - } - - /** - * Load iCalendar functions - */ - public function get_ical() - { - if (!$this->ical) { - $this->ical = libcalendaring::get_ical(); - } - - return $this->ical; - } - - /** - * Get properties of the calendar this user has specified as default - */ - public function get_default_calendar($sensitivity = null, $calendars = null) - { - if ($calendars === null) { - $calendars = $this->driver->list_calendars(calendar_driver::FILTER_PERSONAL | calendar_driver::FILTER_WRITEABLE); - } - - $default_id = $this->rc->config->get('calendar_default_calendar'); - $calendar = $calendars[$default_id] ?: null; - - if (!$calendar || $sensitivity) { - foreach ($calendars as $cal) { - if ($sensitivity && $cal['subtype'] == $sensitivity) { - $calendar = $cal; - break; - } - if ($cal['default'] && $cal['editable']) { - $calendar = $cal; - } - if ($cal['editable']) { - $first = $cal; - } - } - } - - return $calendar ?: $first; - } - - /** - * Render the main calendar view from skin template - */ - function calendar_view() - { - $this->rc->output->set_pagetitle($this->gettext('calendar')); - - // Add JS files to the page header - $this->ui->addJS(); - - $this->ui->init_templates(); - $this->rc->output->add_label('lowest','low','normal','high','highest','delete','cancel','uploading','noemailwarning','close'); - - // initialize attendees autocompletion - $this->rc->autocomplete_init(); - - $this->rc->output->set_env('timezone', $this->timezone->getName()); - $this->rc->output->set_env('calendar_driver', $this->rc->config->get('calendar_driver'), false); - $this->rc->output->set_env('calendar_resources', (bool)$this->rc->config->get('calendar_resources_driver')); - $this->rc->output->set_env('identities-selector', $this->ui->identity_select(array( - 'id' => 'edit-identities-list', - 'aria-label' => $this->gettext('roleorganizer'), - 'class' => 'form-control custom-select', - ))); - - $view = rcube_utils::get_input_value('view', rcube_utils::INPUT_GPC); - if (in_array($view, array('agendaWeek', 'agendaDay', 'month', 'list'))) - $this->rc->output->set_env('view', $view); - - if ($date = rcube_utils::get_input_value('date', rcube_utils::INPUT_GPC)) - $this->rc->output->set_env('date', $date); - - if ($msgref = rcube_utils::get_input_value('itip', rcube_utils::INPUT_GPC)) - $this->rc->output->set_env('itip_events', $this->itip_events($msgref)); - - $this->rc->output->send("calendar.calendar"); - } - - /** - * Handler for preferences_sections_list hook. - * Adds Calendar settings sections into preferences sections list. - * - * @param array Original parameters - * @return array Modified parameters - */ - function preferences_sections_list($p) - { - $p['list']['calendar'] = array( - 'id' => 'calendar', 'section' => $this->gettext('calendar'), - ); - - return $p; - } - - /** - * Handler for preferences_list hook. - * Adds options blocks into Calendar settings sections in Preferences. - * - * @param array Original parameters - * @return array Modified parameters - */ - function preferences_list($p) - { - if ($p['section'] != 'calendar') { - return $p; - } - - $no_override = array_flip((array)$this->rc->config->get('dont_override')); - - $p['blocks']['view']['name'] = $this->gettext('mainoptions'); - - if (!isset($no_override['calendar_default_view'])) { - if (!$p['current']) { - $p['blocks']['view']['content'] = true; + + if (!isset($no_override['calendar_default_alarm_type']) || !isset($no_override['calendar_default_alarm_offset'])) { + if (empty($p['current'])) { + $p['blocks']['view']['content'] = true; + return $p; + } + + $alarm_type = $alarm_offset = ''; + + if (!isset($no_override['calendar_default_alarm_type'])) { + $field_id = 'rcmfd_alarm'; + $select_type = new html_select(['name' => '_alarm_type', 'id' => $field_id]); + $select_type->add($this->gettext('none'), ''); + + foreach ($this->driver->alarm_types as $type) { + $select_type->add($this->rc->gettext(strtolower("alarm{$type}option"), 'libcalendaring'), $type); + } + + $alarm_type = $select_type->show($this->rc->config->get('calendar_default_alarm_type', '')); + } + + if (!isset($no_override['calendar_default_alarm_offset'])) { + $field_id = 'rcmfd_alarm'; + $input_value = new html_inputfield(['name' => '_alarm_value', 'id' => $field_id . 'value', 'size' => 3]); + $select_offset = new html_select(['name' => '_alarm_offset', 'id' => $field_id . 'offset']); + + foreach (['-M','-H','-D','+M','+H','+D'] as $trigger) { + $select_offset->add($this->rc->gettext('trigger' . $trigger, 'libcalendaring'), $trigger); + } + + $preset = libcalendaring::parse_alarm_value($this->rc->config->get('calendar_default_alarm_offset', '-15M')); + $alarm_offset = $input_value->show($preset[0]) . ' ' . $select_offset->show($preset[1]); + } + + $p['blocks']['view']['options']['alarmtype'] = [ + 'title' => html::label($field_id, rcube::Q($this->gettext('defaultalarmtype'))), + 'content' => html::div('input-group', $alarm_type . ' ' . $alarm_offset), + ]; + } + + if (!isset($no_override['calendar_default_calendar'])) { + if (empty($p['current'])) { + $p['blocks']['view']['content'] = true; + return $p; + } + + // default calendar selection + $field_id = 'rcmfd_default_calendar'; + $filter = calendar_driver::FILTER_PERSONAL | calendar_driver::FILTER_ACTIVE | calendar_driver::FILTER_INSERTABLE; + $select_cal = new html_select(['name' => '_default_calendar', 'id' => $field_id, 'is_escaped' => true]); + + $default_calendar = null; + foreach ((array) $this->driver->list_calendars($filter) as $id => $prop) { + $select_cal->add($prop['name'], strval($id)); + if (!empty($prop['default'])) { + $default_calendar = $id; + } + } + + $p['blocks']['view']['options']['defaultcalendar'] = [ + 'title' => html::label($field_id, rcube::Q($this->gettext('defaultcalendar'))), + 'content' => $select_cal->show($this->rc->config->get('calendar_default_calendar', $default_calendar)), + ]; + } + + if (!isset($no_override['calendar_show_weekno'])) { + if (empty($p['current'])) { + $p['blocks']['view']['content'] = true; + return $p; + } + + $field_id = 'rcmfd_show_weekno'; + $select = new html_select(['name' => '_show_weekno', 'id' => $field_id]); + $select->add($this->gettext('weeknonone'), -1); + $select->add($this->gettext('weeknodatepicker'), 0); + $select->add($this->gettext('weeknoall'), 1); + + $p['blocks']['view']['options']['show_weekno'] = [ + 'title' => html::label($field_id, rcube::Q($this->gettext('showweekno'))), + 'content' => $select->show(intval($this->rc->config->get('calendar_show_weekno'))), + ]; + } + + $p['blocks']['itip']['name'] = $this->gettext('itipoptions'); + + // Invitations handling + if (!isset($no_override['calendar_itip_after_action'])) { + if (empty($p['current'])) { + $p['blocks']['itip']['content'] = true; + return $p; + } + + $field_id = 'rcmfd_after_action'; + $select = new html_select([ + 'name' => '_after_action', + 'id' => $field_id, + 'onchange' => "\$('#{$field_id}_select')[this.value == 4 ? 'show' : 'hide']()" + ]); + + $select->add($this->gettext('afternothing'), ''); + $select->add($this->gettext('aftertrash'), 1); + $select->add($this->gettext('afterdelete'), 2); + $select->add($this->gettext('afterflagdeleted'), 3); + $select->add($this->gettext('aftermoveto'), 4); + + $val = $this->rc->config->get('calendar_itip_after_action', $this->defaults['calendar_itip_after_action']); + $folder = null; + + if ($val !== null && $val !== '' && !is_int($val)) { + $folder = $val; + $val = 4; + } + + $folders = $this->rc->folder_selector([ + 'id' => $field_id . '_select', + 'name' => '_after_action_folder', + 'maxlength' => 30, + 'folder_filter' => 'mail', + 'folder_rights' => 'w', + 'style' => $val !== 4 ? 'display:none' : '', + ]); + + $p['blocks']['itip']['options']['after_action'] = [ + 'title' => html::label($field_id, rcube::Q($this->gettext('afteraction'))), + 'content' => html::div( + 'input-group input-group-combo', + $select->show($val) . $folders->show($folder) + ), + ]; + } + + // category definitions + if (empty($this->driver->nocategories) && !isset($no_override['calendar_categories'])) { + $p['blocks']['categories']['name'] = $this->gettext('categories'); + + if (empty($p['current'])) { + $p['blocks']['categories']['content'] = true; + return $p; + } + + $categories = (array) $this->driver->list_categories(); + $categories_list = ''; + + foreach ($categories as $name => $color) { + $key = md5($name); + $field_class = 'rcmfd_category_' . str_replace(' ', '_', $name); + $category_remove = html::span('input-group-append', + html::a([ + 'class' => 'button icon delete input-group-text', + 'onclick' => '$(this).parent().parent().remove()', + 'title' => $this->gettext('remove_category'), + 'href' => '#rcmfd_new_category', + ], + html::span('inner', $this->gettext('delete')) + ) + ); + + $category_name = new html_inputfield(array('name' => "_categories[$key]", 'class' => $field_class, 'size' => 30, 'disabled' => $this->driver->categoriesimmutable)); + $category_color = new html_inputfield(array('name' => "_colors[$key]", 'class' => "$field_class colors", 'size' => 6)); + $hidden = ''; + + if (!empty($this->driver->categoriesimmutable)) { + $hidden = html::tag('input', ['type' => 'hidden', 'name' => "_categories[$key]", 'value' => $name]); + } + + $categories_list .= $hidden + . html::div('input-group', $category_name->show($name) . $category_color->show($color) . $category_remove); + } + + $p['blocks']['categories']['options']['category_' . $name] = [ + 'content' => html::div(['id' => 'calendarcategories'], $categories_list), + ]; + + $field_id = 'rcmfd_new_category'; + $new_category = new html_inputfield(['name' => '_new_category', 'id' => $field_id, 'size' => 30]); + $add_category = html::span('input-group-append', + html::a( + [ + 'type' => 'button', + 'class' => 'button create input-group-text', + 'title' => $this->gettext('add_category'), + 'onclick' => 'rcube_calendar_add_category()', + 'href' => '#rcmfd_new_category', + ], + html::span('inner', $this->gettext('add_category')) + ) + ); + + $p['blocks']['categories']['options']['categories'] = [ + 'content' => html::div('input-group', $new_category->show('') . $add_category), + ]; + + $this->rc->output->add_label('delete', 'calendar.remove_category'); + $this->rc->output->add_script(' +function rcube_calendar_add_category() { + var name = $("#rcmfd_new_category").val(); + if (name.length) { + var button_label = rcmail.gettext("calendar.remove_category"); + var input = $("").attr({type: "text", name: "_categories[]", size: 30, "class": "form-control"}).val(name); + var color = $("").attr({type: "text", name: "_colors[]", size: 6, "class": "colors form-control"}).val("000000"); + var button = $("").attr({"class": "button icon delete input-group-text", title: button_label, href: "#rcmfd_new_category"}) + .click(function() { $(this).parent().parent().remove(); }) + .append($("").addClass("inner").text(rcmail.gettext("delete"))); + + $("
").addClass("input-group").append(input).append(color).append($("").append(button)) + .appendTo("#calendarcategories"); + color.minicolors(rcmail.env.minicolors_config || {}); + $("#rcmfd_new_category").val(""); + } +}', + 'foot' + ); + + $this->rc->output->add_script(' +$("#rcmfd_new_category").keypress(function(event) { + if (event.which == 13) { + rcube_calendar_add_category(); + event.preventDefault(); + } +});', + 'docready' + ); + + // load miniColors js/css files + jqueryui::miniColors(); + } + + // virtual birthdays calendar + if (!isset($no_override['calendar_contact_birthdays'])) { + $p['blocks']['birthdays']['name'] = $this->gettext('birthdayscalendar'); + + if (empty($p['current'])) { + $p['blocks']['birthdays']['content'] = true; + return $p; + } + + $field_id = 'rcmfd_contact_birthdays'; + $input = new html_checkbox([ + 'name' => '_contact_birthdays', + 'id' => $field_id, + 'value' => 1, + 'onclick' => '$(".calendar_birthday_props").prop("disabled",!this.checked)' + ]); + + $p['blocks']['birthdays']['options']['contact_birthdays'] = [ + 'title' => html::label($field_id, $this->gettext('displaybirthdayscalendar')), + 'content' => $input->show($this->rc->config->get('calendar_contact_birthdays') ? 1 : 0), + ]; + + $input_attrib = [ + 'class' => 'calendar_birthday_props', + 'disabled' => !$this->rc->config->get('calendar_contact_birthdays'), + ]; + + $sources = []; + $checkbox = new html_checkbox(['name' => '_birthday_adressbooks[]'] + $input_attrib); + + foreach ($this->rc->get_address_sources(false, true) as $source) { + $active = in_array($source['id'], (array) $this->rc->config->get('calendar_birthday_adressbooks')) ? $source['id'] : ''; + $sources[] = html::tag('li', null, + html::label(null, + $checkbox->show($active, ['value' => $source['id']]) + . rcube::Q(!empty($source['realname']) ? $source['realname'] : $source['name']) + ) + ); + } + + $p['blocks']['birthdays']['options']['birthday_adressbooks'] = [ + 'title' => rcube::Q($this->gettext('birthdayscalendarsources')), + 'content' => html::tag('ul', 'proplist', implode("\n", $sources)), + ]; + + $field_id = 'rcmfd_birthdays_alarm'; + $select_type = new html_select(['name' => '_birthdays_alarm_type', 'id' => $field_id] + $input_attrib); + $select_type->add($this->gettext('none'), ''); + + foreach ($this->driver->alarm_types as $type) { + $select_type->add($this->rc->gettext(strtolower("alarm{$type}option"), 'libcalendaring'), $type); + } + + $input_value = new html_inputfield(['name' => '_birthdays_alarm_value', 'id' => $field_id . 'value', 'size' => 3] + $input_attrib); + $select_offset = new html_select(['name' => '_birthdays_alarm_offset', 'id' => $field_id . 'offset'] + $input_attrib); + + foreach (['-M','-H','-D'] as $trigger) { + $select_offset->add($this->rc->gettext('trigger' . $trigger, 'libcalendaring'), $trigger); + } + + $preset = libcalendaring::parse_alarm_value($this->rc->config->get('calendar_birthdays_alarm_offset', '-1D')); + $preset_type = $this->rc->config->get('calendar_birthdays_alarm_type', ''); + + $p['blocks']['birthdays']['options']['birthdays_alarmoffset'] = [ + 'title' => html::label($field_id, rcube::Q($this->gettext('showalarms'))), + 'content' => html::div('input-group', + $select_type->show($preset_type) + . $input_value->show($preset[0]) . ' ' . $select_offset->show($preset[1]) + ), + ]; + } + return $p; - } - - $field_id = 'rcmfd_default_view'; - $view = $this->rc->config->get('calendar_default_view', $this->defaults['calendar_default_view']); - $select = new html_select(array('name' => '_default_view', 'id' => $field_id)); - $select->add($this->gettext('day'), "agendaDay"); - $select->add($this->gettext('week'), "agendaWeek"); - $select->add($this->gettext('month'), "month"); - $select->add($this->gettext('agenda'), "list"); - $p['blocks']['view']['options']['default_view'] = array( - 'title' => html::label($field_id, rcube::Q($this->gettext('default_view'))), - 'content' => $select->show($view == 'table' ? 'list' : $view), - ); - } - - if (!isset($no_override['calendar_timeslots'])) { - if (!$p['current']) { - $p['blocks']['view']['content'] = true; + } + + /** + * Handler for preferences_save hook. + * Executed on Calendar settings form submit. + * + * @param array Original parameters + * + * @return array Modified parameters + */ + function preferences_save($p) + { + if ($p['section'] == 'calendar') { + $this->load_driver(); + + // compose default alarm preset value + $alarm_offset = rcube_utils::get_input_value('_alarm_offset', rcube_utils::INPUT_POST); + $alarm_value = rcube_utils::get_input_value('_alarm_value', rcube_utils::INPUT_POST); + $default_alarm = $alarm_offset[0] . intval($alarm_value) . $alarm_offset[1]; + + $birthdays_alarm_offset = rcube_utils::get_input_value('_birthdays_alarm_offset', rcube_utils::INPUT_POST); + $birthdays_alarm_value = rcube_utils::get_input_value('_birthdays_alarm_value', rcube_utils::INPUT_POST); + $birthdays_alarm_value = $birthdays_alarm_offset[0] . intval($birthdays_alarm_value) . $birthdays_alarm_offset[1]; + + $p['prefs'] = [ + 'calendar_default_view' => rcube_utils::get_input_value('_default_view', rcube_utils::INPUT_POST), + 'calendar_timeslots' => intval(rcube_utils::get_input_value('_timeslots', rcube_utils::INPUT_POST)), + 'calendar_first_day' => intval(rcube_utils::get_input_value('_first_day', rcube_utils::INPUT_POST)), + 'calendar_first_hour' => intval(rcube_utils::get_input_value('_first_hour', rcube_utils::INPUT_POST)), + 'calendar_work_start' => intval(rcube_utils::get_input_value('_work_start', rcube_utils::INPUT_POST)), + 'calendar_work_end' => intval(rcube_utils::get_input_value('_work_end', rcube_utils::INPUT_POST)), + 'calendar_show_weekno' => intval(rcube_utils::get_input_value('_show_weekno', rcube_utils::INPUT_POST)), + 'calendar_event_coloring' => intval(rcube_utils::get_input_value('_event_coloring', rcube_utils::INPUT_POST)), + 'calendar_default_alarm_type' => rcube_utils::get_input_value('_alarm_type', rcube_utils::INPUT_POST), + 'calendar_default_alarm_offset' => $default_alarm, + 'calendar_default_calendar' => rcube_utils::get_input_value('_default_calendar', rcube_utils::INPUT_POST), + 'calendar_date_format' => null, // clear previously saved values + 'calendar_time_format' => null, + 'calendar_contact_birthdays' => !empty(rcube_utils::get_input_value('_contact_birthdays', rcube_utils::INPUT_POST)), + 'calendar_birthday_adressbooks' => (array) rcube_utils::get_input_value('_birthday_adressbooks', rcube_utils::INPUT_POST), + 'calendar_birthdays_alarm_type' => rcube_utils::get_input_value('_birthdays_alarm_type', rcube_utils::INPUT_POST), + 'calendar_birthdays_alarm_offset' => $birthdays_alarm_value ?: null, + 'calendar_itip_after_action' => intval(rcube_utils::get_input_value('_after_action', rcube_utils::INPUT_POST)), + ]; + + if ($p['prefs']['calendar_itip_after_action'] == 4) { + $p['prefs']['calendar_itip_after_action'] = rcube_utils::get_input_value('_after_action_folder', rcube_utils::INPUT_POST, true); + } + + // categories + if (empty($this->driver->nocategories)) { + $old_categories = $new_categories = []; + + foreach ($this->driver->list_categories() as $name => $color) { + $old_categories[md5($name)] = $name; + } + + $categories = (array) rcube_utils::get_input_value('_categories', rcube_utils::INPUT_POST); + $colors = (array) rcube_utils::get_input_value('_colors', rcube_utils::INPUT_POST); + + foreach ($categories as $key => $name) { + if (!isset($colors[$key])) { + continue; + } + + $color = preg_replace('/^#/', '', strval($colors[$key])); + + // rename categories in existing events -> driver's job + if (!empty($old_categories[$key])) { + $oldname = $old_categories[$key]; + $this->driver->replace_category($oldname, $name, $color); + unset($old_categories[$key]); + } + else { + $this->driver->add_category($name, $color); + } + + $new_categories[$name] = $color; + } + + // these old categories have been removed, alter events accordingly -> driver's job + foreach ((array) $old_categories as $key => $name) { + $this->driver->remove_category($name); + } + + $p['prefs']['calendar_categories'] = $new_categories; + } + } + return $p; - } + } + + /** + * Dispatcher for calendar actions initiated by the client + */ + function calendar_action() + { + $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); + $cal = rcube_utils::get_input_value('c', rcube_utils::INPUT_GPC); + $success = false; + $reload = false; + + if (isset($cal['showalarms'])) { + $cal['showalarms'] = intval($cal['showalarms']); + } + + switch ($action) { + case "form-new": + case "form-edit": + echo $this->ui->calendar_editform($action, $cal); + exit; + + case "new": + $success = $this->driver->create_calendar($cal); + $reload = true; + break; + + case "edit": + $success = $this->driver->edit_calendar($cal); + $reload = true; + break; + + case "delete": + if ($success = $this->driver->delete_calendar($cal)) { + $this->rc->output->command('plugin.destroy_source', ['id' => $cal['id']]); + } + break; + + case "subscribe": + if (!$this->driver->subscribe_calendar($cal)) { + $this->rc->output->show_message($this->gettext('errorsaving'), 'error'); + } + else { + $calendars = $this->driver->list_calendars(); + $calendar = !empty($calendars[$cal['id']]) ? $calendars[$cal['id']] : null; + + // find parent folder and check if it's a "user calendar" + // if it's also activated we need to refresh it (#5340) + while (!empty($calendar['parent'])) { + if (isset($calendars[$calendar['parent']])) { + $calendar = $calendars[$calendar['parent']]; + } + else { + break; + } + } + + if ($calendar && $calendar['id'] != $cal['id'] + && !empty($calendar['active']) + && $calendar['group'] == "other user" + ) { + $this->rc->output->command('plugin.refresh_source', $calendar['id']); + } + } + return; + + case "search": + $results = []; + $color_mode = $this->rc->config->get('calendar_event_coloring', $this->defaults['calendar_event_coloring']); + $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_calendars($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->calendar_list_item($id, $prop, $jsenv); + $cal = $jsenv[$id]; + $cal['editname'] = $editname; + $cal['html'] = $html; + + if (!empty($prop['color'])) { + $cal['css'] = $this->ui->calendar_css_classes($id, $prop, $color_mode); + } + + $results[] = $cal; + } + + // report more results available + if (!empty($this->driver->search_more_results)) { + $this->rc->output->show_message('autocompletemore', 'notice'); + } + + $reqid = rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC); + $this->rc->output->command('multi_thread_http_response', $results, $reqid); + return; + } + + if ($success) { + $this->rc->output->show_message('successfullysaved', 'confirmation'); + } + else { + $error_msg = $this->gettext('errorsaving'); + if (!empty($this->driver->last_error)) { + $error_msg .= ': ' . $this->driver->last_error; + } + $this->rc->output->show_message($error_msg, 'error'); + } + + $this->rc->output->command('plugin.unlock_saving'); + + if ($success && $reload) { + $this->rc->output->command('plugin.reload_view'); + } + } + + /** + * Dispatcher for event actions initiated by the client + */ + function event_action() + { + $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); + $event = rcube_utils::get_input_value('e', rcube_utils::INPUT_POST, true); + $success = $reload = $got_msg = false; + $old = null; + + // read old event data in order to find changes + if ((!empty($event['_notify']) || !empty($event['_decline'])) && $action != 'new') { + $old = $this->driver->get_event($event); + + // load main event if savemode is 'all' or if deleting 'future' events + if (($event['_savemode'] == 'all' || ($event['_savemode'] == 'future' && $action == 'remove' && empty($event['_decline']))) + && !empty($old['recurrence_id']) + ) { + $old['id'] = $old['recurrence_id']; + $old = $this->driver->get_event($old); + } + } + + switch ($action) { + case "new": + // create UID for new event + $event['uid'] = $this->generate_uid(); + if (!$this->write_preprocess($event, $action)) { + $got_msg = true; + } + else if ($success = $this->driver->new_event($event)) { + $event['id'] = $event['uid']; + $event['_savemode'] = 'all'; + + $this->cleanup_event($event); + $this->event_save_success($event, null, $action, true); + } + + $reload = $success && !empty($event['recurrence']) ? 2 : 1; + break; + + case "edit": + if (!$this->write_preprocess($event, $action)) { + $got_msg = true; + } + else if ($success = $this->driver->edit_event($event)) { + $this->cleanup_event($event); + $this->event_save_success($event, $old, $action, $success); + } + + $reload = $success && (!empty($event['recurrence']) || !empty($event['_savemode']) || !empty($event['_fromcalendar'])) ? 2 : 1; + break; + + case "resize": + if (!$this->write_preprocess($event, $action)) { + $got_msg = true; + } + else if ($success = $this->driver->resize_event($event)) { + $this->event_save_success($event, $old, $action, $success); + } + + $reload = !empty($event['_savemode']) ? 2 : 1; + break; + + case "move": + if (!$this->write_preprocess($event, $action)) { + $got_msg = true; + } + else if ($success = $this->driver->move_event($event)) { + $this->event_save_success($event, $old, $action, $success); + } + + $reload = $success && !empty($event['_savemode']) ? 2 : 1; + break; + + case "remove": + // remove previous deletes + $undo_time = $this->driver->undelete ? $this->rc->config->get('undo_timeout', 0) : 0; + + // search for event if only UID is given + if (!isset($event['calendar']) && !empty($event['uid'])) { + if (!($event = $this->driver->get_event($event, calendar_driver::FILTER_WRITEABLE))) { + break; + } + $undo_time = 0; + } + + // Note: the driver is responsible for setting $_SESSION['calendar_event_undo'] + // containing 'ts' and 'data' elements + $success = $this->driver->remove_event($event, $undo_time < 1); + $reload = (!$success || !empty($event['_savemode'])) ? 2 : 1; + + if ($undo_time > 0 && $success) { + // display message with Undo link. + $onclick = sprintf("%s.http_request('event', 'action=undo', %s.display_message('', 'loading'))", + rcmail_output::JS_OBJECT_NAME, + rcmail_output::JS_OBJECT_NAME + ); + $msg = html::span(null, $this->gettext('successremoval')) + . ' ' . html::a(['onclick' => $onclick], $this->gettext('undo')); + + $this->rc->output->show_message($msg, 'confirmation', null, true, $undo_time); + $got_msg = true; + } + else if ($success) { + $this->rc->output->show_message('calendar.successremoval', 'confirmation'); + $got_msg = true; + } + + // send cancellation for the main event + if ($event['_savemode'] == 'all') { + unset($old['_instance'], $old['recurrence_date'], $old['recurrence_id']); + } + // send an update for the main event's recurrence rule instead of a cancellation message + else if ($event['_savemode'] == 'future' && $success !== false && $success !== true) { + $event['_savemode'] = 'all'; // force event_save_success() to load master event + $action = 'edit'; + $success = true; + } + + // send iTIP reply that participant has declined the event + if ($success && !empty($event['_decline'])) { + $emails = $this->get_user_emails(); + $organizer = null; + + foreach ($old['attendees'] as $i => $attendee) { + if ($attendee['role'] == 'ORGANIZER') { + $organizer = $attendee; + } + else if (!empty($attendee['email']) && in_array(strtolower($attendee['email']), $emails)) { + $old['attendees'][$i]['status'] = 'DECLINED'; + $reply_sender = $attendee['email']; + } + } + + if ($event['_savemode'] == 'future' && $event['id'] != $old['id']) { + $old['thisandfuture'] = true; + } + + $itip = $this->load_itip(); + $itip->set_sender_email($reply_sender); + + if ($organizer && $itip->send_itip_message($old, 'REPLY', $organizer, 'itipsubjectdeclined', 'itipmailbodydeclined')) { + $mailto = !empty($organizer['name']) ? $organizer['name'] : $organizer['email']; + $msg = $this->gettext(['name' => 'sentresponseto', 'vars' => ['mailto' => $mailto]]); + + $this->rc->output->command('display_message', $msg, 'confirmation'); + } + else { + $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); + } + } + else if ($success) { + $this->event_save_success($event, $old, $action, $success); + } + + break; + + case "undo": + // Restore deleted event + if (!empty($_SESSION['calendar_event_undo']['data'])) { + $event = $_SESSION['calendar_event_undo']['data']; + $success = $this->driver->restore_event($event); + } + + if ($success) { + $this->rc->session->remove('calendar_event_undo'); + $this->rc->output->show_message('calendar.successrestore', 'confirmation'); + $got_msg = true; + $reload = 2; + } + + break; + + case "rsvp": + $itip_sending = $this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']); + $status = rcube_utils::get_input_value('status', rcube_utils::INPUT_POST); + $attendees = rcube_utils::get_input_value('attendees', rcube_utils::INPUT_POST); + $reply_comment = $event['comment']; + + $this->write_preprocess($event, 'edit'); + $ev = $this->driver->get_event($event); + $ev['attendees'] = $event['attendees']; + $ev['free_busy'] = $event['free_busy']; + $ev['_savemode'] = $event['_savemode']; + $ev['comment'] = $reply_comment; + + // send invitation to delegatee + add it as attendee + if ($status == 'delegated' && !empty($event['to'])) { + $itip = $this->load_itip(); + if ($itip->delegate_to($ev, $event['to'], !empty($event['rsvp']), $attendees)) { + $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation'); + $noreply = false; + } + } + + $event = $ev; + + // compose a list of attendees affected by this change + $updated_attendees = array_filter(array_map(function($j) use ($event) { + return $event['attendees'][$j]; + }, + $attendees + )); + + if ($success = $this->driver->edit_rsvp($event, $status, $updated_attendees)) { + $noreply = rcube_utils::get_input_value('noreply', rcube_utils::INPUT_GPC); + $noreply = intval($noreply) || $status == 'needs-action' || $itip_sending === 0; + $reload = $event['calendar'] != $ev['calendar'] || !empty($event['recurrence']) ? 2 : 1; + $emails = $this->get_user_emails(); + $organizer = null; + + foreach ($event['attendees'] as $i => $attendee) { + if ($attendee['role'] == 'ORGANIZER') { + $organizer = $attendee; + } + else if (!empty($attendee['email']) && in_array(strtolower($attendee['email']), $emails)) { + $reply_sender = $attendee['email']; + } + } + + if (!$noreply) { + $itip = $this->load_itip(); + $itip->set_sender_email($reply_sender); + $event['thisandfuture'] = $event['_savemode'] == 'future'; + + if ($organizer && $itip->send_itip_message($event, 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status)) { + $mailto = !empty($organizer['name']) ? $organizer['name'] : $organizer['email']; + $msg = $this->gettext(['name' => 'sentresponseto', 'vars' => ['mailto' => $mailto]]); + + $this->rc->output->command('display_message', $msg, 'confirmation'); + } + else { + $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); + } + } + + // refresh all calendars + if ($event['calendar'] != $ev['calendar']) { + $this->rc->output->command('plugin.refresh_calendar', ['source' => null, 'refetch' => true]); + $reload = 0; + } + } + + break; + + case "dismiss": + $event['ids'] = explode(',', $event['id']); + $plugin = $this->rc->plugins->exec_hook('dismiss_alarms', $event); + $success = $plugin['success']; + + foreach ($event['ids'] as $id) { + if (strpos($id, 'cal:') === 0) { + $success |= $this->driver->dismiss_alarm(substr($id, 4), $event['snooze']); + } + } + + break; + + case "changelog": + $data = $this->driver->get_event_changelog($event); + 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 (!empty($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.render_event_changelog', $data); + } + else { + $this->rc->output->command('plugin.render_event_changelog', false); + } + + $got_msg = true; + $reload = false; + + break; + + case "diff": + $data = $this->driver->get_event_diff($event, $event['rev1'], $event['rev2']); + if (is_array($data)) { + // convert some properties, similar to self::_client_event() + $lib = $this->lib; + array_walk($data['changes'], function(&$change, $i) use ($event, $lib) { + // convert date cols + foreach (['start', 'end', 'created', 'changed'] as $col) { + if ($change['property'] == $col) { + $change['old'] = $lib->adjust_timezone($change['old'], strlen($change['old']) == 10)->format('c'); + $change['new'] = $lib->adjust_timezone($change['new'], strlen($change['new']) == 10)->format('c'); + } + } + // 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'] == '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']['classname'] = rcube_utils::file2class($change['new']['mimetype'], $change['new']['name']); + } + } + // 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.event_show_diff', $data); + } + else { + $this->rc->output->command('display_message', $this->gettext('objectdiffnotavailable'), 'error'); + } + + $got_msg = true; + $reload = false; + + break; + + case "show": + if ($event = $this->driver->get_event_revison($event, $event['rev'])) { + $this->rc->output->command('plugin.event_show_revision', $this->_client_event($event)); + } + else { + $this->rc->output->command('display_message', $this->gettext('objectnotfound'), 'error'); + } + + $got_msg = true; + $reload = false; + break; + + case "restore": + if ($success = $this->driver->restore_event_revision($event, $event['rev'])) { + $_event = $this->driver->get_event($event); + $reload = $_event['recurrence'] ? 2 : 1; + $msg = $this->gettext(['name' => 'objectrestoresuccess', 'vars' => ['rev' => $event['rev']]]); + $this->rc->output->command('display_message', $msg, 'confirmation'); + $this->rc->output->command('plugin.close_history_dialog'); + } + else { + $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error'); + $reload = 0; + } + + $got_msg = true; + break; + } + + // show confirmation/error message + if (!$got_msg) { + if ($success) { + $this->rc->output->show_message('successfullysaved', 'confirmation'); + } + else { + $this->rc->output->show_message('calendar.errorsaving', 'error'); + } + } + + // unlock client + $this->rc->output->command('plugin.unlock_saving', $success); + + // update event object on the client or trigger a complete refresh if too complicated + if ($reload && empty($_REQUEST['_framed'])) { + $args = ['source' => $event['calendar']]; + if ($reload > 1) { + $args['refetch'] = true; + } + else if ($success && $action != 'remove') { + $args['update'] = $this->_client_event($this->driver->get_event($event), true); + } + $this->rc->output->command('plugin.refresh_calendar', $args); + } + } + + /** + * Helper method sending iTip notifications after successful event updates + */ + private function event_save_success(&$event, $old, $action, $success) + { + // $success is a new event ID + if ($success !== true) { + // send update notification on the main event + if ($event['_savemode'] == 'future' && !empty($event['_notify']) + && !empty($old['attendees']) && !empty($old['recurrence_id']) + ) { + $master = $this->driver->get_event(['id' => $old['recurrence_id'], 'calendar' => $old['calendar']], 0, true); + unset($master['_instance'], $master['recurrence_date']); + + $sent = $this->notify_attendees($master, null, $action, $event['_comment'], false); + if ($sent < 0) { + $this->rc->output->show_message('calendar.errornotifying', 'error'); + } + + $event['attendees'] = $master['attendees']; // this tricks us into the next if clause + } + + // delete old reference if saved as new + if ($event['_savemode'] == 'future' || $event['_savemode'] == 'new') { + $old = null; + } + + $event['id'] = $success; + $event['_savemode'] = 'all'; + } + + // send out notifications + if (!empty($event['_notify']) && (!empty($event['attendees']) || !empty($old['attendees']))) { + $_savemode = $event['_savemode']; + + // send notification for the main event when savemode is 'all' + if ($action != 'remove' && $_savemode == 'all' + && (!empty($event['recurrence_id']) || !empty($old['recurrence_id']) || ($old && $old['id'] != $event['id'])) + ) { + if (!empty($event['recurrence_id'])) { + $event['id'] = $event['recurrence_id']; + } + else if (!empty($old['recurrence_id'])) { + $event['id'] = $old['recurrence_id']; + } + else { + $event['id'] = $old['id']; + } + $event = $this->driver->get_event($event, 0, true); + unset($event['_instance'], $event['recurrence_date']); + } + else { + // make sure we have the complete record + $event = $action == 'remove' ? $old : $this->driver->get_event($event, 0, true); + } + + $event['_savemode'] = $_savemode; + + if ($old) { + $old['thisandfuture'] = $_savemode == 'future'; + } + + // only notify if data really changed (TODO: do diff check on client already) + if (!$old || $action == 'remove' || self::event_diff($event, $old)) { + $comment = isset($event['_comment']) ? $event['_comment'] : null; + $sent = $this->notify_attendees($event, $old, $action, $comment); + + if ($sent > 0) { + $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation'); + } + else if ($sent < 0) { + $this->rc->output->show_message('calendar.errornotifying', 'error'); + } + } + } + } + + /** + * Handler for load-requests from fullcalendar + * This will return pure JSON formatted output + */ + function load_events() + { + $start = $this->input_timestamp('start', rcube_utils::INPUT_GET); + $end = $this->input_timestamp('end', rcube_utils::INPUT_GET); + $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GET); + $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GET); + + $events = $this->driver->load_events($start, $end, $query, $source); + echo $this->encode($events, !empty($query)); + exit; + } + + /** + * Handler for requests fetching event counts for calendars + */ + public function count_events() + { + // don't update session on these requests (avoiding race conditions) + $this->rc->session->nowrite = true; + + $start = rcube_utils::get_input_value('start', rcube_utils::INPUT_GET); + $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GET); + $end = rcube_utils::get_input_value('end', rcube_utils::INPUT_GET); + + if (!$start) { + $start = new DateTime('today 00:00:00', $this->timezone); + $start = $start->format('U'); + } + + $counts = $this->driver->count_events($source, $start, $end); + + $this->rc->output->command('plugin.update_counts', ['counts' => $counts]); + } + + /** + * Load event data from an iTip message attachment + */ + public function itip_events($msgref) + { + $path = explode('/', $msgref); + $msg = array_pop($path); + $mbox = join('/', $path); + list($uid, $mime_id) = explode('#', $msg); + $events = []; + + if ($event = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event')) { + $partstat = 'NEEDS-ACTION'; + + $event['id'] = $event['uid']; + $event['temporary'] = true; + $event['readonly'] = true; + $event['calendar'] = '--invitation--itip'; + $event['className'] = 'fc-invitation-' . strtolower($partstat); + $event['_mbox'] = $mbox; + $event['_uid'] = $uid; + $event['_part'] = $mime_id; + + $events[] = $this->_client_event($event, true); + + // add recurring instances + if (!empty($event['recurrence'])) { + // Some installations can't handle all occurrences (aborting the request w/o an error in log) + $freq = !empty($event['recurrence']['FREQ']) ? $event['recurrence']['FREQ'] : null; + $end = clone $event['start']; + $end->add(new DateInterval($freq == 'DAILY' ? 'P1Y' : 'P10Y')); + + foreach ($this->driver->get_recurring_events($event, $event['start'], $end) as $recurring) { + $recurring['temporary'] = true; + $recurring['readonly'] = true; + $recurring['calendar'] = '--invitation--itip'; + + $events[] = $this->_client_event($recurring, true); + } + } + } + + return $events; + } + + /** + * Handler for keep-alive requests + * This will check for updated data in active calendars and sync them to the client + */ + public function refresh($attr) + { + // refresh the entire calendar every 10th time to also sync deleted events + if (rand(0, 10) == 10) { + $this->rc->output->command('plugin.refresh_calendar', ['refetch' => true]); + return; + } + + $counts = []; + + foreach ($this->driver->list_calendars(calendar_driver::FILTER_ACTIVE) as $cal) { + $events = $this->driver->load_events( + rcube_utils::get_input_value('start', rcube_utils::INPUT_GPC), + rcube_utils::get_input_value('end', rcube_utils::INPUT_GPC), + rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC), + $cal['id'], + 1, + $attr['last'] + ); + + foreach ($events as $event) { + $this->rc->output->command( + 'plugin.refresh_calendar', + ['source' => $cal['id'], 'update' => $this->_client_event($event)] + ); + } + + // refresh count for this calendar + if (!empty($cal['counts'])) { + $today = new DateTime('today 00:00:00', $this->timezone); + $counts += $this->driver->count_events($cal['id'], $today->format('U')); + } + } + + if (!empty($counts)) { + $this->rc->output->command('plugin.update_counts', ['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(); + + $time = !empty($p['time']) ? $p['time'] : time(); + + if ($alarms = $this->driver->pending_alarms($time)) { + foreach ($alarms as $alarm) { + $alarm['id'] = 'cal:' . $alarm['id']; // prefix ID with cal: + $p['alarms'][] = $alarm; + } + } + + // get alarms for birthdays calendar + if ( + $this->rc->config->get('calendar_contact_birthdays') + && $this->rc->config->get('calendar_birthdays_alarm_type') == 'DISPLAY' + ) { + $cache = $this->rc->get_cache('calendar.birthdayalarms', 'db'); + + foreach ($this->driver->load_birthday_events($time, $time + 86400 * 60) as $e) { + $alarm = libcalendaring::get_next_alarm($e); + + // overwrite alarm time with snooze value (or null if dismissed) + if ($dismissed = $cache->get($e['id'])) { + $alarm['time'] = $dismissed['notifyat']; + } + + // add to list if alarm is set + if ($alarm && !empty($alarm['time']) && $alarm['time'] <= $time) { + $e['id'] = 'cal:bday:' . $e['id']; + $e['notifyat'] = $alarm['time']; + $p['alarms'][] = $e; + } + } + } + + return $p; + } + + /** + * Handler for alarm dismiss hook triggered by libcalendaring + */ + public function dismiss_alarms($p) + { + $this->load_driver(); + + foreach ((array) $p['ids'] as $id) { + if (strpos($id, 'cal:bday:') === 0) { + $p['success'] |= $this->driver->dismiss_birthday_alarm(substr($id, 9), $p['snooze']); + } + else if (strpos($id, 'cal:') === 0) { + $p['success'] |= $this->driver->dismiss_alarm(substr($id, 4), $p['snooze']); + } + } + + return $p; + } + + /** + * Handler for check-recent requests which are accidentally sent to calendar + */ + function check_recent() + { + // NOP + $this->rc->output->send(); + } + + /** + * Hook triggered when a contact is saved + */ + function contact_update($p) + { + // clear birthdays calendar cache + if (!empty($p['record']['birthday'])) { + $cache = $this->rc->get_cache('calendar.birthdays', 'db'); + $cache->remove(); + } + } + + /** + * + */ + function import_events() + { + // 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 && !empty($_FILES['_data']['tmp_name'])) { + $calendar = rcube_utils::get_input_value('calendar', rcube_utils::INPUT_GPC); + $rangestart = !empty($_REQUEST['_range']) ? date_create("now -" . intval($_REQUEST['_range']) . " months") : 0; + + // 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, $calendar, $rangestart, $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 teh uploaded file directly + $count = $this->import_from_file($_FILES['_data']['tmp_name'], $calendar, $rangestart, $errors); + } + + if ($count) { + $this->rc->output->command('display_message', $this->gettext(['name' => 'importsuccess', 'vars' => ['nr' => $count]]), 'confirmation'); + $this->rc->output->command('plugin.import_success', ['source' => $calendar, 'refetch' => true]); + } + else if (!$errors) { + $this->rc->output->command('display_message', $this->gettext('importnone'), 'notice'); + $this->rc->output->command('plugin.import_success', ['source' => $calendar]); + } + else { + $this->rc->output->command('plugin.import_error', ['message' => $this->gettext('importerror') . ($msg ? ': ' . $msg : '')]); + } + } + else { + if ($err == UPLOAD_ERR_INI_SIZE || $err == UPLOAD_ERR_FORM_SIZE) { + $max = $this->rc->show_bytes(parse_bytes(ini_get('upload_max_filesize'))); + $msg = $this->rc->gettext(['name' => 'filesizeerror', 'vars' => ['size' => $max]]); + } + else { + $msg = $this->rc->gettext('fileuploaderror'); + } + + $this->rc->output->command('plugin.import_error', ['message' => $msg]); + } + + $this->rc->output->send('iframe'); + } + + /** + * Helper function to parse and import a single .ics file + */ + private function import_from_file($filepath, $calendar, $rangestart, &$errors) + { + $user_email = $this->rc->user->get_username(); + $ical = $this->get_ical(); + $errors = !$ical->fopen($filepath); + + $count = $i = 0; + + foreach ($ical as $event) { + // keep the browser connection alive on long import jobs + if (++$i > 100 && $i % 100 == 0) { + echo ""; + ob_flush(); + } + + // TODO: correctly handle recurring events which start before $rangestart + if ($rangestart && $event['end'] < $rangestart + && (empty($event['recurrence']) || (!empty($event['recurrence']['until']) && $event['recurrence']['until'] < $rangestart)) + ) { + continue; + } + + $event['_owner'] = $user_email; + $event['calendar'] = $calendar; + + if ($this->driver->new_event($event)) { + $count++; + } + else { + $errors++; + } + } + + return $count; + } + + /** + * Construct the ics file for exporting events to iCalendar format; + */ + function export_events($terminate = true) + { + $start = rcube_utils::get_input_value('start', rcube_utils::INPUT_GET); + $end = rcube_utils::get_input_value('end', rcube_utils::INPUT_GET); + $event_id = rcube_utils::get_input_value('id', rcube_utils::INPUT_GET); + $attachments = rcube_utils::get_input_value('attachments', rcube_utils::INPUT_GET); + $calid = rcube_utils::get_input_value('source', rcube_utils::INPUT_GET); + + if (!isset($start)) { + $start = 'today -1 year'; + } + if (!is_numeric($start)) { + $start = strtotime($start . ' 00:00:00'); + } + if (!$end) { + $end = 'today +10 years'; + } + if (!is_numeric($end)) { + $end = strtotime($end . ' 23:59:59'); + } + + $filename = $calid; + $calendars = $this->driver->list_calendars(); + $events = []; + + if (!empty($calendars[$calid])) { + $filename = !empty($calendars[$calid]['name']) ? $calendars[$calid]['name'] : $calid; + $filename = asciiwords(html_entity_decode($filename)); // to 7bit ascii + + if (!empty($event_id)) { + if ($event = $this->driver->get_event(['calendar' => $calid, 'id' => $event_id], 0, true)) { + if (!empty($event['recurrence_id'])) { + $event = $this->driver->get_event(['calendar' => $calid, 'id' => $event['recurrence_id']], 0, true); + } + + $events = [$event]; + $filename = asciiwords($event['title']); + + if (empty($filename)) { + $filename = 'event'; + } + } + } + else { + $events = $this->driver->load_events($start, $end, null, $calid, 0); + if (empty($filename)) { + $filename = $calid; + } + } + } + + header("Content-Type: text/calendar"); + header("Content-Disposition: inline; filename=".$filename.'.ics'); + + $this->get_ical()->export($events, '', true, $attachments ? [$this->driver, 'get_attachment_body'] : null); + + if ($terminate) { + exit; + } + } + + /** + * Handler for iCal feed requests + */ + function ical_feed_export() + { + $session_exists = !empty($_SESSION['user_id']); + + // process HTTP auth info + if (!empty($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) { + $_POST['_user'] = $_SERVER['PHP_AUTH_USER']; // used for rcmail::autoselect_host() + $auth = $this->rc->plugins->exec_hook('authenticate', [ + 'host' => $this->rc->autoselect_host(), + 'user' => trim($_SERVER['PHP_AUTH_USER']), + 'pass' => $_SERVER['PHP_AUTH_PW'], + 'cookiecheck' => true, + 'valid' => true, + ]); + + if ($auth['valid'] && !$auth['abort']) { + $this->rc->login($auth['user'], $auth['pass'], $auth['host']); + } + } + + // require HTTP auth + if (empty($_SESSION['user_id'])) { + header('WWW-Authenticate: Basic realm="Kolab Calendar"'); + header('HTTP/1.0 401 Unauthorized'); + exit; + } + + // decode calendar feed hash + $format = 'ics'; + $calhash = rcube_utils::get_input_value('_cal', rcube_utils::INPUT_GET); + + if (preg_match(($suff_regex = '/\.([a-z0-9]{3,5})$/i'), $calhash, $m)) { + $format = strtolower($m[1]); + $calhash = preg_replace($suff_regex, '', $calhash); + } + + if (!strpos($calhash, ':')) { + $calhash = base64_decode($calhash); + } + + list($user, $_GET['source']) = explode(':', $calhash, 2); + + // sanity check user + if ($this->rc->user->get_username() == $user) { + $this->setup(); + $this->load_driver(); + $this->export_events(false); + } + else { + header('HTTP/1.0 404 Not Found'); + } + + // don't save session data + if (!$session_exists) { + session_destroy(); + } + + exit; + } + + /** + * + */ + function load_settings() + { + $this->lib->load_settings(); + $this->defaults += $this->lib->defaults; + + $settings = []; + + // configuration + $settings['default_view'] = (string) $this->rc->config->get('calendar_default_view', $this->defaults['calendar_default_view']); + $settings['timeslots'] = (int) $this->rc->config->get('calendar_timeslots', $this->defaults['calendar_timeslots']); + $settings['first_day'] = (int) $this->rc->config->get('calendar_first_day', $this->defaults['calendar_first_day']); + $settings['first_hour'] = (int) $this->rc->config->get('calendar_first_hour', $this->defaults['calendar_first_hour']); + $settings['work_start'] = (int) $this->rc->config->get('calendar_work_start', $this->defaults['calendar_work_start']); + $settings['work_end'] = (int) $this->rc->config->get('calendar_work_end', $this->defaults['calendar_work_end']); + $settings['agenda_range'] = (int) $this->rc->config->get('calendar_agenda_range', $this->defaults['calendar_agenda_range']); + $settings['event_coloring'] = (int) $this->rc->config->get('calendar_event_coloring', $this->defaults['calendar_event_coloring']); + $settings['time_indicator'] = (int) $this->rc->config->get('calendar_time_indicator', $this->defaults['calendar_time_indicator']); + $settings['invite_shared'] = (int) $this->rc->config->get('calendar_allow_invite_shared', $this->defaults['calendar_allow_invite_shared']); + $settings['itip_notify'] = (int) $this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']); + $settings['show_weekno'] = (int) $this->rc->config->get('calendar_show_weekno', $this->defaults['calendar_show_weekno']); + $settings['default_calendar'] = $this->rc->config->get('calendar_default_calendar'); + $settings['invitation_calendars'] = (bool) $this->rc->config->get('kolab_invitation_calendars', false); + + // 'table' view has been replaced by 'list' view + if ($settings['default_view'] == 'table') { + $settings['default_view'] = 'list'; + } + + // get user identity to create default attendee + if ($this->ui->screen == 'calendar') { + 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'] = [ + 'name' => $identity['name'], + 'email' => strtolower($identity['email']), + 'emails' => ';' . strtolower(join(';', $identity['emails'])) + ]; + } + + // freebusy token authentication URL + if (($url = $this->rc->config->get('calendar_freebusy_session_auth_url')) + && ($uniqueid = $this->rc->config->get('kolab_uniqueid')) + ) { + if ($url === true) { + $url = '/freebusy'; + } + $url = rtrim(rcube_utils::resolve_url($url), '/ '); + $url .= '/' . urlencode($this->rc->get_user_name()); + $url .= '/' . urlencode($uniqueid); + + $settings['freebusy_url'] = $url; + } + + return $settings; + } + + /** + * Encode events as JSON + * + * @param array Events as array + * @param bool Add CSS class names according to calendar and categories + * + * @return string JSON encoded events + */ + function encode($events, $addcss = false) + { + $json = []; + foreach ($events as $event) { + $json[] = $this->_client_event($event, $addcss); + } + return rcube_output::json_serialize($json); + } + + /** + * Convert an event object to be used on the client + */ + private function _client_event($event, $addcss = false) + { + // compose a human readable strings for alarms_text and recurrence_text + if (!empty($event['valarms'])) { + $event['alarms_text'] = libcalendaring::alarms_text($event['valarms']); + $event['valarms'] = libcalendaring::to_client_alarms($event['valarms']); + } + + if (!empty($event['recurrence'])) { + $event['recurrence_text'] = $this->lib->recurrence_text($event['recurrence']); + $event['recurrence'] = $this->lib->to_client_recurrence($event['recurrence'], $event['allday']); + unset($event['recurrence_date']); + } + + if (!empty($event['attachments'])) { + foreach ($event['attachments'] as $k => $attachment) { + $event['attachments'][$k]['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); + + unset($event['attachments'][$k]['data'], $event['attachments'][$k]['content']); + + if (empty($attachment['id'])) { + $event['attachments'][$k]['id'] = $k; + } + } + } + + // convert link URIs references into structs + if (array_key_exists('links', $event)) { + foreach ((array) $event['links'] as $i => $link) { + if (strpos($link, 'imap://') === 0 && ($msgref = $this->driver->get_message_reference($link))) { + $event['links'][$i] = $msgref; + } + } + } + + // check for organizer in attendees list + $organizer = null; + foreach ((array) $event['attendees'] as $i => $attendee) { + if ($attendee['role'] == 'ORGANIZER') { + $organizer = $attendee; + } + if (!empty($attendee['status']) && $attendee['status'] == 'DELEGATED' && empty($attendee['rsvp'])) { + $event['attendees'][$i]['noreply'] = true; + } + else { + unset($event['attendees'][$i]['noreply']); + } + } + + if ($organizer === null && !empty($event['organizer'])) { + $organizer = $event['organizer']; + $organizer['role'] = 'ORGANIZER'; + if (!is_array($event['attendees'])) + $event['attendees'] = [$organizer]; + } + + // Convert HTML description into plain text + if ($this->is_html($event)) { + $h2t = new rcube_html2text($event['description'], false, true, 0); + $event['description'] = trim($h2t->get_text()); + } + + // mapping url => vurl, allday => allDay because of the fullcalendar client script + $event['vurl'] = $event['url']; + $event['allDay'] = !empty($event['allday']); + unset($event['url']); + unset($event['allday']); + + $event['className'] = !empty($event['className']) ? explode(' ', $event['className']) : []; + + if ($event['allDay']) { + $event['end'] = $event['end']->add(new DateInterval('P1D')); + } + + if (!empty($_GET['mode']) && $_GET['mode'] == 'print') { + $event['editable'] = false; + } + + return [ + '_id' => $event['calendar'] . ':' . $event['id'], // unique identifier for fullcalendar + 'start' => $this->lib->adjust_timezone($event['start'], $event['allDay'])->format('c'), + 'end' => $this->lib->adjust_timezone($event['end'], $event['allDay'])->format('c'), + // 'changed' might be empty for event recurrences (Bug #2185) + 'changed' => !empty($event['changed']) ? $this->lib->adjust_timezone($event['changed'])->format('c') : null, + 'created' => !empty($event['created']) ? $this->lib->adjust_timezone($event['created'])->format('c') : null, + 'title' => strval($event['title']), + 'description' => strval($event['description']), + 'location' => strval($event['location']), + ] + $event; + } + + /** + * 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)); + } + + /** + * TEMPORARY: generate random event data for testing + * Create events by opening http:///?_task=calendar&_action=randomdata&_num=500&_date=2014-08-01&_dev=120 + */ + public function generate_randomdata() + { + @set_time_limit(0); + + $num = !empty($_REQUEST['_num']) ? intval($_REQUEST['_num']) : 100; + $date = !empty($_REQUEST['_date']) ? $_REQUEST['_date'] : 'now'; + $dev = !empty($_REQUEST['_dev']) ? $_REQUEST['_dev'] : 30; + $cats = array_keys($this->driver->list_categories()); + $cals = $this->driver->list_calendars(calendar_driver::FILTER_ACTIVE); + $count = 0; + + while ($count++ < $num) { + $spread = intval($dev) * 86400; // days + $refdate = strtotime($date); + $start = round(($refdate + rand(-$spread, $spread)) / 600) * 600; + $duration = round(rand(30, 360) / 30) * 30 * 60; + $allday = rand(0,20) > 18; + $alarm = rand(-30,12) * 5; + $fb = rand(0,2); + + if (date('G', $start) > 23) { + $start -= 3600; + } + + if ($allday) { + $start = strtotime(date('Y-m-d 00:00:00', $start)); + $duration = 86399; + } + + $title = ''; + $len = rand(2, 12); + $words = explode(" ", "The Hough transform is named after Paul Hough who patented the method in 1962." + . " It is a technique which can be used to isolate features of a particular shape within an image." + . " Because it requires that the desired features be specified in some parametric form, the classical" + . " Hough transform is most commonly used for the de- tection of regular curves such as lines, circles," + . " ellipses, etc. A generalized Hough transform can be employed in applications where a simple" + . " analytic description of a feature(s) is not possible. Due to the computational complexity of" + . " the generalized Hough algorithm, we restrict the main focus of this discussion to the classical" + . " Hough transform. Despite its domain restrictions, the classical Hough transform (hereafter" + . " referred to without the classical prefix ) retains many applications, as most manufac- tured" + . " parts (and many anatomical parts investigated in medical imagery) contain feature boundaries" + . " which can be described by regular curves. The main advantage of the Hough transform technique" + . " is that it is tolerant of gaps in feature boundary descriptions and is relatively unaffected" + . " by image noise."); + // $chars = "!# abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890"; + for ($i = 0; $i < $len; $i++) { + $title .= $words[rand(0,count($words)-1)] . " "; + } + + $this->driver->new_event([ + 'uid' => $this->generate_uid(), + 'start' => new DateTime('@'.$start), + 'end' => new DateTime('@'.($start + $duration)), + 'allday' => $allday, + 'title' => rtrim($title), + 'free_busy' => $fb == 2 ? 'outofoffice' : ($fb ? 'busy' : 'free'), + 'categories' => $cats[array_rand($cats)], + 'calendar' => array_rand($cals), + 'alarms' => $alarm > 0 ? "-{$alarm}M:DISPLAY" : '', + 'priority' => rand(0,9), + ]); + } + + $this->rc->output->redirect(''); + } + + /** + * Handler for attachments upload + */ + public function attachment_upload() + { + $handler = new kolab_attachments_handler(); + $handler->attachment_upload(self::SESSION_KEY, 'cal-'); + } + + /** + * 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(); + } + + $event_id = rcube_utils::get_input_value('_event', rcube_utils::INPUT_GPC); + $calendar = rcube_utils::get_input_value('_cal', 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); + + $event = ['id' => $event_id, 'calendar' => $calendar, 'rev' => $rev]; + + if ($calendar == '--invitation--itip') { + $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_GPC); + $part = rcube_utils::get_input_value('_part', rcube_utils::INPUT_GPC); + $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_GPC); + + $event = $this->lib->mail_get_itip_object($mbox, $uid, $part, 'event'); + $attachment = $event['attachments'][$id]; + $attachment['body'] = &$attachment['data']; + } + else { + $attachment = $this->driver->get_attachment($id, $event); + } + + // show part page + if (!empty($_GET['_frame'])) { + $handler->attachment_page($attachment); + } + // deliver attachment content + else if ($attachment) { + if ($calendar != '--invitation--itip') { + $attachment['body'] = $this->driver->get_attachment_body($id, $event); + } + + $handler->attachment_get($attachment); + } + + // if we arrive here, the requested part was not found + header('HTTP/1.1 404 Not Found'); + exit; + } + + /** + * Determine whether the given event description is HTML formatted + */ + private function is_html($event) + { + // check for opening and closing or tags + return preg_match('/<(html|body)(\s+[a-z]|>)/', $event['description'], $m) + && strpos($event['description'], '') > 0; + } + + /** + * Prepares new/edited event properties before save + */ + private function write_preprocess(&$event, $action) + { + // Remove double timezone specification (T2313) + $event['start'] = preg_replace('/\s*\(.*\)/', '', $event['start']); + $event['end'] = preg_replace('/\s*\(.*\)/', '', $event['end']); + + // convert dates into DateTime objects in user's current timezone + $event['start'] = new DateTime($event['start'], $this->timezone); + $event['end'] = new DateTime($event['end'], $this->timezone); + $event['allday'] = !empty($event['allDay']); + unset($event['allDay']); + + // start/end is all we need for 'move' action (#1480) + if ($action == 'move') { + return true; + } + + // convert the submitted recurrence settings + if (!empty($event['recurrence'])) { + $event['recurrence'] = $this->lib->from_client_recurrence($event['recurrence'], $event['start']); + + // align start date with the first occurrence + if (!empty($event['recurrence']) && !empty($event['syncstart']) + && (empty($event['_savemode']) || $event['_savemode'] == 'all') + ) { + $next = $this->find_first_occurrence($event); + + if (!$next) { + $this->rc->output->show_message('calendar.recurrenceerror', 'error'); + return false; + } + else if ($event['start'] != $next) { + $diff = $event['start']->diff($event['end'], true); + + $event['start'] = $next; + $event['end'] = clone $next; + $event['end']->add($diff); + } + } + } + + // convert the submitted alarm values + if (!empty($event['valarms'])) { + $event['valarms'] = libcalendaring::from_client_alarms($event['valarms']); + } + + $attachments = []; + $eventid = 'cal-' . (!empty($event['id']) ? $event['id'] : 'new-event'); + + if (!empty($_SESSION[self::SESSION_KEY]) && $_SESSION[self::SESSION_KEY]['id'] == $eventid) { + if (!empty($_SESSION[self::SESSION_KEY]['attachments'])) { + foreach ($_SESSION[self::SESSION_KEY]['attachments'] as $id => $attachment) { + if (!empty($event['attachments']) && in_array($id, $event['attachments'])) { + $attachments[$id] = $this->rc->plugins->exec_hook('attachment_get', $attachment); + } + } + } + } + + $event['attachments'] = $attachments; + + // convert link references into simple URIs + if (array_key_exists('links', $event)) { + $event['links'] = array_map(function($link) { + return is_array($link) ? $link['uri'] : strval($link); + }, + (array) $event['links'] + ); + } + + // check for organizer in attendees + if ($action == 'new' || $action == 'edit') { + if (empty($event['attendees'])) { + $event['attendees'] = []; + } + + $emails = $this->get_user_emails(); + $organizer = $owner = false; + + foreach ((array) $event['attendees'] as $i => $attendee) { + if ($attendee['role'] == 'ORGANIZER') { + $organizer = $i; + } + if (!empty($attendee['email']) && in_array(strtolower($attendee['email']), $emails)) { + $owner = $i; + } + if (!isset($attendee['rsvp'])) { + $event['attendees'][$i]['rsvp'] = true; + } + else if (is_string($attendee['rsvp'])) { + $event['attendees'][$i]['rsvp'] = $attendee['rsvp'] == 'true' || $attendee['rsvp'] == '1'; + } + } + + if (!empty($event['_identity'])) { + $identity = $this->rc->user->get_identity($event['_identity']); + } + + // set new organizer identity + if ($organizer !== false && $identity) { + $event['attendees'][$organizer]['name'] = $identity['name']; + $event['attendees'][$organizer]['email'] = $identity['email']; + } + // set owner as organizer if yet missing + else if ($organizer === false && $owner !== false) { + $event['attendees'][$owner]['role'] = 'ORGANIZER'; + unset($event['attendees'][$owner]['rsvp']); + } + // fallback to the selected identity + else if ($organizer === false && $identity) { + $event['attendees'][] = [ + 'role' => 'ORGANIZER', + 'name' => $identity['name'], + 'email' => $identity['email'], + ]; + } + } + + // mapping url => vurl because of the fullcalendar client script + if (array_key_exists('vurl', $event)) { + $event['url'] = $event['vurl']; + unset($event['vurl']); + } + + return true; + } + + /** + * Releases some resources after successful event save + */ + private function cleanup_event(&$event) + { + // remove temp. attachment files + if (!empty($_SESSION[self::SESSION_KEY]) && ($eventid = $_SESSION[self::SESSION_KEY]['id'])) { + $this->rc->plugins->exec_hook('attachments_cleanup', ['group' => $eventid]); + $this->rc->session->remove(self::SESSION_KEY); + } + } + + /** + * Send out an invitation/notification to all event attendees + */ + private function notify_attendees($event, $old, $action = 'edit', $comment = null, $rsvp = null) + { + $is_cancelled = false; + if ($action == 'remove' || ($event['status'] == 'CANCELLED' && $old['status'] != $event['status'])) { + $event['cancelled'] = true; + $is_cancelled = true; + } + + if ($rsvp === null) { + $rsvp = !$old || $event['sequence'] > $old['sequence']; + } + + $itip = $this->load_itip(); + $emails = $this->get_user_emails(); + $itip_notify = (int) $this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']); + + // add comment to the iTip attachment + $event['comment'] = $comment; + + // set a valid recurrence-id if this is a recurrence instance + libcalendaring::identify_recurrence_instance($event); + + // compose multipart message using PEAR:Mail_Mime + $method = $action == 'remove' ? 'CANCEL' : 'REQUEST'; + $message = $itip->compose_itip_message($event, $method, $rsvp); + + // list existing attendees from $old event + $old_attendees = []; + if (!empty($old['attendees'])) { + foreach ((array) $old['attendees'] as $attendee) { + $old_attendees[] = $attendee['email']; + } + } + + // send to every attendee + $sent = 0; + $current = []; + foreach ((array) $event['attendees'] as $attendee) { + // skip myself for obvious reasons + if (empty($attendee['email']) || in_array(strtolower($attendee['email']), $emails)) { + continue; + } + + $current[] = strtolower($attendee['email']); + + // skip if notification is disabled for this attendee + if (!empty($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 || $event['sequence'] > $old['sequence']; + $bodytext = $is_cancelled ? 'eventcancelmailbody' : ($is_new ? 'invitationmailbody' : 'eventupdatemailbody'); + $subject = $is_cancelled ? 'eventcancelsubject' : ($is_new ? 'invitationsubject' : ($event['title'] ? 'eventupdatesubject' : 'eventupdatesubjectempty')); + + $event['comment'] = $comment; + + // finally send the message + if ($itip->send_itip_message($event, $method, $attendee, $subject, $bodytext, $message, $is_rsvp)) { + $sent++; + } + else { + $sent = -100; + } + } + + // TODO: on change of a recurring (main) event, also send updates to differing attendess of recurrence exceptions + + // send CANCEL message to removed attendees + if (!empty($old['attendees'])) { + foreach ($old['attendees'] as $attendee) { + if ($attendee['role'] == 'ORGANIZER' + || empty($attendee['email']) + || in_array(strtolower($attendee['email']), $current) + ) { + continue; + } + + $vevent = $old; + $vevent['cancelled'] = $is_cancelled; + $vevent['attendees'] = [$attendee]; + $vevent['comment'] = $comment; + + if ($itip->send_itip_message($vevent, 'CANCEL', $attendee, 'eventcancelsubject', 'eventcancelmailbody')) { + $sent++; + } + else { + $sent = -100; + } + } + } + + return $sent; + } + + /** + * Echo simple free/busy status text for the given user and time range + */ + public function freebusy_status() + { + $email = rcube_utils::get_input_value('email', rcube_utils::INPUT_GPC); + $start = $this->input_timestamp('start', rcube_utils::INPUT_GPC); + $end = $this->input_timestamp('end', rcube_utils::INPUT_GPC); + + if (!$start) $start = time(); + if (!$end) $end = $start + 3600; + + $status = 'UNKNOWN'; + $fbtypemap = [ + calendar::FREEBUSY_UNKNOWN => 'UNKNOWN', + calendar::FREEBUSY_FREE => 'FREE', + calendar::FREEBUSY_BUSY => 'BUSY', + calendar::FREEBUSY_TENTATIVE => 'TENTATIVE', + calendar::FREEBUSY_OOF => 'OUT-OF-OFFICE' + ]; + + // if the backend has free-busy information + $fblist = $this->driver->get_freebusy_list($email, $start, $end); + + if (is_array($fblist)) { + $status = 'FREE'; + + foreach ($fblist as $slot) { + list($from, $to, $type) = $slot; + if ($from < $end && $to > $start) { + $status = isset($type) && !empty($fbtypemap[$type]) ? $fbtypemap[$type] : 'BUSY'; + break; + } + } + } + + // let this information be cached for 5min + $this->rc->output->future_expire_header(300); + + echo $status; + exit; + } + + /** + * Return a list of free/busy time slots within the given period + * Echo data in JSON encoding + */ + public function freebusy_times() + { + $email = rcube_utils::get_input_value('email', rcube_utils::INPUT_GPC); + $start = $this->input_timestamp('start', rcube_utils::INPUT_GPC); + $end = $this->input_timestamp('end', rcube_utils::INPUT_GPC); + $interval = intval(rcube_utils::get_input_value('interval', rcube_utils::INPUT_GPC)); + $strformat = $interval > 60 ? 'Ymd' : 'YmdHis'; + + if (!$start) $start = time(); + if (!$end) $end = $start + 86400 * 30; + if (!$interval) $interval = 60; // 1 hour + + if (!$dte) { + $dts = new DateTime('@'.$start); + $dts->setTimezone($this->timezone); + } + + $fblist = $this->driver->get_freebusy_list($email, $start, $end); + $slots = ''; + + // prepare freebusy list before use (for better performance) + if (is_array($fblist)) { + foreach ($fblist as $idx => $slot) { + list($from, $to, ) = $slot; + + // check for possible all-day times + if (gmdate('His', $from) == '000000' && gmdate('His', $to) == '235959') { + // shift into the user's timezone for sane matching + $fblist[$idx][0] -= $this->gmt_offset; + $fblist[$idx][1] -= $this->gmt_offset; + } + } + } + + // build a list from $start till $end with blocks representing the fb-status + for ($s = 0, $t = $start; $t <= $end; $s++) { + $t_end = $t + $interval * 60; + $dt = new DateTime('@'.$t); + $dt->setTimezone($this->timezone); + + // determine attendee's status + if (is_array($fblist)) { + $status = self::FREEBUSY_FREE; + + foreach ($fblist as $slot) { + list($from, $to, $type) = $slot; + + if ($from < $t_end && $to > $t) { + $status = isset($type) ? $type : self::FREEBUSY_BUSY; + if ($status == self::FREEBUSY_BUSY) { + // can't get any worse :-) + break; + } + } + } + } + else { + $status = self::FREEBUSY_UNKNOWN; + } + + // use most compact format, assume $status is one digit/character + $slots .= $status; + $t = $t_end; + } + + $dte = new DateTime('@'.$t_end); + $dte->setTimezone($this->timezone); - $field_id = 'rcmfd_timeslot'; - $choices = array('1', '2', '3', '4', '6'); - $select = new html_select(array('name' => '_timeslots', 'id' => $field_id)); - $select->add($choices); - $p['blocks']['view']['options']['timeslots'] = array( - 'title' => html::label($field_id, rcube::Q($this->gettext('timeslots'))), - 'content' => $select->show(strval($this->rc->config->get('calendar_timeslots', $this->defaults['calendar_timeslots']))), - ); + // let this information be cached for 5min + $this->rc->output->future_expire_header(300); + + echo rcube_output::json_serialize([ + 'email' => $email, + 'start' => $dts->format('c'), + 'end' => $dte->format('c'), + 'interval' => $interval, + 'slots' => $slots, + ]); + exit; } - if (!isset($no_override['calendar_first_day'])) { - if (!$p['current']) { - $p['blocks']['view']['content'] = true; - return $p; - } - - $field_id = 'rcmfd_firstday'; - $select = new html_select(array('name' => '_first_day', 'id' => $field_id)); - $select->add($this->gettext('sunday'), '0'); - $select->add($this->gettext('monday'), '1'); - $select->add($this->gettext('tuesday'), '2'); - $select->add($this->gettext('wednesday'), '3'); - $select->add($this->gettext('thursday'), '4'); - $select->add($this->gettext('friday'), '5'); - $select->add($this->gettext('saturday'), '6'); - $p['blocks']['view']['options']['first_day'] = array( - 'title' => html::label($field_id, rcube::Q($this->gettext('first_day'))), - 'content' => $select->show(strval($this->rc->config->get('calendar_first_day', $this->defaults['calendar_first_day']))), - ); - } - - if (!isset($no_override['calendar_first_hour'])) { - if (!$p['current']) { - $p['blocks']['view']['content'] = true; - return $p; - } + /** + * Handler for printing calendars + */ + public function print_view() + { + $title = $this->gettext('print'); + + $view = rcube_utils::get_input_value('view', rcube_utils::INPUT_GPC); + if (!in_array($view, ['agendaWeek', 'agendaDay', 'month', 'list'])) { + $view = 'agendaDay'; + } + + $this->rc->output->set_env('view', $view); + + if ($date = rcube_utils::get_input_value('date', rcube_utils::INPUT_GPC)) { + $this->rc->output->set_env('date', $date); + } + + if ($range = rcube_utils::get_input_value('range', rcube_utils::INPUT_GPC)) { + $this->rc->output->set_env('listRange', intval($range)); + } + + if ($search = rcube_utils::get_input_value('search', rcube_utils::INPUT_GPC)) { + $this->rc->output->set_env('search', $search); + $title .= ' "' . $search . '"'; + } + + // Add JS to the page + $this->ui->addJS(); - $time_format = $this->rc->config->get('time_format', libcalendaring::to_php_date_format($this->rc->config->get('calendar_time_format', $this->defaults['calendar_time_format']))); - $select_hours = new html_select(); - for ($h = 0; $h < 24; $h++) - $select_hours->add(date($time_format, mktime($h, 0, 0)), $h); + $this->register_handler('plugin.calendar_css', [$this->ui, 'calendar_css']); + $this->register_handler('plugin.calendar_list', [$this->ui, 'calendar_list']); - $field_id = 'rcmfd_firsthour'; - $p['blocks']['view']['options']['first_hour'] = array( - 'title' => html::label($field_id, rcube::Q($this->gettext('first_hour'))), - 'content' => $select_hours->show($this->rc->config->get('calendar_first_hour', $this->defaults['calendar_first_hour']), array('name' => '_first_hour', 'id' => $field_id)), - ); + $this->rc->output->set_pagetitle($title); + $this->rc->output->send('calendar.print'); } - if (!isset($no_override['calendar_work_start'])) { - if (!$p['current']) { - $p['blocks']['view']['content'] = true; - return $p; - } - - $field_id = 'rcmfd_workstart'; - $work_start = $this->rc->config->get('calendar_work_start', $this->defaults['calendar_work_start']); - $work_end = $this->rc->config->get('calendar_work_end', $this->defaults['calendar_work_end']); - $p['blocks']['view']['options']['workinghours'] = array( - 'title' => html::label($field_id, rcube::Q($this->gettext('workinghours'))), - 'content' => html::div('input-group', - $select_hours->show($work_start, array('name' => '_work_start', 'id' => $field_id)) - . html::span('input-group-append input-group-prepend', html::span('input-group-text',' — ')) - . $select_hours->show($work_end, array('name' => '_work_end', 'id' => $field_id)) - ) - ); - } - - if (!isset($no_override['calendar_event_coloring'])) { - if (!$p['current']) { - $p['blocks']['view']['content'] = true; - return $p; - } + /** + * Compare two event objects and return differing properties + * + * @param array Event A + * @param array Event B + * + * @return array List of differing event properties + */ + public static function event_diff($a, $b) + { + $diff = []; + $ignore = ['changed' => 1, 'attachments' => 1]; + + foreach (array_unique(array_merge(array_keys($a), array_keys($b))) as $key) { + if (empty($ignore[$key]) && $key[0] != '_') { + $av = isset($a[$key]) ? $a[$key] : null; + $bv = isset($b[$key]) ? $b[$key] : null; + + if ($av != $bv) { + $diff[] = $key; + } + } + } + + // only compare number of attachments + $ac = !empty($a['attachments']) ? count($a['attachments']) : 0; + $bc = !empty($b['attachments']) ? count($b['attachments']) : 0; - $field_id = 'rcmfd_coloring'; - $select_colors = new html_select(array('name' => '_event_coloring', 'id' => $field_id)); - $select_colors->add($this->gettext('coloringmode0'), 0); - $select_colors->add($this->gettext('coloringmode1'), 1); - $select_colors->add($this->gettext('coloringmode2'), 2); - $select_colors->add($this->gettext('coloringmode3'), 3); + if ($ac != $bc) { + $diff[] = 'attachments'; + } - $p['blocks']['view']['options']['eventcolors'] = array( - 'title' => html::label($field_id, rcube::Q($this->gettext('eventcoloring'))), - 'content' => $select_colors->show($this->rc->config->get('calendar_event_coloring', $this->defaults['calendar_event_coloring'])), - ); + return $diff; } - // loading driver is expensive, don't do it if not needed - $this->load_driver(); + /** + * Update attendee properties on the given event object + * + * @param array The event object to be altered + * @param array List of hash arrays each represeting an updated/added attendee + */ + public static function merge_attendee_data(&$event, $attendees, $removed = null) + { + if (!empty($attendees) && !is_array($attendees[0])) { + $attendees = [$attendees]; + } + + foreach ($attendees as $attendee) { + $found = false; - if (!isset($no_override['calendar_default_alarm_type']) || !isset($no_override['calendar_default_alarm_offset'])) { - if (!$p['current']) { - $p['blocks']['view']['content'] = true; - return $p; - } + foreach ($event['attendees'] as $i => $candidate) { + if ($candidate['email'] == $attendee['email']) { + $event['attendees'][$i] = $attendee; + $found = true; + break; + } + } + + if (!$found) { + $event['attendees'][] = $attendee; + } + } - $alarm_type = $alarm_offset = ''; + // filter out removed attendees + if (!empty($removed)) { + $event['attendees'] = array_filter($event['attendees'], function($attendee) use ($removed) { + return !in_array($attendee['email'], $removed); + }); + } + } - if (!isset($no_override['calendar_default_alarm_type'])) { - $field_id = 'rcmfd_alarm'; - $select_type = new html_select(array('name' => '_alarm_type', 'id' => $field_id)); - $select_type->add($this->gettext('none'), ''); + /**** Resource management functions ****/ - foreach ($this->driver->alarm_types as $type) { - $select_type->add($this->rc->gettext(strtolower("alarm{$type}option"), 'libcalendaring'), $type); + /** + * Getter for the configured implementation of the resource directory interface + */ + private function resources_directory() + { + if (!empty($this->resources_dir)) { + return $this->resources_dir; } - $alarm_type = $select_type->show($this->rc->config->get('calendar_default_alarm_type', '')); - } + if ($driver_name = $this->rc->config->get('calendar_resources_driver')) { + $driver_class = 'resources_driver_' . $driver_name; - if (!isset($no_override['calendar_default_alarm_offset'])) { - $field_id = 'rcmfd_alarm'; - $input_value = new html_inputfield(array('name' => '_alarm_value', 'id' => $field_id . 'value', 'size' => 3)); - $select_offset = new html_select(array('name' => '_alarm_offset', 'id' => $field_id . 'offset')); + require_once($this->home . '/drivers/resources_driver.php'); + require_once($this->home . '/drivers/' . $driver_name . '/' . $driver_class . '.php'); - foreach (array('-M','-H','-D','+M','+H','+D') as $trigger) { - $select_offset->add($this->rc->gettext('trigger' . $trigger, 'libcalendaring'), $trigger); + $this->resources_dir = new $driver_class($this); } - $preset = libcalendaring::parse_alarm_value($this->rc->config->get('calendar_default_alarm_offset', '-15M')); - $alarm_offset = $input_value->show($preset[0]) . ' ' . $select_offset->show($preset[1]); - } + return $this->resources_dir; + } + + /** + * Handler for resoruce autocompletion requests + */ + public function resources_autocomplete() + { + $search = rcube_utils::get_input_value('_search', rcube_utils::INPUT_GPC, true); + $sid = rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC); + $maxnum = (int)$this->rc->config->get('autocomplete_max', 15); + $results = []; + + if ($directory = $this->resources_directory()) { + foreach ($directory->load_resources($search, $maxnum) as $rec) { + $results[] = [ + 'name' => $rec['name'], + 'email' => $rec['email'], + 'type' => $rec['_type'], + ]; + } + } - $p['blocks']['view']['options']['alarmtype'] = array( - 'title' => html::label($field_id, rcube::Q($this->gettext('defaultalarmtype'))), - 'content' => html::div('input-group', $alarm_type . ' ' . $alarm_offset), - ); + $this->rc->output->command('ksearch_query_results', $results, $search, $sid); + $this->rc->output->send(); } - if (!isset($no_override['calendar_default_calendar'])) { - if (!$p['current']) { - $p['blocks']['view']['content'] = true; - return $p; - } - // default calendar selection - $field_id = 'rcmfd_default_calendar'; - $filter = calendar_driver::FILTER_PERSONAL | calendar_driver::FILTER_ACTIVE | calendar_driver::FILTER_INSERTABLE; - $select_cal = new html_select(array('name' => '_default_calendar', 'id' => $field_id, 'is_escaped' => true)); - foreach ((array)$this->driver->list_calendars($filter) as $id => $prop) { - $select_cal->add($prop['name'], strval($id)); - if ($prop['default']) - $default_calendar = $id; - } - $p['blocks']['view']['options']['defaultcalendar'] = array( - 'title' => html::label($field_id, rcube::Q($this->gettext('defaultcalendar'))), - 'content' => $select_cal->show($this->rc->config->get('calendar_default_calendar', $default_calendar)), - ); - } - - if (!isset($no_override['calendar_show_weekno'])) { - if (!$p['current']) { - $p['blocks']['view']['content'] = true; - return $p; - } + /** + * Handler for load-requests for resource data + */ + function resources_list() + { + $data = []; + + if ($directory = $this->resources_directory()) { + foreach ($directory->load_resources() as $rec) { + $data[] = $rec; + } + } + + $this->rc->output->command('plugin.resource_data', $data); + $this->rc->output->send(); + } - $field_id = 'rcmfd_show_weekno'; - $select = new html_select(array('name' => '_show_weekno', 'id' => $field_id)); - $select->add($this->gettext('weeknonone'), -1); - $select->add($this->gettext('weeknodatepicker'), 0); - $select->add($this->gettext('weeknoall'), 1); + /** + * Handler for requests loading resource owner information + */ + function resources_owner() + { + if ($directory = $this->resources_directory()) { + $id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); + $data = $directory->get_resource_owner($id); + } - $p['blocks']['view']['options']['show_weekno'] = array( - 'title' => html::label($field_id, rcube::Q($this->gettext('showweekno'))), - 'content' => $select->show(intval($this->rc->config->get('calendar_show_weekno'))), - ); + $this->rc->output->command('plugin.resource_owner', $data); + $this->rc->output->send(); } - $p['blocks']['itip']['name'] = $this->gettext('itipoptions'); + /** + * Deliver event data for a resource's calendar + */ + function resources_calendar() + { + $events = []; - // Invitations handling - if (!isset($no_override['calendar_itip_after_action'])) { - if (!$p['current']) { - $p['blocks']['itip']['content'] = true; - return $p; - } - - $field_id = 'rcmfd_after_action'; - $select = new html_select(array('name' => '_after_action', 'id' => $field_id, - 'onchange' => "\$('#{$field_id}_select')[this.value == 4 ? 'show' : 'hide']()")); - - $select->add($this->gettext('afternothing'), ''); - $select->add($this->gettext('aftertrash'), 1); - $select->add($this->gettext('afterdelete'), 2); - $select->add($this->gettext('afterflagdeleted'), 3); - $select->add($this->gettext('aftermoveto'), 4); - - $val = $this->rc->config->get('calendar_itip_after_action', $this->defaults['calendar_itip_after_action']); - if ($val !== null && $val !== '' && !is_int($val)) { - $folder = $val; - $val = 4; - } - - $folders = $this->rc->folder_selector(array( - 'id' => $field_id . '_select', - 'name' => '_after_action_folder', - 'maxlength' => 30, - 'folder_filter' => 'mail', - 'folder_rights' => 'w', - 'style' => $val !== 4 ? 'display:none' : '', - )); - - $p['blocks']['itip']['options']['after_action'] = array( - 'title' => html::label($field_id, rcube::Q($this->gettext('afteraction'))), - 'content' => html::div('input-group input-group-combo', $select->show($val) . $folders->show($folder)), - ); - } - - // category definitions - if (!$this->driver->nocategories && !isset($no_override['calendar_categories'])) { - $p['blocks']['categories']['name'] = $this->gettext('categories'); - - if (!$p['current']) { - $p['blocks']['categories']['content'] = true; - return $p; - } - - $categories = (array) $this->driver->list_categories(); - $categories_list = ''; - foreach ($categories as $name => $color) { - $key = md5($name); - $field_class = 'rcmfd_category_' . str_replace(' ', '_', $name); - $category_remove = html::span('input-group-append', html::a(array( - 'class' => 'button icon delete input-group-text', - 'onclick' => '$(this).parent().parent().remove()', - 'title' => $this->gettext('remove_category'), - 'href' => '#rcmfd_new_category', - ), html::span('inner', $this->gettext('delete')) - )); - $category_name = new html_inputfield(array('name' => "_categories[$key]", 'class' => $field_class, 'size' => 30, 'disabled' => $this->driver->categoriesimmutable)); - $category_color = new html_inputfield(array('name' => "_colors[$key]", 'class' => "$field_class colors", 'size' => 6)); - $hidden = $this->driver->categoriesimmutable ? html::tag('input', array('type' => 'hidden', 'name' => "_categories[$key]", 'value' => $name)) : ''; - $categories_list .= $hidden . html::div('input-group', $category_name->show($name) . $category_color->show($color) . $category_remove); - } - - $p['blocks']['categories']['options']['category_' . $name] = array( - 'content' => html::div(array('id' => 'calendarcategories'), $categories_list), - ); + if ($directory = $this->resources_directory()) { + $id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); + $start = $this->input_timestamp('start', rcube_utils::INPUT_GET); + $end = $this->input_timestamp('end', rcube_utils::INPUT_GET); - $field_id = 'rcmfd_new_category'; - $new_category = new html_inputfield(array('name' => '_new_category', 'id' => $field_id, 'size' => 30)); - $add_category = html::span('input-group-append', html::a(array( - 'type' => 'button', - 'class' => 'button create input-group-text', - 'title' => $this->gettext('add_category'), - 'onclick' => 'rcube_calendar_add_category()', - 'href' => '#rcmfd_new_category', - ), html::span('inner', $this->gettext('add_category')) - )); - $p['blocks']['categories']['options']['categories'] = array( - 'content' => html::div('input-group', $new_category->show('') . $add_category), - ); + $events = $directory->get_resource_calendar($id, $start, $end); + } - $this->rc->output->add_label('delete', 'calendar.remove_category'); - $this->rc->output->add_script('function rcube_calendar_add_category() { - var name = $("#rcmfd_new_category").val(); - if (name.length) { - var button_label = rcmail.gettext("calendar.remove_category"); - var input = $("").attr({type: "text", name: "_categories[]", size: 30, "class": "form-control"}).val(name); - var color = $("").attr({type: "text", name: "_colors[]", size: 6, "class": "colors form-control"}).val("000000"); - var button = $("").attr({"class": "button icon delete input-group-text", title: button_label, href: "#rcmfd_new_category"}) - .click(function() { $(this).parent().parent().remove(); }) - .append($("").addClass("inner").text(rcmail.gettext("delete"))); - - $("
").addClass("input-group").append(input).append(color).append($("").append(button)) - .appendTo("#calendarcategories"); - color.minicolors(rcmail.env.minicolors_config || {}); - $("#rcmfd_new_category").val(""); - } - }', 'foot'); - - $this->rc->output->add_script('$("#rcmfd_new_category").keypress(function(event) { - if (event.which == 13) { - rcube_calendar_add_category(); - event.preventDefault(); - } - }); - ', 'docready'); - - // load miniColors js/css files - jqueryui::miniColors(); - } - - // virtual birthdays calendar - if (!isset($no_override['calendar_contact_birthdays'])) { - $p['blocks']['birthdays']['name'] = $this->gettext('birthdayscalendar'); - - if (!$p['current']) { - $p['blocks']['birthdays']['content'] = true; - return $p; - } - - $field_id = 'rcmfd_contact_birthdays'; - $input = new html_checkbox(array('name' => '_contact_birthdays', 'id' => $field_id, 'value' => 1, 'onclick' => '$(".calendar_birthday_props").prop("disabled",!this.checked)')); - - $p['blocks']['birthdays']['options']['contact_birthdays'] = array( - 'title' => html::label($field_id, $this->gettext('displaybirthdayscalendar')), - 'content' => $input->show($this->rc->config->get('calendar_contact_birthdays')?1:0), - ); - - $input_attrib = array( - 'class' => 'calendar_birthday_props', - 'disabled' => !$this->rc->config->get('calendar_contact_birthdays'), - ); - - $sources = array(); - $checkbox = new html_checkbox(array('name' => '_birthday_adressbooks[]') + $input_attrib); - foreach ($this->rc->get_address_sources(false, true) as $source) { - $active = in_array($source['id'], (array)$this->rc->config->get('calendar_birthday_adressbooks', array())) ? $source['id'] : ''; - $sources[] = html::tag('li', null, html::label(null, $checkbox->show($active, array('value' => $source['id'])) . rcube::Q($source['realname'] ?: $source['name']))); - } - - $p['blocks']['birthdays']['options']['birthday_adressbooks'] = array( - 'title' => rcube::Q($this->gettext('birthdayscalendarsources')), - 'content' => html::tag('ul', 'proplist', implode("\n", $sources)), - ); - - $field_id = 'rcmfd_birthdays_alarm'; - $select_type = new html_select(array('name' => '_birthdays_alarm_type', 'id' => $field_id) + $input_attrib); - $select_type->add($this->gettext('none'), ''); - foreach ($this->driver->alarm_types as $type) { - $select_type->add($this->rc->gettext(strtolower("alarm{$type}option"), 'libcalendaring'), $type); - } - - $input_value = new html_inputfield(array('name' => '_birthdays_alarm_value', 'id' => $field_id . 'value', 'size' => 3) + $input_attrib); - $select_offset = new html_select(array('name' => '_birthdays_alarm_offset', 'id' => $field_id . 'offset') + $input_attrib); - foreach (array('-M','-H','-D') as $trigger) - $select_offset->add($this->rc->gettext('trigger' . $trigger, 'libcalendaring'), $trigger); - - $preset = libcalendaring::parse_alarm_value($this->rc->config->get('calendar_birthdays_alarm_offset', '-1D')); - $preset_type = $this->rc->config->get('calendar_birthdays_alarm_type', ''); - - $p['blocks']['birthdays']['options']['birthdays_alarmoffset'] = array( - 'title' => html::label($field_id, rcube::Q($this->gettext('showalarms'))), - 'content' => html::div('input-group', $select_type->show($preset_type) . $input_value->show($preset[0]) . ' ' . $select_offset->show($preset[1])), - ); - } - - return $p; - } - - /** - * Handler for preferences_save hook. - * Executed on Calendar settings form submit. - * - * @param array Original parameters - * @return array Modified parameters - */ - function preferences_save($p) - { - if ($p['section'] == 'calendar') { - $this->load_driver(); - - // compose default alarm preset value - $alarm_offset = rcube_utils::get_input_value('_alarm_offset', rcube_utils::INPUT_POST); - $alarm_value = rcube_utils::get_input_value('_alarm_value', rcube_utils::INPUT_POST); - $default_alarm = $alarm_offset[0] . intval($alarm_value) . $alarm_offset[1]; - - $birthdays_alarm_offset = rcube_utils::get_input_value('_birthdays_alarm_offset', rcube_utils::INPUT_POST); - $birthdays_alarm_value = rcube_utils::get_input_value('_birthdays_alarm_value', rcube_utils::INPUT_POST); - $birthdays_alarm_value = $birthdays_alarm_offset[0] . intval($birthdays_alarm_value) . $birthdays_alarm_offset[1]; - - $p['prefs'] = array( - 'calendar_default_view' => rcube_utils::get_input_value('_default_view', rcube_utils::INPUT_POST), - 'calendar_timeslots' => intval(rcube_utils::get_input_value('_timeslots', rcube_utils::INPUT_POST)), - 'calendar_first_day' => intval(rcube_utils::get_input_value('_first_day', rcube_utils::INPUT_POST)), - 'calendar_first_hour' => intval(rcube_utils::get_input_value('_first_hour', rcube_utils::INPUT_POST)), - 'calendar_work_start' => intval(rcube_utils::get_input_value('_work_start', rcube_utils::INPUT_POST)), - 'calendar_work_end' => intval(rcube_utils::get_input_value('_work_end', rcube_utils::INPUT_POST)), - 'calendar_show_weekno' => intval(rcube_utils::get_input_value('_show_weekno', rcube_utils::INPUT_POST)), - 'calendar_event_coloring' => intval(rcube_utils::get_input_value('_event_coloring', rcube_utils::INPUT_POST)), - 'calendar_default_alarm_type' => rcube_utils::get_input_value('_alarm_type', rcube_utils::INPUT_POST), - 'calendar_default_alarm_offset' => $default_alarm, - 'calendar_default_calendar' => rcube_utils::get_input_value('_default_calendar', rcube_utils::INPUT_POST), - 'calendar_date_format' => null, // clear previously saved values - 'calendar_time_format' => null, - 'calendar_contact_birthdays' => rcube_utils::get_input_value('_contact_birthdays', rcube_utils::INPUT_POST) ? true : false, - 'calendar_birthday_adressbooks' => (array) rcube_utils::get_input_value('_birthday_adressbooks', rcube_utils::INPUT_POST), - 'calendar_birthdays_alarm_type' => rcube_utils::get_input_value('_birthdays_alarm_type', rcube_utils::INPUT_POST), - 'calendar_birthdays_alarm_offset' => $birthdays_alarm_value ?: null, - 'calendar_itip_after_action' => intval(rcube_utils::get_input_value('_after_action', rcube_utils::INPUT_POST)), - ); - - if ($p['prefs']['calendar_itip_after_action'] == 4) { - $p['prefs']['calendar_itip_after_action'] = rcube_utils::get_input_value('_after_action_folder', rcube_utils::INPUT_POST, true); - } - - // categories - if (!$this->driver->nocategories) { - $old_categories = $new_categories = array(); - foreach ($this->driver->list_categories() as $name => $color) { - $old_categories[md5($name)] = $name; - } - - $categories = (array) rcube_utils::get_input_value('_categories', rcube_utils::INPUT_POST); - $colors = (array) rcube_utils::get_input_value('_colors', rcube_utils::INPUT_POST); - - foreach ($categories as $key => $name) { - if (!isset($colors[$key])) { - continue; - } - - $color = preg_replace('/^#/', '', strval($colors[$key])); - - // rename categories in existing events -> driver's job - if ($oldname = $old_categories[$key]) { - $this->driver->replace_category($oldname, $name, $color); - unset($old_categories[$key]); - } - else - $this->driver->add_category($name, $color); - - $new_categories[$name] = $color; - } - - // these old categories have been removed, alter events accordingly -> driver's job - foreach ((array)$old_categories[$key] as $key => $name) { - $this->driver->remove_category($name); - } - - $p['prefs']['calendar_categories'] = $new_categories; - } - } - - return $p; - } - - /** - * Dispatcher for calendar actions initiated by the client - */ - function calendar_action() - { - $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); - $cal = rcube_utils::get_input_value('c', rcube_utils::INPUT_GPC); - $success = $reload = false; - - if (isset($cal['showalarms'])) - $cal['showalarms'] = intval($cal['showalarms']); - - switch ($action) { - case "form-new": - case "form-edit": - echo $this->ui->calendar_editform($action, $cal); + echo $this->encode($events); exit; - case "new": - $success = $this->driver->create_calendar($cal); - $reload = true; - break; - case "edit": - $success = $this->driver->edit_calendar($cal); - $reload = true; - break; - case "delete": - if ($success = $this->driver->delete_calendar($cal)) - $this->rc->output->command('plugin.destroy_source', array('id' => $cal['id'])); - break; - case "subscribe": - if (!$this->driver->subscribe_calendar($cal)) - $this->rc->output->show_message($this->gettext('errorsaving'), 'error'); - else { - $calendars = $this->driver->list_calendars(); - $calendar = $calendars[$cal['id']]; - - // find parent folder and check if it's a "user calendar" - // if it's also activated we need to refresh it (#5340) - while ($calendar['parent']) { - if (isset($calendars[$calendar['parent']])) - $calendar = $calendars[$calendar['parent']]; - else - break; - } - - if ($calendar['id'] != $cal['id'] && $calendar['active'] && $calendar['group'] == "other user") - $this->rc->output->command('plugin.refresh_source', $calendar['id']); - } - return; - case "search": - $results = array(); - $color_mode = $this->rc->config->get('calendar_event_coloring', $this->defaults['calendar_event_coloring']); - $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_calendars($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->calendar_list_item($id, $prop, $jsenv); - $cal = $jsenv[$id]; - $cal['editname'] = $editname; - $cal['html'] = $html; - if (!empty($prop['color'])) - $cal['css'] = $this->ui->calendar_css_classes($id, $prop, $color_mode); - - $results[] = $cal; - } - // report more results available - if ($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 { - $error_msg = $this->gettext('errorsaving') . ($this->driver->last_error ? ': ' . $this->driver->last_error :''); - $this->rc->output->show_message($error_msg, 'error'); - } - - $this->rc->output->command('plugin.unlock_saving'); - - if ($success && $reload) - $this->rc->output->command('plugin.reload_view'); - } - - - /** - * Dispatcher for event actions initiated by the client - */ - function event_action() - { - $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); - $event = rcube_utils::get_input_value('e', rcube_utils::INPUT_POST, true); - $success = $reload = $got_msg = false; - - // read old event data in order to find changes - if (($event['_notify'] || $event['_decline']) && $action != 'new') { - $old = $this->driver->get_event($event); - - // load main event if savemode is 'all' or if deleting 'future' events - if (($event['_savemode'] == 'all' || ($event['_savemode'] == 'future' && $action == 'remove' && !$event['_decline'])) && $old['recurrence_id']) { - $old['id'] = $old['recurrence_id']; - $old = $this->driver->get_event($old); - } - } - - switch ($action) { - case "new": - // create UID for new event - $event['uid'] = $this->generate_uid(); - if (!$this->write_preprocess($event, $action)) { - $got_msg = true; - } - else if ($success = $this->driver->new_event($event)) { - $event['id'] = $event['uid']; - $event['_savemode'] = 'all'; - $this->cleanup_event($event); - $this->event_save_success($event, null, $action, true); - } - $reload = $success && $event['recurrence'] ? 2 : 1; - break; - - case "edit": - if (!$this->write_preprocess($event, $action)) { - $got_msg = true; - } - else if ($success = $this->driver->edit_event($event)) { - $this->cleanup_event($event); - $this->event_save_success($event, $old, $action, $success); - } - $reload = $success && ($event['recurrence'] || $event['_savemode'] || $event['_fromcalendar']) ? 2 : 1; - break; - - case "resize": - if (!$this->write_preprocess($event, $action)) { - $got_msg = true; - } - else if ($success = $this->driver->resize_event($event)) { - $this->event_save_success($event, $old, $action, $success); - } - $reload = $event['_savemode'] ? 2 : 1; - break; - - case "move": - if (!$this->write_preprocess($event, $action)) { - $got_msg = true; - } - else if ($success = $this->driver->move_event($event)) { - $this->event_save_success($event, $old, $action, $success); - } - $reload = $success && $event['_savemode'] ? 2 : 1; - break; - - case "remove": - // remove previous deletes - $undo_time = $this->driver->undelete ? $this->rc->config->get('undo_timeout', 0) : 0; + } - // search for event if only UID is given - if (!isset($event['calendar']) && $event['uid']) { - if (!($event = $this->driver->get_event($event, calendar_driver::FILTER_WRITEABLE))) { - break; - } - $undo_time = 0; - } - - // Note: the driver is responsible for setting $_SESSION['calendar_event_undo'] - // containing 'ts' and 'data' elements - $success = $this->driver->remove_event($event, $undo_time < 1); - $reload = (!$success || $event['_savemode']) ? 2 : 1; - - if ($undo_time > 0 && $success) { - // display message with Undo link. - $msg = html::span(null, $this->gettext('successremoval')) - . ' ' . html::a(array('onclick' => sprintf("%s.http_request('event', 'action=undo', %s.display_message('', 'loading'))", - rcmail_output::JS_OBJECT_NAME, rcmail_output::JS_OBJECT_NAME)), $this->gettext('undo')); - $this->rc->output->show_message($msg, 'confirmation', null, true, $undo_time); - $got_msg = true; - } - else if ($success) { - $this->rc->output->show_message('calendar.successremoval', 'confirmation'); - $got_msg = true; - } - - // send cancellation for the main event - if ($event['_savemode'] == 'all') { - unset($old['_instance'], $old['recurrence_date'], $old['recurrence_id']); - } - // send an update for the main event's recurrence rule instead of a cancellation message - else if ($event['_savemode'] == 'future' && $success !== false && $success !== true) { - $event['_savemode'] = 'all'; // force event_save_success() to load master event - $action = 'edit'; - $success = true; - } - - // send iTIP reply that participant has declined the event - if ($success && $event['_decline']) { - $emails = $this->get_user_emails(); - foreach ($old['attendees'] as $i => $attendee) { - if ($attendee['role'] == 'ORGANIZER') - $organizer = $attendee; - else if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { - $old['attendees'][$i]['status'] = 'DECLINED'; - $reply_sender = $attendee['email']; - } - } - - if ($event['_savemode'] == 'future' && $event['id'] != $old['id']) { - $old['thisandfuture'] = true; - } - - $itip = $this->load_itip(); - $itip->set_sender_email($reply_sender); - if ($organizer && $itip->send_itip_message($old, 'REPLY', $organizer, 'itipsubjectdeclined', 'itipmailbodydeclined')) - $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ? $organizer['name'] : $organizer['email']))), 'confirmation'); - else - $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); - } - else if ($success) { - $this->event_save_success($event, $old, $action, $success); + /**** Event invitation plugin hooks ****/ + + /** + * Find an event in user calendars + */ + protected function find_event($event, &$mode) + { + $this->load_driver(); + + // We search for writeable calendars in personal namespace by default + $mode = calendar_driver::FILTER_WRITEABLE | calendar_driver::FILTER_PERSONAL; + $result = $this->driver->get_event($event, $mode); + // ... now check shared folders if not found + if (!$result) { + $result = $this->driver->get_event($event, calendar_driver::FILTER_WRITEABLE | calendar_driver::FILTER_SHARED); + if ($result) { + $mode |= calendar_driver::FILTER_SHARED; + } } - break; - case "undo": - // Restore deleted event - if ($event = $_SESSION['calendar_event_undo']['data']) - $success = $this->driver->restore_event($event); + return $result; + } - if ($success) { - $this->rc->session->remove('calendar_event_undo'); - $this->rc->output->show_message('calendar.successrestore', 'confirmation'); - $got_msg = true; - $reload = 2; - } - - break; - - case "rsvp": - $itip_sending = $this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']); - $status = rcube_utils::get_input_value('status', rcube_utils::INPUT_POST); - $attendees = rcube_utils::get_input_value('attendees', rcube_utils::INPUT_POST); - $reply_comment = $event['comment']; - - $this->write_preprocess($event, 'edit'); - $ev = $this->driver->get_event($event); - $ev['attendees'] = $event['attendees']; - $ev['free_busy'] = $event['free_busy']; - $ev['_savemode'] = $event['_savemode']; - $ev['comment'] = $reply_comment; - - // send invitation to delegatee + add it as attendee - if ($status == 'delegated' && $event['to']) { - $itip = $this->load_itip(); - if ($itip->delegate_to($ev, $event['to'], (bool)$event['rsvp'], $attendees)) { - $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation'); - $noreply = false; - } - } - - $event = $ev; - - // compose a list of attendees affected by this change - $updated_attendees = array_filter(array_map(function($j) use ($event) { - return $event['attendees'][$j]; - }, $attendees)); - - if ($success = $this->driver->edit_rsvp($event, $status, $updated_attendees)) { - $noreply = rcube_utils::get_input_value('noreply', rcube_utils::INPUT_GPC); - $noreply = intval($noreply) || $status == 'needs-action' || $itip_sending === 0; - $reload = $event['calendar'] != $ev['calendar'] || $event['recurrence'] ? 2 : 1; - $organizer = null; - $emails = $this->get_user_emails(); - - foreach ($event['attendees'] as $i => $attendee) { - if ($attendee['role'] == 'ORGANIZER') { - $organizer = $attendee; + /** + * Handler for calendar/itip-status requests + */ + function event_itip_status() + { + $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); + + $this->load_driver(); + + // find local copy of the referenced event (in personal namespace) + $existing = $this->find_event($data, $mode); + $is_shared = $mode & calendar_driver::FILTER_SHARED; + $itip = $this->load_itip(); + $response = $itip->get_itip_status($data, $existing); + + // get a list of writeable calendars to save new events to + if ( + (!$existing || $is_shared) + && empty($data['nosave']) + && ($response['action'] == 'rsvp' || $response['action'] == 'import') + ) { + $calendars = $this->driver->list_calendars($mode); + $calendar_select = new html_select([ + 'name' => 'calendar', + 'id' => 'itip-saveto', + 'is_escaped' => true, + 'class' => 'form-control custom-select' + ]); + + $calendar_select->add('--', ''); + $numcals = 0; + foreach ($calendars as $calendar) { + if (!empty($calendar['editable'])) { + $calendar_select->add($calendar['name'], $calendar['id']); + $numcals++; + } } - else if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { - $reply_sender = $attendee['email']; + if ($numcals < 1) { + $calendar_select = null; } - } - - if (!$noreply) { - $itip = $this->load_itip(); - $itip->set_sender_email($reply_sender); - $event['thisandfuture'] = $event['_savemode'] == 'future'; - if ($organizer && $itip->send_itip_message($event, 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status)) - $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ? $organizer['name'] : $organizer['email']))), 'confirmation'); - else - $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); - } - - // refresh all calendars - if ($event['calendar'] != $ev['calendar']) { - $this->rc->output->command('plugin.refresh_calendar', array('source' => null, 'refetch' => true)); - $reload = 0; - } - } - break; - - case "dismiss": - $event['ids'] = explode(',', $event['id']); - $plugin = $this->rc->plugins->exec_hook('dismiss_alarms', $event); - $success = $plugin['success']; - foreach ($event['ids'] as $id) { - if (strpos($id, 'cal:') === 0) - $success |= $this->driver->dismiss_alarm(substr($id, 4), $event['snooze']); - } - break; - - case "changelog": - $data = $this->driver->get_event_changelog($event); - 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.render_event_changelog', $data); } - else { - $this->rc->output->command('plugin.render_event_changelog', false); - } - $got_msg = true; - $reload = false; - break; - - case "diff": - $data = $this->driver->get_event_diff($event, $event['rev1'], $event['rev2']); - if (is_array($data)) { - // convert some properties, similar to self::_client_event() - $lib = $this->lib; - array_walk($data['changes'], function(&$change, $i) use ($event, $lib) { - // convert date cols - foreach (array('start','end','created','changed') as $col) { - if ($change['property'] == $col) { - $change['old'] = $lib->adjust_timezone($change['old'], strlen($change['old']) == 10)->format('c'); - $change['new'] = $lib->adjust_timezone($change['new'], strlen($change['new']) == 10)->format('c'); - } - } - // 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'] == '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']['classname'] = rcube_utils::file2class($change['new']['mimetype'], $change['new']['name']); - } - // 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.event_show_diff', $data); + + if (!empty($calendar_select)) { + $default_calendar = $this->get_default_calendar($data['sensitivity'], $calendars); + $response['select'] = html::span('folder-select', $this->gettext('saveincalendar') + . ' ' + . $calendar_select->show($is_shared ? $existing['calendar'] : $default_calendar['id']) + ); } - else { - $this->rc->output->command('display_message', $this->gettext('objectdiffnotavailable'), 'error'); + else if (!empty($data['nosave'])) { + $response['select'] = html::tag('input', ['type' => 'hidden', 'name' => 'calendar', 'id' => 'itip-saveto', 'value' => '']); } - $got_msg = true; - $reload = false; - break; - case "show": - if ($event = $this->driver->get_event_revison($event, $event['rev'])) { - $this->rc->output->command('plugin.event_show_revision', $this->_client_event($event)); + // render small agenda view for the respective day + if ($data['method'] == 'REQUEST' && !empty($data['date']) && $response['action'] == 'rsvp') { + $event_start = rcube_utils::anytodatetime($data['date']); + $day_start = new Datetime(gmdate('Y-m-d 00:00', $data['date']), $this->lib->timezone); + $day_end = new Datetime(gmdate('Y-m-d 23:59', $data['date']), $this->lib->timezone); + + // get events on that day from the user's personal calendars + $calendars = $this->driver->list_calendars(calendar_driver::FILTER_PERSONAL); + $events = $this->driver->load_events($day_start->format('U'), $day_end->format('U'), null, array_keys($calendars)); + + usort($events, function($a, $b) { return $a['start'] > $b['start'] ? 1 : -1; }); + + $before = $after = []; + foreach ($events as $event) { + // TODO: skip events with free_busy == 'free' ? + if ($event['uid'] == $data['uid'] + || $event['end'] < $day_start || $event['start'] > $day_end + || $event['status'] == 'CANCELLED' + || (!empty($event['className']) && strpos($event['className'], 'declined') !== false) + ) { + continue; + } + + if ($event['start'] < $event_start) { + $before[] = $this->mail_agenda_event_row($event); + } + else { + $after[] = $this->mail_agenda_event_row($event); + } + } + + $response['append'] = [ + 'selector' => '.calendar-agenda-preview', + 'replacements' => [ + '%before%' => !empty($before) ? join("\n", array_slice($before, -3)) : html::div('event-row no-event', $this->gettext('noearlierevents')), + '%after%' => !empty($after) ? join("\n", array_slice($after, 0, 3)) : html::div('event-row no-event', $this->gettext('nolaterevents')), + ], + ]; } - else { - $this->rc->output->command('display_message', $this->gettext('objectnotfound'), 'error'); + + $this->rc->output->command('plugin.update_itip_object_status', $response); + } + + /** + * Handler for calendar/itip-remove requests + */ + function event_itip_remove() + { + $uid = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST); + $instance = rcube_utils::get_input_value('_instance', rcube_utils::INPUT_POST); + $savemode = rcube_utils::get_input_value('_savemode', rcube_utils::INPUT_POST); + $listmode = calendar_driver::FILTER_WRITEABLE | calendar_driver::FILTER_PERSONAL; + $success = false; + + // search for event if only UID is given + if ($event = $this->driver->get_event(['uid' => $uid, '_instance' => $instance], $listmode)) { + $event['_savemode'] = $savemode; + $success = $this->driver->remove_event($event, true); } - $got_msg = true; - $reload = false; - break; - case "restore": - if ($success = $this->driver->restore_event_revision($event, $event['rev'])) { - $_event = $this->driver->get_event($event); - $reload = $_event['recurrence'] ? 2 : 1; - $this->rc->output->command('display_message', $this->gettext(array('name' => 'objectrestoresuccess', 'vars' => array('rev' => $event['rev']))), 'confirmation'); - $this->rc->output->command('plugin.close_history_dialog'); + if ($success) { + $this->rc->output->show_message('calendar.successremoval', 'confirmation'); } else { - $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error'); - $reload = 0; - } - $got_msg = true; - break; - } - - // show confirmation/error message - if (!$got_msg) { - if ($success) - $this->rc->output->show_message('successfullysaved', 'confirmation'); - else - $this->rc->output->show_message('calendar.errorsaving', 'error'); - } - - // unlock client - $this->rc->output->command('plugin.unlock_saving', $success); - - // update event object on the client or trigger a complete refresh if too complicated - if ($reload && empty($_REQUEST['_framed'])) { - $args = array('source' => $event['calendar']); - if ($reload > 1) - $args['refetch'] = true; - else if ($success && $action != 'remove') - $args['update'] = $this->_client_event($this->driver->get_event($event), true); - $this->rc->output->command('plugin.refresh_calendar', $args); - } - } - - /** - * Helper method sending iTip notifications after successful event updates - */ - private function event_save_success(&$event, $old, $action, $success) - { - // $success is a new event ID - if ($success !== true) { - // send update notification on the main event - if ($event['_savemode'] == 'future' && $event['_notify'] && $old['attendees'] && $old['recurrence_id']) { - $master = $this->driver->get_event(array('id' => $old['recurrence_id'], 'calendar' => $old['calendar']), 0, true); - unset($master['_instance'], $master['recurrence_date']); - - $sent = $this->notify_attendees($master, null, $action, $event['_comment'], false); - if ($sent < 0) - $this->rc->output->show_message('calendar.errornotifying', 'error'); - - $event['attendees'] = $master['attendees']; // this tricks us into the next if clause - } - - // delete old reference if saved as new - if ($event['_savemode'] == 'future' || $event['_savemode'] == 'new') { - $old = null; - } - - $event['id'] = $success; - $event['_savemode'] = 'all'; - } - - // send out notifications - if ($event['_notify'] && ($event['attendees'] || $old['attendees'])) { - $_savemode = $event['_savemode']; - - // send notification for the main event when savemode is 'all' - if ($action != 'remove' && $_savemode == 'all' && ($event['recurrence_id'] || $old['recurrence_id'] || ($old && $old['id'] != $event['id']))) { - $event['id'] = $event['recurrence_id'] ?: ($old['recurrence_id'] ?: $old['id']); - $event = $this->driver->get_event($event, 0, true); - unset($event['_instance'], $event['recurrence_date']); - } - else { - // make sure we have the complete record - $event = $action == 'remove' ? $old : $this->driver->get_event($event, 0, true); - } - - $event['_savemode'] = $_savemode; - - if ($old) { - $old['thisandfuture'] = $_savemode == 'future'; - } - - // only notify if data really changed (TODO: do diff check on client already) - if (!$old || $action == 'remove' || self::event_diff($event, $old)) { - $sent = $this->notify_attendees($event, $old, $action, $event['_comment']); - if ($sent > 0) - $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation'); - else if ($sent < 0) - $this->rc->output->show_message('calendar.errornotifying', 'error'); - } - } - } - - /** - * Handler for load-requests from fullcalendar - * This will return pure JSON formatted output - */ - function load_events() - { - $start = $this->input_timestamp('start', rcube_utils::INPUT_GET); - $end = $this->input_timestamp('end', rcube_utils::INPUT_GET); - $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GET); - $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GET); - - $events = $this->driver->load_events($start, $end, $query, $source); - echo $this->encode($events, !empty($query)); - exit; - } - - /** - * Handler for requests fetching event counts for calendars - */ - public function count_events() - { - // don't update session on these requests (avoiding race conditions) - $this->rc->session->nowrite = true; - - $start = rcube_utils::get_input_value('start', rcube_utils::INPUT_GET); - if (!$start) { - $start = new DateTime('today 00:00:00', $this->timezone); - $start = $start->format('U'); - } - - $counts = $this->driver->count_events( - rcube_utils::get_input_value('source', rcube_utils::INPUT_GET), - $start, - rcube_utils::get_input_value('end', rcube_utils::INPUT_GET) - ); - - $this->rc->output->command('plugin.update_counts', array('counts' => $counts)); - } - - /** - * Load event data from an iTip message attachment - */ - public function itip_events($msgref) - { - $path = explode('/', $msgref); - $msg = array_pop($path); - $mbox = join('/', $path); - list($uid, $mime_id) = explode('#', $msg); - $events = array(); - - if ($event = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event')) { - $partstat = 'NEEDS-ACTION'; -/* - $user_emails = $this->lib->get_user_emails(); - foreach ($event['attendees'] as $attendee) { - if (in_array($attendee['email'], $user_emails)) { - $partstat = $attendee['status']; - break; - } - } -*/ - $event['id'] = $event['uid']; - $event['temporary'] = true; - $event['readonly'] = true; - $event['calendar'] = '--invitation--itip'; - $event['className'] = 'fc-invitation-' . strtolower($partstat); - $event['_mbox'] = $mbox; - $event['_uid'] = $uid; - $event['_part'] = $mime_id; - - $events[] = $this->_client_event($event, true); - - // add recurring instances - if (!empty($event['recurrence'])) { - // Some installations can't handle all occurrences (aborting the request w/o an error in log) - $end = clone $event['start']; - $end->add(new DateInterval($event['recurrence']['FREQ'] == 'DAILY' ? 'P1Y' : 'P10Y')); - - foreach ($this->driver->get_recurring_events($event, $event['start'], $end) as $recurring) { - $recurring['temporary'] = true; - $recurring['readonly'] = true; - $recurring['calendar'] = '--invitation--itip'; - $events[] = $this->_client_event($recurring, true); - } - } - } - - return $events; - } - - /** - * Handler for keep-alive requests - * This will check for updated data in active calendars and sync them to the client - */ - public function refresh($attr) - { - // refresh the entire calendar every 10th time to also sync deleted events - if (rand(0,10) == 10) { - $this->rc->output->command('plugin.refresh_calendar', array('refetch' => true)); - return; - } - - $counts = array(); - - foreach ($this->driver->list_calendars(calendar_driver::FILTER_ACTIVE) as $cal) { - $events = $this->driver->load_events( - rcube_utils::get_input_value('start', rcube_utils::INPUT_GPC), - rcube_utils::get_input_value('end', rcube_utils::INPUT_GPC), - rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC), - $cal['id'], - 1, - $attr['last'] - ); - - foreach ($events as $event) { - $this->rc->output->command('plugin.refresh_calendar', - array('source' => $cal['id'], 'update' => $this->_client_event($event))); - } - - // refresh count for this calendar - if ($cal['counts']) { - $today = new DateTime('today 00:00:00', $this->timezone); - $counts += $this->driver->count_events($cal['id'], $today->format('U')); - } - } - - if (!empty($counts)) { - $this->rc->output->command('plugin.update_counts', array('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(); - $time = $p['time'] ?: time(); - if ($alarms = $this->driver->pending_alarms($time)) { - foreach ($alarms as $alarm) { - $alarm['id'] = 'cal:' . $alarm['id']; // prefix ID with cal: - $p['alarms'][] = $alarm; - } - } - - // get alarms for birthdays calendar - if ($this->rc->config->get('calendar_contact_birthdays') && $this->rc->config->get('calendar_birthdays_alarm_type') == 'DISPLAY') { - $cache = $this->rc->get_cache('calendar.birthdayalarms', 'db'); - - foreach ($this->driver->load_birthday_events($time, $time + 86400 * 60) as $e) { - $alarm = libcalendaring::get_next_alarm($e); - - // overwrite alarm time with snooze value (or null if dismissed) - if ($dismissed = $cache->get($e['id'])) - $alarm['time'] = $dismissed['notifyat']; - - // add to list if alarm is set - if ($alarm && $alarm['time'] && $alarm['time'] <= $time) { - $e['id'] = 'cal:bday:' . $e['id']; - $e['notifyat'] = $alarm['time']; - $p['alarms'][] = $e; - } - } - } - - return $p; - } - - /** - * Handler for alarm dismiss hook triggered by libcalendaring - */ - public function dismiss_alarms($p) - { - $this->load_driver(); - foreach ((array)$p['ids'] as $id) { - if (strpos($id, 'cal:bday:') === 0) { - $p['success'] |= $this->driver->dismiss_birthday_alarm(substr($id, 9), $p['snooze']); - } - else if (strpos($id, 'cal:') === 0) { - $p['success'] |= $this->driver->dismiss_alarm(substr($id, 4), $p['snooze']); - } - } - - return $p; - } - - /** - * Handler for check-recent requests which are accidentally sent to calendar - */ - function check_recent() - { - // NOP - $this->rc->output->send(); - } - - /** - * Hook triggered when a contact is saved - */ - function contact_update($p) - { - // clear birthdays calendar cache - if (!empty($p['record']['birthday'])) { - $cache = $this->rc->get_cache('calendar.birthdays', 'db'); - $cache->remove(); - } - } - - /** - * - */ - function import_events() - { - // 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']) { - $calendar = rcube_utils::get_input_value('calendar', rcube_utils::INPUT_GPC); - $rangestart = $_REQUEST['_range'] ? date_create("now -" . intval($_REQUEST['_range']) . " months") : 0; - - // 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, $calendar, $rangestart, $errors); - unlink($tmpfile); - } - } - } - - rmdir($tmpdir); - $zip->close(); - } - else { - $errors = 1; - $msg = 'Failed to open zip file.'; - } + $this->rc->output->show_message('calendar.errorsaving', 'error'); } - else { - $errors = 1; - $msg = 'Zip files are not supported for import.'; - } - } - else { - // attempt to import teh uploaded file directly - $count = $this->import_from_file($_FILES['_data']['tmp_name'], $calendar, $rangestart, $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' => $calendar, 'refetch' => true)); - } - else if (!$errors) { - $this->rc->output->command('display_message', $this->gettext('importnone'), 'notice'); - $this->rc->output->command('plugin.import_success', array('source' => $calendar)); - } - 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, $calendar, $rangestart, &$errors) - { - $user_email = $this->rc->user->get_username(); - - $ical = $this->get_ical(); - $errors = !$ical->fopen($filepath); - $count = $i = 0; - foreach ($ical as $event) { - // keep the browser connection alive on long import jobs - if (++$i > 100 && $i % 100 == 0) { - echo ""; - ob_flush(); - } - - // TODO: correctly handle recurring events which start before $rangestart - if ($event['end'] < $rangestart && (!$event['recurrence'] || ($event['recurrence']['until'] && $event['recurrence']['until'] < $rangestart))) - continue; - - $event['_owner'] = $user_email; - $event['calendar'] = $calendar; - if ($this->driver->new_event($event)) { - $count++; - } - else { - $errors++; - } - } - - return $count; - } - - - /** - * Construct the ics file for exporting events to iCalendar format; - */ - function export_events($terminate = true) - { - $start = rcube_utils::get_input_value('start', rcube_utils::INPUT_GET); - $end = rcube_utils::get_input_value('end', rcube_utils::INPUT_GET); - - if (!isset($start)) - $start = 'today -1 year'; - if (!is_numeric($start)) - $start = strtotime($start . ' 00:00:00'); - if (!$end) - $end = 'today +10 years'; - if (!is_numeric($end)) - $end = strtotime($end . ' 23:59:59'); - - $event_id = rcube_utils::get_input_value('id', rcube_utils::INPUT_GET); - $attachments = rcube_utils::get_input_value('attachments', rcube_utils::INPUT_GET); - $calid = $filename = rcube_utils::get_input_value('source', rcube_utils::INPUT_GET); - - $calendars = $this->driver->list_calendars(); - $events = array(); - - if ($calendars[$calid]) { - $filename = $calendars[$calid]['name'] ? $calendars[$calid]['name'] : $calid; - $filename = asciiwords(html_entity_decode($filename)); // to 7bit ascii - if (!empty($event_id)) { - if ($event = $this->driver->get_event(array('calendar' => $calid, 'id' => $event_id), 0, true)) { - if ($event['recurrence_id']) { - $event = $this->driver->get_event(array('calendar' => $calid, 'id' => $event['recurrence_id']), 0, true); - } - $events = array($event); - $filename = asciiwords($event['title']); - if (empty($filename)) - $filename = 'event'; - } - } - else { - $events = $this->driver->load_events($start, $end, null, $calid, 0); - if (empty($filename)) - $filename = $calid; - } - } - - header("Content-Type: text/calendar"); - header("Content-Disposition: inline; filename=".$filename.'.ics'); - - $this->get_ical()->export($events, '', true, $attachments ? array($this->driver, 'get_attachment_body') : null); - - if ($terminate) - exit; - } - - - /** - * Handler for iCal feed requests - */ - function ical_feed_export() - { - $session_exists = !empty($_SESSION['user_id']); - - // process HTTP auth info - if (!empty($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) { - $_POST['_user'] = $_SERVER['PHP_AUTH_USER']; // used for rcmail::autoselect_host() - $auth = $this->rc->plugins->exec_hook('authenticate', array( - 'host' => $this->rc->autoselect_host(), - 'user' => trim($_SERVER['PHP_AUTH_USER']), - 'pass' => $_SERVER['PHP_AUTH_PW'], - 'cookiecheck' => true, - 'valid' => true, - )); - if ($auth['valid'] && !$auth['abort']) - $this->rc->login($auth['user'], $auth['pass'], $auth['host']); - } - - // require HTTP auth - if (empty($_SESSION['user_id'])) { - header('WWW-Authenticate: Basic realm="Roundcube Calendar"'); - header('HTTP/1.0 401 Unauthorized'); - exit; - } - - // decode calendar feed hash - $format = 'ics'; - $calhash = rcube_utils::get_input_value('_cal', rcube_utils::INPUT_GET); - if (preg_match(($suff_regex = '/\.([a-z0-9]{3,5})$/i'), $calhash, $m)) { - $format = strtolower($m[1]); - $calhash = preg_replace($suff_regex, '', $calhash); - } - - if (!strpos($calhash, ':')) - $calhash = base64_decode($calhash); - - list($user, $_GET['source']) = explode(':', $calhash, 2); - - // sanity check user - if ($this->rc->user->get_username() == $user) { - $this->setup(); - $this->load_driver(); - $this->export_events(false); - } - else { - header('HTTP/1.0 404 Not Found'); - } - - // don't save session data - if (!$session_exists) - session_destroy(); - exit; - } - - /** - * - */ - function load_settings() - { - $this->lib->load_settings(); - $this->defaults += $this->lib->defaults; - - $settings = array(); - - // configuration - $settings['default_view'] = (string) $this->rc->config->get('calendar_default_view', $this->defaults['calendar_default_view']); - $settings['timeslots'] = (int) $this->rc->config->get('calendar_timeslots', $this->defaults['calendar_timeslots']); - $settings['first_day'] = (int) $this->rc->config->get('calendar_first_day', $this->defaults['calendar_first_day']); - $settings['first_hour'] = (int) $this->rc->config->get('calendar_first_hour', $this->defaults['calendar_first_hour']); - $settings['work_start'] = (int) $this->rc->config->get('calendar_work_start', $this->defaults['calendar_work_start']); - $settings['work_end'] = (int) $this->rc->config->get('calendar_work_end', $this->defaults['calendar_work_end']); - $settings['agenda_range'] = (int) $this->rc->config->get('calendar_agenda_range', $this->defaults['calendar_agenda_range']); - $settings['event_coloring'] = (int) $this->rc->config->get('calendar_event_coloring', $this->defaults['calendar_event_coloring']); - $settings['time_indicator'] = (int) $this->rc->config->get('calendar_time_indicator', $this->defaults['calendar_time_indicator']); - $settings['invite_shared'] = (int) $this->rc->config->get('calendar_allow_invite_shared', $this->defaults['calendar_allow_invite_shared']); - $settings['itip_notify'] = (int) $this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']); - $settings['show_weekno'] = (int) $this->rc->config->get('calendar_show_weekno', $this->defaults['calendar_show_weekno']); - $settings['default_calendar'] = $this->rc->config->get('calendar_default_calendar'); - $settings['invitation_calendars'] = (bool) $this->rc->config->get('kolab_invitation_calendars', false); - - // 'table' view has been replaced by 'list' view - if ($settings['default_view'] == 'table') { - $settings['default_view'] = 'list'; - } - - // get user identity to create default attendee - if ($this->ui->screen == 'calendar') { - foreach ($this->rc->user->list_emails() as $rec) { - if (!$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']))); - } - - // freebusy token authentication URL - if (($url = $this->rc->config->get('calendar_freebusy_session_auth_url')) - && ($uniqueid = $this->rc->config->get('kolab_uniqueid')) - ) { - if ($url === true) $url = '/freebusy'; - $url = rtrim(rcube_utils::resolve_url($url), '/ '); - $url .= '/' . urlencode($this->rc->get_user_name()); - $url .= '/' . urlencode($uniqueid); - - $settings['freebusy_url'] = $url; - } - - return $settings; - } - - /** - * Encode events as JSON - * - * @param array Events as array - * @param boolean Add CSS class names according to calendar and categories - * @return string JSON encoded events - */ - function encode($events, $addcss = false) - { - $json = array(); - foreach ($events as $event) { - $json[] = $this->_client_event($event, $addcss); - } - return rcube_output::json_serialize($json); - } - - /** - * Convert an event object to be used on the client - */ - private function _client_event($event, $addcss = false) - { - // compose a human readable strings for alarms_text and recurrence_text - if ($event['valarms']) { - $event['alarms_text'] = libcalendaring::alarms_text($event['valarms']); - $event['valarms'] = libcalendaring::to_client_alarms($event['valarms']); - } - if ($event['recurrence']) { - $event['recurrence_text'] = $this->lib->recurrence_text($event['recurrence']); - $event['recurrence'] = $this->lib->to_client_recurrence($event['recurrence'], $event['allday']); - unset($event['recurrence_date']); - } - - foreach ((array)$event['attachments'] as $k => $attachment) { - $event['attachments'][$k]['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); - - unset($event['attachments'][$k]['data'], $event['attachments'][$k]['content']); - - if (!$attachment['id']) { - $event['attachments'][$k]['id'] = $k; - } - } - - // convert link URIs references into structs - if (array_key_exists('links', $event)) { - foreach ((array) $event['links'] as $i => $link) { - if (strpos($link, 'imap://') === 0 && ($msgref = $this->driver->get_message_reference($link))) { - $event['links'][$i] = $msgref; - } - } - } - - // check for organizer in attendees list - $organizer = null; - foreach ((array)$event['attendees'] as $i => $attendee) { - if ($attendee['role'] == 'ORGANIZER') { - $organizer = $attendee; - } - if ($attendee['status'] == 'DELEGATED' && $attendee['rsvp'] == false) { - $event['attendees'][$i]['noreply'] = true; - } - else { - unset($event['attendees'][$i]['noreply']); - } - } - - if ($organizer === null && !empty($event['organizer'])) { - $organizer = $event['organizer']; - $organizer['role'] = 'ORGANIZER'; - if (!is_array($event['attendees'])) - $event['attendees'] = array(); - array_unshift($event['attendees'], $organizer); - } - - // Convert HTML description into plain text - if ($this->is_html($event)) { - $h2t = new rcube_html2text($event['description'], false, true, 0); - $event['description'] = trim($h2t->get_text()); - } - - // mapping url => vurl, allday => allDay because of the fullcalendar client script - $event['vurl'] = $event['url']; - $event['allDay'] = !empty($event['allday']); - unset($event['url']); - unset($event['allday']); - - $event['className'] = $event['className'] ? explode(' ', $event['className']) : array(); - - if ($event['allDay']) { - $event['end'] = $event['end']->add(new DateInterval('P1D')); - } - - if ($_GET['mode'] == 'print') { - $event['editable'] = false; - } - - return array( - '_id' => $event['calendar'] . ':' . $event['id'], // unique identifier for fullcalendar - 'start' => $this->lib->adjust_timezone($event['start'], $event['allDay'])->format('c'), - 'end' => $this->lib->adjust_timezone($event['end'], $event['allDay'])->format('c'), - // 'changed' might be empty for event recurrences (Bug #2185) - 'changed' => $event['changed'] ? $this->lib->adjust_timezone($event['changed'])->format('c') : null, - 'created' => $event['created'] ? $this->lib->adjust_timezone($event['created'])->format('c') : null, - 'title' => strval($event['title']), - 'description' => strval($event['description']), - 'location' => strval($event['location']), - ) + $event; - } - - - /** - * 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)); - } - - - /** - * TEMPORARY: generate random event data for testing - * Create events by opening http:///?_task=calendar&_action=randomdata&_num=500&_date=2014-08-01&_dev=120 - */ - public function generate_randomdata() - { - @set_time_limit(0); - - $num = $_REQUEST['_num'] ? intval($_REQUEST['_num']) : 100; - $date = $_REQUEST['_date'] ?: 'now'; - $dev = $_REQUEST['_dev'] ?: 30; - $cats = array_keys($this->driver->list_categories()); - $cals = $this->driver->list_calendars(calendar_driver::FILTER_ACTIVE); - $count = 0; - - while ($count++ < $num) { - $spread = intval($dev) * 86400; // days - $refdate = strtotime($date); - $start = round(($refdate + rand(-$spread, $spread)) / 600) * 600; - $duration = round(rand(30, 360) / 30) * 30 * 60; - $allday = rand(0,20) > 18; - $alarm = rand(-30,12) * 5; - $fb = rand(0,2); - - if (date('G', $start) > 23) - $start -= 3600; - - if ($allday) { - $start = strtotime(date('Y-m-d 00:00:00', $start)); - $duration = 86399; - } - - $title = ''; - $len = rand(2, 12); - $words = explode(" ", "The Hough transform is named after Paul Hough who patented the method in 1962. It is a technique which can be used to isolate features of a particular shape within an image. Because it requires that the desired features be specified in some parametric form, the classical Hough transform is most commonly used for the de- tection of regular curves such as lines, circles, ellipses, etc. A generalized Hough transform can be employed in applications where a simple analytic description of a feature(s) is not possible. Due to the computational complexity of the generalized Hough algorithm, we restrict the main focus of this discussion to the classical Hough transform. Despite its domain restrictions, the classical Hough transform (hereafter referred to without the classical prefix ) retains many applications, as most manufac- tured parts (and many anatomical parts investigated in medical imagery) contain feature boundaries which can be described by regular curves. The main advantage of the Hough transform technique is that it is tolerant of gaps in feature boundary descriptions and is relatively unaffected by image noise."); -// $chars = "!# abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890"; - for ($i = 0; $i < $len; $i++) - $title .= $words[rand(0,count($words)-1)] . " "; - - $this->driver->new_event(array( - 'uid' => $this->generate_uid(), - 'start' => new DateTime('@'.$start), - 'end' => new DateTime('@'.($start + $duration)), - 'allday' => $allday, - 'title' => rtrim($title), - 'free_busy' => $fb == 2 ? 'outofoffice' : ($fb ? 'busy' : 'free'), - 'categories' => $cats[array_rand($cats)], - 'calendar' => array_rand($cals), - 'alarms' => $alarm > 0 ? "-{$alarm}M:DISPLAY" : '', - 'priority' => rand(0,9), - )); - } - - $this->rc->output->redirect(''); - } - - /** - * Handler for attachments upload - */ - public function attachment_upload() - { - $handler = new kolab_attachments_handler(); - $handler->attachment_upload(self::SESSION_KEY, 'cal-'); - } - - /** - * 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(); - } - - $event_id = rcube_utils::get_input_value('_event', rcube_utils::INPUT_GPC); - $calendar = rcube_utils::get_input_value('_cal', 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); - - $event = array('id' => $event_id, 'calendar' => $calendar, 'rev' => $rev); - - if ($calendar == '--invitation--itip') { - $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_GPC); - $part = rcube_utils::get_input_value('_part', rcube_utils::INPUT_GPC); - $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_GPC); - - $event = $this->lib->mail_get_itip_object($mbox, $uid, $part, 'event'); - $attachment = $event['attachments'][$id]; - $attachment['body'] = &$attachment['data']; - } - else { - $attachment = $this->driver->get_attachment($id, $event); - } - - // show part page - if (!empty($_GET['_frame'])) { - $handler->attachment_page($attachment); - } - // deliver attachment content - else if ($attachment) { - if ($calendar != '--invitation--itip') { - $attachment['body'] = $this->driver->get_attachment_body($id, $event); - } - - $handler->attachment_get($attachment); - } - - // if we arrive here, the requested part was not found - header('HTTP/1.1 404 Not Found'); - exit; - } - - /** - * Determine whether the given event description is HTML formatted - */ - private function is_html($event) - { - // check for opening and closing or tags - return (preg_match('/<(html|body)(\s+[a-z]|>)/', $event['description'], $m) && strpos($event['description'], '') > 0); - } - - /** - * Prepares new/edited event properties before save - */ - private function write_preprocess(&$event, $action) - { - // Remove double timezone specification (T2313) - $event['start'] = preg_replace('/\s*\(.*\)/', '', $event['start']); - $event['end'] = preg_replace('/\s*\(.*\)/', '', $event['end']); - - // convert dates into DateTime objects in user's current timezone - $event['start'] = new DateTime($event['start'], $this->timezone); - $event['end'] = new DateTime($event['end'], $this->timezone); - $event['allday'] = !empty($event['allDay']); - unset($event['allDay']); - - // start/end is all we need for 'move' action (#1480) - if ($action == 'move') { - return true; - } - - // convert the submitted recurrence settings - if (is_array($event['recurrence'])) { - $event['recurrence'] = $this->lib->from_client_recurrence($event['recurrence'], $event['start']); - - // align start date with the first occurrence - if (!empty($event['recurrence']) && !empty($event['syncstart']) - && (empty($event['_savemode']) || $event['_savemode'] == 'all') - ) { - $next = $this->find_first_occurrence($event); - - if (!$next) { - $this->rc->output->show_message('calendar.recurrenceerror', 'error'); - return false; - } - else if ($event['start'] != $next) { - $diff = $event['start']->diff($event['end'], true); - - $event['start'] = $next; - $event['end'] = clone $next; - $event['end']->add($diff); - } - } - } - - // convert the submitted alarm values - if ($event['valarms']) { - $event['valarms'] = libcalendaring::from_client_alarms($event['valarms']); - } - - $attachments = array(); - $eventid = 'cal-'.$event['id']; - - if (is_array($_SESSION[self::SESSION_KEY]) && $_SESSION[self::SESSION_KEY]['id'] == $eventid) { - if (!empty($_SESSION[self::SESSION_KEY]['attachments'])) { - foreach ($_SESSION[self::SESSION_KEY]['attachments'] as $id => $attachment) { - if (is_array($event['attachments']) && in_array($id, $event['attachments'])) { - $attachments[$id] = $this->rc->plugins->exec_hook('attachment_get', $attachment); - } - } - } - } - - $event['attachments'] = $attachments; - - // convert link references into simple URIs - if (array_key_exists('links', $event)) { - $event['links'] = array_map(function($link) { - return is_array($link) ? $link['uri'] : strval($link); - }, (array)$event['links']); - } - - // check for organizer in attendees - if ($action == 'new' || $action == 'edit') { - if (!$event['attendees']) - $event['attendees'] = array(); - - $emails = $this->get_user_emails(); - $organizer = $owner = false; - foreach ((array)$event['attendees'] as $i => $attendee) { - if ($attendee['role'] == 'ORGANIZER') - $organizer = $i; - if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) - $owner = $i; - if (!isset($attendee['rsvp'])) - $event['attendees'][$i]['rsvp'] = true; - else if (is_string($attendee['rsvp'])) - $event['attendees'][$i]['rsvp'] = $attendee['rsvp'] == 'true' || $attendee['rsvp'] == '1'; - } - - if (!empty($event['_identity'])) { - $identity = $this->rc->user->get_identity($event['_identity']); - } - - // set new organizer identity - if ($organizer !== false && $identity) { - $event['attendees'][$organizer]['name'] = $identity['name']; - $event['attendees'][$organizer]['email'] = $identity['email']; - } - // set owner as organizer if yet missing - else if ($organizer === false && $owner !== false) { - $event['attendees'][$owner]['role'] = 'ORGANIZER'; - unset($event['attendees'][$owner]['rsvp']); - } - // fallback to the selected identity - else if ($organizer === false && $identity) { - $event['attendees'][] = array( - 'role' => 'ORGANIZER', - 'name' => $identity['name'], - 'email' => $identity['email'], - ); - } } - // mapping url => vurl because of the fullcalendar client script - if (array_key_exists('vurl', $event)) { - $event['url'] = $event['vurl']; - unset($event['vurl']); - } + /** + * Handler for URLs that allow an invitee to respond on his invitation mail + */ + public function itip_attend_response($p) + { + $this->setup(); - return true; - } + if ($p['action'] == 'attend') { + $this->ui->init(); - /** - * Releases some resources after successful event save - */ - private function cleanup_event(&$event) - { - // remove temp. attachment files - if (!empty($_SESSION[self::SESSION_KEY]) && ($eventid = $_SESSION[self::SESSION_KEY]['id'])) { - $this->rc->plugins->exec_hook('attachments_cleanup', array('group' => $eventid)); - $this->rc->session->remove(self::SESSION_KEY); - } - } + $this->rc->output->set_env('task', 'calendar'); // override some env vars + $this->rc->output->set_env('refresh_interval', 0); + $this->rc->output->set_pagetitle($this->gettext('calendar')); - /** - * Send out an invitation/notification to all event attendees - */ - private function notify_attendees($event, $old, $action = 'edit', $comment = null, $rsvp = null) - { - if ($action == 'remove' || ($event['status'] == 'CANCELLED' && $old['status'] != $event['status'])) { - $event['cancelled'] = true; - $is_cancelled = true; - } + $itip = $this->load_itip(); + $token = rcube_utils::get_input_value('_t', rcube_utils::INPUT_GPC); - if ($rsvp === null) - $rsvp = !$old || $event['sequence'] > $old['sequence']; + // read event info stored under the given token + if ($invitation = $itip->get_invitation($token)) { + $this->token = $token; + $this->event = $invitation['event']; - $itip = $this->load_itip(); - $emails = $this->get_user_emails(); - $itip_notify = (int)$this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']); + // show message about cancellation + if (!empty($invitation['cancelled'])) { + $this->invitestatus = html::div('rsvp-status declined', $itip->gettext('eventcancelled')); + } + // save submitted RSVP status + else if (!empty($_POST['rsvp'])) { + $status = null; + foreach (['accepted', 'tentative', 'declined'] as $method) { + if ($_POST['rsvp'] == $itip->gettext('itip' . $method)) { + $status = $method; + break; + } + } + + // send itip reply to organizer + $invitation['event']['comment'] = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST); + if ($status && $itip->update_invitation($invitation, $invitation['attendee'], strtoupper($status))) { + $this->invitestatus = html::div('rsvp-status ' . strtolower($status), $itip->gettext('youhave'.strtolower($status))); + } + else { + $this->rc->output->command('display_message', $this->gettext('errorsaving'), 'error', -1); + } + + // if user is logged in... + // FIXME: we should really consider removing this functionality + // it's confusing that it creates/updates an event only for logged-in user + // what if the logged-in user is not the same as the attendee? + if ($this->rc->user->ID) { + $this->load_driver(); + + $invitation = $itip->get_invitation($token); + $existing = $this->driver->get_event($this->event); + + // save the event to his/her default calendar if not yet present + if (!$existing && ($calendar = $this->get_default_calendar($invitation['event']['sensitivity']))) { + $invitation['event']['calendar'] = $calendar['id']; + if ($this->driver->new_event($invitation['event'])) { + $msg = $this->gettext(['name' => 'importedsuccessfully', 'vars' => ['calendar' => $calendar['name']]]); + $this->rc->output->command('display_message', $msg, 'confirmation'); + } + else { + $this->rc->output->command('display_message', $this->gettext('errorimportingevent'), 'error'); + } + } + else if ($existing + && ($this->event['sequence'] >= $existing['sequence'] + || $this->event['changed'] >= $existing['changed']) + && ($calendar = $this->driver->get_calendar($existing['calendar'])) + ) { + $this->event = $invitation['event']; + $this->event['id'] = $existing['id']; + + unset($this->event['comment']); + + // merge attendees status + // e.g. preserve my participant status for regular updates + $this->lib->merge_attendees($this->event, $existing, $status); + + // update attachments list + $event['deleted_attachments'] = true; + + // show me as free when declined (#1670) + if ($status == 'declined') { + $this->event['free_busy'] = 'free'; + } + + if ($this->driver->edit_event($this->event)) { + $msg = $this->gettext(['name' => 'updatedsuccessfully', 'vars' => ['calendar' => $calendar->get_name()]]); + $this->rc->output->command('display_message', $msg, 'confirmation'); + } + else { + $this->rc->output->command('display_message', $this->gettext('errorimportingevent'), 'error'); + } + } + } + } - // add comment to the iTip attachment - $event['comment'] = $comment; + $this->register_handler('plugin.event_inviteform', [$this, 'itip_event_inviteform']); + $this->register_handler('plugin.event_invitebox', [$this->ui, 'event_invitebox']); - // set a valid recurrence-id if this is a recurrence instance - libcalendaring::identify_recurrence_instance($event); + if (empty($this->invitestatus)) { + $this->itip->set_rsvp_actions(['accepted', 'tentative', 'declined']); + $this->register_handler('plugin.event_rsvp_buttons', [$this->ui, 'event_rsvp_buttons']); + } - // compose multipart message using PEAR:Mail_Mime - $method = $action == 'remove' ? 'CANCEL' : 'REQUEST'; - $message = $itip->compose_itip_message($event, $method, $rsvp); + $this->rc->output->set_pagetitle($itip->gettext('itipinvitation') . ' ' . $this->event['title']); + } + else { + $this->rc->output->command('display_message', $this->gettext('itipinvalidrequest'), 'error', -1); + } - // list existing attendees from $old event - $old_attendees = array(); - foreach ((array)$old['attendees'] as $attendee) { - $old_attendees[] = $attendee['email']; - } - - // send to every attendee - $sent = 0; $current = array(); - foreach ((array)$event['attendees'] as $attendee) { - $current[] = strtolower($attendee['email']); - - // skip myself for obvious reasons - if (!$attendee['email'] || in_array(strtolower($attendee['email']), $emails)) - continue; + $this->rc->output->send('calendar.itipattend'); + } + } - // skip if notification is disabled for this attendee - if ($attendee['noreply'] && $itip_notify & 2) - continue; + /** + * + */ + public function itip_event_inviteform($attrib) + { + $hidden = new html_hiddenfield(['name' => "_t", 'value' => $this->token]); - // skip if this attendee has delegated and set RSVP=FALSE - if ($attendee['status'] == 'DELEGATED' && $attendee['rsvp'] === false) - continue; + return html::tag('form', [ + 'action' => $this->rc->url(['task' => 'calendar', 'action' => 'attend']), + 'method' => 'post', + 'noclose' => true + ] + $attrib + ) . $hidden->show(); + } - // which template to use for mail text - $is_new = !in_array($attendee['email'], $old_attendees); - $is_rsvp = $is_new || $event['sequence'] > $old['sequence']; - $bodytext = $is_cancelled ? 'eventcancelmailbody' : ($is_new ? 'invitationmailbody' : 'eventupdatemailbody'); - $subject = $is_cancelled ? 'eventcancelsubject' : ($is_new ? 'invitationsubject' : ($event['title'] ? 'eventupdatesubject':'eventupdatesubjectempty')); - - $event['comment'] = $comment; + /** + * + */ + private function mail_agenda_event_row($event, $class = '') + { + $time = !empty($event['allday']) ? $this->gettext('all-day') : + $this->rc->format_date($event['start'], $this->rc->config->get('time_format')) + . ' - ' . + $this->rc->format_date($event['end'], $this->rc->config->get('time_format')); - // finally send the message - if ($itip->send_itip_message($event, $method, $attendee, $subject, $bodytext, $message, $is_rsvp)) - $sent++; - else - $sent = -100; + return html::div(rtrim('event-row ' . ($class ?: $event['className'])), + html::span('event-date', $time) + . html::span('event-title', rcube::Q($event['title'])) + ); } - // TODO: on change of a recurring (main) event, also send updates to differing attendess of recurrence exceptions + /** + * + */ + public function mail_messages_list($p) + { + if (!empty($p['cols']) && in_array('attachment', (array) $p['cols']) && !empty($p['messages'])) { + foreach ($p['messages'] as $header) { + $part = new StdClass; + $part->mimetype = $header->ctype; + + if (libcalendaring::part_is_vcalendar($part)) { + $header->list_flags['attachmentClass'] = 'ical'; + } + else if (in_array($header->ctype, ['multipart/alternative', 'multipart/mixed'])) { + // TODO: fetch bodystructure and search for ical parts. Maybe too expensive? + if (!empty($header->structure) && !empty($header->structure->parts)) { + foreach ($header->structure->parts as $part) { + if (libcalendaring::part_is_vcalendar($part) + && !empty($part->ctype_parameters['method']) + ) { + $header->list_flags['attachmentClass'] = 'ical'; + break; + } + } + } + } + } + } + } - // send CANCEL message to removed attendees - foreach ((array)$old['attendees'] as $attendee) { - if ($attendee['role'] == 'ORGANIZER' || !$attendee['email'] || in_array(strtolower($attendee['email']), $current)) - continue; + /** + * Add UI element to copy event invitations or updates to the calendar + */ + public function mail_messagebody_html($p) + { + // load iCalendar functions (if necessary) + if (!empty($this->lib->ical_parts)) { + $this->get_ical(); + $this->load_itip(); + } - $vevent = $old; - $vevent['cancelled'] = $is_cancelled; - $vevent['attendees'] = array($attendee); - $vevent['comment'] = $comment; - if ($itip->send_itip_message($vevent, 'CANCEL', $attendee, 'eventcancelsubject', 'eventcancelmailbody')) - $sent++; - else - $sent = -100; - } - - return $sent; - } - - /** - * Echo simple free/busy status text for the given user and time range - */ - public function freebusy_status() - { - $email = rcube_utils::get_input_value('email', rcube_utils::INPUT_GPC); - $start = $this->input_timestamp('start', rcube_utils::INPUT_GPC); - $end = $this->input_timestamp('end', rcube_utils::INPUT_GPC); + $html = ''; + $has_events = false; + $ical_objects = $this->lib->get_mail_ical_objects(); - if (!$start) $start = time(); - if (!$end) $end = $start + 3600; + // show a box for every event in the file + foreach ($ical_objects as $idx => $event) { + if ($event['_type'] != 'event') { + // skip non-event objects (#2928) + continue; + } - $fbtypemap = array(calendar::FREEBUSY_UNKNOWN => 'UNKNOWN', calendar::FREEBUSY_FREE => 'FREE', calendar::FREEBUSY_BUSY => 'BUSY', calendar::FREEBUSY_TENTATIVE => 'TENTATIVE', calendar::FREEBUSY_OOF => 'OUT-OF-OFFICE'); - $status = 'UNKNOWN'; + $has_events = true; - // if the backend has free-busy information - $fblist = $this->driver->get_freebusy_list($email, $start, $end); - - if (is_array($fblist)) { - $status = 'FREE'; + // get prepared inline UI for this event object + if ($ical_objects->method) { + $append = ''; + $date_str = $this->rc->format_date($event['start'], $this->rc->config->get('date_format'), empty($event['start']->_dateonly)); + $date = new DateTime($event['start']->format('Y-m-d') . ' 12:00:00', new DateTimeZone('UTC')); - foreach ($fblist as $slot) { - list($from, $to, $type) = $slot; - if ($from < $end && $to > $start) { - $status = isset($type) && $fbtypemap[$type] ? $fbtypemap[$type] : 'BUSY'; - break; - } - } - } - - // let this information be cached for 5min - $this->rc->output->future_expire_header(300); - - echo $status; - exit; - } - - /** - * Return a list of free/busy time slots within the given period - * Echo data in JSON encoding - */ - public function freebusy_times() - { - $email = rcube_utils::get_input_value('email', rcube_utils::INPUT_GPC); - $start = $this->input_timestamp('start', rcube_utils::INPUT_GPC); - $end = $this->input_timestamp('end', rcube_utils::INPUT_GPC); - $interval = intval(rcube_utils::get_input_value('interval', rcube_utils::INPUT_GPC)); - $strformat = $interval > 60 ? 'Ymd' : 'YmdHis'; - - if (!$start) $start = time(); - if (!$end) $end = $start + 86400 * 30; - if (!$interval) $interval = 60; // 1 hour + // prepare a small agenda preview to be filled with actual event data on async request + if ($ical_objects->method == 'REQUEST') { + $append = html::div('calendar-agenda-preview', + html::tag('h3', 'preview-title', $this->gettext('agenda') . ' ' . html::span('date', $date_str)) + . '%before%' . $this->mail_agenda_event_row($event, 'current') . '%after%' + ); + } - if (!$dte) { - $dts = new DateTime('@'.$start); - $dts->setTimezone($this->timezone); - } + $html .= html::div('calendar-invitebox invitebox boxinformation', + $this->itip->mail_itip_inline_ui( + $event, + $ical_objects->method, + $ical_objects->mime_id . ':' . $idx, + 'calendar', + rcube_utils::anytodatetime($ical_objects->message_date), + $this->rc->url(['task' => 'calendar']) . '&view=agendaDay&date=' . $date->format('U') + ) . $append + ); + } - $fblist = $this->driver->get_freebusy_list($email, $start, $end); - $slots = ''; + // limit listing + if ($idx >= 3) { + break; + } + } - // prepare freebusy list before use (for better performance) - if (is_array($fblist)) { - foreach ($fblist as $idx => $slot) { - list($from, $to, ) = $slot; - - // check for possible all-day times - if (gmdate('His', $from) == '000000' && gmdate('His', $to) == '235959') { - // shift into the user's timezone for sane matching - $fblist[$idx][0] -= $this->gmt_offset; - $fblist[$idx][1] -= $this->gmt_offset; + // prepend event boxes to message body + if ($html) { + $this->ui->init(); + $p['content'] = $html . $p['content']; + $this->rc->output->add_label('calendar.savingdata','calendar.deleteventconfirm','calendar.declinedeleteconfirm'); } - } - } - - // build a list from $start till $end with blocks representing the fb-status - for ($s = 0, $t = $start; $t <= $end; $s++) { - $t_end = $t + $interval * 60; - $dt = new DateTime('@'.$t); - $dt->setTimezone($this->timezone); - // determine attendee's status - if (is_array($fblist)) { - $status = self::FREEBUSY_FREE; - - foreach ($fblist as $slot) { - list($from, $to, $type) = $slot; - - if ($from < $t_end && $to > $t) { - $status = isset($type) ? $type : self::FREEBUSY_BUSY; - if ($status == self::FREEBUSY_BUSY) // can't get any worse :-) - break; - } + // add "Save to calendar" button into attachment menu + if ($has_events) { + $this->add_button([ + 'id' => 'attachmentsavecal', + 'name' => 'attachmentsavecal', + 'type' => 'link', + 'wrapper' => 'li', + 'command' => 'attachment-save-calendar', + 'class' => 'icon calendarlink disabled', + 'classact' => 'icon calendarlink active', + 'innerclass' => 'icon calendar', + 'label' => 'calendar.savetocalendar', + ], + 'attachmentmenu' + ); } - } - else { - $status = self::FREEBUSY_UNKNOWN; - } - - // use most compact format, assume $status is one digit/character - $slots .= $status; - $t = $t_end; + + return $p; } - $dte = new DateTime('@'.$t_end); - $dte->setTimezone($this->timezone); + /** + * Handler for POST request to import an event attached to a mail message + */ + public function mail_import_itip() + { + $itip_sending = $this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']); + + $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); + $delete = intval(rcube_utils::get_input_value('_del', rcube_utils::INPUT_POST)); + $noreply = intval(rcube_utils::get_input_value('_noreply', rcube_utils::INPUT_POST)); + $noreply = $noreply || $status == 'needs-action' || $itip_sending === 0; + $instance = rcube_utils::get_input_value('_instance', rcube_utils::INPUT_POST); + $savemode = rcube_utils::get_input_value('_savemode', rcube_utils::INPUT_POST); + $comment = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST); + + $error_msg = $this->gettext('errorimportingevent'); + $success = false; + $deleted = false; + + if ($status == 'delegated') { + $to = rcube_utils::get_input_value('_to', rcube_utils::INPUT_POST, true); + $delegates = rcube_mime::decode_address_list($to, 1, false); + $delegate = reset($delegates); + + if (empty($delegate) || empty($delegate['mailto'])) { + $this->rc->output->command('display_message', $this->rc->gettext('libcalendaring.delegateinvalidaddress'), 'error'); + return; + } + } - // let this information be cached for 5min - $this->rc->output->future_expire_header(300); + // successfully parsed events? + if ($event = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event')) { + // forward iTip request to delegatee + if (!empty($delegate)) { + $rsvpme = rcube_utils::get_input_value('_rsvp', rcube_utils::INPUT_POST); + $itip = $this->load_itip(); - echo rcube_output::json_serialize(array( - 'email' => $email, - 'start' => $dts->format('c'), - 'end' => $dte->format('c'), - 'interval' => $interval, - 'slots' => $slots, - )); - exit; - } + $event['comment'] = $comment; - /** - * Handler for printing calendars - */ - public function print_view() - { - $title = $this->gettext('print'); + if ($itip->delegate_to($event, $delegate, !empty($rsvpme))) { + $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation'); + } + else { + $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); + } - $view = rcube_utils::get_input_value('view', rcube_utils::INPUT_GPC); - if (!in_array($view, array('agendaWeek', 'agendaDay', 'month', 'list'))) - $view = 'agendaDay'; + unset($event['comment']); - $this->rc->output->set_env('view', $view); + // the delegator is set to non-participant, thus save as non-blocking + $event['free_busy'] = 'free'; + } - if ($date = rcube_utils::get_input_value('date', rcube_utils::INPUT_GPC)) - $this->rc->output->set_env('date', $date); + $mode = calendar_driver::FILTER_PERSONAL + | calendar_driver::FILTER_SHARED + | calendar_driver::FILTER_WRITEABLE; - if ($range = rcube_utils::get_input_value('range', rcube_utils::INPUT_GPC)) - $this->rc->output->set_env('listRange', intval($range)); + // find writeable calendar to store event + $cal_id = rcube_utils::get_input_value('_folder', rcube_utils::INPUT_POST); + $dontsave = $cal_id === '' && $event['_method'] == 'REQUEST'; + $calendars = $this->driver->list_calendars($mode); + $calendar = isset($calendars[$cal_id]) ? $calendars[$cal_id] : null; - if ($search = rcube_utils::get_input_value('search', rcube_utils::INPUT_GPC)) { - $this->rc->output->set_env('search', $search); - $title .= ' "' . $search . '"'; - } + // select default calendar except user explicitly selected 'none' + if (!$calendar && !$dontsave) { + $calendar = $this->get_default_calendar($event['sensitivity'], $calendars); + } - // Add JS to the page - $this->ui->addJS(); + $metadata = [ + 'uid' => $event['uid'], + '_instance' => isset($event['_instance']) ? $event['_instance'] : null, + 'changed' => is_object($event['changed']) ? $event['changed']->format('U') : 0, + 'sequence' => intval($event['sequence']), + 'fallback' => strtoupper($status), + 'method' => $event['_method'], + 'task' => 'calendar', + ]; + + // update my attendee status according to submitted method + if (!empty($status)) { + $organizer = null; + $emails = $this->get_user_emails(); + foreach ($event['attendees'] as $i => $attendee) { + if ($attendee['role'] == 'ORGANIZER') { + $organizer = $attendee; + } + else if (!empty($attendee['email']) && in_array(strtolower($attendee['email']), $emails)) { + $event['attendees'][$i]['status'] = strtoupper($status); + if (!in_array($event['attendees'][$i]['status'], ['NEEDS-ACTION', 'DELEGATED'])) { + $event['attendees'][$i]['rsvp'] = false; // unset RSVP attribute + } + + $metadata['attendee'] = $attendee['email']; + $metadata['rsvp'] = $attendee['role'] != 'NON-PARTICIPANT'; + + $reply_sender = $attendee['email']; + $event_attendee = $attendee; + } + } - $this->register_handler('plugin.calendar_css', array($this->ui, 'calendar_css')); - $this->register_handler('plugin.calendar_list', array($this->ui, 'calendar_list')); + // add attendee with this user's default identity if not listed + if (!$reply_sender) { + $sender_identity = $this->rc->user->list_emails(true); + $event['attendees'][] = [ + 'name' => $sender_identity['name'], + 'email' => $sender_identity['email'], + 'role' => 'OPT-PARTICIPANT', + 'status' => strtoupper($status), + ]; + $metadata['attendee'] = $sender_identity['email']; + } + } - $this->rc->output->set_pagetitle($title); - $this->rc->output->send('calendar.print'); - } + // save to calendar + if ($calendar && !empty($calendar['editable'])) { + // check for existing event with the same UID + $existing = $this->find_event($event, $mode); - /** - * Compare two event objects and return differing properties - * - * @param array Event A - * @param array Event B - * @return array List of differing event properties - */ - public static function event_diff($a, $b) - { - $diff = array(); - $ignore = array('changed' => 1, 'attachments' => 1); + // we'll create a new copy if user decided to change the calendar + if ($existing && $cal_id && $calendar && $calendar['id'] != $existing['calendar']) { + $existing = null; + } - foreach (array_unique(array_merge(array_keys($a), array_keys($b))) as $key) { - if (!$ignore[$key] && $key[0] != '_' && $a[$key] != $b[$key]) { - $diff[] = $key; - } - } + if ($existing) { + $calendar = $calendars[$existing['calendar']]; + + // forward savemode for correct updates of recurring events + $existing['_savemode'] = $savemode ?: (!empty($event['_savemode']) ? $event['_savemode'] : null); + + // only update attendee status + if ($event['_method'] == 'REPLY') { + // try to identify the attendee using the email sender address + $existing_attendee = -1; + $existing_attendee_emails = []; + + foreach ($existing['attendees'] as $i => $attendee) { + $existing_attendee_emails[] = $attendee['email']; + if ($this->itip->compare_email($attendee['email'], $event['_sender'], $event['_sender_utf'])) { + $existing_attendee = $i; + } + } + + $event_attendee = null; + $update_attendees = []; + + foreach ($event['attendees'] as $attendee) { + if ($this->itip->compare_email($attendee['email'], $event['_sender'], $event['_sender_utf'])) { + $event_attendee = $attendee; + $update_attendees[] = $attendee; + $metadata['fallback'] = $attendee['status']; + $metadata['attendee'] = $attendee['email']; + $metadata['rsvp'] = !empty($attendee['rsvp']) || $attendee['role'] != 'NON-PARTICIPANT'; + + if ($attendee['status'] != 'DELEGATED') { + break; + } + } + // also copy delegate attendee + else if (!empty($attendee['delegated-from']) + && $this->itip->compare_email($attendee['delegated-from'], $event['_sender'], $event['_sender_utf']) + ) { + $update_attendees[] = $attendee; + if (!in_array_nocase($attendee['email'], $existing_attendee_emails)) { + $existing['attendees'][] = $attendee; + } + } + } + + // if delegatee has declined, set delegator's RSVP=True + if ($event_attendee + && $event_attendee['status'] == 'DECLINED' + && !empty($event_attendee['delegated-from']) + ) { + foreach ($existing['attendees'] as $i => $attendee) { + if ($attendee['email'] == $event_attendee['delegated-from']) { + $existing['attendees'][$i]['rsvp'] = true; + break; + } + } + } + + // Accept sender as a new participant (different email in From: and the iTip) + // Use ATTENDEE entry from the iTip with replaced email address + if (!$event_attendee) { + // remove the organizer + $itip_attendees = array_filter( + $event['attendees'], + function($item) { return $item['role'] != 'ORGANIZER'; } + ); + + // there must be only one attendee + if (is_array($itip_attendees) && count($itip_attendees) == 1) { + $event_attendee = $itip_attendees[key($itip_attendees)]; + $event_attendee['email'] = $event['_sender']; + $update_attendees[] = $event_attendee; + $metadata['fallback'] = $event_attendee['status']; + $metadata['attendee'] = $event_attendee['email']; + $metadata['rsvp'] = !empty($event_attendee['rsvp']) || $event_attendee['role'] != 'NON-PARTICIPANT'; + } + } + + // found matching attendee entry in both existing and new events + if ($existing_attendee >= 0 && $event_attendee) { + $existing['attendees'][$existing_attendee] = $event_attendee; + $success = $this->driver->update_attendees($existing, $update_attendees); + } + // update the entire attendees block + else if ( + ($event['sequence'] >= $existing['sequence'] || $event['changed'] >= $existing['changed']) + && $event_attendee + ) { + $existing['attendees'][] = $event_attendee; + $success = $this->driver->update_attendees($existing, $update_attendees); + } + else if (!$event_attendee) { + $error_msg = $this->gettext('errorunknownattendee'); + } + else { + $error_msg = $this->gettext('newerversionexists'); + } + } + // delete the event when declined (#1670) + else if ($status == 'declined' && $delete) { + $deleted = $this->driver->remove_event($existing, true); + $success = true; + } + // import the (newer) event + else if ($event['sequence'] >= $existing['sequence'] || $event['changed'] >= $existing['changed']) { + $event['id'] = $existing['id']; + $event['calendar'] = $existing['calendar']; + + // merge attendees status + // e.g. preserve my participant status for regular updates + $this->lib->merge_attendees($event, $existing, $status); + + // set status=CANCELLED on CANCEL messages + if ($event['_method'] == 'CANCEL') { + $event['status'] = 'CANCELLED'; + } + + // update attachments list, allow attachments update only on REQUEST (#5342) + if ($event['_method'] == 'REQUEST') { + $event['deleted_attachments'] = true; + } + else { + unset($event['attachments']); + } + + // show me as free when declined (#1670) + if ($status == 'declined' + || (!empty($event['status']) && $event['status'] == 'CANCELLED') + || $event_attendee['role'] == 'NON-PARTICIPANT' + ) { + $event['free_busy'] = 'free'; + } + + $success = $this->driver->edit_event($event); + } + else if (!empty($status)) { + $existing['attendees'] = $event['attendees']; + if ($status == 'declined' || $event_attendee['role'] == 'NON-PARTICIPANT') { + // 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_calendars'))) { + if ($status == 'declined' + || $event['status'] == 'CANCELLED' + || $event_attendee['role'] == 'NON-PARTICIPANT' + ) { + $event['free_busy'] = 'free'; + } + + // if the RSVP reply only refers to a single instance: + // store unmodified master event with current instance as exception + if (!empty($instance) && !empty($savemode) && $savemode != 'all') { + $master = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event'); + if ($master['recurrence'] && empty($master['_instance'])) { + // compute recurring events until this instance's date + if ($recurrence_date = rcube_utils::anytodatetime($instance, $master['start']->getTimezone())) { + $recurrence_date->setTime(23,59,59); + + foreach ($this->driver->get_recurring_events($master, $master['start'], $recurrence_date) as $recurring) { + if ($recurring['_instance'] == $instance) { + // copy attendees block with my partstat to exception + $recurring['attendees'] = $event['attendees']; + $master['recurrence']['EXCEPTIONS'][] = $recurring; + $event = $recurring; // set reference for iTip reply + break; + } + } + + $master['calendar'] = $event['calendar'] = $calendar['id']; + $success = $this->driver->new_event($master); + } + else { + $master = null; + } + } + else { + $master = null; + } + } + + // save to the selected/default calendar + if (!$master) { + $event['calendar'] = $calendar['id']; + $success = $this->driver->new_event($event); + } + } + else if ($status == 'declined') { + $error_msg = null; + } + } + else if ($status == 'declined' || $dontsave) { + $error_msg = null; + } + else { + $error_msg = $this->gettext('nowritecalendarfound'); + } + } - // only compare number of attachments - if (count((array) $a['attachments']) != count((array) $b['attachments'])) { - $diff[] = 'attachments'; - } + if ($success) { + if ($event['_method'] == 'REPLY') { + $message = 'attendeupdateesuccess'; + } + else { + $message = $deleted ? 'successremoval' : ($existing ? 'updatedsuccessfully' : 'importedsuccessfully'); + } + + $msg = $this->gettext(['name' => $message, 'vars' => ['calendar' => $calendar['name']]]); + $this->rc->output->command('display_message', $msg, 'confirmation'); + } - return $diff; - } + if ($success || $dontsave) { + $metadata['calendar'] = isset($event['calendar']) ? $event['calendar'] : null; + $metadata['nosave'] = $dontsave; + $metadata['rsvp'] = !empty($metadata['rsvp']); - /** - * Update attendee properties on the given event object - * - * @param array The event object to be altered - * @param array List of hash arrays each represeting an updated/added attendee - */ - public static function merge_attendee_data(&$event, $attendees, $removed = null) - { - if (!empty($attendees) && !is_array($attendees[0])) { - $attendees = array($attendees); - } + $metadata['after_action'] = $this->rc->config->get('calendar_itip_after_action', $this->defaults['calendar_itip_after_action']); + $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'); + } - foreach ($attendees as $attendee) { - $found = false; + // send iTip reply + if ($event['_method'] == 'REQUEST' && !empty($organizer) && !$noreply + && !in_array(strtolower($organizer['email']), $emails) && !$error_msg + ) { + $event['comment'] = $comment; + $itip = $this->load_itip(); + $itip->set_sender_email($reply_sender); - foreach ($event['attendees'] as $i => $candidate) { - if ($candidate['email'] == $attendee['email']) { - $event['attendees'][$i] = $attendee; - $found = true; - break; + if ($itip->send_itip_message($event, 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status)) { + $mailto = $organizer['name'] ? $organizer['name'] : $organizer['email']; + $msg = $this->gettext(['name' => 'sentresponseto', 'vars' => ['mailto' => $mailto]]); + $this->rc->output->command('display_message', $msg, 'confirmation'); + } + else { + $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); + } } - } - if (!$found) { - $event['attendees'][] = $attendee; - } + $this->rc->output->send(); } - // filter out removed attendees - if (!empty($removed)) { - $event['attendees'] = array_filter($event['attendees'], function($attendee) use ($removed) { - return !in_array($attendee['email'], $removed); - }); - } - } + /** + * Handler for calendar/itip-remove requests + */ + function mail_itip_decline_reply() + { + $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); + + if (($event = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event')) + && $event['_method'] == 'REPLY' + ) { + $event['comment'] = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST); + foreach ($event['attendees'] as $_attendee) { + if ($_attendee['role'] != 'ORGANIZER') { + $attendee = $_attendee; + break; + } + } - /**** Resource management functions ****/ + $itip = $this->load_itip(); - /** - * Getter for the configured implementation of the resource directory interface - */ - private function resources_directory() - { - if (is_object($this->resources_dir)) { - return $this->resources_dir; + if ($itip->send_itip_message($event, 'CANCEL', $attendee, 'itipsubjectcancel', 'itipmailbodycancel')) { + $mailto = !empty($attendee['name']) ? $attendee['name'] : $attendee['email']; + $msg = $this->gettext(['name' => 'sentresponseto', 'vars' => ['mailto' => $mailto]]); + $this->rc->output->command('display_message', $msg, 'confirmation'); + } + else { + $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); + } + } + else { + $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); + } } - if ($driver_name = $this->rc->config->get('calendar_resources_driver')) { - $driver_class = 'resources_driver_' . $driver_name; + /** + * Handler for calendar/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(); + } - require_once($this->home . '/drivers/resources_driver.php'); - require_once($this->home . '/drivers/' . $driver_name . '/' . $driver_class . '.php'); + /** + * 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; - $this->resources_dir = new $driver_class($this); - } + // establish imap connection + $imap = $this->rc->get_storage(); + $imap->set_folder($mbox); - return $this->resources_dir; - } + if ($uid && $mime_id) { + $part = $imap->get_message_part($uid, $mime_id); + // $headers = $imap->get_message_headers($uid); - /** - * Handler for resoruce autocompletion requests - */ - public function resources_autocomplete() - { - $search = rcube_utils::get_input_value('_search', rcube_utils::INPUT_GPC, true); - $sid = rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC); - $maxnum = (int)$this->rc->config->get('autocomplete_max', 15); - $results = array(); + if ($part) { + if (!empty($part->ctype_parameters['charset'])) { + $charset = $part->ctype_parameters['charset']; + } + $events = $this->get_ical()->import($part, $charset); + } + } - if ($directory = $this->resources_directory()) { - foreach ($directory->load_resources($search, $maxnum) as $rec) { - $results[] = array( - 'name' => $rec['name'], - 'email' => $rec['email'], - 'type' => $rec['_type'], - ); - } - } - - $this->rc->output->command('ksearch_query_results', $results, $search, $sid); - $this->rc->output->send(); - } - - /** - * Handler for load-requests for resource data - */ - function resources_list() - { - $data = array(); - - if ($directory = $this->resources_directory()) { - foreach ($directory->load_resources() as $rec) { - $data[] = $rec; - } - } - - $this->rc->output->command('plugin.resource_data', $data); - $this->rc->output->send(); - } - - /** - * Handler for requests loading resource owner information - */ - function resources_owner() - { - if ($directory = $this->resources_directory()) { - $id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); - $data = $directory->get_resource_owner($id); - } - - $this->rc->output->command('plugin.resource_owner', $data); - $this->rc->output->send(); - } - - /** - * Deliver event data for a resource's calendar - */ - function resources_calendar() - { - $events = array(); - - if ($directory = $this->resources_directory()) { - $id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); - $start = $this->input_timestamp('start', rcube_utils::INPUT_GET); - $end = $this->input_timestamp('end', rcube_utils::INPUT_GET); - - $events = $directory->get_resource_calendar($id, $start, $end); - } - - echo $this->encode($events); - exit; - } - - - /**** Event invitation plugin hooks ****/ - - /** - * Find an event in user calendars - */ - protected function find_event($event, &$mode) - { - $this->load_driver(); - - // We search for writeable calendars in personal namespace by default - $mode = calendar_driver::FILTER_WRITEABLE | calendar_driver::FILTER_PERSONAL; - $result = $this->driver->get_event($event, $mode); - // ... now check shared folders if not found - if (!$result) { - $result = $this->driver->get_event($event, calendar_driver::FILTER_WRITEABLE | calendar_driver::FILTER_SHARED); - if ($result) { - $mode |= calendar_driver::FILTER_SHARED; - } - } - - return $result; - } - - /** - * Handler for calendar/itip-status requests - */ - function event_itip_status() - { - $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); - - $this->load_driver(); - - // find local copy of the referenced event (in personal namespace) - $existing = $this->find_event($data, $mode); - $is_shared = $mode & calendar_driver::FILTER_SHARED; - $itip = $this->load_itip(); - $response = $itip->get_itip_status($data, $existing); - - // get a list of writeable calendars to save new events to - if ((!$existing || $is_shared) - && !$data['nosave'] - && ($response['action'] == 'rsvp' || $response['action'] == 'import') - ) { - $calendars = $this->driver->list_calendars($mode); - $calendar_select = new html_select(array( - 'name' => 'calendar', - 'id' => 'itip-saveto', - 'is_escaped' => true, - 'class' => 'form-control custom-select' - )); - $calendar_select->add('--', ''); - $numcals = 0; - foreach ($calendars as $calendar) { - if ($calendar['editable']) { - $calendar_select->add($calendar['name'], $calendar['id']); - $numcals++; - } - } - if ($numcals < 1) - $calendar_select = null; - } - - if ($calendar_select) { - $default_calendar = $this->get_default_calendar($data['sensitivity'], $calendars); - $response['select'] = html::span('folder-select', $this->gettext('saveincalendar') . ' ' . - $calendar_select->show($is_shared ? $existing['calendar'] : $default_calendar['id'])); - } - else if ($data['nosave']) { - $response['select'] = html::tag('input', array('type' => 'hidden', 'name' => 'calendar', 'id' => 'itip-saveto', 'value' => '')); - } - - // render small agenda view for the respective day - if ($data['method'] == 'REQUEST' && !empty($data['date']) && $response['action'] == 'rsvp') { - $event_start = rcube_utils::anytodatetime($data['date']); - $day_start = new Datetime(gmdate('Y-m-d 00:00', $data['date']), $this->lib->timezone); - $day_end = new Datetime(gmdate('Y-m-d 23:59', $data['date']), $this->lib->timezone); - - // get events on that day from the user's personal calendars - $calendars = $this->driver->list_calendars(calendar_driver::FILTER_PERSONAL); - $events = $this->driver->load_events($day_start->format('U'), $day_end->format('U'), null, array_keys($calendars)); - usort($events, function($a, $b) { return $a['start'] > $b['start'] ? 1 : -1; }); - - $before = $after = array(); - foreach ($events as $event) { - // TODO: skip events with free_busy == 'free' ? - if ($event['uid'] == $data['uid'] - || $event['end'] < $day_start || $event['start'] > $day_end - || $event['status'] == 'CANCELLED' - || (!empty($event['className']) && strpos($event['className'], 'declined') !== false) - ) { - continue; - } - - if ($event['start'] < $event_start) - $before[] = $this->mail_agenda_event_row($event); - else - $after[] = $this->mail_agenda_event_row($event); - } - - $response['append'] = array( - 'selector' => '.calendar-agenda-preview', - 'replacements' => array( - '%before%' => !empty($before) ? join("\n", array_slice($before, -3)) : html::div('event-row no-event', $this->gettext('noearlierevents')), - '%after%' => !empty($after) ? join("\n", array_slice($after, 0, 3)) : html::div('event-row no-event', $this->gettext('nolaterevents')), - ), - ); - } - - $this->rc->output->command('plugin.update_itip_object_status', $response); - } - - /** - * Handler for calendar/itip-remove requests - */ - function event_itip_remove() - { - $success = false; - $uid = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST); - $instance = rcube_utils::get_input_value('_instance', rcube_utils::INPUT_POST); - $savemode = rcube_utils::get_input_value('_savemode', rcube_utils::INPUT_POST); - $listmode = calendar_driver::FILTER_WRITEABLE | calendar_driver::FILTER_PERSONAL; - - // search for event if only UID is given - if ($event = $this->driver->get_event(array('uid' => $uid, '_instance' => $instance), $listmode)) { - $event['_savemode'] = $savemode; - $success = $this->driver->remove_event($event, true); - } - - if ($success) { - $this->rc->output->show_message('calendar.successremoval', 'confirmation'); - } - else { - $this->rc->output->show_message('calendar.errorsaving', 'error'); - } - } - - /** - * Handler for URLs that allow an invitee to respond on his invitation mail - */ - public function itip_attend_response($p) - { - $this->setup(); - - if ($p['action'] == 'attend') { - $this->ui->init(); - - $this->rc->output->set_env('task', 'calendar'); // override some env vars - $this->rc->output->set_env('refresh_interval', 0); - $this->rc->output->set_pagetitle($this->gettext('calendar')); - - $itip = $this->load_itip(); - $token = rcube_utils::get_input_value('_t', rcube_utils::INPUT_GPC); - - // read event info stored under the given token - if ($invitation = $itip->get_invitation($token)) { - $this->token = $token; - $this->event = $invitation['event']; - - // show message about cancellation - if ($invitation['cancelled']) { - $this->invitestatus = html::div('rsvp-status declined', $itip->gettext('eventcancelled')); - } - // save submitted RSVP status - else if (!empty($_POST['rsvp'])) { - $status = null; - foreach (array('accepted','tentative','declined') as $method) { - if ($_POST['rsvp'] == $itip->gettext('itip' . $method)) { - $status = $method; - break; - } - } - - // send itip reply to organizer - $invitation['event']['comment'] = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST); - if ($status && $itip->update_invitation($invitation, $invitation['attendee'], strtoupper($status))) { - $this->invitestatus = html::div('rsvp-status ' . strtolower($status), $itip->gettext('youhave'.strtolower($status))); - } - else - $this->rc->output->command('display_message', $this->gettext('errorsaving'), 'error', -1); - - // if user is logged in... - // FIXME: we should really consider removing this functionality - // it's confusing that it creates/updates an event only for logged-in user - // what if the logged-in user is not the same as the attendee? - if ($this->rc->user->ID) { - $this->load_driver(); + $success = $existing = 0; - $invitation = $itip->get_invitation($token); - $existing = $this->driver->get_event($this->event); + if (!empty($events)) { + // find writeable calendar to store event + $cal_id = !empty($_REQUEST['_calendar']) ? rcube_utils::get_input_value('_calendar', rcube_utils::INPUT_POST) : null; + $calendars = $this->driver->list_calendars(calendar_driver::FILTER_PERSONAL); - // save the event to his/her default calendar if not yet present - if (!$existing && ($calendar = $this->get_default_calendar($invitation['event']['sensitivity']))) { - $invitation['event']['calendar'] = $calendar['id']; - if ($this->driver->new_event($invitation['event'])) - $this->rc->output->command('display_message', $this->gettext(array('name' => 'importedsuccessfully', 'vars' => array('calendar' => $calendar['name']))), 'confirmation'); - else - $this->rc->output->command('display_message', $this->gettext('errorimportingevent'), 'error'); - } - else if ($existing - && ($this->event['sequence'] >= $existing['sequence'] || $this->event['changed'] >= $existing['changed']) - && ($calendar = $this->driver->get_calendar($existing['calendar'])) - ) { - $this->event = $invitation['event']; - $this->event['id'] = $existing['id']; - - unset($this->event['comment']); - - // merge attendees status - // e.g. preserve my participant status for regular updates - $this->lib->merge_attendees($this->event, $existing, $status); - - // update attachments list - $event['deleted_attachments'] = true; - - // show me as free when declined (#1670) - if ($status == 'declined') - $this->event['free_busy'] = 'free'; - - if ($this->driver->edit_event($this->event)) - $this->rc->output->command('display_message', $this->gettext(array('name' => 'updatedsuccessfully', 'vars' => array('calendar' => $calendar->get_name()))), 'confirmation'); - else - $this->rc->output->command('display_message', $this->gettext('errorimportingevent'), 'error'); - } - } - } - - $this->register_handler('plugin.event_inviteform', array($this, 'itip_event_inviteform')); - $this->register_handler('plugin.event_invitebox', array($this->ui, 'event_invitebox')); - - if (!$this->invitestatus) { - $this->itip->set_rsvp_actions(array('accepted','tentative','declined')); - $this->register_handler('plugin.event_rsvp_buttons', array($this->ui, 'event_rsvp_buttons')); - } - - $this->rc->output->set_pagetitle($itip->gettext('itipinvitation') . ' ' . $this->event['title']); - } - else - $this->rc->output->command('display_message', $this->gettext('itipinvalidrequest'), 'error', -1); - - $this->rc->output->send('calendar.itipattend'); - } - } - - /** - * - */ - public function itip_event_inviteform($attrib) - { - $hidden = new html_hiddenfield(array('name' => "_t", 'value' => $this->token)); - return html::tag('form', array('action' => $this->rc->url(array('task' => 'calendar', 'action' => 'attend')), 'method' => 'post', 'noclose' => true) + $attrib) . $hidden->show(); - } - - /** - * - */ - private function mail_agenda_event_row($event, $class = '') - { - $time = $event['allday'] ? $this->gettext('all-day') : - $this->rc->format_date($event['start'], $this->rc->config->get('time_format')) . ' - ' . - $this->rc->format_date($event['end'], $this->rc->config->get('time_format')); - - return html::div(rtrim('event-row ' . ($class ?: $event['className'])), - html::span('event-date', $time) . - html::span('event-title', rcube::Q($event['title'])) - ); - } - - /** - * - */ - public function mail_messages_list($p) - { - if (in_array('attachment', (array)$p['cols']) && !empty($p['messages'])) { - foreach ($p['messages'] as $header) { - $part = new StdClass; - $part->mimetype = $header->ctype; - if (libcalendaring::part_is_vcalendar($part)) { - $header->list_flags['attachmentClass'] = 'ical'; - } - else if (in_array($header->ctype, array('multipart/alternative', 'multipart/mixed'))) { - // TODO: fetch bodystructure and search for ical parts. Maybe too expensive? - if (!empty($header->structure) && is_array($header->structure->parts)) { - foreach ($header->structure->parts as $part) { - if (libcalendaring::part_is_vcalendar($part) && !empty($part->ctype_parameters['method'])) { - $header->list_flags['attachmentClass'] = 'ical'; - break; - } - } - } - } - } - } - } - - /** - * Add UI element to copy event invitations or updates to the calendar - */ - 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_events = false; - $ical_objects = $this->lib->get_mail_ical_objects(); - - // show a box for every event in the file - foreach ($ical_objects as $idx => $event) { - if ($event['_type'] != 'event') // skip non-event objects (#2928) - continue; - - $has_events = true; - - // get prepared inline UI for this event object - if ($ical_objects->method) { - $append = ''; - $date_str = $this->rc->format_date($event['start'], $this->rc->config->get('date_format'), empty($event['start']->_dateonly)); - $date = new DateTime($event['start']->format('Y-m-d') . ' 12:00:00', new DateTimeZone('UTC')); - - // prepare a small agenda preview to be filled with actual event data on async request - if ($ical_objects->method == 'REQUEST') { - $append = html::div('calendar-agenda-preview', - html::tag('h3', 'preview-title', $this->gettext('agenda') . ' ' . html::span('date', $date_str)) - . '%before%' . $this->mail_agenda_event_row($event, 'current') . '%after%'); - } - - $html .= html::div('calendar-invitebox invitebox boxinformation', - $this->itip->mail_itip_inline_ui( - $event, - $ical_objects->method, - $ical_objects->mime_id . ':' . $idx, - 'calendar', - rcube_utils::anytodatetime($ical_objects->message_date), - $this->rc->url(array('task' => 'calendar')) . '&view=agendaDay&date=' . $date->format('U') - ) . $append - ); - } - - // limit listing - if ($idx >= 3) - break; - } - - // prepend event boxes to message body - if ($html) { - $this->ui->init(); - $p['content'] = $html . $p['content']; - $this->rc->output->add_label('calendar.savingdata','calendar.deleteventconfirm','calendar.declinedeleteconfirm'); - } - - // add "Save to calendar" button into attachment menu - if ($has_events) { - $this->add_button(array( - 'id' => 'attachmentsavecal', - 'name' => 'attachmentsavecal', - 'type' => 'link', - 'wrapper' => 'li', - 'command' => 'attachment-save-calendar', - 'class' => 'icon calendarlink disabled', - 'classact' => 'icon calendarlink active', - 'innerclass' => 'icon calendar', - 'label' => 'calendar.savetocalendar', - ), 'attachmentmenu'); - } - - return $p; - } - - - /** - * Handler for POST request to import an event attached to a mail message - */ - public function mail_import_itip() - { - $itip_sending = $this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']); - - $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); - $delete = intval(rcube_utils::get_input_value('_del', rcube_utils::INPUT_POST)); - $noreply = intval(rcube_utils::get_input_value('_noreply', rcube_utils::INPUT_POST)); - $noreply = $noreply || $status == 'needs-action' || $itip_sending === 0; - $instance = rcube_utils::get_input_value('_instance', rcube_utils::INPUT_POST); - $savemode = rcube_utils::get_input_value('_savemode', rcube_utils::INPUT_POST); - $comment = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST); - - $error_msg = $this->gettext('errorimportingevent'); - $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->rc->gettext('libcalendaring.delegateinvalidaddress'), 'error'); - return; - } - } - - // successfully parsed events? - if ($event = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event')) { - // forward iTip request to delegatee - if ($delegate) { - $rsvpme = rcube_utils::get_input_value('_rsvp', rcube_utils::INPUT_POST); - $itip = $this->load_itip(); + foreach ($events as $event) { + // save to calendar + $calendar = !empty($calendars[$cal_id]) ? $calendars[$cal_id] : $this->get_default_calendar($event['sensitivity']); + if ($calendar && $calendar['editable'] && $event['_type'] == 'event') { + $event['calendar'] = $calendar['id']; - $event['comment'] = $comment; + if (!$this->driver->get_event($event['uid'], calendar_driver::FILTER_WRITEABLE)) { + $success += (bool)$this->driver->new_event($event); + } + else { + $existing++; + } + } + } + } - if ($itip->delegate_to($event, $delegate, !empty($rsvpme))) { - $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation'); + if ($success) { + $msg = $this->gettext(['name' => 'importsuccess', 'vars' => ['nr' => $success]]); + $this->rc->output->command('display_message', $msg, 'confirmation'); + } + else if ($existing) { + $this->rc->output->command('display_message', $this->gettext('importwarningexists'), 'warning'); } else { - $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); + $this->rc->output->command('display_message', $this->gettext('errorimportingevent'), 'error'); } + } + + /** + * Read email message and return contents for a new event based on that message + */ + public function mail_message2event() + { + $this->ui->init(); + $this->ui->addJS(); + $this->ui->init_templates(); + $this->ui->calendar_list([], true); // set env['calendars'] - unset($event['comment']); + $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_GET); + $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_GET); + $event = []; - // the delegator is set to non-participant, thus save as non-blocking - $event['free_busy'] = 'free'; - } + // establish imap connection + $imap = $this->rc->get_storage(); + $message = new rcube_message($uid, $mbox); - $mode = calendar_driver::FILTER_PERSONAL - | calendar_driver::FILTER_SHARED - | calendar_driver::FILTER_WRITEABLE; + if ($message->headers) { + $event['title'] = trim($message->subject); + $event['description'] = trim($message->first_text_part()); - // find writeable calendar to store event - $cal_id = rcube_utils::get_input_value('_folder', rcube_utils::INPUT_POST); - $dontsave = $cal_id === '' && $event['_method'] == 'REQUEST'; - $calendars = $this->driver->list_calendars($mode); - $calendar = $calendars[$cal_id]; + $this->load_driver(); - // select default calendar except user explicitly selected 'none' - if (!$calendar && !$dontsave) - $calendar = $this->get_default_calendar($event['sensitivity'], $calendars); + // add a reference to the email message + if ($msgref = $this->driver->get_message_reference($message->headers, $mbox)) { + $event['links'] = [$msgref]; + } + // copy mail attachments to event + else if ($message->attachments) { + $eventid = 'cal-'; + if (empty($_SESSION[self::SESSION_KEY]) || $_SESSION[self::SESSION_KEY]['id'] != $eventid) { + $_SESSION[self::SESSION_KEY] = [ + 'id' => $eventid, + 'attachments' => [], + ]; + } - $metadata = array( - 'uid' => $event['uid'], - '_instance' => $event['_instance'], - 'changed' => is_object($event['changed']) ? $event['changed']->format('U') : 0, - 'sequence' => intval($event['sequence']), - 'fallback' => strtoupper($status), - 'method' => $event['_method'], - 'task' => 'calendar', - ); + foreach ((array) $message->attachments as $part) { + $attachment = [ + 'data' => $imap->get_message_part($uid, $part->mime_id, $part), + 'size' => $part->size, + 'name' => $part->filename, + 'mimetype' => $part->mimetype, + 'group' => $eventid, + ]; - // update my attendee status according to submitted method - if (!empty($status)) { - $organizer = null; - $emails = $this->get_user_emails(); - foreach ($event['attendees'] as $i => $attendee) { - if ($attendee['role'] == 'ORGANIZER') { - $organizer = $attendee; - } - else if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { - $event['attendees'][$i]['status'] = strtoupper($status); - if (!in_array($event['attendees'][$i]['status'], array('NEEDS-ACTION','DELEGATED'))) - $event['attendees'][$i]['rsvp'] = false; // unset RSVP attribute - - $metadata['attendee'] = $attendee['email']; - $metadata['rsvp'] = $attendee['role'] != 'NON-PARTICIPANT'; - $reply_sender = $attendee['email']; - $event_attendee = $attendee; - } - } - - // add attendee with this user's default identity if not listed - if (!$reply_sender) { - $sender_identity = $this->rc->user->list_emails(true); - $event['attendees'][] = array( - 'name' => $sender_identity['name'], - 'email' => $sender_identity['email'], - 'role' => 'OPT-PARTICIPANT', - 'status' => strtoupper($status), - ); - $metadata['attendee'] = $sender_identity['email']; - } - } - - // save to calendar - if ($calendar && $calendar['editable']) { - // check for existing event with the same UID - $existing = $this->find_event($event, $mode); - - // we'll create a new copy if user decided to change the calendar - if ($existing && $cal_id && $calendar && $calendar['id'] != $existing['calendar']) { - $existing = null; - } - - if ($existing) { - $calendar = $calendars[$existing['calendar']]; - - // forward savemode for correct updates of recurring events - $existing['_savemode'] = $savemode ?: $event['_savemode']; - - // only update attendee status - if ($event['_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 ($this->itip->compare_email($attendee['email'], $event['_sender'], $event['_sender_utf'])) { - $existing_attendee = $i; - } - } - - $event_attendee = null; - $update_attendees = array(); - - foreach ($event['attendees'] as $attendee) { - if ($this->itip->compare_email($attendee['email'], $event['_sender'], $event['_sender_utf'])) { - $event_attendee = $attendee; - $update_attendees[] = $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']) - && $this->itip->compare_email($attendee['delegated-from'], $event['_sender'], $event['_sender_utf']) - ) { - $update_attendees[] = $attendee; - if (!in_array_nocase($attendee['email'], $existing_attendee_emails)) { - $existing['attendees'][] = $attendee; - } - } - } - - // if delegatee has declined, set delegator's RSVP=True - if ($event_attendee && $event_attendee['status'] == 'DECLINED' && $event_attendee['delegated-from']) { - foreach ($existing['attendees'] as $i => $attendee) { - if ($attendee['email'] == $event_attendee['delegated-from']) { - $existing['attendees'][$i]['rsvp'] = true; - break; - } - } - } - - // Accept sender as a new participant (different email in From: and the iTip) - // Use ATTENDEE entry from the iTip with replaced email address - if (!$event_attendee) { - // remove the organizer - $itip_attendees = array_filter($event['attendees'], function($item) { return $item['role'] != 'ORGANIZER'; }); - - // there must be only one attendee - if (is_array($itip_attendees) && count($itip_attendees) == 1) { - $event_attendee = $itip_attendees[key($itip_attendees)]; - $event_attendee['email'] = $event['_sender']; - $update_attendees[] = $event_attendee; - $metadata['fallback'] = $event_attendee['status']; - $metadata['attendee'] = $event_attendee['email']; - $metadata['rsvp'] = $event_attendee['rsvp'] || $event_attendee['role'] != 'NON-PARTICIPANT'; - } - } - - // found matching attendee entry in both existing and new events - if ($existing_attendee >= 0 && $event_attendee) { - $existing['attendees'][$existing_attendee] = $event_attendee; - $success = $this->driver->update_attendees($existing, $update_attendees); - } - // update the entire attendees block - else if (($event['sequence'] >= $existing['sequence'] || $event['changed'] >= $existing['changed']) && $event_attendee) { - $existing['attendees'][] = $event_attendee; - $success = $this->driver->update_attendees($existing, $update_attendees); - } - else if (!$event_attendee) { - $error_msg = $this->gettext('errorunknownattendee'); + $attachment = $this->rc->plugins->exec_hook('attachment_save', $attachment); + + if (!empty($attachment['status']) && !$attachment['abort']) { + $id = $attachment['id']; + $attachment['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); + + // store new attachment in session + unset($attachment['status'], $attachment['abort'], $attachment['data']); + $_SESSION[self::SESSION_KEY]['attachments'][$id] = $attachment; + + $attachment['id'] = 'rcmfile' . $attachment['id']; // add prefix to consider it 'new' + $event['attachments'][] = $attachment; + } + } } - else { - $error_msg = $this->gettext('newerversionexists'); - } - } - // delete the event when declined (#1670) - else if ($status == 'declined' && $delete) { - $deleted = $this->driver->remove_event($existing, true); - $success = true; - } - // import the (newer) event - else if ($event['sequence'] >= $existing['sequence'] || $event['changed'] >= $existing['changed']) { - $event['id'] = $existing['id']; - $event['calendar'] = $existing['calendar']; - - // merge attendees status - // e.g. preserve my participant status for regular updates - $this->lib->merge_attendees($event, $existing, $status); - - // set status=CANCELLED on CANCEL messages - if ($event['_method'] == 'CANCEL') - $event['status'] = 'CANCELLED'; - - // update attachments list, allow attachments update only on REQUEST (#5342) - if ($event['_method'] == 'REQUEST') - $event['deleted_attachments'] = true; - else - unset($event['attachments']); - - // show me as free when declined (#1670) - if ($status == 'declined' || $event['status'] == 'CANCELLED' || $event_attendee['role'] == 'NON-PARTICIPANT') - $event['free_busy'] = 'free'; - - $success = $this->driver->edit_event($event); - } - else if (!empty($status)) { - $existing['attendees'] = $event['attendees']; - if ($status == 'declined' || $event_attendee['role'] == 'NON-PARTICIPANT') // 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_calendars'))) { - if ($status == 'declined' || $event['status'] == 'CANCELLED' || $event_attendee['role'] == 'NON-PARTICIPANT') { - $event['free_busy'] = 'free'; - } - - // if the RSVP reply only refers to a single instance: - // store unmodified master event with current instance as exception - if (!empty($instance) && !empty($savemode) && $savemode != 'all') { - $master = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event'); - if ($master['recurrence'] && !$master['_instance']) { - // compute recurring events until this instance's date - if ($recurrence_date = rcube_utils::anytodatetime($instance, $master['start']->getTimezone())) { - $recurrence_date->setTime(23,59,59); - - foreach ($this->driver->get_recurring_events($master, $master['start'], $recurrence_date) as $recurring) { - if ($recurring['_instance'] == $instance) { - // copy attendees block with my partstat to exception - $recurring['attendees'] = $event['attendees']; - $master['recurrence']['EXCEPTIONS'][] = $recurring; - $event = $recurring; // set reference for iTip reply - break; - } + + $this->rc->output->set_env('event_prop', $event); + } + else { + $this->rc->output->command('display_message', $this->gettext('messageopenerror'), 'error'); + } + + $this->rc->output->send('calendar.dialog'); + } + + /** + * Handler for the 'message_compose' plugin hook. This will check for + * a compose parameter 'calendar_event' and create an attachment with the + * referenced event in iCal format + */ + public function mail_message_compose($args) + { + // set the submitted event ID as attachment + if (!empty($args['param']['calendar_event'])) { + $this->load_driver(); + + list($cal, $id) = explode(':', $args['param']['calendar_event'], 2); + + if ($event = $this->driver->get_event(['id' => $id, 'calendar' => $cal])) { + $filename = asciiwords($event['title']); + if (empty($filename)) { + $filename = 'event'; } - $master['calendar'] = $event['calendar'] = $calendar['id']; - $success = $this->driver->new_event($master); - } - else { - $master = null; - } + // save ics to a temp file and register as attachment + $tmp_path = tempnam($this->rc->config->get('temp_dir'), 'rcmAttmntCal'); + $export = $this->get_ical()->export([$event], '', false, [$this->driver, 'get_attachment_body']); + + file_put_contents($tmp_path, $export); + + $args['attachments'][] = [ + 'path' => $tmp_path, + 'name' => $filename . '.ics', + 'mimetype' => 'text/calendar', + 'size' => filesize($tmp_path), + ]; + $args['param']['subject'] = $event['title']; } - else { - $master = null; - } - } - - // save to the selected/default calendar - if (!$master) { - $event['calendar'] = $calendar['id']; - $success = $this->driver->new_event($event); - } - } - else if ($status == 'declined') - $error_msg = null; - } - else if ($status == 'declined' || $dontsave) - $error_msg = null; - else - $error_msg = $this->gettext('nowritecalendarfound'); - } - - if ($success) { - $message = $event['_method'] == 'REPLY' ? 'attendeupdateesuccess' : ($deleted ? 'successremoval' : ($existing ? 'updatedsuccessfully' : 'importedsuccessfully')); - $this->rc->output->command('display_message', $this->gettext(array('name' => $message, 'vars' => array('calendar' => $calendar['name']))), 'confirmation'); - } - - if ($success || $dontsave) { - $metadata['calendar'] = $event['calendar']; - $metadata['nosave'] = $dontsave; - $metadata['rsvp'] = intval($metadata['rsvp']); - $metadata['after_action'] = $this->rc->config->get('calendar_itip_after_action', $this->defaults['calendar_itip_after_action']); - $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 ($event['_method'] == 'REQUEST' && $organizer && !$noreply && !in_array(strtolower($organizer['email']), $emails) && !$error_msg) { - $event['comment'] = $comment; - $itip = $this->load_itip(); - $itip->set_sender_email($reply_sender); - if ($itip->send_itip_message($event, 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status)) - $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ? $organizer['name'] : $organizer['email']))), 'confirmation'); - else - $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); - } - - $this->rc->output->send(); - } - - /** - * Handler for calendar/itip-remove requests - */ - function mail_itip_decline_reply() - { - $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); - - if (($event = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event')) && $event['_method'] == 'REPLY') { - $event['comment'] = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST); - - foreach ($event['attendees'] as $_attendee) { - if ($_attendee['role'] != 'ORGANIZER') { - $attendee = $_attendee; - break; - } - } - - $itip = $this->load_itip(); - if ($itip->send_itip_message($event, 'CANCEL', $attendee, 'itipsubjectcancel', 'itipmailbodycancel')) - $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $attendee['name'] ? $attendee['name'] : $attendee['email']))), 'confirmation'); - else - $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); - } - else { - $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); - } - } - - /** - * Handler for calendar/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(); - } - - /** - * 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); - if ($part->ctype_parameters['charset']) - $charset = $part->ctype_parameters['charset']; -// $headers = $imap->get_message_headers($uid); - - if ($part) { - $events = $this->get_ical()->import($part, $charset); - } - } - - $success = $existing = 0; - if (!empty($events)) { - // find writeable calendar to store event - $cal_id = !empty($_REQUEST['_calendar']) ? rcube_utils::get_input_value('_calendar', rcube_utils::INPUT_POST) : null; - $calendars = $this->driver->list_calendars(calendar_driver::FILTER_PERSONAL); - - foreach ($events as $event) { - // save to calendar - $calendar = $calendars[$cal_id] ?: $this->get_default_calendar($event['sensitivity']); - if ($calendar && $calendar['editable'] && $event['_type'] == 'event') { - $event['calendar'] = $calendar['id']; - - if (!$this->driver->get_event($event['uid'], calendar_driver::FILTER_WRITEABLE)) { - $success += (bool)$this->driver->new_event($event); - } - 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('errorimportingevent'), 'error'); - } - } - - /** - * Read email message and return contents for a new event based on that message - */ - public function mail_message2event() - { - $this->ui->init(); - $this->ui->addJS(); - $this->ui->init_templates(); - $this->ui->calendar_list(array(), true); // set env['calendars'] - - $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_GET); - $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_GET); - $event = array(); - - // establish imap connection - $imap = $this->rc->get_storage(); - $message = new rcube_message($uid, $mbox); - - if ($message->headers) { - $event['title'] = trim($message->subject); - $event['description'] = trim($message->first_text_part()); - - $this->load_driver(); - - // add a reference to the email message - if ($msgref = $this->driver->get_message_reference($message->headers, $mbox)) { - $event['links'] = array($msgref); - } - // copy mail attachments to event - else if ($message->attachments) { - $eventid = 'cal-'; - if (!is_array($_SESSION[self::SESSION_KEY]) || $_SESSION[self::SESSION_KEY]['id'] != $eventid) { - $_SESSION[self::SESSION_KEY] = array(); - $_SESSION[self::SESSION_KEY]['id'] = $eventid; - $_SESSION[self::SESSION_KEY]['attachments'] = array(); - } - - foreach ((array)$message->attachments as $part) { - $attachment = array( - 'data' => $imap->get_message_part($uid, $part->mime_id, $part), - 'size' => $part->size, - 'name' => $part->filename, - 'mimetype' => $part->mimetype, - 'group' => $eventid, - ); - - $attachment = $this->rc->plugins->exec_hook('attachment_save', $attachment); - - if ($attachment['status'] && !$attachment['abort']) { - $id = $attachment['id']; - $attachment['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); - - // store new attachment in session - unset($attachment['status'], $attachment['abort'], $attachment['data']); - $_SESSION[self::SESSION_KEY]['attachments'][$id] = $attachment; - - $attachment['id'] = 'rcmfile' . $attachment['id']; // add prefix to consider it 'new' - $event['attachments'][] = $attachment; - } - } - } - - $this->rc->output->set_env('event_prop', $event); - } - else { - $this->rc->output->command('display_message', $this->gettext('messageopenerror'), 'error'); - } - - $this->rc->output->send('calendar.dialog'); - } - - /** - * Handler for the 'message_compose' plugin hook. This will check for - * a compose parameter 'calendar_event' and create an attachment with the - * referenced event in iCal format - */ - public function mail_message_compose($args) - { - // set the submitted event ID as attachment - if (!empty($args['param']['calendar_event'])) { - $this->load_driver(); - - list($cal, $id) = explode(':', $args['param']['calendar_event'], 2); - if ($event = $this->driver->get_event(array('id' => $id, 'calendar' => $cal))) { - $filename = asciiwords($event['title']); - if (empty($filename)) - $filename = 'event'; - - // save ics to a temp file and register as attachment - $tmp_path = tempnam($this->rc->config->get('temp_dir'), 'rcmAttmntCal'); - file_put_contents($tmp_path, $this->get_ical()->export(array($event), '', false, array($this->driver, 'get_attachment_body'))); - - $args['attachments'][] = array( - 'path' => $tmp_path, - 'name' => $filename . '.ics', - 'mimetype' => 'text/calendar', - 'size' => filesize($tmp_path), - ); - $args['param']['subject'] = $event['title']; - } - } - - return $args; - } - - - /** - * Get a list of email addresses of the current user (from login and identities) - */ - public function get_user_emails() - { - return $this->lib->get_user_emails(); - } - - - /** - * Build an absolute URL with the given parameters - */ - public function get_url($param = array()) - { - $param += array('task' => 'calendar'); - return $this->rc->url($param, true, true); - } - - - public function ical_feed_hash($source) - { - return base64_encode($this->rc->user->get_username() . ':' . $source); - } - - /** - * Handler for user_delete plugin hook - */ - public function user_delete($args) - { - // delete itipinvitations entries related to this user - $db = $this->rc->get_dbh(); - $table_itipinvitations = $db->table_name('itipinvitations', true); - $db->query("DELETE FROM $table_itipinvitations WHERE `user_id` = ?", $args['user']->ID); - - $this->setup(); - $this->load_driver(); - return $this->driver->user_delete($args); - } - - /** - * Find first occurrence of a recurring event excluding start date - * - * @param array $event Event data (with 'start' and 'recurrence') - * - * @return DateTime Date of the first occurrence - */ - public function find_first_occurrence($event) - { - // Make sure libkolab plugin is loaded in case of Kolab driver - $this->load_driver(); - - // Use libkolab to compute recurring events (and libkolab plugin) - // Horde-based fallback has many bugs - if (class_exists('kolabformat') && class_exists('kolabcalendaring') && class_exists('kolab_date_recurrence')) { - $object = kolab_format::factory('event', 3.0); - $object->set($event); - - $recurrence = new kolab_date_recurrence($object); - } - else { - // fallback to libcalendaring (Horde-based) recurrence implementation - require_once(__DIR__ . '/lib/calendar_recurrence.php'); - $recurrence = new calendar_recurrence($this, $event); - } - - return $recurrence->first_occurrence(); - } - - /** - * Get date-time input from UI and convert to unix timestamp - */ - protected function input_timestamp($name, $type) - { - $ts = rcube_utils::get_input_value($name, $type); - - if ($ts && (!is_numeric($ts) || strpos($ts, 'T'))) { - $ts = new DateTime($ts, $this->timezone); - $ts = $ts->getTimestamp(); - } - - return $ts; - } - - /** - * 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': + } + + return $args; + } + + /** + * Get a list of email addresses of the current user (from login and identities) + */ + public function get_user_emails() + { + return $this->lib->get_user_emails(); + } + + /** + * Build an absolute URL with the given parameters + */ + public function get_url($param = []) + { + $param += ['task' => 'calendar']; + return $this->rc->url($param, true, true); + } + + public function ical_feed_hash($source) + { + return base64_encode($this->rc->user->get_username() . ':' . $source); + } + + /** + * Handler for user_delete plugin hook + */ + public function user_delete($args) + { + // delete itipinvitations entries related to this user + $db = $this->rc->get_dbh(); + $table_itipinvitations = $db->table_name('itipinvitations', true); + + $db->query("DELETE FROM $table_itipinvitations WHERE `user_id` = ?", $args['user']->ID); + + $this->setup(); + $this->load_driver(); + + return $this->driver->user_delete($args); + } + + /** + * Find first occurrence of a recurring event excluding start date + * + * @param array $event Event data (with 'start' and 'recurrence') + * + * @return DateTime Date of the first occurrence + */ + public function find_first_occurrence($event) + { + // Make sure libkolab plugin is loaded in case of Kolab driver $this->load_driver(); - return $this->driver; + + // Use libkolab to compute recurring events (and libkolab plugin) + // Horde-based fallback has many bugs + if (class_exists('kolabformat') && class_exists('kolabcalendaring') && class_exists('kolab_date_recurrence')) { + $object = kolab_format::factory('event', 3.0); + $object->set($event); + + $recurrence = new kolab_date_recurrence($object); + } + else { + // fallback to libcalendaring (Horde-based) recurrence implementation + require_once(__DIR__ . '/lib/calendar_recurrence.php'); + $recurrence = new calendar_recurrence($this, $event); + } + + return $recurrence->first_occurrence(); + } + + /** + * Get date-time input from UI and convert to unix timestamp + */ + protected function input_timestamp($name, $type) + { + $ts = rcube_utils::get_input_value($name, $type); + + if ($ts && (!is_numeric($ts) || strpos($ts, 'T'))) { + $ts = new DateTime($ts, $this->timezone); + $ts = $ts->getTimestamp(); + } + + return $ts; } - return null; - } + /** + * 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/calendar/composer.json b/plugins/calendar/composer.json index aac326f9..195ae845 100644 --- a/plugins/calendar/composer.json +++ b/plugins/calendar/composer.json @@ -1,38 +1,38 @@ { "name": "kolab/calendar", "type": "roundcube-plugin", "description": "Calendar plugin", "homepage": "https://git.kolab.org/diffusion/RPK/", "license": "AGPLv3", "version": "3.5.5", "authors": [ { "name": "Thomas Bruederli", "email": "bruederli@kolabsys.com", "role": "Lead" }, { "name": "Aleksander Machniak", "email": "machniak@kolabsys.com", "role": "Developer" } ], "repositories": [ { "type": "composer", "url": "https://plugins.roundcube.net" } ], "require": { - "php": ">=5.3.0", + "php": ">=5.4.0", "roundcube/plugin-installer": ">=0.1.3", "kolab/libcalendaring": ">=3.4.0", "kolab/libkolab": ">=3.4.0" }, "extra": { "roundcube": { "min-version": "1.4.0", "sql-dir": "drivers/database/SQL" } } } diff --git a/plugins/calendar/drivers/calendar_driver.php b/plugins/calendar/drivers/calendar_driver.php index a0161193..2c0fc6c8 100644 --- a/plugins/calendar/drivers/calendar_driver.php +++ b/plugins/calendar/drivers/calendar_driver.php @@ -1,833 +1,863 @@ * @author Thomas Bruederli * * Copyright (C) 2010, Lazlo Westerhof * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /** * Struct of an internal event object how it is passed from/to the driver classes: * * $event = array( * 'id' => 'Event ID used for editing', * 'uid' => 'Unique identifier of this event', * 'calendar' => 'Calendar identifier to add event to or where the event is stored', * 'start' => DateTime, // Event start date/time as DateTime object * 'end' => DateTime, // Event end date/time as DateTime object * 'allday' => true|false, // Boolean flag if this is an all-day event * 'changed' => DateTime, // Last modification date of event * 'title' => 'Event title/summary', * 'location' => 'Location string', * 'description' => 'Event description', * 'url' => 'URL to more information', * '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 * // + more properties (see http://www.kanzaki.com/docs/ical/recur.html) * 'EXDATE' => array(), // list of DateTime objects of exception Dates/Times * 'EXCEPTIONS' => array(), list of event objects which denote exceptions in the recurrence chain * ), * 'recurrence_id' => 'ID of the recurrence group', // usually the ID of the starting event * '_instance' => 'ID of the recurring instance', // identifies an instance within a recurrence chain * 'categories' => 'Event category', * 'free_busy' => 'free|busy|outofoffice|tentative', // Show time as * 'status' => 'TENTATIVE|CONFIRMED|CANCELLED', // event status according to RFC 2445 * 'priority' => 0-9, // Event priority (0=undefined, 1=highest, 9=lowest) * 'sensitivity' => 'public|private|confidential', // Event sensitivity * 'alarms' => '-15M:DISPLAY', // DEPRECATED Reminder settings inspired by valarm definition (e.g. display alert 15 minutes before event) * '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 * ), * ), * 'attachments' => array( // List of attachments * 'name' => 'File name', * 'mimetype' => 'Content type', * 'size' => 1..n, // in bytes * 'id' => 'Attachment identifier' * ), * 'deleted_attachments' => array(), // array of attachment identifiers to delete when event is updated * 'attendees' => array( // List of event participants * 'name' => 'Participant name', * 'email' => 'Participant e-mail address', // used as identifier * 'role' => 'ORGANIZER|REQ-PARTICIPANT|OPT-PARTICIPANT|CHAIR', * 'status' => 'NEEDS-ACTION|UNKNOWN|ACCEPTED|TENTATIVE|DECLINED' * 'rsvp' => true|false, * ), * * '_savemode' => 'all|future|current|new', // How changes on recurring event should be handled * '_notify' => true|false, // whether to notify event attendees about changes * '_fromcalendar' => 'Calendar identifier where the event was stored before', * ); */ /** * Interface definition for calendar driver classes */ abstract class calendar_driver { - 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; - const BIRTHDAY_CALENDAR_ID = '__bdays__'; - - // features supported by backend - public $alarms = false; - public $attendees = false; - public $freebusy = false; - public $attachments = false; - public $undelete = false; - public $history = false; - public $categoriesimmutable = false; - public $alarm_types = array('DISPLAY'); - public $alarm_absolute = true; - public $last_error; - - protected $default_categories = array( - 'Personal' => 'c0c0c0', - 'Work' => 'ff0000', - 'Family' => '00ff00', - 'Holiday' => 'ff6600', - ); - - /** - * Get a list of available calendars from this source - * - * @param integer Bitmask defining filter criterias. - * See FILTER_* constants for possible values. - * @return array List of calendars - */ - abstract function list_calendars($filter = 0); - - /** - * 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 - * showalarms: True if alarms are enabled - * @return mixed ID of the calendar on success, False on error - */ - abstract function create_calendar($prop); - - /** - * Update properties of an existing calendar - * - * @param array Hash array with calendar properties - * id: Calendar Identifier - * name: Calendar name - * color: The color of the calendar - * showalarms: True if alarms are enabled (if supported) - * @return boolean True on success, Fales on failure - */ - abstract function edit_calendar($prop); - - /** - * Set active/subscribed state of a calendar - * - * @param array Hash array with calendar properties - * id: Calendar Identifier - * active: True if calendar is active, false if not - * @return boolean True on success, Fales on failure - */ - abstract function subscribe_calendar($prop); - - /** - * Delete the given calendar with all its contents - * - * @param array Hash array with calendar properties - * id: Calendar Identifier - * @return boolean True on success, Fales on failure - */ - abstract function delete_calendar($prop); - - /** - * 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 - */ - abstract function search_calendars($query, $source); - - /** - * Add a single event to the database - * - * @param array Hash array with event properties (see header of this file) - * @return mixed New event ID on success, False on error - */ - abstract function new_event($event); - - /** - * Update an event entry with the given data - * - * @param array Hash array with event properties (see header of this file) - * @return boolean True on success, False on error - */ - abstract function edit_event($event); - - /** - * Extended event editing with possible changes to the argument - * - * @param array Hash array with event properties - * @param string New participant status - * @param array List of hash arrays with updated attendees - * @return boolean True on success, False on error - */ - public function edit_rsvp(&$event, $status, $attendees) - { - return $this->edit_event($event); - } - - /** - * Update the participant status for the given attendee - * - * @param array Hash array with event properties - * @param array List of hash arrays each represeting an updated attendee - * @return boolean True on success, False on error - */ - public function update_attendees(&$event, $attendees) - { - return $this->edit_event($event); - } - - /** - * Move a single event - * - * @param array Hash array with event properties: - * id: Event identifier - * start: Event start date/time as DateTime object - * end: Event end date/time as DateTime object - * allday: Boolean flag if this is an all-day event - * @return boolean True on success, False on error - */ - abstract function move_event($event); - - /** - * Resize a single event - * - * @param array Hash array with event properties: - * id: Event identifier - * start: Event start date/time as DateTime object with timezone - * end: Event end date/time as DateTime object with timezone - * @return boolean True on success, False on error - */ - abstract function resize_event($event); - - /** - * Remove a single event from the database - * - * @param array Hash array with event properties: - * id: Event identifier - * @param boolean Remove event irreversible (mark as deleted otherwise, - * if supported by the backend) - * - * @return boolean True on success, False on error - */ - abstract function remove_event($event, $force = true); - - /** - * Restores a single deleted event (if supported) - * - * @param array Hash array with event properties: - * id: Event identifier - * - * @return boolean True on success, False on error - */ - public function restore_event($event) - { - return false; - } - - /** - * Return data of a single event - * - * @param mixed UID string or hash array with event properties: - * id: Event identifier - * uid: Event UID - * _instance: Instance identifier in combination with uid (optional) - * calendar: Calendar identifier (optional) - * @param integer Bitmask defining the scope to search events in. - * See FILTER_* constants for possible values. - * @param boolean If true, recurrence exceptions shall be added - * - * @return array Event object as hash array - */ - abstract function get_event($event, $scope = 0, $full = false); - - /** - * Get events from source. - * - * @param integer Date range start (unix timestamp) - * @param integer Date range end (unix timestamp) - * @param string Search query (optional) - * @param mixed List of calendar IDs to load events from (either as array or comma-separated string) - * @param boolean Include virtual/recurring events (optional) - * @param integer Only list events modified since this time (unix timestamp) - * @return array A list of event objects (see header of this file for struct of an event) - */ - abstract function load_events($start, $end, $query = null, $calendars = null, $virtual = 1, $modifiedsince = null); - - /** - * Get number of events in the given calendar - * - * @param mixed List of calendar IDs to count events (either as array or comma-separated string) - * @param integer Date range start (unix timestamp) - * @param integer Date range end (unix timestamp) - * @return array Hash array with counts grouped by calendar ID - */ - abstract function count_events($calendars, $start, $end = null); - - /** - * Get a list of pending alarms to be displayed to the user - * - * @param integer Current time (unix timestamp) - * @param mixed List of calendar IDs to show alarms for (either as array or comma-separated string) - * @return array A list of alarms, each encoded as hash array: - * id: Event identifier - * uid: Unique identifier of this event - * start: Event start date/time as DateTime object - * end: Event end date/time as DateTime object - * allday: Boolean flag if this is an all-day event - * title: Event title/summary - * location: Location string - */ - abstract function pending_alarms($time, $calendars = 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 Event identifier - * @param integer Suspend the alarm for this number of seconds - */ - abstract function dismiss_alarm($event_id, $snooze = 0); - - /** - * Check the given event object for validity - * - * @param array Event object as hash array - * @return boolean True if valid, false if not - */ - public function validate($event) - { - $valid = true; - - if (!is_object($event['start']) || !is_a($event['start'], 'DateTime')) - $valid = false; - if (!is_object($event['end']) || !is_a($event['end'], 'DateTime')) - $valid = false; - - return $valid; - } - - - /** - * Get list of event's attachments. - * Drivers can return list of attachments as event property. - * If they will do not do this list_attachments() method will be used. - * - * @param array $event Hash array with event properties: - * id: Event identifier - * calendar: Calendar identifier - * - * @return array List of attachments, each as hash array: - * id: Attachment identifier - * name: Attachment name - * mimetype: MIME content type of the attachment - * size: Attachment size - */ - public function list_attachments($event) { } - - /** - * Get attachment properties - * - * @param string $id Attachment identifier - * @param array $event Hash array with event properties: - * id: Event identifier - * calendar: Calendar identifier - * - * @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, $event) { } - - /** - * Get attachment body - * - * @param string $id Attachment identifier - * @param array $event Hash array with event properties: - * id: Event identifier - * calendar: Calendar identifier - * - * @return string Attachment body - */ - public function get_attachment_body($id, $event) { } - - /** - * 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; - } - - /** - * List availabale categories - * The default implementation reads them from config/user prefs - */ - public function list_categories() - { - $rcmail = rcube::get_instance(); - return $rcmail->config->get('calendar_categories', $this->default_categories); - } - - /** - * Create a new category - */ - public function add_category($name, $color) { } - - /** - * Remove the given category - */ - public function remove_category($name) { } - - /** - * Update/replace a category - */ - public function replace_category($oldname, $name, $color) { } - - /** - * Fetch free/busy information from a person within the given range - * - * @param string E-mail address of attendee - * @param integer Requested period start date/time as unix timestamp - * @param integer Requested period end date/time as unix timestamp - * - * @return array List of busy timeslots within the requested range - */ - public function get_freebusy_list($email, $start, $end) - { - return false; - } - - /** - * Create instances of a recurring event - * - * @param array Hash array with event properties - * @param object DateTime Start date of the recurrence window - * @param object DateTime End date of the recurrence window - * @return array List of recurring event instances - */ - public function get_recurring_events($event, $start, $end = null) - { - $events = array(); - - if ($event['recurrence']) { - // include library class - require_once(dirname(__FILE__) . '/../lib/calendar_recurrence.php'); - - $rcmail = rcmail::get_instance(); - $recurrence = new calendar_recurrence($rcmail->plugins->get_plugin('calendar'), $event); - $recurrence_id_format = libcalendaring::recurrence_id_format($event); - - // determine a reasonable end date if none given - if (!$end) { - switch ($event['recurrence']['FREQ']) { - case 'YEARLY': $intvl = 'P100Y'; break; - case 'MONTHLY': $intvl = 'P20Y'; break; - default: $intvl = 'P10Y'; break; - } + 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; + const BIRTHDAY_CALENDAR_ID = '__bdays__'; + + // features supported by backend + public $alarms = false; + public $attendees = false; + public $freebusy = false; + public $attachments = false; + public $undelete = false; + public $history = false; + public $alarm_types = ['DISPLAY']; + public $alarm_absolute = true; + public $categoriesimmutable = false; + public $last_error; + + protected $default_categories = [ + 'Personal' => 'c0c0c0', + 'Work' => 'ff0000', + 'Family' => '00ff00', + 'Holiday' => 'ff6600', + ]; + + /** + * Get a list of available calendars from this source + * + * @param int $filter Bitmask defining filter criterias. + * See FILTER_* constants for possible values. + * + * @return array List of calendars + */ + abstract function list_calendars($filter = 0); + + /** + * Create a new calendar assigned to the current user + * + * @param array $prop Hash array with calendar properties + * name: Calendar name + * color: The color of the calendar + * showalarms: True if alarms are enabled + * + * @return mixed ID of the calendar on success, False on error + */ + abstract function create_calendar($prop); + + /** + * Update properties of an existing calendar + * + * @param array $prop Hash array with calendar properties + * id: Calendar Identifier + * name: Calendar name + * color: The color of the calendar + * showalarms: True if alarms are enabled (if supported) + * + * @return bool True on success, Fales on failure + */ + abstract function edit_calendar($prop); + + /** + * Set active/subscribed state of a calendar + * + * @param array $prop Hash array with calendar properties + * id: Calendar Identifier + * active: True if calendar is active, false if not + * + * @return bool True on success, Fales on failure + */ + abstract function subscribe_calendar($prop); + + /** + * Delete the given calendar with all its contents + * + * @param array $prop Hash array with calendar properties + * id: Calendar Identifier + * + * @return bool True on success, Fales on failure + */ + abstract function delete_calendar($prop); + + /** + * Search for shared or otherwise not listed calendars the user has access + * + * @param string $query Search string + * @param string $source Section/source to search + * + * @return array List of calendars + */ + abstract function search_calendars($query, $source); + + /** + * Add a single event to the database + * + * @param array $event Hash array with event properties (see header of this file) + * + * @return mixed New event ID on success, False on error + */ + abstract function new_event($event); + + /** + * Update an event entry with the given data + * + * @param array $event Hash array with event properties (see header of this file) + * + * @return bool True on success, False on error + */ + abstract function edit_event($event); + + /** + * Extended event editing with possible changes to the argument + * + * @param array &$event Hash array with event properties + * @param string $status New participant status + * @param array $attendees List of hash arrays with updated attendees + * + * @return bool True on success, False on error + */ + public function edit_rsvp(&$event, $status, $attendees) + { + return $this->edit_event($event); + } - $end = clone $event['start']; - $end->add(new DateInterval($intvl)); - } - - $i = 0; - while ($next_event = $recurrence->next_instance()) { - // add to output if in range - if (($next_event['start'] <= $end && $next_event['end'] >= $start)) { - $next_event['_instance'] = $next_event['start']->format($recurrence_id_format); - $next_event['id'] = $next_event['uid'] . '-' . $exception['_instance']; - $next_event['recurrence_id'] = $event['uid']; - $events[] = $next_event; + /** + * Update the participant status for the given attendee + * + * @param array &$event Hash array with event properties + * @param array $attendees List of hash arrays each represeting an updated attendee + * + * @return bool True on success, False on error + */ + public function update_attendees(&$event, $attendees) + { + return $this->edit_event($event); + } + + /** + * Move a single event + * + * @param array $event Hash array with event properties: + * id: Event identifier + * start: Event start date/time as DateTime object + * end: Event end date/time as DateTime object + * allday: Boolean flag if this is an all-day event + * + * @return bool True on success, False on error + */ + abstract function move_event($event); + + /** + * Resize a single event + * + * @param array $event Hash array with event properties: + * id: Event identifier + * start: Event start date/time as DateTime object with timezone + * end: Event end date/time as DateTime object with timezone + * + * @return bool True on success, False on error + */ + abstract function resize_event($event); + + /** + * Remove a single event from the database + * + * @param array $event Hash array with event properties: + * id: Event identifier + * @param bool $force Remove event irreversible (mark as deleted otherwise, + * if supported by the backend) + * + * @return bool True on success, False on error + */ + abstract function remove_event($event, $force = true); + + /** + * Restores a single deleted event (if supported) + * + * @param array $event Hash array with event properties: + * id: Event identifier + * + * @return bool True on success, False on error + */ + public function restore_event($event) + { + return false; + } + + /** + * Return data of a single event + * + * @param mixed $event UID string or hash array with event properties: + * id: Event identifier + * uid: Event UID + * _instance: Instance identifier in combination with uid (optional) + * calendar: Calendar identifier (optional) + * @param int $scope Bitmask defining the scope to search events in. + * See FILTER_* constants for possible values. + * @param bool $full If true, recurrence exceptions shall be added + * + * @return array Event object as hash array + */ + abstract function get_event($event, $scope = 0, $full = false); + + /** + * Get events from source. + * + * @param int $start Date range start (unix timestamp) + * @param int $end Date range end (unix timestamp) + * @param string $query Search query (optional) + * @param mixed $calendars List of calendar IDs to load events from (either as array or comma-separated string) + * @param bool $virtual Include virtual/recurring events (optional) + * @param int $modifiedsince Only list events modified since this time (unix timestamp) + * + * @return array A list of event objects (see header of this file for struct of an event) + */ + abstract function load_events($start, $end, $query = null, $calendars = null, $virtual = 1, $modifiedsince = null); + + /** + * Get number of events in the given calendar + * + * @param mixed $calendars List of calendar IDs to count events (either as array or comma-separated string) + * @param int $start Date range start (unix timestamp) + * @param int $end Date range end (unix timestamp) + * + * @return array Hash array with counts grouped by calendar ID + */ + abstract function count_events($calendars, $start, $end = null); + + /** + * Get a list of pending alarms to be displayed to the user + * + * @param int $time Current time (unix timestamp) + * @param mixed $calendars List of calendar IDs to show alarms for (either as array or comma-separated string) + * + * @return array A list of alarms, each encoded as hash array: + * id: Event identifier + * uid: Unique identifier of this event + * start: Event start date/time as DateTime object + * end: Event end date/time as DateTime object + * allday: Boolean flag if this is an all-day event + * title: Event title/summary + * location: Location string + */ + abstract function pending_alarms($time, $calendars = 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 $event_id Event identifier + * @param int $snooze Suspend the alarm for this number of seconds + */ + abstract function dismiss_alarm($event_id, $snooze = 0); + + /** + * Check the given event object for validity + * + * @param array $event Event object as hash array + * + * @return boolean True if valid, false if not + */ + public function validate($event) + { + $valid = true; + + if (empty($event['start']) || !is_object($event['start']) || !is_a($event['start'], 'DateTime')) { + $valid = false; } - else if ($next_event['start'] > $end) { // stop loop if out of range - break; + + if (empty($event['end']) || !is_object($event['end']) || !is_a($event['end'], 'DateTime')) { + $valid = false; } - // avoid endless recursion loops - if (++$i > 1000) { - break; + return $valid; + } + + /** + * Get list of event's attachments. + * Drivers can return list of attachments as event property. + * If they will do not do this list_attachments() method will be used. + * + * @param array $event Hash array with event properties: + * id: Event identifier + * calendar: Calendar identifier + * + * @return array List of attachments, each as hash array: + * id: Attachment identifier + * name: Attachment name + * mimetype: MIME content type of the attachment + * size: Attachment size + */ + public function list_attachments($event) { } + + /** + * Get attachment properties + * + * @param string $id Attachment identifier + * @param array $event Hash array with event properties: + * id: Event identifier + * calendar: Calendar identifier + * + * @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, $event) { } + + /** + * Get attachment body + * + * @param string $id Attachment identifier + * @param array $event Hash array with event properties: + * id: Event identifier + * calendar: Calendar identifier + * + * @return string Attachment body + */ + public function get_attachment_body($id, $event) { } + + /** + * 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; + } + + /** + * List availabale categories + * The default implementation reads them from config/user prefs + */ + public function list_categories() + { + $rcmail = rcube::get_instance(); + return $rcmail->config->get('calendar_categories', $this->default_categories); + } + + /** + * Create a new category + */ + public function add_category($name, $color) { } + + /** + * Remove the given category + */ + public function remove_category($name) { } + + /** + * Update/replace a category + */ + public function replace_category($oldname, $name, $color) { } + + /** + * Fetch free/busy information from a person within the given range + * + * @param string $email E-mail address of attendee + * @param int $start Requested period start date/time as unix timestamp + * @param int $end Requested period end date/time as unix timestamp + * + * @return array List of busy timeslots within the requested range + */ + public function get_freebusy_list($email, $start, $end) + { + return false; + } + + /** + * Create instances of a recurring event + * + * @param array $event Hash array with event properties + * @param DateTime $start Start date of the recurrence window + * @param DateTime $end End date of the recurrence window + * + * @return array List of recurring event instances + */ + public function get_recurring_events($event, $start, $end = null) + { + $events = []; + + if (!empty($event['recurrence'])) { + // include library class + require_once(dirname(__FILE__) . '/../lib/calendar_recurrence.php'); + + $rcmail = rcmail::get_instance(); + $recurrence = new calendar_recurrence($rcmail->plugins->get_plugin('calendar'), $event); + $recurrence_id_format = libcalendaring::recurrence_id_format($event); + + // determine a reasonable end date if none given + if (!$end) { + switch ($event['recurrence']['FREQ']) { + case 'YEARLY': $intvl = 'P100Y'; break; + case 'MONTHLY': $intvl = 'P20Y'; break; + default: $intvl = 'P10Y'; break; + } + + $end = clone $event['start']; + $end->add(new DateInterval($intvl)); + } + + $i = 0; + while ($next_event = $recurrence->next_instance()) { + // add to output if in range + if (($next_event['start'] <= $end && $next_event['end'] >= $start)) { + $next_event['_instance'] = $next_event['start']->format($recurrence_id_format); + $next_event['id'] = $next_event['uid'] . '-' . $exception['_instance']; + $next_event['recurrence_id'] = $event['uid']; + $events[] = $next_event; + } + else if ($next_event['start'] > $end) { // stop loop if out of range + break; + } + + // avoid endless recursion loops + if (++$i > 1000) { + break; + } + } } - } + + return $events; + } + + /** + * Provide a list of revisions for the given event + * + * @param array $event Hash array with event properties: + * id: Event identifier + * calendar: Calendar 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 + * destination: Destination calendar for 'move' type + */ + public function get_event_changelog($event) + { + return false; + } + + /** + * Get a list of property changes beteen two revisions of an event + * + * @param array $event Hash array with event properties: + * id: Event identifier + * calendar: Calendar 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_event_diff($event, $rev1, $rev2) + { + return false; } - return $events; - } - - /** - * Provide a list of revisions for the given event - * - * @param array $event Hash array with event properties: - * id: Event identifier - * calendar: Calendar 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 - * destination: Destination calendar for 'move' type - */ - public function get_event_changelog($event) - { - return false; - } - - /** - * Get a list of property changes beteen two revisions of an event - * - * @param array $event Hash array with event properties: - * id: Event identifier - * calendar: Calendar 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_event_diff($event, $rev1, $rev2) - { - return false; - } - - /** - * Return full data of a specific revision of an event - * - * @param mixed UID string or hash array with event properties: - * id: Event identifier - * calendar: Calendar identifier - * @param mixed $rev Revision number - * - * @return array Event object as hash array - * @see self::get_event() - */ - public function get_event_revison($event, $rev) - { - return false; - } - - /** - * Command the backend to restore a certain revision of an event. - * This shall replace the current event with an older version. - * - * @param mixed UID string or hash array with event properties: - * id: Event identifier - * calendar: Calendar identifier - * @param mixed $rev Revision number - * - * @return boolean True on success, False on failure - */ - public function restore_event_revision($event, $rev) - { - return false; - } - - - /** - * 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) - { - $table = new html_table(array('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 full data of a specific revision of an event + * + * @param mixed $event UID string or hash array with event properties: + * id: Event identifier + * calendar: Calendar identifier + * @param mixed $rev Revision number + * + * @return array Event object as hash array + * @see self::get_event() + */ + public function get_event_revison($event, $rev) + { + return false; } - return $table->show(); - } - - /** - * Compose a list of birthday events from the contact records in the user's address books. - * - * This is a default implementation using Roundcube's address book API. - * It can be overriden with a more optimized version by the individual drivers. - * - * @param integer Event's new start (unix timestamp) - * @param integer Event's new end (unix timestamp) - * @param string Search query (optional) - * @param integer Only list events modified since this time (unix timestamp) - * @return array A list of event records - */ - public function load_birthday_events($start, $end, $search = null, $modifiedsince = null) - { - // ignore update requests for simplicity reasons - if (!empty($modifiedsince)) { - return array(); + /** + * Command the backend to restore a certain revision of an event. + * This shall replace the current event with an older version. + * + * @param mixed $event UID string or hash array with event properties: + * id: Event identifier + * calendar: Calendar identifier + * @param mixed $rev Revision number + * + * @return boolean True on success, False on failure + */ + public function restore_event_revision($event, $rev) + { + return false; } - // convert to DateTime for comparisons - $start = new DateTime('@'.$start); - $end = new DateTime('@'.$end); - // extract the current year - $year = $start->format('Y'); - $year2 = $end->format('Y'); - - $events = array(); - $search = mb_strtolower($search); - $rcmail = rcmail::get_instance(); - $cache = $rcmail->get_cache('calendar.birthdays', 'db', 3600); - $cache->expunge(); - - $alarm_type = $rcmail->config->get('calendar_birthdays_alarm_type', ''); - $alarm_offset = $rcmail->config->get('calendar_birthdays_alarm_offset', '-1D'); - $alarms = $alarm_type ? $alarm_offset . ':' . $alarm_type : null; - - // let the user select the address books to consider in prefs - $selected_sources = $rcmail->config->get('calendar_birthday_adressbooks'); - $sources = $selected_sources ?: array_keys($rcmail->get_address_sources(false, true)); - foreach ($sources as $source) { - $abook = $rcmail->get_address_book($source); - - // skip LDAP address books unless selected by the user - if (!$abook || ($abook instanceof rcube_ldap && empty($selected_sources))) { - continue; - } - - $abook->set_pagesize(10000); - - // check for cached results - $cache_records = array(); - $cached = $cache->get($source); - - // iterate over (cached) contacts - foreach (($cached ?: $abook->search('*', '', 2, true, true, array('birthday'))) as $contact) { - $event = self::parse_contact($contact, $source); - - if (empty($event)) { - continue; + /** + * Callback function to produce driver-specific calendar create/edit form + * + * @param string $action Request action 'form-edit|form-new' + * @param array $calendar Calendar properties (e.g. id, color) + * @param array $formfields Edit form fields + * + * @return string HTML content of the form + */ + public function calendar_form($action, $calendar, $formfields) + { + $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']); } - // add stripped record to cache - if (empty($cached)) { - $cache_records[] = array( - 'ID' => $contact['ID'], - 'name' => $event['_displayname'], - 'birthday' => $event['start']->format('Y-m-d'), - ); + return $table->show(); + } + + /** + * Compose a list of birthday events from the contact records in the user's address books. + * + * This is a default implementation using Roundcube's address book API. + * It can be overriden with a more optimized version by the individual drivers. + * + * @param int $start Event's new start (unix timestamp) + * @param int $end Event's new end (unix timestamp) + * @param string $search Search query (optional) + * @param int $modifiedsince Only list events modified since this time (unix timestamp) + * + * @return array A list of event records + */ + public function load_birthday_events($start, $end, $search = null, $modifiedsince = null) + { + // ignore update requests for simplicity reasons + if (!empty($modifiedsince)) { + return []; } - // filter by search term (only name is involved here) - if (!empty($search) && strpos(mb_strtolower($event['title']), $search) === false) { - continue; + // convert to DateTime for comparisons + $start = new DateTime('@'.$start); + $end = new DateTime('@'.$end); + // extract the current year + $year = $start->format('Y'); + $year2 = $end->format('Y'); + + $events = []; + $search = mb_strtolower($search); + $rcmail = rcmail::get_instance(); + $cache = $rcmail->get_cache('calendar.birthdays', 'db', 3600); + $cache->expunge(); + + $alarm_type = $rcmail->config->get('calendar_birthdays_alarm_type', ''); + $alarm_offset = $rcmail->config->get('calendar_birthdays_alarm_offset', '-1D'); + $alarms = $alarm_type ? $alarm_offset . ':' . $alarm_type : null; + + // let the user select the address books to consider in prefs + $selected_sources = $rcmail->config->get('calendar_birthday_adressbooks'); + $sources = $selected_sources ?: array_keys($rcmail->get_address_sources(false, true)); + + foreach ($sources as $source) { + $abook = $rcmail->get_address_book($source); + + // skip LDAP address books unless selected by the user + if (!$abook || ($abook instanceof rcube_ldap && empty($selected_sources))) { + continue; + } + + // skip collected recipients/senders addressbooks + if (is_a($abook, 'rcube_addresses')) { + continue; + } + + $abook->set_pagesize(10000); + + // check for cached results + $cache_records = []; + $cached = $cache->get($source); + + // iterate over (cached) contacts + foreach (($cached ?: $abook->search('*', '', 2, true, true, ['birthday'])) as $contact) { + $event = self::parse_contact($contact, $source); + + if (empty($event)) { + continue; + } + + // add stripped record to cache + if (empty($cached)) { + $cache_records[] = [ + 'ID' => $contact['ID'], + 'name' => $event['_displayname'], + 'birthday' => $event['start']->format('Y-m-d'), + ]; + } + + // filter by search term (only name is involved here) + if (!empty($search) && strpos(mb_strtolower($event['title']), $search) === false) { + continue; + } + + $bday = clone $event['start']; + $byear = $bday->format('Y'); + + // quick-and-dirty recurrence computation: just replace the year + $bday->setDate($year, $bday->format('n'), $bday->format('j')); + $bday->setTime(12, 0, 0); + $this_year = $year; + + // date range reaches over multiple years: use end year if not in range + if (($bday > $end || $bday < $start) && $year2 != $year) { + $bday->setDate($year2, $bday->format('n'), $bday->format('j')); + $this_year = $year2; + } + + // birthday is within requested range + if ($bday <= $end && $bday >= $start) { + unset($event['_displayname']); + $event['alarms'] = $alarms; + + // if this is not the first occurence modify event details + // but not when this is "all birthdays feed" request + if ($year2 - $year < 10 && ($age = ($this_year - $byear))) { + $label = ['name' => 'birthdayage', 'vars' => ['age' => $age]]; + + $event['description'] = $rcmail->gettext($label, 'calendar'); + $event['start'] = $bday; + $event['end'] = clone $bday; + + unset($event['recurrence']); + } + + // add the main instance + $events[] = $event; + } + } + + // store collected contacts in cache + if (empty($cached)) { + $cache->write($source, $cache_records); + } } - $bday = clone $event['start']; - $byear = $bday->format('Y'); + return $events; + } - // quick-and-dirty recurrence computation: just replace the year - $bday->setDate($year, $bday->format('n'), $bday->format('j')); - $bday->setTime(12, 0, 0); - $this_year = $year; + /** + * Get a single birthday calendar event + */ + public function get_birthday_event($id) + { + // decode $id + list(, $source, $contact_id, $year) = explode(':', rcube_ldap::dn_decode($id)); - // date range reaches over multiple years: use end year if not in range - if (($bday > $end || $bday < $start) && $year2 != $year) { - $bday->setDate($year2, $bday->format('n'), $bday->format('j')); - $this_year = $year2; - } + $rcmail = rcmail::get_instance(); - // birthday is within requested range - if ($bday <= $end && $bday >= $start) { - unset($event['_displayname']); - $event['alarms'] = $alarms; - - // if this is not the first occurence modify event details - // but not when this is "all birthdays feed" request - if ($year2 - $year < 10 && ($age = ($this_year - $byear))) { - $event['description'] = $rcmail->gettext(array('name' => 'birthdayage', 'vars' => array('age' => $age)), 'calendar'); - $event['start'] = $bday; - $event['end'] = clone $bday; - unset($event['recurrence']); - } - - // add the main instance - $events[] = $event; + if (strlen($source) && $contact_id && ($abook = $rcmail->get_address_book($source))) { + if ($contact = $abook->get_record($contact_id, true)) { + return self::parse_contact($contact, $source); + } } - } - - // store collected contacts in cache - if (empty($cached)) { - $cache->write($source, $cache_records); - } } - return $events; - } + /** + * Parse contact and create an event for its birthday + * + * @param array $contact Contact data + * @param string $source Addressbook source ID + * + * @return array|null Birthday event data + */ + public static function parse_contact($contact, $source) + { + if (!is_array($contact)) { + return; + } - /** - * Get a single birthday calendar event - */ - public function get_birthday_event($id) - { - // decode $id - list(,$source,$contact_id,$year) = explode(':', rcube_ldap::dn_decode($id)); + if (!empty($contact['birthday']) && is_array($contact['birthday'])) { + $contact['birthday'] = reset($contact['birthday']); + } - $rcmail = rcmail::get_instance(); + if (empty($contact['birthday'])) { + return; + } - if (strlen($source) && $contact_id && ($abook = $rcmail->get_address_book($source))) { - if ($contact = $abook->get_record($contact_id, true)) { - return self::parse_contact($contact, $source); - } - } - } - - /** - * Parse contact and create an event for its birthday - * - * @param array $contact Contact data - * @param string $source Addressbook source ID - * - * @return array Birthday event data - */ - public static function parse_contact($contact, $source) - { - if (!is_array($contact)) { - return; - } + try { + $bday = $contact['birthday']; + if (!$bday instanceof DateTime) { + $bday = new DateTime($bday, new DateTimezone('UTC')); + } + $bday->_dateonly = true; + } + catch (Exception $e) { + rcube::raise_error([ + 'code' => 600, + 'file' => __FILE__, + 'line' => __LINE__, + 'message' => 'BIRTHDAY PARSE ERROR: ' . $e->getMessage() + ], + true, false + ); + return; + } - if (is_array($contact['birthday'])) { - $contact['birthday'] = reset($contact['birthday']); + $rcmail = rcmail::get_instance(); + $birthyear = $bday->format('Y'); + $display_name = rcube_addressbook::compose_display_name($contact); + $label = ['name' => 'birthdayeventtitle', 'vars' => ['name' => $display_name]]; + $event_title = $rcmail->gettext($label, 'calendar'); + $uid = rcube_ldap::dn_encode('bday:' . $source . ':' . $contact['ID'] . ':' . $birthyear); + + return [ + 'id' => $uid, + 'uid' => $uid, + 'calendar' => self::BIRTHDAY_CALENDAR_ID, + 'title' => $event_title, + 'description' => '', + 'allday' => true, + 'start' => $bday, + 'end' => clone $bday, + 'recurrence' => ['FREQ' => 'YEARLY', 'INTERVAL' => 1], + 'free_busy' => 'free', + '_displayname' => $display_name, + ]; } - if (empty($contact['birthday'])) { - return; + /** + * Store alarm dismissal for birtual birthay events + * + * @param string $event_id Event identifier + * @param int $snooze Suspend the alarm for this number of seconds + */ + public function dismiss_birthday_alarm($event_id, $snooze = 0) + { + $rcmail = rcmail::get_instance(); + $cache = $rcmail->get_cache('calendar.birthdayalarms', 'db', 86400 * 30); + $cache->remove($event_id); + + // compute new notification time or disable if not snoozed + $notifyat = $snooze > 0 ? time() + $snooze : null; + $cache->set($event_id, ['snooze' => $snooze, 'notifyat' => $notifyat]); + + return true; } - try { - $bday = $contact['birthday']; - if (!$bday instanceof DateTime) { - $bday = new DateTime($bday, new DateTimezone('UTC')); - } - $bday->_dateonly = true; + /** + * Handler for user_delete plugin hook + * + * @param array $args Hash array with hook arguments + * + * @return array Return arguments for plugin hooks + */ + public function user_delete($args) + { + // TO BE OVERRIDDEN + return $args; } - catch (Exception $e) { - rcube::raise_error(array( - 'code' => 600, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => 'BIRTHDAY PARSE ERROR: ' . $e->getMessage()), - true, false); - return; - } - - $rcmail = rcmail::get_instance(); - $birthyear = $bday->format('Y'); - $display_name = rcube_addressbook::compose_display_name($contact); - $label = array('name' => 'birthdayeventtitle', 'vars' => array('name' => $display_name)); - $event_title = $rcmail->gettext($label, 'calendar'); - $uid = rcube_ldap::dn_encode('bday:' . $source . ':' . $contact['ID'] . ':' . $birthyear); - - $event = array( - 'id' => $uid, - 'uid' => $uid, - 'calendar' => self::BIRTHDAY_CALENDAR_ID, - 'title' => $event_title, - 'description' => '', - 'allday' => true, - 'start' => $bday, - 'end' => clone $bday, - 'recurrence' => array('FREQ' => 'YEARLY', 'INTERVAL' => 1), - 'free_busy' => 'free', - '_displayname' => $display_name, - ); - - return $event; - } - - /** - * Store alarm dismissal for birtual birthay events - * - * @param string Event identifier - * @param integer Suspend the alarm for this number of seconds - */ - public function dismiss_birthday_alarm($event_id, $snooze = 0) - { - $rcmail = rcmail::get_instance(); - $cache = $rcmail->get_cache('calendar.birthdayalarms', 'db', 86400 * 30); - $cache->remove($event_id); - - // compute new notification time or disable if not snoozed - $notifyat = $snooze > 0 ? time() + $snooze : null; - $cache->set($event_id, array('snooze' => $snooze, 'notifyat' => $notifyat)); - - return true; - } - - /** - * 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/calendar/drivers/database/database_driver.php b/plugins/calendar/drivers/database/database_driver.php index c057825b..7ecf0dcf 100644 --- a/plugins/calendar/drivers/database/database_driver.php +++ b/plugins/calendar/drivers/database/database_driver.php @@ -1,1529 +1,1530 @@ * @author Thomas Bruederli * * Copyright (C) 2010, Lazlo Westerhof * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class database_driver extends calendar_driver { const DB_DATE_FORMAT = 'Y-m-d H:i:s'; public static $scheduling_properties = array('start', 'end', 'allday', 'recurrence', 'location', 'cancelled'); // features this backend supports public $alarms = true; public $attendees = true; public $freebusy = false; public $attachments = true; public $alarm_types = array('DISPLAY'); private $rc; private $cal; private $cache = array(); private $calendars = array(); private $calendar_ids = ''; private $free_busy_map = array('free' => 0, 'busy' => 1, 'out-of-office' => 2, 'outofoffice' => 2, 'tentative' => 3); private $sensitivity_map = array('public' => 0, 'private' => 1, 'confidential' => 2); private $server_timezone; private $db_events = 'events'; private $db_calendars = 'calendars'; private $db_attachments = 'attachments'; /** * Default constructor */ public function __construct($cal) { $this->cal = $cal; $this->rc = $cal->rc; $this->server_timezone = new DateTimeZone(date_default_timezone_get()); // read database config $db = $this->rc->get_dbh(); $this->db_events = $db->table_name($this->rc->config->get('db_table_events', $this->db_events)); $this->db_calendars = $db->table_name($this->rc->config->get('db_table_calendars', $this->db_calendars)); $this->db_attachments = $db->table_name($this->rc->config->get('db_table_attachments', $this->db_attachments)); $this->_read_calendars(); } /** * Read available calendars for the current user and store them internally */ private function _read_calendars() { $hidden = array_filter(explode(',', $this->rc->config->get('hidden_calendars', ''))); if (!empty($this->rc->user->ID)) { $calendar_ids = array(); $result = $this->rc->db->query( "SELECT *, `calendar_id` AS id FROM `{$this->db_calendars}`" . " WHERE `user_id` = ?" . " ORDER BY `name`", $this->rc->user->ID ); while ($result && ($arr = $this->rc->db->fetch_assoc($result))) { $arr['showalarms'] = intval($arr['showalarms']); $arr['active'] = !in_array($arr['id'], $hidden); $arr['name'] = html::quote($arr['name']); $arr['listname'] = html::quote($arr['name']); $arr['rights'] = 'lrswikxteav'; $arr['editable'] = true; $this->calendars[$arr['calendar_id']] = $arr; $calendar_ids[] = $this->rc->db->quote($arr['calendar_id']); } $this->calendar_ids = join(',', $calendar_ids); } } /** * Get a list of available calendars from this source * * @param integer Bitmask defining filter criterias * * @return array List of calendars */ public function list_calendars($filter = 0) { // attempt to create a default calendar for this user if (empty($this->calendars)) { if ($this->create_calendar(array('name' => 'Default', 'color' => 'cc0000', 'showalarms' => true))) { $this->_read_calendars(); } } $calendars = $this->calendars; // filter active calendars if ($filter & self::FILTER_ACTIVE) { foreach ($calendars as $idx => $cal) { if (!$cal['active']) { unset($calendars[$idx]); } } } // 'personal' is unsupported in this driver // append the virtual birthdays calendar if ($this->rc->config->get('calendar_contact_birthdays', false)) { $prefs = $this->rc->config->get('birthday_calendar', array('color' => '87CEFA')); $hidden = array_filter(explode(',', $this->rc->config->get('hidden_calendars', ''))); $id = self::BIRTHDAY_CALENDAR_ID; - if (!$active || !in_array($id, $hidden)) { + if (empty($active) || !in_array($id, $hidden)) { $calendars[$id] = array( 'id' => $id, 'name' => $this->cal->gettext('birthdays'), 'listname' => $this->cal->gettext('birthdays'), 'color' => $prefs['color'], 'showalarms' => (bool)$this->rc->config->get('calendar_birthdays_alarm_type'), 'active' => !in_array($id, $hidden), 'group' => 'x-birthdays', 'editable' => false, 'default' => false, 'children' => false, ); } } return $calendars; } /** * 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) { $result = $this->rc->db->query( "INSERT INTO `{$this->db_calendars}`" . " (`user_id`, `name`, `color`, `showalarms`)" . " VALUES (?, ?, ?, ?)", $this->rc->user->ID, $prop['name'], strval($prop['color']), - $prop['showalarms'] ? 1 : 0 + !empty($prop['showalarms']) ? 1 : 0 ); if ($result) { return $this->rc->db->insert_id($this->db_calendars); } return false; } /** * Update properties of an existing calendar * * @see calendar_driver::edit_calendar() */ public function edit_calendar($prop) { // birthday calendar properties are saved in user prefs if ($prop['id'] == self::BIRTHDAY_CALENDAR_ID) { $prefs['birthday_calendar'] = $this->rc->config->get('birthday_calendar', array('color' => '87CEFA')); if (isset($prop['color'])) { $prefs['birthday_calendar']['color'] = $prop['color']; } if (isset($prop['showalarms'])) { $prefs['calendar_birthdays_alarm_type'] = $prop['showalarms'] ? $this->alarm_types[0] : ''; } $this->rc->user->save_prefs($prefs); return true; } $query = $this->rc->db->query( "UPDATE `{$this->db_calendars}`" . " SET `name` = ?, `color` = ?, `showalarms` = ?" . " WHERE `calendar_id` = ? AND `user_id` = ?", $prop['name'], strval($prop['color']), $prop['showalarms'] ? 1 : 0, $prop['id'], $this->rc->user->ID ); return $this->rc->db->affected_rows($query); } /** * Set active/subscribed state of a calendar * Save a list of hidden calendars in user prefs * * @see calendar_driver::subscribe_calendar() */ public function subscribe_calendar($prop) { $hidden = array_flip(explode(',', $this->rc->config->get('hidden_calendars', ''))); if ($prop['active']) { unset($hidden[$prop['id']]); } else { $hidden[$prop['id']] = 1; } return $this->rc->user->save_prefs(array('hidden_calendars' => join(',', array_keys($hidden)))); } /** * Delete the given calendar with all its contents * * @see calendar_driver::delete_calendar() */ public function delete_calendar($prop) { if (!$this->calendars[$prop['id']]) { return false; } // events and attachments will be deleted by foreign key cascade $query = $this->rc->db->query( "DELETE FROM `{$this->db_calendars}` WHERE `calendar_id` = ? AND `user_id` = ?", $prop['id'], $this->rc->user->ID ); return $this->rc->db->affected_rows($query); } /** * 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) { // not implemented return array(); } /** * Add a single event to the database * * @param array Hash array with event properties * @see calendar_driver::new_event() */ public function new_event($event) { if (!$this->validate($event)) { return false; } if (!empty($this->calendars)) { if ($event['calendar'] && !$this->calendars[$event['calendar']]) { return false; } if (!$event['calendar']) { $event['calendar'] = reset(array_keys($this->calendars)); } if ($event_id = $this->_insert_event($event)) { $this->_update_recurring($event); } return $event_id; } return false; } /** * */ private function _insert_event(&$event) { $event = $this->_save_preprocess($event); $now = $this->rc->db->now(); $this->rc->db->query( "INSERT INTO `{$this->db_events}`" . " (`calendar_id`, `created`, `changed`, `uid`, `recurrence_id`, `instance`," . " `isexception`, `start`, `end`, `all_day`, `recurrence`, `title`, `description`," . " `location`, `categories`, `url`, `free_busy`, `priority`, `sensitivity`," . " `status`, `attendees`, `alarms`, `notifyat`)" . " VALUES (?, $now, $now, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", $event['calendar'], strval($event['uid']), - intval($event['recurrence_id']), - strval($event['_instance']), - intval($event['isexception']), + isset($event['recurrence_id']) ? intval($event['recurrence_id']) : 0, + isset($event['_instance']) ? strval($event['_instance']) : '', + isset($event['isexception']) ? intval($event['isexception']) : 0, $event['start']->format(self::DB_DATE_FORMAT), $event['end']->format(self::DB_DATE_FORMAT), intval($event['all_day']), $event['_recurrence'], strval($event['title']), - strval($event['description']), - strval($event['location']), - join(',', (array)$event['categories']), - strval($event['url']), + isset($event['description']) ? strval($event['description']) : '', + isset($event['location']) ? strval($event['location']) : '', + isset($event['categories']) ? join(',', (array) $event['categories']) : '', + isset($event['url']) ? strval($event['url']) : '', intval($event['free_busy']), intval($event['priority']), intval($event['sensitivity']), - strval($event['status']), + isset($event['status']) ? strval($event['status']) : '', $event['attendees'], - $event['alarms'], + isset($event['alarms']) ? $event['alarms'] : null, $event['notifyat'] ); $event_id = $this->rc->db->insert_id($this->db_events); if ($event_id) { $event['id'] = $event_id; // add attachments if (!empty($event['attachments'])) { foreach ($event['attachments'] as $attachment) { $this->add_attachment($attachment, $event_id); unset($attachment); } } return $event_id; } return false; } /** * Update an event entry with the given data * * @param array Hash array with event properties * @see calendar_driver::edit_event() */ public function edit_event($event) { if (!empty($this->calendars)) { $update_master = false; $update_recurring = true; $old = $this->get_event($event); $ret = true; // check if update affects scheduling and update attendee status accordingly $reschedule = $this->_check_scheduling($event, $old, true); // increment sequence number if (empty($event['sequence']) && $reschedule) { - $event['sequence'] = max($event['sequence'], $old['sequence']) + 1; + $event['sequence'] = $old['sequence'] + 1; } // modify a recurring event, check submitted savemode to do the right things if ($old['recurrence'] || $old['recurrence_id']) { $master = $old['recurrence_id'] ? $this->get_event(array('id' => $old['recurrence_id'])) : $old; // keep saved exceptions (not submitted by the client) - if ($old['recurrence']['EXDATE']) { + if (!empty($old['recurrence']['EXDATE'])) { $event['recurrence']['EXDATE'] = $old['recurrence']['EXDATE']; } - switch ($event['_savemode']) { + $savemode = isset($event['_savemode']) ? $event['_savemode'] : null; + switch ($savemode) { case 'new': $event['uid'] = $this->cal->generate_uid(); return $this->new_event($event); case 'current': // save as exception $event['isexception'] = 1; $update_recurring = false; // set exception to first instance (= master) if ($event['id'] == $master['id']) { $event += $old; $event['recurrence_id'] = $master['id']; $event['_instance'] = libcalendaring::recurrence_instance_identifier($old, $master['allday']); $event['isexception'] = 1; $event_id = $this->_insert_event($event); return $event_id; } break; case 'future': if ($master['id'] != $event['id']) { // set until-date on master event, then save this instance as new recurring event $master['recurrence']['UNTIL'] = clone $event['start']; $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); unset($master['recurrence']['COUNT']); $update_master = true; // if recurrence COUNT, update value to the correct number of future occurences if ($event['recurrence']['COUNT']) { $fromdate = clone $event['start']; $fromdate->setTimezone($this->server_timezone); $query = $this->rc->db->query( "SELECT `event_id` FROM `{$this->db_events}`" . " WHERE `calendar_id` IN ({$this->calendar_ids})" . " AND `start` >= ? AND `recurrence_id` = ?", $fromdate->format(self::DB_DATE_FORMAT), $master['id'] ); if ($count = $this->rc->db->num_rows($query)) { $event['recurrence']['COUNT'] = $count; } } $update_recurring = true; $event['recurrence_id'] = 0; $event['isexception'] = 0; $event['_instance'] = ''; break; } // else: 'future' == 'all' if modifying the master event default: // 'all' is default $event['id'] = $master['id']; $event['recurrence_id'] = 0; // use start date from master but try to be smart on time or duration changes $old_start_date = $old['start']->format('Y-m-d'); $old_start_time = $old['allday'] ? '' : $old['start']->format('H:i'); $old_duration = $old['end']->format('U') - $old['start']->format('U'); $new_start_date = $event['start']->format('Y-m-d'); $new_start_time = $event['allday'] ? '' : $event['start']->format('H:i'); $new_duration = $event['end']->format('U') - $event['start']->format('U'); $diff = $old_start_date != $new_start_date || $old_start_time != $new_start_time || $old_duration != $new_duration; $date_shift = $old['start']->diff($event['start']); // shifted or resized if ($diff && ($old_start_date == $new_start_date || $old_duration == $new_duration)) { $event['start'] = $master['start']->add($old['start']->diff($event['start'])); $event['end'] = clone $event['start']; $event['end']->add(new DateInterval('PT'.$new_duration.'S')); } // dates did not change, use the ones from master else if ($new_start_date . $new_start_time == $old_start_date . $old_start_time) { $event['start'] = $master['start']; $event['end'] = $master['end']; } // adjust recurrence-id when start changed and therefore the entire recurrence chain changes if (is_array($event['recurrence']) && ($old_start_date != $new_start_date || $old_start_time != $new_start_time) && ($exceptions = $this->_load_exceptions($old)) ) { $recurrence_id_format = libcalendaring::recurrence_id_format($event); foreach ($exceptions as $exception) { $recurrence_id = rcube_utils::anytodatetime($exception['_instance'], $old['start']->getTimezone()); if (is_a($recurrence_id, 'DateTime')) { $recurrence_id->add($date_shift); $exception['_instance'] = $recurrence_id->format($recurrence_id_format); $this->_update_event($exception, false); } } } $ret = $event['id']; // return master ID break; } } $success = $this->_update_event($event, $update_recurring); if ($success && $update_master) { $this->_update_event($master, true); } return $success ? $ret : false; } return false; } /** * Extended event editing with possible changes to the argument * * @param array Hash array with event properties * @param string New participant status * @param array List of hash arrays with updated attendees * * @return boolean True on success, False on error */ public function edit_rsvp(&$event, $status, $attendees) { $update_event = $event; // apply changes to master (and all exceptions) if ($event['_savemode'] == 'all' && $event['recurrence_id']) { $update_event = $this->get_event(array('id' => $event['recurrence_id'])); $update_event['_savemode'] = $event['_savemode']; calendar::merge_attendee_data($update_event, $attendees); } if ($ret = $this->update_attendees($update_event, $attendees)) { // replace $event with effectively updated event (for iTip reply) if ($ret !== true && $ret != $update_event['id'] && ($new_event = $this->get_event(array('id' => $ret)))) { $event = $new_event; } else { $event = $update_event; } } return $ret; } /** * Update the participant status for the given attendees * * @see calendar_driver::update_attendees() */ public function update_attendees(&$event, $attendees) { $success = $this->edit_event($event, true); // apply attendee updates to recurrence exceptions too if ($success && $event['_savemode'] == 'all' && !empty($event['recurrence']) && empty($event['recurrence_id']) && ($exceptions = $this->_load_exceptions($event)) ) { foreach ($exceptions as $exception) { calendar::merge_attendee_data($exception, $attendees); $this->_update_event($exception, false); } } return $success; } /** * Determine whether the current change affects scheduling and reset attendee status accordingly */ private function _check_scheduling(&$event, $old, $update = true) { // skip this check when importing iCal/iTip events if (isset($event['sequence']) || !empty($event['_method'])) { return false; } $reschedule = false; // iterate through the list of properties considered 'significant' for scheduling foreach (self::$scheduling_properties as $prop) { - $a = $old[$prop]; - $b = $event[$prop]; + $a = isset($old[$prop]) ? $old[$prop] : null; + $b = isset($event[$prop]) ? $event[$prop] : null; - if ($event['allday'] && ($prop == 'start' || $prop == 'end') && $a instanceof DateTime && $b instanceof DateTime) { + if (!empty($event['allday']) && ($prop == 'start' || $prop == 'end') + && $a instanceof DateTime && $b instanceof DateTime + ) { $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']) { + if (!empty($a['COUNT']) && !empty($b['COUNT']) && $b['COUNT'] < $a['COUNT']) { unset($a['COUNT'], $b['COUNT']); } - else if ($a['UNTIL'] && $b['UNTIL'] && $b['UNTIL'] < $a['UNTIL']) { + else if (!empty($a['UNTIL']) && !empty($b['UNTIL']) && $b['UNTIL'] < $a['UNTIL']) { unset($a['UNTIL'], $b['UNTIL']); } } if ($a != $b) { $reschedule = true; break; } } // reset all attendee status to needs-action (#4360) if ($update && $reschedule && is_array($event['attendees'])) { $is_organizer = false; $emails = $this->cal->get_user_emails(); $attendees = $event['attendees']; foreach ($attendees as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER' && $attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $is_organizer = true; } else if ($attendee['role'] != 'ORGANIZER' && $attendee['role'] != 'NON-PARTICIPANT' && $attendee['status'] != 'DELEGATED' ) { $attendees[$i]['status'] = 'NEEDS-ACTION'; $attendees[$i]['rsvp'] = true; } } // update attendees only if I'm the organizer if ($is_organizer || ($event['organizer'] && in_array(strtolower($event['organizer']['email']), $emails))) { $event['attendees'] = $attendees; } } return $reschedule; } /** * Convert save data to be used in SQL statements */ private function _save_preprocess($event) { // shift dates to server's timezone (except for all-day events) if (!$event['allday']) { $event['start'] = clone $event['start']; $event['start']->setTimezone($this->server_timezone); $event['end'] = clone $event['end']; $event['end']->setTimezone($this->server_timezone); } // compose vcalendar-style recurrencue rule from structured data - $rrule = $event['recurrence'] ? libcalendaring::to_rrule($event['recurrence']) : ''; + $rrule = !empty($event['recurrence']) ? libcalendaring::to_rrule($event['recurrence']) : ''; + + $sensitivity = strtolower($event['sensitivity']); + $free_busy = strtolower($event['free_busy']); $event['_recurrence'] = rtrim($rrule, ';'); - $event['free_busy'] = intval($this->free_busy_map[strtolower($event['free_busy'])]); - $event['sensitivity'] = intval($this->sensitivity_map[strtolower($event['sensitivity'])]); + $event['free_busy'] = isset($this->free_busy_map[$free_busy]) ? $this->free_busy_map[$free_busy] : null; + $event['sensitivity'] = isset($this->sensitivity_map[$sensitivity]) ? $this->sensitivity_map[$sensitivity] : null; + $event['all_day'] = !empty($event['allday']) ? 1 : 0; if ($event['free_busy'] == 'tentative') { $event['status'] = 'TENTATIVE'; } - if (isset($event['allday'])) { - $event['all_day'] = $event['allday'] ? 1 : 0; - } - // compute absolute time to notify the user $event['notifyat'] = $this->_get_notification($event); - if (is_array($event['valarms'])) { + if (!empty($event['valarms'])) { $event['alarms'] = $this->serialize_alarms($event['valarms']); } // process event attendees if (!empty($event['attendees'])) { $event['attendees'] = json_encode((array)$event['attendees']); } else { $event['attendees'] = ''; } return $event; } /** * Compute absolute time to notify the user */ private function _get_notification($event) { - if ($event['valarms'] && $event['start'] > new DateTime()) { + if (!empty($event['valarms']) && $event['start'] > new DateTime()) { $alarm = libcalendaring::get_next_alarm($event); if ($alarm['time'] && in_array($alarm['action'], $this->alarm_types)) { return date('Y-m-d H:i:s', $alarm['time']); } } } /** * Save the given event record to database * * @param array Event data * @param boolean True if recurring events instances should be updated, too */ private function _update_event($event, $update_recurring = true) { $event = $this->_save_preprocess($event); $sql_args = array(); $set_cols = array('start', 'end', 'all_day', 'recurrence_id', 'isexception', 'sequence', 'title', 'description', 'location', 'categories', 'url', 'free_busy', 'priority', 'sensitivity', 'status', 'attendees', 'alarms', 'notifyat' ); foreach ($set_cols as $col) { - if (is_object($event[$col]) && is_a($event[$col], 'DateTime')) { + if (!empty($event[$col]) && is_a($event[$col], 'DateTime')) { $sql_args[$col] = $event[$col]->format(self::DB_DATE_FORMAT); } - else if (is_array($event[$col])) { - $sql_args[$col] = join(',', $event[$col]); - } else if (array_key_exists($col, $event)) { - $sql_args[$col] = $event[$col]; + $sql_args[$col] = is_array($event[$col]) ? join(',', $event[$col]) : $event[$col]; } } - if ($event['_recurrence']) { + if (!empty($event['_recurrence'])) { $sql_args['recurrence'] = $event['_recurrence']; } - if ($event['_instance']) { + if (!empty($event['_instance'])) { $sql_args['instance'] = $event['_instance']; } - if ($event['_fromcalendar'] && $event['_fromcalendar'] != $event['calendar']) { + if (!empty($event['_fromcalendar']) && $event['_fromcalendar'] != $event['calendar']) { $sql_args['calendar_id'] = $event['calendar']; } $sql_set = ''; foreach (array_keys($sql_args) as $col) { $sql_set .= ", `$col` = ?"; } $sql_args = array_values($sql_args); $sql_args[] = $event['id']; $query = $this->rc->db->query( "UPDATE `{$this->db_events}`" . " SET `changed` = " . $this->rc->db->now() . $sql_set . " WHERE `event_id` = ? AND `calendar_id` IN ({$this->calendar_ids})", $sql_args ); $success = $this->rc->db->affected_rows($query); // add attachments if ($success && !empty($event['attachments'])) { foreach ($event['attachments'] as $attachment) { $this->add_attachment($attachment, $event['id']); unset($attachment); } } // remove attachments - if ($success && !empty($event['deleted_attachments'])) { + if ($success && !empty($event['deleted_attachments']) && is_array($event['deleted_attachments'])) { foreach ($event['deleted_attachments'] as $attachment) { $this->remove_attachment($attachment, $event['id']); } } if ($success) { unset($this->cache[$event['id']]); if ($update_recurring) { $this->_update_recurring($event); } } return $success; } /** * Insert "fake" entries for recurring occurences of this event */ private function _update_recurring($event) { if (empty($this->calendars)) { return; } if (!empty($event['recurrence'])) { $exdata = array(); $exceptions = $this->_load_exceptions($event); foreach ($exceptions as $exception) { $exdate = substr($exception['_instance'], 0, 8); $exdata[$exdate] = $exception; } } // clear existing recurrence copies $this->rc->db->query( "DELETE FROM `{$this->db_events}`" . " WHERE `recurrence_id` = ? AND `isexception` = 0 AND `calendar_id` IN ({$this->calendar_ids})", $event['id'] ); // create new fake entries if (!empty($event['recurrence'])) { // include library class require_once($this->cal->home . '/lib/calendar_recurrence.php'); $recurrence = new calendar_recurrence($this->cal, $event); $count = 0; $event['allday'] = $event['all_day']; $duration = $event['start']->diff($event['end']); $recurrence_id_format = libcalendaring::recurrence_id_format($event); while ($next_start = $recurrence->next_start()) { $instance = $next_start->format($recurrence_id_format); $datestr = substr($instance, 0, 8); // skip exceptions // TODO: merge updated data from master event - if ($exdata[$datestr]) { + if (!empty($exdata[$datestr])) { continue; } $next_start->setTimezone($this->server_timezone); $next_end = clone $next_start; $next_end->add($duration); $notify_at = $this->_get_notification(array( - 'alarms' => $event['alarms'], + 'alarms' => !empty($event['alarms']) ? $event['alarms'] : null, 'start' => $next_start, 'end' => $next_end, 'status' => $event['status'] )); $now = $this->rc->db->now(); $query = $this->rc->db->query( "INSERT INTO `{$this->db_events}`" . " (`calendar_id`, `recurrence_id`, `created`, `changed`, `uid`, `instance`, `start`, `end`," . " `all_day`, `sequence`, `recurrence`, `title`, `description`, `location`, `categories`," . " `url`, `free_busy`, `priority`, `sensitivity`, `status`, `alarms`, `attendees`, `notifyat`)" . " SELECT `calendar_id`, ?, $now, $now, `uid`, ?, ?, ?," . " `all_day`, `sequence`, `recurrence`, `title`, `description`, `location`, `categories`," . " `url`, `free_busy`, `priority`, `sensitivity`, `status`, `alarms`, `attendees`, ?" . " FROM `{$this->db_events}` WHERE `event_id` = ? AND `calendar_id` IN ({$this->calendar_ids})", $event['id'], $instance, $next_start->format(self::DB_DATE_FORMAT), $next_end->format(self::DB_DATE_FORMAT), $notify_at, $event['id'] ); if (!$this->rc->db->affected_rows($query)) { break; } // stop adding events for inifinite recurrence after 20 years - if (++$count > 999 || (!$recurrence->recurEnd && !$recurrence->recurCount && $next_start->format('Y') > date('Y') + 20)) { + if (++$count > 999 || (empty($recurrence->recurEnd) && empty($recurrence->recurCount) && $next_start->format('Y') > date('Y') + 20)) { break; } } // remove all exceptions after recurrence end - if ($next_end && !empty($exceptions)) { + if (!empty($next_end) && !empty($exceptions)) { $this->rc->db->query( "DELETE FROM `{$this->db_events}`" . " WHERE `recurrence_id` = ? AND `isexception` = 1 AND `start` > ?" . " AND `calendar_id` IN ({$this->calendar_ids})", $event['id'], $next_end->format(self::DB_DATE_FORMAT) ); } } } /** * */ private function _load_exceptions($event, $instance_id = null) { $sql_add_where = ''; if (!empty($instance_id)) { $sql_add_where = " AND `instance` = ?"; } $result = $this->rc->db->query( "SELECT * FROM `{$this->db_events}`" . " WHERE `recurrence_id` = ? AND `isexception` = 1" . " AND `calendar_id` IN ({$this->calendar_ids})" . $sql_add_where . " ORDER BY `instance`, `start`", $event['id'], $instance_id ); $exceptions = array(); while (($sql_arr = $this->rc->db->fetch_assoc($result)) && $sql_arr['event_id']) { $exception = $this->_read_postprocess($sql_arr); $instance = $exception['_instance'] ?: $exception['start']->format($exception['allday'] ? 'Ymd' : 'Ymd\THis'); $exceptions[$instance] = $exception; } return $exceptions; } /** * Move a single event * * @param array Hash array with event properties * @see calendar_driver::move_event() */ public function move_event($event) { // let edit_event() do all the magic return $this->edit_event($event + (array)$this->get_event($event)); } /** * Resize a single event * * @param array Hash array with event properties * @see calendar_driver::resize_event() */ public function resize_event($event) { // let edit_event() do all the magic return $this->edit_event($event + (array)$this->get_event($event)); } /** * Remove a single event from the database * * @param array Hash array with event properties * @param boolean Remove record irreversible (@TODO) * * @see calendar_driver::remove_event() */ public function remove_event($event, $force = true) { if (!empty($this->calendars)) { $event += (array)$this->get_event($event); $master = $event; $update_master = false; $savemode = 'all'; $ret = true; // read master if deleting a recurring event if ($event['recurrence'] || $event['recurrence_id']) { $master = $event['recurrence_id'] ? $this->get_event(array('id' => $event['recurrence_id'])) : $event; $savemode = $event['_savemode']; } switch ($savemode) { case 'current': // add exception to master event $master['recurrence']['EXDATE'][] = $event['start']; $update_master = true; // just delete this single occurence $query = $this->rc->db->query( "DELETE FROM `{$this->db_events}`" . " WHERE `calendar_id` IN ({$this->calendar_ids}) AND `event_id` = ?", $event['id'] ); break; case 'future': if ($master['id'] != $event['id']) { // set until-date on master event $master['recurrence']['UNTIL'] = clone $event['start']; $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); unset($master['recurrence']['COUNT']); $update_master = true; // delete this and all future instances $fromdate = clone $event['start']; $fromdate->setTimezone($this->server_timezone); $query = $this->rc->db->query( "DELETE FROM `{$this->db_events}`" . " WHERE `calendar_id` IN ({$this->calendar_ids}) AND `start` >= ? AND `recurrence_id` = ?", $fromdate->format(self::DB_DATE_FORMAT), $master['id'] ); $ret = $master['id']; break; } // else: future == all if modifying the master event default: // 'all' is default $query = $this->rc->db->query( "DELETE FROM `{$this->db_events}`" . " WHERE (`event_id` = ? OR `recurrence_id` = ?) AND `calendar_id` IN ({$this->calendar_ids})", $master['id'], $master['id'] ); break; } $success = $this->rc->db->affected_rows($query); if ($success && $update_master) { $this->_update_event($master, true); } return $success ? $ret : false; } return false; } /** * Return data of a specific event * * @param mixed Hash array with event properties or event UID * @param integer Bitmask defining the scope to search events in * @param boolean If true, recurrence exceptions shall be added * * @return array Hash array with event properties */ public function get_event($event, $scope = 0, $full = false) { - $id = is_array($event) ? ($event['id'] ?: $event['uid']) : $event; - $cal = is_array($event) ? $event['calendar'] : null; + $id = is_array($event) ? (!empty($event['id']) ? $event['id'] : $event['uid']) : $event; + $cal = is_array($event) && !empty($event['calendar']) ? $event['calendar'] : null; $col = is_array($event) && is_numeric($id) ? 'event_id' : 'uid'; - if ($this->cache[$id]) { + if (!empty($this->cache[$id])) { return $this->cache[$id]; } // get event from the address books birthday calendar if ($cal == self::BIRTHDAY_CALENDAR_ID) { return $this->get_birthday_event($id); } $where_add = ''; - if (is_array($event) && !$event['id'] && !empty($event['_instance'])) { + if (is_array($event) && empty($event['id']) && !empty($event['_instance'])) { $where_add = " AND e.instance = " . $this->rc->db->quote($event['_instance']); } if ($scope & self::FILTER_ACTIVE) { - $calendars = $this->calendars; - foreach ($calendars as $idx => $cal) { - if (!$cal['active']) { - unset($calendars[$idx]); + $calendars = []; + foreach ($this->calendars as $idx => $cal) { + if (!empty($cal['active'])) { + $calendars[] = $idx; } } $cals = join(',', $calendars); } else { $cals = $this->calendar_ids; } $result = $this->rc->db->query( "SELECT e.*, (SELECT COUNT(`attachment_id`) FROM `{$this->db_attachments}`" . " WHERE `event_id` = e.event_id OR `event_id` = e.recurrence_id) AS _attachments" . " FROM `{$this->db_events}` AS e" . " WHERE e.calendar_id IN ($cals) AND e.$col = ?" . $where_add, $id ); if ($result && ($sql_arr = $this->rc->db->fetch_assoc($result)) && $sql_arr['event_id']) { $event = $this->_read_postprocess($sql_arr); // also load recurrence exceptions if (!empty($event['recurrence']) && $full) { $event['recurrence']['EXCEPTIONS'] = array_values($this->_load_exceptions($event)); } $this->cache[$id] = $event; return $this->cache[$id]; } return false; } /** * Get event data * * @see calendar_driver::load_events() */ public function load_events($start, $end, $query = null, $calendars = null, $virtual = 1, $modifiedsince = null) { if (empty($calendars)) { $calendars = array_keys($this->calendars); } else if (!is_array($calendars)) { $calendars = explode(',', strval($calendars)); } // only allow to select from calendars of this use $calendar_ids = array_map(array($this->rc->db, 'quote'), array_intersect($calendars, array_keys($this->calendars))); // compose (slow) SQL query for searching // FIXME: improve searching using a dedicated col and normalized values + $sql_add = ''; if ($query) { foreach (array('title','location','description','categories','attendees') as $col) { $sql_query[] = $this->rc->db->ilike($col, '%'.$query.'%'); } - $sql_add = " AND (" . join(' OR ', $sql_query) . ")"; + $sql_add .= " AND (" . join(' OR ', $sql_query) . ")"; } if (!$virtual) { $sql_add .= " AND e.recurrence_id = 0"; } if ($modifiedsince) { $sql_add .= " AND e.changed >= " . $this->rc->db->quote(date('Y-m-d H:i:s', $modifiedsince)); } $events = array(); if (!empty($calendar_ids)) { $result = $this->rc->db->query( "SELECT e.*, (SELECT COUNT(`attachment_id`) FROM `{$this->db_attachments}`" . " WHERE `event_id` = e.event_id OR `event_id` = e.recurrence_id) AS _attachments" . " FROM `{$this->db_events}` e" . " WHERE e.calendar_id IN (" . join(',', $calendar_ids) . ")" . " AND e.start <= " . $this->rc->db->fromunixtime($end) . " AND e.end >= " . $this->rc->db->fromunixtime($start) . $sql_add ); while ($result && ($sql_arr = $this->rc->db->fetch_assoc($result))) { $event = $this->_read_postprocess($sql_arr); $add = true; if (!empty($event['recurrence']) && !$event['recurrence_id']) { // load recurrence exceptions (i.e. for export) if (!$virtual) { $event['recurrence']['EXCEPTIONS'] = $this->_load_exceptions($event); } // check for exception on first instance else { $instance = libcalendaring::recurrence_instance_identifier($event); $exceptions = $this->_load_exceptions($event, $instance); if ($exceptions && is_array($exceptions[$instance])) { $event = $exceptions[$instance]; $add = false; } } } if ($add) { $events[] = $event; } } } // add events from the address books birthday calendar if (in_array(self::BIRTHDAY_CALENDAR_ID, $calendars) && empty($query)) { - $events = array_merge($events, $this->load_birthday_events($start, $end, $search, $modifiedsince)); + $events = array_merge($events, $this->load_birthday_events($start, $end, null, $modifiedsince)); } return $events; } /** * Get number of events in the given calendar * * @param mixed List of calendar IDs to count events (either as array or comma-separated string) * @param integer Date range start (unix timestamp) * @param integer Date range end (unix timestamp) * * @return array Hash array with counts grouped by calendar ID */ public function count_events($calendars, $start, $end = null) { // not implemented return array(); } /** * Convert sql record into a rcube style event object */ private function _read_postprocess($event) { $free_busy_map = array_flip($this->free_busy_map); $sensitivity_map = array_flip($this->sensitivity_map); $event['id'] = $event['event_id']; $event['start'] = new DateTime($event['start']); $event['end'] = new DateTime($event['end']); $event['allday'] = intval($event['all_day']); $event['created'] = new DateTime($event['created']); $event['changed'] = new DateTime($event['changed']); $event['free_busy'] = $free_busy_map[$event['free_busy']]; $event['sensitivity'] = $sensitivity_map[$event['sensitivity']]; $event['calendar'] = $event['calendar_id']; $event['recurrence_id'] = intval($event['recurrence_id']); $event['isexception'] = intval($event['isexception']); // parse recurrence rule if ($event['recurrence'] && preg_match_all('/([A-Z]+)=([^;]+);?/', $event['recurrence'], $m, PREG_SET_ORDER)) { $event['recurrence'] = array(); foreach ($m as $rr) { if (is_numeric($rr[2])) { $rr[2] = intval($rr[2]); } else if ($rr[1] == 'UNTIL') { $rr[2] = date_create($rr[2]); } else if ($rr[1] == 'RDATE') { $rr[2] = array_map('date_create', explode(',', $rr[2])); } else if ($rr[1] == 'EXDATE') { $rr[2] = array_map('date_create', explode(',', $rr[2])); } $event['recurrence'][$rr[1]] = $rr[2]; } } if ($event['recurrence_id']) { libcalendaring::identify_recurrence_instance($event); } if (strlen($event['instance'])) { $event['_instance'] = $event['instance']; if (empty($event['recurrence_id'])) { $event['recurrence_date'] = rcube_utils::anytodatetime($event['_instance'], $event['start']->getTimezone()); } } - if ($event['_attachments'] > 0) { + if (!empty($event['_attachments'])) { $event['attachments'] = (array)$this->list_attachments($event); } // decode serialized event attendees if (strlen($event['attendees'])) { $event['attendees'] = $this->unserialize_attendees($event['attendees']); } else { $event['attendees'] = array(); } // decode serialized alarms if ($event['alarms']) { $event['valarms'] = $this->unserialize_alarms($event['alarms']); } unset($event['event_id'], $event['calendar_id'], $event['notifyat'], $event['all_day'], $event['instance'], $event['_attachments']); return $event; } /** * Get a list of pending alarms to be displayed to the user * * @see calendar_driver::pending_alarms() */ public function pending_alarms($time, $calendars = null) { if (empty($calendars)) { $calendars = array_keys($this->calendars); } else if (!is_array($calendars)) { $calendars = explode(',', (array) $calendars); } // only allow to select from calendars with activated alarms $calendar_ids = array(); foreach ($calendars as $cid) { if ($this->calendars[$cid] && $this->calendars[$cid]['showalarms']) { $calendar_ids[] = $cid; } } $calendar_ids = array_map(array($this->rc->db, 'quote'), $calendar_ids); $alarms = array(); if (!empty($calendar_ids)) { $stime = $this->rc->db->fromunixtime($time); $result = $this->rc->db->query( "SELECT * FROM `{$this->db_events}`" . " WHERE `calendar_id` IN (" . join(',', $calendar_ids) . ")" . " AND `notifyat` <= $stime AND `end` > $stime" ); while ($event = $this->rc->db->fetch_assoc($result)) { $alarms[] = $this->_read_postprocess($event); } } return $alarms; } /** * Feedback after showing/sending an alarm notification * * @see calendar_driver::dismiss_alarm() */ public function dismiss_alarm($event_id, $snooze = 0) { // set new notifyat time or unset if not snoozed $notify_at = $snooze > 0 ? date(self::DB_DATE_FORMAT, time() + $snooze) : null; $query = $this->rc->db->query( "UPDATE `{$this->db_events}`" . " SET `changed` = " . $this->rc->db->now() . ", `notifyat` = ?" . " WHERE `event_id` = ? AND `calendar_id` IN ({$this->calendar_ids})", $notify_at, $event_id ); return $this->rc->db->affected_rows($query); } /** * Save an attachment related to the given event */ private function add_attachment($attachment, $event_id) { if (isset($attachment['data'])) { $data = $attachment['data']; } else if (!empty($attachment['path'])) { $data = file_get_contents($attachment['path']); } else { return false; } $query = $this->rc->db->query( "INSERT INTO `{$this->db_attachments}`" . " (`event_id`, `filename`, `mimetype`, `size`, `data`)" . " VALUES (?, ?, ?, ?, ?)", $event_id, $attachment['name'], $attachment['mimetype'], strlen($data), base64_encode($data) ); return $this->rc->db->affected_rows($query); } /** * Remove a specific attachment from the given event */ private function remove_attachment($attachment_id, $event_id) { $query = $this->rc->db->query( "DELETE FROM `{$this->db_attachments}`" . " WHERE `attachment_id` = ? AND `event_id` IN (" . "SELECT `event_id` FROM `{$this->db_events}`" . " WHERE `event_id` = ? AND `calendar_id` IN ({$this->calendar_ids}))", $attachment_id, $event_id ); return $this->rc->db->affected_rows($query); } /** * List attachments of specified event */ public function list_attachments($event) { $attachments = array(); if (!empty($this->calendar_ids)) { $result = $this->rc->db->query( "SELECT `attachment_id` AS id, `filename` AS name, `mimetype`, `size`" . " FROM `{$this->db_attachments}`" . " WHERE `event_id` IN (" . "SELECT `event_id` FROM `{$this->db_events}`" . " WHERE `event_id` = ? AND `calendar_id` IN ({$this->calendar_ids}))" . " ORDER BY `filename`", $event['recurrence_id'] ? $event['recurrence_id'] : $event['event_id'] ); while ($arr = $this->rc->db->fetch_assoc($result)) { $attachments[] = $arr; } } return $attachments; } /** * Get attachment properties */ public function get_attachment($id, $event) { if (!empty($this->calendar_ids)) { $result = $this->rc->db->query( "SELECT `attachment_id` AS id, `filename` AS name, `mimetype`, `size` " . " FROM `{$this->db_attachments}`" . " WHERE `attachment_id` = ? AND `event_id` IN (" . "SELECT `event_id` FROM `{$this->db_events}`" . " WHERE `event_id` = ? AND `calendar_id` IN ({$this->calendar_ids}))", $id, - $event['recurrence_id'] ? $event['recurrence_id'] : $event['id'] + !empty($event['recurrence_id']) ? $event['recurrence_id'] : $event['id'] ); if ($result && ($arr = $this->rc->db->fetch_assoc($result))) { return $arr; } } } /** * Get attachment body */ public function get_attachment_body($id, $event) { if (!empty($this->calendar_ids)) { $result = $this->rc->db->query( "SELECT `data` FROM `{$this->db_attachments}`" . " WHERE `attachment_id` = ? AND `event_id` IN (" . "SELECT `event_id` FROM `{$this->db_events}`" . " WHERE `event_id` = ? AND `calendar_id` IN ({$this->calendar_ids}))", $id, $event['id'] ); if ($arr = $this->rc->db->fetch_assoc($result)) { return base64_decode($arr['data']); } } } /** * Remove the given category */ public function remove_category($name) { $query = $this->rc->db->query( "UPDATE `{$this->db_events}` SET `categories` = ''" . " WHERE `categories` = ? AND `calendar_id` IN ({$this->calendar_ids})", $name ); return $this->rc->db->affected_rows($query); } /** * Update/replace a category */ public function replace_category($oldname, $name, $color) { $query = $this->rc->db->query( "UPDATE `{$this->db_events}` SET `categories` = ?" . " WHERE `categories` = ? AND `calendar_id` IN ({$this->calendar_ids})", $name, $oldname ); return $this->rc->db->affected_rows($query); } /** * Helper method to serialize the list of alarms into a string */ private function serialize_alarms($valarms) { foreach ((array)$valarms as $i => $alarm) { if ($alarm['trigger'] instanceof DateTime) { $valarms[$i]['trigger'] = '@' . $alarm['trigger']->format('c'); } } return $valarms ? json_encode($valarms) : null; } /** * Helper method to decode a serialized list of alarms */ private function unserialize_alarms($alarms) { // decode json serialized alarms if ($alarms && $alarms[0] == '[') { $valarms = json_decode($alarms, true); foreach ($valarms as $i => $alarm) { if ($alarm['trigger'][0] == '@') { try { $valarms[$i]['trigger'] = new DateTime(substr($alarm['trigger'], 1)); } catch (Exception $e) { unset($valarms[$i]); } } } } // convert legacy alarms data else if (strlen($alarms)) { list($trigger, $action) = explode(':', $alarms, 2); if ($trigger = libcalendaring::parse_alarm_value($trigger)) { $valarms = array(array('action' => $action, 'trigger' => $trigger[3] ?: $trigger[0])); } } return $valarms; } /** * Helper method to decode the attendees list from string */ private function unserialize_attendees($s_attendees) { $attendees = array(); // decode json serialized string if ($s_attendees[0] == '[') { $attendees = json_decode($s_attendees, true); } // decode the old serialization format else { foreach (explode("\n", $s_attendees) as $line) { $att = array(); foreach (rcube_utils::explode_quoted_string(';', $line) as $prop) { list($key, $value) = explode("=", $prop); $att[strtolower($key)] = stripslashes(trim($value, '""')); } $attendees[] = $att; } } return $attendees; } } diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php index 440d6e39..4b817951 100644 --- a/plugins/calendar/drivers/kolab/kolab_calendar.php +++ b/plugins/calendar/drivers/kolab/kolab_calendar.php @@ -1,913 +1,958 @@ * @author Aleksander Machniak * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_calendar extends kolab_storage_folder_api { - public $ready = false; - public $rights = 'lrs'; - public $editable = false; - public $attachments = true; - public $alarms = false; - public $history = false; - public $subscriptions = true; - public $categories = array(); - public $storage; - - public $type = 'event'; - - protected $cal; - protected $events = array(); - protected $search_fields = array('title', 'description', 'location', 'attendees', 'categories'); - - /** - * Factory method to instantiate a kolab_calendar object - * - * @param string Calendar ID (encoded IMAP folder name) - * @param object calendar plugin object - * @return object kolab_calendar instance - */ - public static function factory($id, $calendar) - { - $imap = $calendar->rc->get_storage(); - $imap_folder = kolab_storage::id_decode($id); - $info = $imap->folder_info($imap_folder, true); - if (empty($info) || $info['noselect'] || strpos(kolab_storage::folder_type($imap_folder), 'event') !== 0) { - return new kolab_user_calendar($imap_folder, $calendar); - } - else { - return new kolab_calendar($imap_folder, $calendar); - } - } - - /** - * Default constructor - */ - public function __construct($imap_folder, $calendar) - { - $this->cal = $calendar; - $this->imap = $calendar->rc->get_storage(); - $this->name = $imap_folder; - - // ID is derrived from folder name - $this->id = kolab_storage::folder_id($this->name, true); - $old_id = kolab_storage::folder_id($this->name, false); - - // fetch objects from the given IMAP folder - $this->storage = kolab_storage::get_folder($this->name); - $this->ready = $this->storage && $this->storage->valid; - - // Set writeable and alarms flags according to folder permissions - if ($this->ready) { - if ($this->storage->get_namespace() == 'personal') { - $this->editable = true; - $this->rights = 'lrswikxteav'; - $this->alarms = true; - } - else { - $rights = $this->storage->get_myrights(); - if ($rights && !PEAR::isError($rights)) { - $this->rights = $rights; - if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) - $this->editable = strpos($rights, 'i');; - } - } - - // user-specific alarms settings win - $prefs = $this->cal->rc->config->get('kolab_calendars', array()); - if (isset($prefs[$this->id]['showalarms'])) - $this->alarms = $prefs[$this->id]['showalarms']; - else if (isset($prefs[$old_id]['showalarms'])) - $this->alarms = $prefs[$old_id]['showalarms']; - } + public $ready = false; + public $rights = 'lrs'; + public $editable = false; + public $attachments = true; + public $alarms = false; + public $history = false; + public $subscriptions = true; + public $categories = []; + public $storage; + + public $type = 'event'; + + protected $cal; + protected $events = []; + protected $search_fields = ['title', 'description', 'location', 'attendees', 'categories']; + + /** + * Factory method to instantiate a kolab_calendar object + * + * @param string Calendar ID (encoded IMAP folder name) + * @param object Calendar plugin object + * + * @return kolab_calendar Self instance + */ + public static function factory($id, $calendar) + { + $imap = $calendar->rc->get_storage(); + $imap_folder = kolab_storage::id_decode($id); + $info = $imap->folder_info($imap_folder, true); + + if ( + empty($info) + || !empty($info['noselect']) + || strpos(kolab_storage::folder_type($imap_folder), 'event') !== 0 + ) { + return new kolab_user_calendar($imap_folder, $calendar); + } - $this->default = $this->storage->default; - $this->subtype = $this->storage->subtype; - } - - - /** - * Getter for the IMAP folder name - * - * @return string Name of the IMAP folder - */ - public function get_realname() - { - return $this->name; - } - - /** - * - */ - public function get_title() - { - return null; - } - - - /** - * Return color to display this calendar - */ - public function get_color($default = null) - { - // color is defined in folder METADATA - if ($color = $this->storage->get_color()) { - return $color; + return new kolab_calendar($imap_folder, $calendar); } - // calendar color is stored in user prefs (temporary solution) - $prefs = $this->cal->rc->config->get('kolab_calendars', array()); - - if (!empty($prefs[$this->id]) && !empty($prefs[$this->id]['color'])) - return $prefs[$this->id]['color']; - - return $default ?: 'cc0000'; - } - - /** - * Compose an URL for CalDAV access to this calendar (if configured) - */ - public function get_caldav_url() - { - if ($template = $this->cal->rc->config->get('calendar_caldav_url', null)) { - return strtr($template, array( - '%h' => $_SERVER['HTTP_HOST'], - '%u' => urlencode($this->cal->rc->get_user_name()), - '%i' => urlencode($this->storage->get_uid()), - '%n' => urlencode($this->name), - )); - } + /** + * Default constructor + */ + public function __construct($imap_folder, $calendar) + { + $this->cal = $calendar; + $this->imap = $calendar->rc->get_storage(); + $this->name = $imap_folder; - return false; - } + // ID is derrived from folder name + $this->id = kolab_storage::folder_id($this->name, true); + $old_id = kolab_storage::folder_id($this->name, false); + // fetch objects from the given IMAP folder + $this->storage = kolab_storage::get_folder($this->name); + $this->ready = $this->storage && $this->storage->valid; + + // Set writeable and alarms flags according to folder permissions + if ($this->ready) { + if ($this->storage->get_namespace() == 'personal') { + $this->editable = true; + $this->rights = 'lrswikxteav'; + $this->alarms = true; + } + else { + $rights = $this->storage->get_myrights(); + if ($rights && !PEAR::isError($rights)) { + $this->rights = $rights; + if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) { + $this->editable = strpos($rights, 'i');; + } + } + } + + // user-specific alarms settings win + $prefs = $this->cal->rc->config->get('kolab_calendars', []); + if (isset($prefs[$this->id]['showalarms'])) { + $this->alarms = $prefs[$this->id]['showalarms']; + } + else if (isset($prefs[$old_id]['showalarms'])) { + $this->alarms = $prefs[$old_id]['showalarms']; + } + } - /** - * Update properties of this calendar folder - * - * @see calendar_driver::edit_calendar() - */ - public function update(&$prop) - { - $prop['oldname'] = $this->get_realname(); - $newfolder = kolab_storage::folder_update($prop); + $this->default = $this->storage->default; + $this->subtype = $this->storage->subtype; + } - if ($newfolder === false) { - $this->cal->last_error = $this->cal->gettext(kolab_storage::$last_error); - return false; + /** + * Getter for the IMAP folder name + * + * @return string Name of the IMAP folder + */ + public function get_realname() + { + return $this->name; } - // create ID - return kolab_storage::folder_id($newfolder); - } - - /** - * Getter for a single event object - */ - public function get_event($id) - { - // remove our occurrence identifier if it's there - $master_id = preg_replace('/-\d{8}(T\d{6})?$/', '', $id); - - // directly access storage object - if (!$this->events[$id] && $master_id == $id && ($record = $this->storage->get_object($id))) { - $this->events[$id] = $this->_to_driver_event($record, true); + /** + * + */ + public function get_title() + { + return null; } - // maybe a recurring instance is requested - if (!$this->events[$id] && $master_id != $id) { - $instance_id = substr($id, strlen($master_id) + 1); + /** + * Return color to display this calendar + */ + public function get_color($default = null) + { + // color is defined in folder METADATA + if ($color = $this->storage->get_color()) { + return $color; + } - if ($record = $this->storage->get_object($master_id)) { - $master = $this->_to_driver_event($record); - } + // calendar color is stored in user prefs (temporary solution) + $prefs = $this->cal->rc->config->get('kolab_calendars', []); - if ($master) { - // check for match in top-level exceptions (aka loose single occurrences) - if ($master['_formatobj'] && ($instance = $master['_formatobj']->get_instance($instance_id))) { - $this->events[$id] = $this->_to_driver_event($instance, false, true, $master); + if (!empty($prefs[$this->id]) && !empty($prefs[$this->id]['color'])) { + return $prefs[$this->id]['color']; } - // check for match on the first instance already - else if ($master['_instance'] && $master['_instance'] == $instance_id) { - $this->events[$id] = $master; + + return $default ?: 'cc0000'; + } + + /** + * Compose an URL for CalDAV access to this calendar (if configured) + */ + public function get_caldav_url() + { + if ($template = $this->cal->rc->config->get('calendar_caldav_url', null)) { + return strtr($template, [ + '%h' => $_SERVER['HTTP_HOST'], + '%u' => urlencode($this->cal->rc->get_user_name()), + '%i' => urlencode($this->storage->get_uid()), + '%n' => urlencode($this->name), + ]); } - else if (is_array($master['recurrence'])) { - // For performance reasons we'll get only the specific instance - if (($date = substr($id, strlen($master_id) + 1, 8)) && strlen($date) == 8 && is_numeric($date)) { - $start_date = new DateTime($date . 'T000000', $master['start']->getTimezone()); - } - $this->get_recurring_events($record, $start_date ?: $master['start'], null, $id, 1); + return false; + } + + /** + * Update properties of this calendar folder + * + * @see calendar_driver::edit_calendar() + */ + public function update(&$prop) + { + $prop['oldname'] = $this->get_realname(); + $newfolder = kolab_storage::folder_update($prop); + + if ($newfolder === false) { + $this->cal->last_error = $this->cal->gettext(kolab_storage::$last_error); + return false; } - } + + // create ID + return kolab_storage::folder_id($newfolder); } - return $this->events[$id]; - } + /** + * Getter for a single event object + */ + public function get_event($id) + { + // remove our occurrence identifier if it's there + $master_id = preg_replace('/-\d{8}(T\d{6})?$/', '', $id); - /** - * Get attachment body - * @see calendar_driver::get_attachment_body() - */ - public function get_attachment_body($id, $event) - { - if (!$this->ready) - return false; + // directly access storage object + if (empty($this->events[$id]) && $master_id == $id && ($record = $this->storage->get_object($id))) { + $this->events[$id] = $this->_to_driver_event($record, true); + } - $data = $this->storage->get_attachment($event['id'], $id); + // maybe a recurring instance is requested + if (empty($this->events[$id]) && $master_id != $id) { + $instance_id = substr($id, strlen($master_id) + 1); + + if ($record = $this->storage->get_object($master_id)) { + $master = $this->_to_driver_event($record); + } - if ($data == null) { - // try again with master UID - $uid = preg_replace('/-\d+(T\d{6})?$/', '', $event['id']); - if ($uid != $event['id']) { - $data = $this->storage->get_attachment($uid, $id); + if ($master) { + // check for match in top-level exceptions (aka loose single occurrences) + if (!empty($master['_formatobj']) && ($instance = $master['_formatobj']->get_instance($instance_id))) { + $this->events[$id] = $this->_to_driver_event($instance, false, true, $master); + } + // check for match on the first instance already + else if (!empty($master['_instance']) && $master['_instance'] == $instance_id) { + $this->events[$id] = $master; + } + else if (!empty($master['recurrence'])) { + $start_date = $master['start']; + // For performance reasons we'll get only the specific instance + if (($date = substr($id, strlen($master_id) + 1, 8)) && strlen($date) == 8 && is_numeric($date)) { + $start_date = new DateTime($date . 'T000000', $master['start']->getTimezone()); + } + + $this->get_recurring_events($record, $start_date, null, $id, 1); + } + } } - } - return $data; - } - - /** - * @param integer Event's new start (unix timestamp) - * @param integer Event's new end (unix timestamp) - * @param string Search query (optional) - * @param boolean Include virtual events (optional) - * @param array Additional parameters to query storage - * @param array Additional query to filter events - * @return array A list of event records - */ - public function list_events($start, $end, $search = null, $virtual = 1, $query = array(), $filter_query = null) - { - // convert to DateTime for comparisons - // #5190: make the range a little bit wider - // to workaround possible timezone differences - try { - $start = new DateTime('@' . ($start - 12 * 3600)); - } - catch (Exception $e) { - $start = new DateTime('@0'); - } - try { - $end = new DateTime('@' . ($end + 12 * 3600)); - } - catch (Exception $e) { - $end = new DateTime('today +10 years'); + return $this->events[$id]; } - // get email addresses of the current user - $user_emails = $this->cal->get_user_emails(); + /** + * Get attachment body + * @see calendar_driver::get_attachment_body() + */ + public function get_attachment_body($id, $event) + { + if (!$this->ready) { + return false; + } - // query Kolab storage - $query[] = array('dtstart', '<=', $end); - $query[] = array('dtend', '>=', $start); + $data = $this->storage->get_attachment($event['id'], $id); - if (is_array($filter_query)) { - $query = array_merge($query, $filter_query); - } + if ($data == null) { + // try again with master UID + $uid = preg_replace('/-\d+(T\d{6})?$/', '', $event['id']); + if ($uid != $event['id']) { + $data = $this->storage->get_attachment($uid, $id); + } + } - if (!empty($search)) { - $search = mb_strtolower($search); - $words = rcube_utils::tokenize_string($search, 1); - foreach (rcube_utils::normalize_string($search, true) as $word) { - $query[] = array('words', 'LIKE', $word); + return $data; + } + + /** + * @param int Event's new start (unix timestamp) + * @param int Event's new end (unix timestamp) + * @param string Search query (optional) + * @param bool Include virtual events (optional) + * @param array Additional parameters to query storage + * @param array Additional query to filter events + * + * @return array A list of event records + */ + public function list_events($start, $end, $search = null, $virtual = 1, $query = [], $filter_query = null) + { + // convert to DateTime for comparisons + // #5190: make the range a little bit wider + // to workaround possible timezone differences + try { + $start = new DateTime('@' . ($start - 12 * 3600)); + } + catch (Exception $e) { + $start = new DateTime('@0'); + } + try { + $end = new DateTime('@' . ($end + 12 * 3600)); + } + catch (Exception $e) { + $end = new DateTime('today +10 years'); } - } - else { - $words = array(); - } - // set partstat filter to skip pending and declined invitations - if (empty($filter_query) && $this->cal->rc->config->get('kolab_invitation_calendars') - && $this->get_namespace() != 'other' - ) { - $partstat_exclude = array('NEEDS-ACTION','DECLINED'); - } - else { - $partstat_exclude = array(); - } + // get email addresses of the current user + $user_emails = $this->cal->get_user_emails(); - $events = array(); - foreach ($this->storage->select($query) as $record) { - $event = $this->_to_driver_event($record, !$virtual, false); - - // remember seen categories - if ($event['categories']) { - $cat = is_array($event['categories']) ? $event['categories'][0] : $event['categories']; - $this->categories[$cat]++; - } - - // list events in requested time window - if ($event['start'] <= $end && $event['end'] >= $start) { - unset($event['_attendees']); - $add = true; - // skip the first instance of a recurring event if listed in exdate - if ($virtual && !empty($event['recurrence']['EXDATE'])) { - $event_date = $event['start']->format('Ymd'); - $event_tz = $event['start']->getTimezone(); - - foreach ((array) $event['recurrence']['EXDATE'] as $exdate) { - $ex = clone $exdate; - $ex->setTimezone($event_tz); - - if ($ex->format('Ymd') == $event_date) { - $add = false; - break; - } - } - } - - // find and merge exception for the first instance - if ($virtual && !empty($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS'])) { - foreach ($event['recurrence']['EXCEPTIONS'] as $exception) { - if ($event['_instance'] == $exception['_instance']) { - unset($exception['calendar'], $exception['className'], $exception['_folder_id']); - // clone date objects from main event before adjusting them with exception data - if (is_object($event['start'])) $event['start'] = clone $record['start']; - if (is_object($event['end'])) $event['end'] = clone $record['end']; - kolab_driver::merge_exception_data($event, $exception); - } - } - } - - if ($add) - $events[] = $event; - } - - // resolve recurring events - if ($record['recurrence'] && $virtual == 1) { - $events = array_merge($events, $this->get_recurring_events($record, $start, $end)); - } - // add top-level exceptions (aka loose single occurrences) - else if (is_array($record['exceptions'])) { - foreach ($record['exceptions'] as $ex) { - $component = $this->_to_driver_event($ex, false, false, $record); - if ($component['start'] <= $end && $component['end'] >= $start) { - $events[] = $component; - } - } - } - } + // query Kolab storage + $query[] = ['dtstart', '<=', $end]; + $query[] = ['dtend', '>=', $start]; - // post-filter all events by fulltext search and partstat values - $me = $this; - $events = array_filter($events, function($event) use ($words, $partstat_exclude, $user_emails, $me) { - // fulltext search - if (count($words)) { - $hits = 0; - foreach ($words as $word) { - $hits += $me->fulltext_match($event, $word, false); + if (is_array($filter_query)) { + $query = array_merge($query, $filter_query); } - if ($hits < count($words)) { - return false; + + $words = []; + $partstat_exclude = []; + $events = []; + + if (!empty($search)) { + $search = mb_strtolower($search); + $words = rcube_utils::tokenize_string($search, 1); + foreach (rcube_utils::normalize_string($search, true) as $word) { + $query[] = ['words', 'LIKE', $word]; + } } - } - // partstat filter - if (count($partstat_exclude) && is_array($event['attendees'])) { - foreach ($event['attendees'] as $attendee) { - if (in_array($attendee['email'], $user_emails) && in_array($attendee['status'], $partstat_exclude)) { - return false; - } - } - } - - return true; - }); - - // Apply event-to-mail relations - $config = kolab_storage_config::get_instance(); - $config->apply_links($events); - - // avoid session race conditions that will loose temporary subscriptions - $this->cal->rc->session->nowrite = true; - - return $events; - } - - /** - * Get number of events in the given calendar - * - * @param integer Date range start (unix timestamp) - * @param integer Date range end (unix timestamp) - * @param array Additional query to filter events - * - * @return integer Count - */ - public function count_events($start, $end = null, $filter_query = null) - { - // convert to DateTime for comparisons - try { - $start = new DateTime('@'.$start); - } - catch (Exception $e) { - $start = new DateTime('@0'); - } - if ($end) { - try { - $end = new DateTime('@'.$end); - } - catch (Exception $e) { - $end = null; - } - } + // set partstat filter to skip pending and declined invitations + if (empty($filter_query) + && $this->cal->rc->config->get('kolab_invitation_calendars') + && $this->get_namespace() != 'other' + ) { + $partstat_exclude = ['NEEDS-ACTION', 'DECLINED']; + } - // query Kolab storage - $query[] = array('dtend', '>=', $start); + foreach ($this->storage->select($query) as $record) { + $event = $this->_to_driver_event($record, !$virtual, false); - if ($end) - $query[] = array('dtstart', '<=', $end); + // remember seen categories + if (!empty($event['categories'])) { + $cat = is_array($event['categories']) ? $event['categories'][0] : $event['categories']; + $this->categories[$cat]++; + } - // add query to exclude pending/declined invitations - if (empty($filter_query)) { - foreach ($this->cal->get_user_emails() as $email) { - $query[] = array('tags', '!=', 'x-partstat:' . $email . ':needs-action'); - $query[] = array('tags', '!=', 'x-partstat:' . $email . ':declined'); - } - } - else if (is_array($filter_query)) { - $query = array_merge($query, $filter_query); - } + // list events in requested time window + if ($event['start'] <= $end && $event['end'] >= $start) { + unset($event['_attendees']); + $add = true; + + // skip the first instance of a recurring event if listed in exdate + if ($virtual && !empty($event['recurrence']['EXDATE'])) { + $event_date = $event['start']->format('Ymd'); + $event_tz = $event['start']->getTimezone(); + + foreach ((array) $event['recurrence']['EXDATE'] as $exdate) { + $ex = clone $exdate; + $ex->setTimezone($event_tz); + + if ($ex->format('Ymd') == $event_date) { + $add = false; + break; + } + } + } + + // find and merge exception for the first instance + if ($virtual && !empty($event['recurrence']) && !empty($event['recurrence']['EXCEPTIONS'])) { + foreach ($event['recurrence']['EXCEPTIONS'] as $exception) { + if ($event['_instance'] == $exception['_instance']) { + unset($exception['calendar'], $exception['className'], $exception['_folder_id']); + // clone date objects from main event before adjusting them with exception data + if (is_object($event['start'])) { + $event['start'] = clone $record['start']; + } + if (is_object($event['end'])) { + $event['end'] = clone $record['end']; + } + kolab_driver::merge_exception_data($event, $exception); + } + } + } + + if ($add) { + $events[] = $event; + } + } - // we rely the Kolab storage query (no post-filtering) - return $this->storage->count($query); - } - - /** - * Create a new event record - * - * @see calendar_driver::new_event() - * - * @return mixed The created record ID on success, False on error - */ - public function insert_event($event) - { - if (!is_array($event)) - return false; - - // email links are stored separately - $links = $event['links']; - unset($event['links']); - - //generate new event from RC input - $object = $this->_from_driver_event($event); - $saved = $this->storage->save($object, 'event'); - - if (!$saved) { - rcube::raise_error(array( - 'code' => 600, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving event object to Kolab server"), - true, false); - $saved = false; - } - else { - // save links in configuration.relation object - if ($this->save_links($event['uid'], $links)) { - $object['links'] = $links; - } + // resolve recurring events + if (!empty($record['recurrence']) && $virtual == 1) { + $events = array_merge($events, $this->get_recurring_events($record, $start, $end)); + } + // add top-level exceptions (aka loose single occurrences) + else if (!empty($record['exceptions'])) { + foreach ($record['exceptions'] as $ex) { + $component = $this->_to_driver_event($ex, false, false, $record); + if ($component['start'] <= $end && $component['end'] >= $start) { + $events[] = $component; + } + } + } + } - $this->events = array($event['uid'] => $this->_to_driver_event($object, true)); - } + // post-filter all events by fulltext search and partstat values + $me = $this; + $events = array_filter($events, function($event) use ($words, $partstat_exclude, $user_emails, $me) { + // fulltext search + if (count($words)) { + $hits = 0; + foreach ($words as $word) { + $hits += $me->fulltext_match($event, $word, false); + } + if ($hits < count($words)) { + return false; + } + } - return $saved; - } - - /** - * Update a specific event record - * - * @see calendar_driver::new_event() - * - * @return boolean True on success, False on error - */ - public function update_event($event, $exception_id = null) - { - $updated = false; - $old = $this->storage->get_object($event['uid'] ?: $event['id']); - if (!$old || PEAR::isError($old)) - return false; - - // email links are stored separately - $links = $event['links']; - unset($event['links']); - - $object = $this->_from_driver_event($event, $old); - $saved = $this->storage->save($object, 'event', $old['uid']); - - if (!$saved) { - rcube::raise_error(array( - 'code' => 600, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving event object to Kolab server"), - true, false); - } - else { - // save links in configuration.relation object - if ($this->save_links($event['uid'], $links)) { - $object['links'] = $links; - } - - $updated = true; - $this->events = array($event['uid'] => $this->_to_driver_event($object, true)); - - // refresh local cache with recurring instances - if ($exception_id) { - $this->get_recurring_events($object, $event['start'], $event['end'], $exception_id); - } - } + // partstat filter + if (count($partstat_exclude) && !empty($event['attendees'])) { + foreach ($event['attendees'] as $attendee) { + if ( + in_array($attendee['email'], $user_emails) + && in_array($attendee['status'], $partstat_exclude) + ) { + return false; + } + } + } - return $updated; - } - - /** - * Delete an event record - * - * @see calendar_driver::remove_event() - * - * @return boolean True on success, False on error - */ - public function delete_event($event, $force = true) - { - $deleted = $this->storage->delete($event['uid'] ?: $event['id'], $force); - - if (!$deleted) { - rcube::raise_error(array( - 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, - 'message' => sprintf("Error deleting event object '%s' from Kolab server", $event['id'])), - true, false); - } + return true; + }); + + // Apply event-to-mail relations + $config = kolab_storage_config::get_instance(); + $config->apply_links($events); + + // avoid session race conditions that will loose temporary subscriptions + $this->cal->rc->session->nowrite = true; + + return $events; + } + + /** + * Get number of events in the given calendar + * + * @param int Date range start (unix timestamp) + * @param int Date range end (unix timestamp) + * @param array Additional query to filter events + * + * @return int Count + */ + public function count_events($start, $end = null, $filter_query = null) + { + // convert to DateTime for comparisons + try { + $start = new DateTime('@'.$start); + } + catch (Exception $e) { + $start = new DateTime('@0'); + } + if ($end) { + try { + $end = new DateTime('@'.$end); + } + catch (Exception $e) { + $end = null; + } + } - return $deleted; - } - - /** - * Restore deleted event record - * - * @see calendar_driver::undelete_event() - * - * @return boolean True on success, False on error - */ - public function restore_event($event) - { - // Make sure this is not an instance identifier - $uid = preg_replace('/-\d{8}(T\d{6})?$/', '', $event['id']); - - if ($this->storage->undelete($uid)) { - return true; - } - else { - rcube::raise_error(array( - 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, - 'message' => sprintf("Error undeleting the event object '%s' from the Kolab server", $event['id'])), - true, false); - } + // query Kolab storage + $query[] = ['dtend', '>=', $start]; - return false; - } - - /** - * Find messages linked with an event - */ - protected function get_links($uid) - { - $storage = kolab_storage_config::get_instance(); - return $storage->get_object_links($uid); - } - - /** - * - */ - protected function save_links($uid, $links) - { - $storage = kolab_storage_config::get_instance(); - return $storage->save_object_links($uid, (array) $links); - } - - /** - * Create instances of a recurring event - * - * @param array $event Hash array with event properties - * @param DateTime $start Start date of the recurrence window - * @param DateTime $end End date of the recurrence window - * @param string $event_id ID of a specific recurring event instance - * @param int $limit Max. number of instances to return - * - * @return array List of recurring event instances - */ - public function get_recurring_events($event, $start, $end = null, $event_id = null, $limit = null) - { - $object = $event['_formatobj']; - if (!$object) { - $rec = $this->storage->get_object($event['uid'] ?: $event['id']); - $object = $rec['_formatobj']; - } + if ($end) { + $query[] = ['dtstart', '<=', $end]; + } - if (!is_object($object)) - return array(); + // add query to exclude pending/declined invitations + if (empty($filter_query)) { + foreach ($this->cal->get_user_emails() as $email) { + $query[] = ['tags', '!=', 'x-partstat:' . $email . ':needs-action']; + $query[] = ['tags', '!=', 'x-partstat:' . $email . ':declined']; + } + } + else if (is_array($filter_query)) { + $query = array_merge($query, $filter_query); + } - // determine a reasonable end date if none given - if (!$end) { - $end = clone $event['start']; - $end->add(new DateInterval('P100Y')); + // we rely the Kolab storage query (no post-filtering) + return $this->storage->count($query); } - // copy the recurrence rule from the master event (to be used in the UI) - $recurrence_rule = $event['recurrence']; - unset($recurrence_rule['EXCEPTIONS'], $recurrence_rule['EXDATE']); + /** + * Create a new event record + * + * @see calendar_driver::new_event() + * + * @return array|false The created record ID on success, False on error + */ + public function insert_event($event) + { + if (!is_array($event)) { + return false; + } - // read recurrence exceptions first - $events = array(); - $exdata = array(); - $futuredata = array(); - $recurrence_id_format = libcalendaring::recurrence_id_format($event); + // email links are stored separately + $links = !empty($event['links']) ? $event['links'] : []; + unset($event['links']); + + //generate new event from RC input + $object = $this->_from_driver_event($event); + $saved = $this->storage->save($object, 'event'); + + if (!$saved) { + rcube::raise_error([ + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error saving event object to Kolab server" + ], + true, false + ); + $saved = false; + } + else { + // save links in configuration.relation object + if ($this->save_links($event['uid'], $links)) { + $object['links'] = $links; + } - if (is_array($event['recurrence']['EXCEPTIONS'])) { - foreach ($event['recurrence']['EXCEPTIONS'] as $exception) { - if (!$exception['_instance']) - $exception['_instance'] = libcalendaring::recurrence_instance_identifier($exception, $event['allday']); + $this->events = [$event['uid'] => $this->_to_driver_event($object, true)]; + } + + return $saved; + } - $rec_event = $this->_to_driver_event($exception, false, false, $event); - $rec_event['id'] = $event['uid'] . '-' . $exception['_instance']; - $rec_event['isexception'] = 1; + /** + * Update a specific event record + * + * @see calendar_driver::new_event() + * + * @return bool True on success, False on error + */ + public function update_event($event, $exception_id = null) + { + $updated = false; + $old = $this->storage->get_object(!empty($event['uid']) ? $event['uid'] : $event['id']); - // found the specifically requested instance: register exception (single occurrence wins) - if ($rec_event['id'] == $event_id && (!$this->events[$event_id] || $this->events[$event_id]['thisandfuture'])) { - $rec_event['recurrence'] = $recurrence_rule; - $rec_event['recurrence_id'] = $event['uid']; - $this->events[$rec_event['id']] = $rec_event; + if (!$old || PEAR::isError($old)) { + return false; } - // remember this exception's date - $exdate = substr($exception['_instance'], 0, 8); - if (!$exdata[$exdate] || $exdata[$exdate]['thisandfuture']) { - $exdata[$exdate] = $rec_event; + // email links are stored separately + $links = !empty($event['links']) ? $event['links'] : []; + unset($event['links']); + + $object = $this->_from_driver_event($event, $old); + $saved = $this->storage->save($object, 'event', $old['uid']); + + if (!$saved) { + rcube::raise_error([ + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error saving event object to Kolab server" + ], + true, false + ); } - if ($rec_event['thisandfuture']) { - $futuredata[$exdate] = $rec_event; + else { + // save links in configuration.relation object + if ($this->save_links($event['uid'], $links)) { + $object['links'] = $links; + } + + $updated = true; + $this->events = [$event['uid'] => $this->_to_driver_event($object, true)]; + + // refresh local cache with recurring instances + if ($exception_id) { + $this->get_recurring_events($object, $event['start'], $event['end'], $exception_id); + } } - } - } - // found the specifically requested instance, exiting... - if ($event_id && !empty($this->events[$event_id])) { - return array($this->events[$event_id]); - } + return $updated; + } + + /** + * Delete an event record + * + * @see calendar_driver::remove_event() + * + * @return bool True on success, False on error + */ + public function delete_event($event, $force = true) + { + $deleted = $this->storage->delete(!empty($event['uid']) ? $event['uid'] : $event['id'], $force); + + if (!$deleted) { + rcube::raise_error([ + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => sprintf("Error deleting event object '%s' from Kolab server", $event['id']) + ], + true, false + ); + } - // Check first occurrence, it might have been moved - if ($first = $exdata[$event['start']->format('Ymd')]) { - // return it only if not already in the result, but in the requested period - if (!($event['start'] <= $end && $event['end'] >= $start) - && ($first['start'] <= $end && $first['end'] >= $start) - ) { - $events[] = $first; - } + return $deleted; } - if ($limit && count($events) >= $limit) { - return $events; - } + /** + * Restore deleted event record + * + * @see calendar_driver::undelete_event() + * + * @return bool True on success, False on error + */ + public function restore_event($event) + { + // Make sure this is not an instance identifier + $uid = preg_replace('/-\d{8}(T\d{6})?$/', '', $event['id']); - // use libkolab to compute recurring events - $recurrence = new kolab_date_recurrence($object); + if ($this->storage->undelete($uid)) { + return true; + } - $i = 0; - while ($next_event = $recurrence->next_instance()) { - $datestr = $next_event['start']->format('Ymd'); - $instance_id = $next_event['start']->format($recurrence_id_format); + rcube::raise_error([ + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => sprintf("Error undeleting the event object '%s' from the Kolab server", $event['id']) + ], + true, false + ); - // use this event data for future recurring instances - if ($futuredata[$datestr]) - $overlay_data = $futuredata[$datestr]; + return false; + } - $rec_id = $event['uid'] . '-' . $instance_id; - $exception = $exdata[$datestr] ?: $overlay_data; - $event_start = $next_event['start']; - $event_end = $next_event['end']; + /** + * Find messages linked with an event + */ + protected function get_links($uid) + { + $storage = kolab_storage_config::get_instance(); + return $storage->get_object_links($uid); + } + + /** + * + */ + protected function save_links($uid, $links) + { + $storage = kolab_storage_config::get_instance(); + return $storage->save_object_links($uid, (array) $links); + } + + /** + * Create instances of a recurring event + * + * @param array $event Hash array with event properties + * @param DateTime $start Start date of the recurrence window + * @param DateTime $end End date of the recurrence window + * @param string $event_id ID of a specific recurring event instance + * @param int $limit Max. number of instances to return + * + * @return array List of recurring event instances + */ + public function get_recurring_events($event, $start, $end = null, $event_id = null, $limit = null) + { + if (empty($event['_formatobj'])) { + $rec = $this->storage->get_object(!empty($event['uid']) ? $event['uid'] : $event['id']); + $object = $rec['_formatobj']; + } + else { + $object = $event['_formatobj']; + } - // copy some event from exception to get proper start/end dates - if ($exception) { - $event_copy = $next_event; - kolab_driver::merge_exception_dates($event_copy, $exception); - $event_start = $event_copy['start']; - $event_end = $event_copy['end']; - } + if (!is_object($object)) { + return []; + } - // add to output if in range - if (($event_start <= $end && $event_end >= $start) || ($event_id && $rec_id == $event_id)) { - $rec_event = $this->_to_driver_event($next_event, false, false, $event); - $rec_event['_instance'] = $instance_id; - $rec_event['_count'] = $i + 1; + // determine a reasonable end date if none given + if (!$end) { + $end = clone $event['start']; + $end->add(new DateInterval('P100Y')); + } - if ($exception) // copy data from exception - kolab_driver::merge_exception_data($rec_event, $exception); + // read recurrence exceptions first + $events = []; + $exdata = []; + $futuredata = []; + $recurrence_id_format = libcalendaring::recurrence_id_format($event); + + if (!empty($event['recurrence'])) { + // copy the recurrence rule from the master event (to be used in the UI) + $recurrence_rule = $event['recurrence']; + unset($recurrence_rule['EXCEPTIONS'], $recurrence_rule['EXDATE']); + + if (!empty($event['recurrence']['EXCEPTIONS'])) { + foreach ($event['recurrence']['EXCEPTIONS'] as $exception) { + if (empty($exception['_instance'])) { + $exception['_instance'] = libcalendaring::recurrence_instance_identifier($exception, !empty($event['allday'])); + } + + $rec_event = $this->_to_driver_event($exception, false, false, $event); + $rec_event['id'] = $event['uid'] . '-' . $exception['_instance']; + $rec_event['isexception'] = 1; + + // found the specifically requested instance: register exception (single occurrence wins) + if ( + $rec_event['id'] == $event_id + && (empty($this->events[$event_id]) || !empty($this->events[$event_id]['thisandfuture'])) + ) { + $rec_event['recurrence'] = $recurrence_rule; + $rec_event['recurrence_id'] = $event['uid']; + $this->events[$rec_event['id']] = $rec_event; + } + + // remember this exception's date + $exdate = substr($exception['_instance'], 0, 8); + if (empty($exdata[$exdate]) || !empty($exdata[$exdate]['thisandfuture'])) { + $exdata[$exdate] = $rec_event; + } + if (!empty($rec_event['thisandfuture'])) { + $futuredata[$exdate] = $rec_event; + } + } + } + } - $rec_event['id'] = $rec_id; - $rec_event['recurrence_id'] = $event['uid']; - $rec_event['recurrence'] = $recurrence_rule; - unset($rec_event['_attendees']); - $events[] = $rec_event; + // found the specifically requested instance, exiting... + if ($event_id && !empty($this->events[$event_id])) { + return [$this->events[$event_id]]; + } - if ($rec_id == $event_id) { - $this->events[$rec_id] = $rec_event; - break; + // Check first occurrence, it might have been moved + if ($first = $exdata[$event['start']->format('Ymd')]) { + // return it only if not already in the result, but in the requested period + if (!($event['start'] <= $end && $event['end'] >= $start) + && ($first['start'] <= $end && $first['end'] >= $start) + ) { + $events[] = $first; + } } if ($limit && count($events) >= $limit) { - return $events; + return $events; } - } - else if ($next_event['start'] > $end) // stop loop if out of range - break; - // avoid endless recursion loops - if (++$i > 100000) - break; - } + // use libkolab to compute recurring events + $recurrence = new kolab_date_recurrence($object); - return $events; - } + $i = 0; + while ($next_event = $recurrence->next_instance()) { + $datestr = $next_event['start']->format('Ymd'); + $instance_id = $next_event['start']->format($recurrence_id_format); - /** - * Convert from Kolab_Format to internal representation - */ - private function _to_driver_event($record, $noinst = false, $links = true, $master_event = null) - { - $record['calendar'] = $this->id; + // use this event data for future recurring instances + if (!empty($futuredata[$datestr])) { + $overlay_data = $futuredata[$datestr]; + } - // remove (possibly outdated) cached parameters - unset($record['_folder_id'], $record['className']); + $rec_id = $event['uid'] . '-' . $instance_id; + $exception = !empty($exdata[$datestr]) ? $exdata[$datestr] : $overlay_data; + $event_start = $next_event['start']; + $event_end = $next_event['end']; + + // copy some event from exception to get proper start/end dates + if ($exception) { + $event_copy = $next_event; + kolab_driver::merge_exception_dates($event_copy, $exception); + $event_start = $event_copy['start']; + $event_end = $event_copy['end']; + } - if ($links && !array_key_exists('links', $record)) { - $record['links'] = $this->get_links($record['uid']); - } + // add to output if in range + if (($event_start <= $end && $event_end >= $start) || ($event_id && $rec_id == $event_id)) { + $rec_event = $this->_to_driver_event($next_event, false, false, $event); + $rec_event['_instance'] = $instance_id; + $rec_event['_count'] = $i + 1; + + if ($exception) { + // copy data from exception + kolab_driver::merge_exception_data($rec_event, $exception); + } + + $rec_event['id'] = $rec_id; + $rec_event['recurrence_id'] = $event['uid']; + $rec_event['recurrence'] = $recurrence_rule; + unset($rec_event['_attendees']); + $events[] = $rec_event; + + if ($rec_id == $event_id) { + $this->events[$rec_id] = $rec_event; + break; + } + + if ($limit && count($events) >= $limit) { + return $events; + } + } + else if ($next_event['start'] > $end) { + // stop loop if out of range + break; + } - $ns = $this->get_namespace(); + // avoid endless recursion loops + if (++$i > 100000) { + break; + } + } - if ($ns == 'other') { - $record['className'] = 'fc-event-ns-other'; + return $events; } - if ($ns == 'other' || !$this->cal->rc->config->get('kolab_invitation_calendars')) { - $record = kolab_driver::add_partstat_class($record, array('NEEDS-ACTION', 'DECLINED'), $this->get_owner()); + /** + * Convert from Kolab_Format to internal representation + */ + private function _to_driver_event($record, $noinst = false, $links = true, $master_event = null) + { + $record['calendar'] = $this->id; - // Modify invitation status class name, when invitation calendars are disabled - // we'll use opacity only for declined/needs-action events - $record['className'] = str_replace('-invitation', '', $record['className']); - } + // remove (possibly outdated) cached parameters + unset($record['_folder_id'], $record['className']); - // add instance identifier to first occurrence (master event) - $recurrence_id_format = libcalendaring::recurrence_id_format($master_event ? $master_event : $record); - if (!$noinst && $record['recurrence'] && !$record['recurrence_id'] && !$record['_instance']) { - $record['_instance'] = $record['start']->format($recurrence_id_format); - } - else if (is_a($record['recurrence_date'], 'DateTime')) { - $record['_instance'] = $record['recurrence_date']->format($recurrence_id_format); - } + if ($links && !array_key_exists('links', $record)) { + $record['links'] = $this->get_links($record['uid']); + } - // clean up exception data - if ($record['recurrence'] && is_array($record['recurrence']['EXCEPTIONS'])) { - array_walk($record['recurrence']['EXCEPTIONS'], function(&$exception) { - unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj'], $exception['_attachments']); - }); - } + $ns = $this->get_namespace(); - return $record; - } - - /** - * Convert the given event record into a data structure that can be passed to Kolab_Storage backend for saving - * (opposite of self::_to_driver_event()) - */ - private function _from_driver_event($event, $old = array()) - { - // set current user as ORGANIZER - if ($identity = $this->cal->rc->user->list_emails(true)) { - $event['attendees'] = (array) $event['attendees']; - $found = false; - - // there can be only resources on attendees list (T1484) - // let's check the existence of an organizer - foreach ($event['attendees'] as $attendee) { - if ($attendee['role'] == 'ORGANIZER') { - $found = true; - break; - } - } - - if (!$found) { - $event['attendees'][] = array('role' => 'ORGANIZER', 'name' => $identity['name'], 'email' => $identity['email']); - } - - $event['_owner'] = $identity['email']; - } + if ($ns == 'other') { + $record['className'] = 'fc-event-ns-other'; + } - // remove EXDATE values if RDATE is given - if (!empty($event['recurrence']['RDATE'])) { - $event['recurrence']['EXDATE'] = array(); - } + if ($ns == 'other' || !$this->cal->rc->config->get('kolab_invitation_calendars')) { + $record = kolab_driver::add_partstat_class($record, ['NEEDS-ACTION', 'DECLINED'], $this->get_owner()); - // remove recurrence information (e.g. EXDATES and EXCEPTIONS) entirely - if ($event['recurrence'] && empty($event['recurrence']['FREQ']) && empty($event['recurrence']['RDATE'])) { - $event['recurrence'] = array(); - } + // Modify invitation status class name, when invitation calendars are disabled + // we'll use opacity only for declined/needs-action events + $record['className'] = str_replace('-invitation', '', $record['className']); + } - // keep 'comment' from initial itip invitation - if (!empty($old['comment'])) { - $event['comment'] = $old['comment']; - } + // add instance identifier to first occurrence (master event) + $recurrence_id_format = libcalendaring::recurrence_id_format($master_event ? $master_event : $record); + if (!$noinst && !empty($record['recurrence']) && empty($record['recurrence_id']) && empty($record['_instance'])) { + $record['_instance'] = $record['start']->format($recurrence_id_format); + } + else if (isset($record['recurrence_date']) && is_a($record['recurrence_date'], 'DateTime')) { + $record['_instance'] = $record['recurrence_date']->format($recurrence_id_format); + } - // remove some internal properties which should not be cached - $cleanup_fn = function(&$event) { - unset($event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_folder_id'], - $event['calendar'], $event['className'], $event['recurrence_id'], - $event['attachments'], $event['deleted_attachments']); - }; - - $cleanup_fn($event); - - // clean up exception data - if (is_array($event['exceptions'])) { - array_walk($event['exceptions'], function(&$exception) use ($cleanup_fn) { - unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj']); - $cleanup_fn($exception); - }); - } + // clean up exception data + if (!empty($record['recurrence']) && !empty($record['recurrence']['EXCEPTIONS'])) { + array_walk($record['recurrence']['EXCEPTIONS'], function(&$exception) { + unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj'], $exception['_attachments']); + }); + } - // copy meta data (starting with _) from old object - foreach ((array)$old as $key => $val) { - if (!isset($event[$key]) && $key[0] == '_') - $event[$key] = $val; - } + return $record; + } + + /** + * Convert the given event record into a data structure that can be passed to Kolab_Storage backend for saving + * (opposite of self::_to_driver_event()) + */ + private function _from_driver_event($event, $old = []) + { + // set current user as ORGANIZER + if ($identity = $this->cal->rc->user->list_emails(true)) { + $event['attendees'] = !empty($event['attendees']) ? $event['attendees'] : []; + $found = false; + + // there can be only resources on attendees list (T1484) + // let's check the existence of an organizer + foreach ($event['attendees'] as $attendee) { + if (!empty($attendee['role']) && $attendee['role'] == 'ORGANIZER') { + $found = true; + break; + } + } + + if (!$found) { + $event['attendees'][] = ['role' => 'ORGANIZER', 'name' => $identity['name'], 'email' => $identity['email']]; + } + + $event['_owner'] = $identity['email']; + } + + // remove EXDATE values if RDATE is given + if (!empty($event['recurrence']['RDATE'])) { + $event['recurrence']['EXDATE'] = []; + } + + // remove recurrence information (e.g. EXDATES and EXCEPTIONS) entirely + if (!empty($event['recurrence']) && empty($event['recurrence']['FREQ']) && empty($event['recurrence']['RDATE'])) { + $event['recurrence'] = []; + } - return $event; - } - - /** - * Match the given word in the event contents - */ - public function fulltext_match($event, $word, $recursive = true) - { - $hits = 0; - foreach ($this->search_fields as $col) { - $sval = is_array($event[$col]) ? self::_complex2string($event[$col]) : $event[$col]; - if (empty($sval)) - continue; - - // do a simple substring matching (to be improved) - $val = mb_strtolower($sval); - if (strpos($val, $word) !== false) { - $hits++; - break; - } + // keep 'comment' from initial itip invitation + if (!empty($old['comment'])) { + $event['comment'] = $old['comment']; + } + + // remove some internal properties which should not be cached + $cleanup_fn = function(&$event) { + unset($event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_folder_id'], + $event['calendar'], $event['className'], $event['recurrence_id'], + $event['attachments'], $event['deleted_attachments']); + }; + + $cleanup_fn($event); + + // clean up exception data + if (!empty($event['exceptions'])) { + array_walk($event['exceptions'], function(&$exception) use ($cleanup_fn) { + unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj']); + $cleanup_fn($exception); + }); + } + + // copy meta data (starting with _) from old object + foreach ((array) $old as $key => $val) { + if (!isset($event[$key]) && $key[0] == '_') { + $event[$key] = $val; + } + } + + return $event; } - return $hits; - } - - /** - * Convert a complex event attribute to a string value - */ - private static function _complex2string($prop) - { - static $ignorekeys = array('role','status','rsvp'); - - $out = ''; - if (is_array($prop)) { - foreach ($prop as $key => $val) { - if (is_numeric($key)) { - $out .= self::_complex2string($val); - } - else if (!in_array($key, $ignorekeys)) { - $out .= $val . ' '; - } - } - } - else if (is_string($prop) || is_numeric($prop)) { - $out .= $prop . ' '; - } - - return rtrim($out); - } + /** + * Match the given word in the event contents + */ + public function fulltext_match($event, $word, $recursive = true) + { + $hits = 0; + foreach ($this->search_fields as $col) { + if (empty($event[$col])) { + continue; + } + $sval = is_array($event[$col]) ? self::_complex2string($event[$col]) : $event[$col]; + if (empty($sval)) { + continue; + } + + // do a simple substring matching (to be improved) + $val = mb_strtolower($sval); + if (strpos($val, $word) !== false) { + $hits++; + break; + } + } + + return $hits; + } + + /** + * Convert a complex event attribute to a string value + */ + private static function _complex2string($prop) + { + static $ignorekeys = ['role', 'status', 'rsvp']; + + $out = ''; + if (is_array($prop)) { + foreach ($prop as $key => $val) { + if (is_numeric($key)) { + $out .= self::_complex2string($val); + } + else if (!in_array($key, $ignorekeys)) { + $out .= $val . ' '; + } + } + } + else if (is_string($prop) || is_numeric($prop)) { + $out .= $prop . ' '; + } + + return rtrim($out); + } } diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index 9b859bbd..0ab811a5 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -1,2433 +1,2643 @@ * @author Aleksander Machniak * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_driver extends calendar_driver { - const INVITATIONS_CALENDAR_PENDING = '--invitation--pending'; - const INVITATIONS_CALENDAR_DECLINED = '--invitation--declined'; - - // features this backend supports - public $alarms = true; - public $attendees = true; - public $freebusy = true; - public $attachments = true; - public $undelete = true; - public $alarm_types = array('DISPLAY','AUDIO'); - public $categoriesimmutable = true; - - private $rc; - private $cal; - private $calendars; - private $has_writeable = false; - private $freebusy_trigger = false; - private $bonnie_api = false; - - /** - * Default constructor - */ - public function __construct($cal) - { - $cal->require_plugin('libkolab'); - - // load helper classes *after* libkolab has been loaded (#3248) - require_once(dirname(__FILE__) . '/kolab_calendar.php'); - require_once(dirname(__FILE__) . '/kolab_user_calendar.php'); - require_once(dirname(__FILE__) . '/kolab_invitation_calendar.php'); - - $this->cal = $cal; - $this->rc = $cal->rc; - - $this->cal->register_action('push-freebusy', array($this, 'push_freebusy')); - $this->cal->register_action('calendar-acl', array($this, 'calendar_acl')); - - $this->freebusy_trigger = $this->rc->config->get('calendar_freebusy_trigger', false); - - if (kolab_storage::$version == '2.0') { - $this->alarm_types = array('DISPLAY'); - $this->alarm_absolute = false; - } + const INVITATIONS_CALENDAR_PENDING = '--invitation--pending'; + const INVITATIONS_CALENDAR_DECLINED = '--invitation--declined'; + + // features this backend supports + public $alarms = true; + public $attendees = true; + public $freebusy = true; + public $attachments = true; + public $undelete = true; + public $alarm_types = ['DISPLAY', 'AUDIO']; + public $categoriesimmutable = true; + + private $rc; + private $cal; + private $calendars; + private $has_writeable = false; + private $freebusy_trigger = false; + private $bonnie_api = false; + + /** + * Default constructor + */ + public function __construct($cal) + { + $cal->require_plugin('libkolab'); + + // load helper classes *after* libkolab has been loaded (#3248) + require_once(__DIR__ . '/kolab_calendar.php'); + require_once(__DIR__ . '/kolab_user_calendar.php'); + require_once(__DIR__ . '/kolab_invitation_calendar.php'); + + $this->cal = $cal; + $this->rc = $cal->rc; + + $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 (kolab_storage::$version == '2.0') { + $this->alarm_types = ['DISPLAY']; + $this->alarm_absolute = false; + } - // get configuration for the Bonnie API - $this->bonnie_api = libkolab::get_bonnie_api(); + // get configuration for the Bonnie API + $this->bonnie_api = libkolab::get_bonnie_api(); - // calendar uses fully encoded identifiers - kolab_storage::$encode_ids = true; - } + // calendar uses fully encoded identifiers + kolab_storage::$encode_ids = true; + } + /** + * Read available calendars from server + */ + private function _read_calendars() + { + // already read sources + if (isset($this->calendars)) { + return $this->calendars; + } - /** - * Read available calendars from server - */ - private function _read_calendars() - { - // already read sources - if (isset($this->calendars)) - return $this->calendars; + // get all folders that have "event" type, sorted by namespace/name + $folders = kolab_storage::sort_folders( + kolab_storage::get_folders('event') + kolab_storage::get_user_folders('event', true) + ); - // get all folders that have "event" type, sorted by namespace/name - $folders = kolab_storage::sort_folders(kolab_storage::get_folders('event') + kolab_storage::get_user_folders('event', true)); + $this->calendars = []; - $this->calendars = array(); - foreach ($folders as $folder) { - $calendar = $this->_to_calendar($folder); - if ($calendar->ready) { - $this->calendars[$calendar->id] = $calendar; - if ($calendar->editable) { - $this->has_writeable = true; + 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; } - return $this->calendars; - } + /** + * Convert kolab_storage_folder into kolab_calendar + */ + private function _to_calendar($folder) + { + if ($folder instanceof kolab_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 kolab_calendar($folder->name, $this->cal); + } - /** - * Convert kolab_storage_folder into kolab_calendar - */ - private function _to_calendar($folder) - { - if ($folder instanceof kolab_calendar) { - return $folder; + return $calendar; } - if ($folder instanceof kolab_storage_folder_user) { - $calendar = new kolab_user_calendar($folder, $this->cal); - $calendar->subscriptions = count($folder->children) > 0; + /** + * 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(); + + // attempt to create a default calendar for this user + if (!$this->has_writeable) { + if ($this->create_calendar(['name' => 'Calendar', 'color' => 'cc0000'])) { + unset($this->calendars); + $this->_read_calendars(); + } + } + + $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); + $folders = $this->filter_calendars($filter); + $calendars = []; + + // include virtual folders for a full folder tree + if (!is_null($tree)) { + $folders = kolab_storage::folder_hierarchy($folders, $tree); + } + + $parents = array_keys($this->calendars); + + foreach ($folders as $id => $cal) { + $imap_path = explode($delim, $cal->name); + + // find parent + do { + array_pop($imap_path); + $parent_id = kolab_storage::folder_id(join($delim, $imap_path)); + } + while (count($imap_path) > 1 && !in_array($parent_id, $parents)); + + // restore "real" parent ID + if ($parent_id && !in_array($parent_id, $parents)) { + $parent_id = kolab_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::folder_hierarchy() above + // make sure we deal with kolab_calendar instances + $cal = $this->_to_calendar($cal); + $this->calendars[$cal->id] = $cal; + + $is_user = ($cal instanceof kolab_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' => $cal->is_active(), + 'owner' => $cal->get_owner(), + 'removable' => !$cal->default, + ]; + + 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 kolab_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; } - else { - $calendar = new kolab_calendar($folder->name, $this->cal); + + /** + * Get list of calendars according to specified filters + * + * @param int Bitmask defining restrictions. See FILTER_* constants for possible values. + * + * @return array List of calendars + */ + protected function filter_calendars($filter) + { + $this->_read_calendars(); + + $calendars = []; + + $plugin = $this->rc->plugins->exec_hook('calendar_list_filter', [ + 'list' => $this->calendars, + 'calendars' => $calendars, + 'filter' => $filter, + ]); + + if ($plugin['abort']) { + return $plugin['calendars']; + } + + $personal = $filter & self::FILTER_PERSONAL; + $shared = $filter & self::FILTER_SHARED; + + foreach ($this->calendars as $cal) { + if (!$cal->ready) { + continue; + } + if (($filter & self::FILTER_WRITEABLE) && !$cal->editable) { + continue; + } + if (($filter & self::FILTER_INSERTABLE) && !$cal->editable) { + continue; + } + if (($filter & self::FILTER_ACTIVE) && !$cal->is_active()) { + continue; + } + if (($filter & self::FILTER_PRIVATE) && $cal->subtype != 'private') { + continue; + } + if (($filter & self::FILTER_CONFIDENTIAL) && $cal->subtype != 'confidential') { + continue; + } + if ($personal || $shared) { + $ns = $cal->get_namespace(); + if (!(($personal && $ns == 'personal') || ($shared && $ns == 'shared'))) { + continue; + } + } + + $calendars[$cal->id] = $cal; + } + + return $calendars; } - return $calendar; - } - - /** - * Get a list of available calendars from this source - * - * @param integer $filter Bitmask defining filter criterias - * @param object $tree Reference to hierarchical folder tree object - * - * @return array List of calendars - */ - public function list_calendars($filter = 0, &$tree = null) - { - $this->_read_calendars(); - - // attempt to create a default calendar for this user - if (!$this->has_writeable) { - if ($this->create_calendar(array('name' => 'Calendar', 'color' => 'cc0000'))) { - unset($this->calendars); + /** + * Get the kolab_calendar instance for the given calendar ID + * + * @param string Calendar identifier (encoded imap folder name) + * + * @return kolab_calendar Object nor null if calendar doesn't exist + */ + public function get_calendar($id) + { $this->_read_calendars(); - } + + // create calendar object if necesary + if (empty($this->calendars[$id])) { + if (in_array($id, [self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED])) { + return new kolab_invitation_calendar($id, $this->cal); + } + + // for unsubscribed calendar folders + if ($id !== self::BIRTHDAY_CALENDAR_ID) { + $calendar = kolab_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['active'] = true; + $prop['subscribed'] = true; + + $folder = kolab_storage::folder_update($prop); + + if ($folder === false) { + $this->last_error = $this->cal->gettext(kolab_storage::$last_error); + return false; + } + + // create ID + $id = kolab_storage::folder_id($folder); + + // save color in user prefs (temp. solution) + $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); + + if (isset($prop['color'])) { + $prefs['kolab_calendars'][$id]['color'] = $prop['color']; + } + + 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 $id; } - $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); - $folders = $this->filter_calendars($filter); - $calendars = array(); - - // include virtual folders for a full folder tree - if (!is_null($tree)) - $folders = kolab_storage::folder_hierarchy($folders, $tree); - - $parents = array_keys($this->calendars); - - foreach ($folders as $id => $cal) { - $imap_path = explode($delim, $cal->name); - - // find parent - do { - array_pop($imap_path); - $parent_id = kolab_storage::folder_id(join($delim, $imap_path)); - } - while (count($imap_path) > 1 && !in_array($parent_id, $parents)); - - // restore "real" parent ID - if ($parent_id && !in_array($parent_id, $parents)) { - $parent_id = kolab_storage::folder_id($cal->get_parent()); - } - - $parents[] = $cal->id; - - if ($cal->virtual) { - $calendars[$cal->id] = array( - '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::folder_hierarchy() above - // make sure we deal with kolab_calendar instances - $cal = $this->_to_calendar($cal); - $this->calendars[$cal->id] = $cal; - - $is_user = ($cal instanceof kolab_user_calendar); - - $calendars[$cal->id] = array( - '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' => $cal->is_active(), - 'owner' => $cal->get_owner(), - 'removable' => !$cal->default, - ); + /** + * Update properties of an existing calendar + * + * @see calendar_driver::edit_calendar() + */ + public function edit_calendar($prop) + { + if (!empty($prop['id']) && ($cal = $this->get_calendar($prop['id']))) { + $id = $cal->update($prop); + } + else { + $id = $prop['id']; + } + + // fallback to local prefs + $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); + unset($prefs['kolab_calendars'][$prop['id']]['color'], $prefs['kolab_calendars'][$prop['id']]['showalarms']); + + if (isset($prop['color'])) { + $prefs['kolab_calendars'][$id]['color'] = $prop['color']; + } + + if (isset($prop['showalarms']) && $id == self::BIRTHDAY_CALENDAR_ID) { + $prefs['calendar_birthdays_alarm_type'] = $prop['showalarms'] ? $this->alarm_types[0] : ''; + } + else if (isset($prop['showalarms'])) { + $prefs['kolab_calendars'][$id]['showalarms'] = !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']) && ($cal = $this->get_calendar($prop['id'])) && !empty($cal->storage)) { + $ret = false; + if (isset($prop['permanent'])) { + $ret |= $cal->storage->subscribe(intval($prop['permanent'])); + } + if (isset($prop['active'])) { + $ret |= $cal->storage->activate(intval($prop['active'])); + } + + // apply to child folders, too + if (!empty($prop['recursive'])) { + foreach ((array) kolab_storage::list_folders($cal->storage->name, '*', 'event') as $subfolder) { + if (isset($prop['permanent'])) { + if ($prop['permanent']) { + kolab_storage::folder_subscribe($subfolder); + } + else { + kolab_storage::folder_unsubscribe($subfolder); + } + } + + if (isset($prop['active'])) { + if ($prop['active']) { + kolab_storage::folder_activate($subfolder); + } + else { + kolab_storage::folder_deactivate($subfolder); + } + } + } + } + return $ret; + } + else { + // save state in local prefs + $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; + } + + return false; + } + + /** + * Delete the given calendar with all its contents + * + * @see calendar_driver::delete_calendar() + */ + public function delete_calendar($prop) + { + if (!empty($prop['id']) && ($cal = $this->get_calendar($prop['id']))) { + $folder = $cal->get_realname(); + + // TODO: unsubscribe if no admin rights + if (kolab_storage::folder_delete($folder)) { + // remove color in user prefs (temp. solution) + $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); + unset($prefs['kolab_calendars'][$prop['id']]); + + $this->rc->user->save_prefs($prefs); + return true; + } + else { + $this->last_error = kolab_storage::$last_error; + } + } + + return false; + } + + /** + * Search for shared or otherwise not listed calendars the user has access + * + * @param string Search string + * @param string Section/source to search + * + * @return array List of calendars + */ + public function search_calendars($query, $source) + { + if (!kolab_storage::setup()) { + return []; + } + + $this->calendars = []; + $this->search_more_results = false; + + // find unsubscribed IMAP folders that have "event" type + if ($source == 'folders') { + foreach ((array) kolab_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 (kolab_storage::search_users($query, 0, [], $limit, $count) as $user) { + $calendar = new kolab_user_calendar($user, $this->cal); + $this->calendars[$calendar->id] = $calendar; + + // search for calendar folders shared by this user + foreach (kolab_storage::list_user_folders($user, 'event', false) as $foldername) { + $cal = new kolab_calendar($foldername, $this->cal); + $this->calendars[$cal->id] = $cal; + $calendar->subscriptions = true; + } + } + + if ($count > $limit) { + $this->search_more_results = true; + } + } + + // don't list the birthday calendar + $this->rc->config->set('calendar_contact_birthdays', false); + $this->rc->config->set('kolab_invitation_calendars', false); - if (!$is_user) { - $calendars[$cal->id] += array( - '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(); - } + return $this->list_calendars(); } - // list virtual calendars showing invitations - if ($this->rc->config->get('kolab_invitation_calendars') && !($filter & self::FILTER_INSERTABLE)) { - foreach (array(self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED) as $id) { - $cal = new kolab_invitation_calendar($id, $this->cal); - if (!($filter & self::FILTER_ACTIVE) || $cal->is_active()) { - $calendars[$id] = array( - 'id' => $cal->id, - 'name' => $cal->get_name(), - 'listname' => $cal->get_name(), - 'editname' => $cal->get_foldername(), - 'title' => $cal->get_title(), - 'color' => $cal->get_color(), - 'editable' => $cal->editable, - 'rights' => $cal->rights, - 'showalarms' => $cal->alarms, - 'history' => !empty($this->bonnie_api), - 'group' => 'x-invitations', - 'default' => false, - 'active' => $cal->is_active(), - 'owner' => $cal->get_owner(), - 'children' => false, - ); - - if ($id == self::INVITATIONS_CALENDAR_PENDING) { - $calendars[$id]['counts'] = true; - } - - if (is_object($tree)) { - $tree->children[] = $cal; - } - } - } + /** + * Fetch a single event + * + * @see calendar_driver::get_event() + * @return array Hash array with event properties, false if not found + */ + public function get_event($event, $scope = 0, $full = false) + { + if (is_array($event)) { + $id = !empty($event['id']) ? $event['id'] : $event['uid']; + $cal = $event['calendar']; + + // we're looking for a recurring instance: expand the ID to our internal convention for recurring instances + if (empty($event['id']) && !empty($event['_instance'])) { + $id .= '-' . $event['_instance']; + } + } + else { + $id = $event; + } + + if (!empty($cal)) { + if ($storage = $this->get_calendar($cal)) { + $result = $storage->get_event($id); + return self::to_rcube_event($result); + } + + // get event from the address books birthday calendar + if ($cal == self::BIRTHDAY_CALENDAR_ID) { + return $this->get_birthday_event($id); + } + } + // iterate over all calendar folders and search for the event ID + else { + foreach ($this->filter_calendars($scope) as $calendar) { + if ($result = $calendar->get_event($id)) { + return self::to_rcube_event($result); + } + } + } + + return false; } - // 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', array()); // read local prefs - if (!($filter & self::FILTER_ACTIVE) || $prefs[$id]['active']) { - $calendars[$id] = array( - 'id' => $id, - 'name' => $this->cal->gettext('birthdays'), - 'listname' => $this->cal->gettext('birthdays'), - 'color' => $prefs[$id]['color'] ?: '87CEFA', - 'active' => (bool)$prefs[$id]['active'], - 'showalarms' => (bool)$this->rc->config->get('calendar_birthdays_alarm_type'), - 'group' => 'x-birthdays', - 'editable' => false, - 'default' => false, - 'children' => false, - 'history' => false, + /** + * Add a single event to the database + * + * @see calendar_driver::new_event() + */ + public function new_event($event) + { + if (!$this->validate($event)) { + return false; + } + + $event = self::from_rcube_event($event); + + if (!$event['calendar']) { + $this->_read_calendars(); + $cal_ids = array_keys($this->calendars); + $event['calendar'] = reset($cal_ids); + } + + if ($storage = $this->get_calendar($event['calendar'])) { + // if this is a recurrence instance, append as exception to an already existing object for this UID + if (!empty($event['recurrence_date']) && ($master = $storage->get_event($event['uid']))) { + self::add_exception($master, $event); + $success = $storage->update_event($master); + } + else { + $success = $storage->insert_event($event); + } + + if ($success && $this->freebusy_trigger) { + $this->rc->output->command('plugin.ping_url', ['action' => 'calendar/push-freebusy', 'source' => $storage->id]); + $this->freebusy_trigger = false; // disable after first execution (#2355) + } + + return $success; + } + + return false; + } + + /** + * Update an event entry with the given data + * + * @see calendar_driver::new_event() + * @return bool True on success, False on error + */ + public function edit_event($event) + { + if (!($storage = $this->get_calendar($event['calendar']))) { + return false; + } + + return $this->update_event(self::from_rcube_event($event, $storage->get_event($event['id']))); + } + + /** + * Extended event editing with possible changes to the argument + * + * @param array Hash array with event properties + * @param string New participant status + * @param array List of hash arrays with updated attendees + * + * @return bool True on success, False on error + */ + public function edit_rsvp(&$event, $status, $attendees) + { + $update_event = $event; + + // apply changes to master (and all exceptions) + if ($event['_savemode'] == 'all' && !empty($event['recurrence_id'])) { + if ($storage = $this->get_calendar($event['calendar'])) { + $update_event = $storage->get_event($event['recurrence_id']); + $update_event['_savemode'] = $event['_savemode']; + $update_event['id'] = $update_event['uid']; + unset($update_event['recurrence_id']); + calendar::merge_attendee_data($update_event, $attendees); + } + } + + if ($ret = $this->update_attendees($update_event, $attendees)) { + // replace with master event (for iTip reply) + $event = self::to_rcube_event($update_event); + + // re-assign to the according (virtual) calendar + if ($this->rc->config->get('kolab_invitation_calendars')) { + if (strtoupper($status) == 'DECLINED') { + $event['calendar'] = self::INVITATIONS_CALENDAR_DECLINED; + } + else if (strtoupper($status) == 'NEEDS-ACTION') { + $event['calendar'] = self::INVITATIONS_CALENDAR_PENDING; + } + else if (!empty($event['_folder_id'])) { + $event['calendar'] = $event['_folder_id']; + } + } + } + + return $ret; + } + + /** + * Update the participant status for the given attendees + * + * @see calendar_driver::update_attendees() + */ + public function update_attendees(&$event, $attendees) + { + // for this-and-future updates, merge the updated attendees onto all exceptions in range + if ( + ($event['_savemode'] == 'future' && !empty($event['recurrence_id'])) + || (!empty($event['recurrence']) && empty($event['recurrence_id'])) + ) { + if (!($storage = $this->get_calendar($event['calendar']))) { + return false; + } + + // load master event + $master = !empty($event['recurrence_id']) ? $storage->get_event($event['recurrence_id']) : $event; + + // apply attendee update to each existing exception + if (!empty($master['recurrence']) && !empty($master['recurrence']['EXCEPTIONS'])) { + $saved = false; + foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { + // merge the new event properties onto future exceptions + if ($exception['_instance'] >= strval($event['_instance'])) { + calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $attendees); + } + // update a specific instance + if ($exception['_instance'] == $event['_instance'] && $exception['thisandfuture']) { + $saved = true; + } + } + + // add the given event as new exception + if (!$saved && $event['id'] != $master['id']) { + $event['thisandfuture'] = true; + $master['recurrence']['EXCEPTIONS'][] = $event; + } + + // set link to top-level exceptions + $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; + + return $this->update_event($master); + } + } + + // just update the given event (instance) + return $this->update_event($event); + } + + /** + * Move a single event + * + * @see calendar_driver::move_event() + * @return boolean True on success, False on error + */ + public function move_event($event) + { + if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { + unset($ev['sequence']); + self::clear_attandee_noreply($ev); + + return $this->update_event($event + $ev); + } + + return false; + } + + /** + * Resize a single event + * + * @see calendar_driver::resize_event() + * @return boolean True on success, False on error + */ + public function resize_event($event) + { + if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { + unset($ev['sequence']); + self::clear_attandee_noreply($ev); + + return $this->update_event($event + $ev); + } + + return false; + } + + /** + * Remove a single event + * + * @param array Hash array with event properties: + * id: Event identifier + * @param bool Remove record(s) irreversible (mark as deleted otherwise) + * + * @return bool True on success, False on error + */ + public function remove_event($event, $force = true) + { + $ret = true; + $success = false; + + if (!$force) { + unset($event['attendees']); + $this->rc->session->remove('calendar_event_undo'); + $this->rc->session->remove('calendar_restore_event_data'); + $sess_data = $event; + } + + if (($storage = $this->get_calendar($event['calendar'])) && ($event = $storage->get_event($event['id']))) { + $decline = $event['_decline']; + $savemode = 'all'; + $master = $event; + + // read master if deleting a recurring event + if (!empty($event['recurrence']) || !empty($event['recurrence_id']) || !empty($event['isexception'])) { + $master = $storage->get_event($event['uid']); + + if (!empty($event['_savemode'])) { + $savemode = $event['_savemode']; + } + else if (!empty($event['_instance']) || !empty($event['isexception'])) { + $savemode = 'current'; + } + + // force 'current' mode for single occurrences stored as exception + if (empty($event['recurrence']) && empty($event['recurrence_id']) && !empty($event['isexception'])) { + $savemode = 'current'; + } + } + + // removing an exception instance + if ((!empty($event['recurrence_id']) || !empty($event['isexception'])) && !empty(($master['exceptions']))) { + foreach ($master['exceptions'] as $i => $exception) { + if ($exception['_instance'] == $event['_instance']) { + unset($master['exceptions'][$i]); + // set event date back to the actual occurrence + if (!empty($exception['recurrence_date'])) { + $event['start'] = $exception['recurrence_date']; + } + } + } + + if (!empty($master['recurrence'])) { + $master['recurrence']['EXCEPTIONS'] = &$master['exceptions']; + } + } + + switch ($savemode) { + case 'current': + $_SESSION['calendar_restore_event_data'] = $master; + + // remove the matching RDATE entry + if (!empty($master['recurrence']['RDATE'])) { + foreach ($master['recurrence']['RDATE'] as $j => $rdate) { + if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { + unset($master['recurrence']['RDATE'][$j]); + break; + } + } + } + + // add exception to master event + $master['recurrence']['EXDATE'][] = $event['start']; + + $success = $storage->update_event($master); + break; + + case 'future': + $master['_instance'] = libcalendaring::recurrence_instance_identifier($master); + if ($master['_instance'] != $event['_instance']) { + $_SESSION['calendar_restore_event_data'] = $master; + + // set until-date on master event + $master['recurrence']['UNTIL'] = clone $event['start']; + $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); + unset($master['recurrence']['COUNT']); + + // if all future instances are deleted, remove recurrence rule entirely (bug #1677) + if ($master['recurrence']['UNTIL']->format('Ymd') == $master['start']->format('Ymd')) { + $master['recurrence'] = []; + } + // remove matching RDATE entries + else if (!empty($master['recurrence']['RDATE'])) { + foreach ($master['recurrence']['RDATE'] as $j => $rdate) { + if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { + $master['recurrence']['RDATE'] = array_slice($master['recurrence']['RDATE'], 0, $j); + break; + } + } + } + + $success = $storage->update_event($master); + $ret = $master['uid']; + break; + } + + default: // 'all' is default + // removing the master event with loose exceptions (not recurring though) + if (!empty($event['recurrence_date']) && empty($master['recurrence']) && !empty($master['exceptions'])) { + // make the first exception the new master + $newmaster = array_shift($master['exceptions']); + $newmaster['exceptions'] = $master['exceptions']; + $newmaster['_attachments'] = $master['_attachments']; + $newmaster['_mailbox'] = $master['_mailbox']; + $newmaster['_msguid'] = $master['_msguid']; + + $success = $storage->update_event($newmaster); + } + else if ($decline && $this->rc->config->get('kolab_invitation_calendars')) { + // don't delete but set PARTSTAT=DECLINED + if ($this->cal->lib->set_partstat($master, 'DECLINED')) { + $success = $storage->update_event($master); + } + } + + if (!$success) { + $success = $storage->delete_event($master, $force); + } + break; + } + } + + if ($success && !$force) { + if (!empty($master['_folder_id'])) { + $sess_data['_folder_id'] = $master['_folder_id']; + } + $_SESSION['calendar_event_undo'] = ['ts' => time(), 'data' => $sess_data]; + } + + if ($success && $this->freebusy_trigger) { + $this->rc->output->command('plugin.ping_url', [ + 'action' => 'calendar/push-freebusy', + // _folder_id may be set by invitations calendar + 'source' => !empty($master['_folder_id']) ? $master['_folder_id'] : $storage->id, + ]); + } + + return $success ? $ret : false; + } + + /** + * Restore a single deleted event + * + * @param array Hash array with event properties: + * id: Event identifier + * calendar: Event calendar + * + * @return bool True on success, False on error + */ + public function restore_event($event) + { + if ($storage = $this->get_calendar($event['calendar'])) { + if (!empty($_SESSION['calendar_restore_event_data'])) { + $success = $storage->update_event($event = $_SESSION['calendar_restore_event_data']); + } + else { + $success = $storage->restore_event($event); + } + + if ($success && $this->freebusy_trigger) { + $this->rc->output->command('plugin.ping_url', [ + 'action' => 'calendar/push-freebusy', + // _folder_id may be set by invitations calendar + 'source' => !empty($event['_folder_id']) ? $event['_folder_id'] : $storage->id, + ]); + } + + return $success; + } + + return false; + } + + /** + * Wrapper to update an event object depending on the given savemode + */ + private function update_event($event) + { + if (!($storage = $this->get_calendar($event['calendar']))) { + return false; + } + + // move event to another folder/calendar + if (!empty($event['_fromcalendar']) && $event['_fromcalendar'] != $event['calendar']) { + if (!($fromcalendar = $this->get_calendar($event['_fromcalendar']))) { + return false; + } + + $old = $fromcalendar->get_event($event['id']); + + if ($event['_savemode'] != 'new') { + if (!$fromcalendar->storage->move($old['uid'], $storage->storage)) { + return false; + } + + $fromcalendar = $storage; + } + } + else { + $fromcalendar = $storage; + } + + $success = false; + $savemode = 'all'; + $attachments = []; + $old = $master = $storage->get_event($event['id']); + + if (!$old || empty($old['start'])) { + rcube::raise_error([ + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Failed to load event object to update: id=" . $event['id'] + ], + true, false + ); + return false; + } + + // modify a recurring event, check submitted savemode to do the right things + if (!empty($old['recurrence']) || !empty($old['recurrence_id']) || !empty($old['isexception'])) { + $master = $storage->get_event($old['uid']); + + if (!empty($event['_savemode'])) { + $savemode = $event['_savemode']; + } + else { + $savemode = (!empty($old['recurrence_id']) || !empty($old['isexception'])) ? 'current' : 'all'; + } + + // this-and-future on the first instance equals to 'all' + if ($savemode == 'future' && !empty($master['start']) + && $old['_instance'] == libcalendaring::recurrence_instance_identifier($master) + ) { + $savemode = 'all'; + } + // force 'current' mode for single occurrences stored as exception + else if (empty($old['recurrence']) && empty($old['recurrence_id']) && !empty($old['isexception'])) { + $savemode = 'current'; + } + + // Stick to the master timezone for all occurrences (Bifrost#T104637) + $master_tz = $master['start']->getTimezone(); + $event_tz = $event['start']->getTimezone(); + + if ($master_tz->getName() != $event_tz->getName()) { + $event['start']->setTimezone($master_tz); + $event['end']->setTimezone($master_tz); + } + } + + // check if update affects scheduling and update attendee status accordingly + $reschedule = $this->check_scheduling($event, $old, true); + + // keep saved exceptions (not submitted by the client) + if (!empty($old['recurrence']['EXDATE']) && !isset($event['recurrence']['EXDATE'])) { + $event['recurrence']['EXDATE'] = $old['recurrence']['EXDATE']; + } + + if (isset($event['recurrence']['EXCEPTIONS'])) { + // exceptions already provided (e.g. from iCal import) + $with_exceptions = true; + } + else if (!empty($old['recurrence']['EXCEPTIONS'])) { + $event['recurrence']['EXCEPTIONS'] = $old['recurrence']['EXCEPTIONS']; + } + else if (!empty($old['exceptions'])) { + $event['exceptions'] = $old['exceptions']; + } + + // remove some internal properties which should not be saved + unset($event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_owner'], + $event['_notify'], $event['_method'], $event['_sender'], $event['_sender_utf'], $event['_size'] ); - } + + switch ($savemode) { + case 'new': + // save submitted data as new (non-recurring) event + $event['recurrence'] = []; + $event['_copyfrom'] = $master['_msguid']; + $event['_mailbox'] = $master['_mailbox']; + $event['uid'] = $this->cal->generate_uid(); + + unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); + + // copy attachment metadata to new event + $event = self::from_rcube_event($event, $master); + + self::clear_attandee_noreply($event); + if ($success = $storage->insert_event($event)) { + $success = $event['uid']; + } + break; + + case 'future': + // create a new recurring event + $event['_copyfrom'] = $master['_msguid']; + $event['_mailbox'] = $master['_mailbox']; + $event['uid'] = $this->cal->generate_uid(); + + unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); + + // copy attachment metadata to new event + $event = self::from_rcube_event($event, $master); + + // remove recurrence exceptions on re-scheduling + if ($reschedule) { + unset($event['recurrence']['EXCEPTIONS'], $event['exceptions'], $master['recurrence']['EXDATE']); + } + else if (isset($event['recurrence']['EXCEPTIONS']) && is_array($event['recurrence']['EXCEPTIONS'])) { + // only keep relevant exceptions + $event['recurrence']['EXCEPTIONS'] = array_filter( + $event['recurrence']['EXCEPTIONS'], + function($exception) use ($event) { + return $exception['start'] > $event['start']; + } + ); + if (isset($event['recurrence']['EXDATE']) && is_array($event['recurrence']['EXDATE'])) { + $event['recurrence']['EXDATE'] = array_filter( + $event['recurrence']['EXDATE'], + function($exdate) use ($event) { + return $exdate > $event['start']; + } + ); + } + // set link to top-level exceptions + $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; + } + + // compute remaining occurrences + if ($event['recurrence']['COUNT']) { + if (empty($old['_count'])) { + $old['_count'] = $this->get_recurrence_count($master, $old['start']); + } + $event['recurrence']['COUNT'] -= intval($old['_count']); + } + + // remove fixed weekday when date changed + if ($old['start']->format('Y-m-d') != $event['start']->format('Y-m-d')) { + if (!empty($event['recurrence']['BYDAY']) && strlen($event['recurrence']['BYDAY']) == 2) { + unset($event['recurrence']['BYDAY']); + } + if (!empty($old['recurrence']['BYMONTH']) && $old['recurrence']['BYMONTH'] == $old['start']->format('n')) { + unset($event['recurrence']['BYMONTH']); + } + } + + // set until-date on master event + $master['recurrence']['UNTIL'] = clone $old['start']; + $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); + unset($master['recurrence']['COUNT']); + + // remove all exceptions after $event['start'] + if (isset($master['recurrence']['EXCEPTIONS']) && is_array($master['recurrence']['EXCEPTIONS'])) { + $master['recurrence']['EXCEPTIONS'] = array_filter( + $master['recurrence']['EXCEPTIONS'], + function($exception) use ($event) { + return $exception['start'] < $event['start']; + } + ); + // set link to top-level exceptions + $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; + } + + if (isset($master['recurrence']['EXDATE']) && is_array($master['recurrence']['EXDATE'])) { + $master['recurrence']['EXDATE'] = array_filter( + $master['recurrence']['EXDATE'], + function($exdate) use ($event) { + return $exdate < $event['start']; + } + ); + } + + // save new event + if ($success = $storage->insert_event($event)) { + $success = $event['uid']; + + // update master event (no rescheduling!) + self::clear_attandee_noreply($master); + $storage->update_event($master); + } + break; + + case 'current': + // recurring instances shall not store recurrence rules and attachments + $event['recurrence'] = []; + $event['thisandfuture'] = $savemode == 'future'; + unset($event['attachments'], $event['id']); + + // increment sequence of this instance if scheduling is affected + if ($reschedule) { + $event['sequence'] = max($old['sequence'], $master['sequence']) + 1; + } + else if (!isset($event['sequence'])) { + $event['sequence'] = !empty($old['sequence']) ? $old['sequence'] : $master['sequence']; + } + + // save properties to a recurrence exception instance + if (!empty($old['_instance']) && isset($master['recurrence']['EXCEPTIONS'])) { + if ($this->update_recurrence_exceptions($master, $event, $old, $savemode)) { + $success = $storage->update_event($master, $old['id']); + break; + } + } + + $add_exception = true; + + // adjust matching RDATE entry if dates changed + if ( + !empty($master['recurrence']['RDATE']) + && ($old_date = $old['start']->format('Ymd')) != $event['start']->format('Ymd') + ) { + foreach ($master['recurrence']['RDATE'] as $j => $rdate) { + if ($rdate->format('Ymd') == $old_date) { + $master['recurrence']['RDATE'][$j] = $event['start']; + sort($master['recurrence']['RDATE']); + $add_exception = false; + break; + } + } + } + + // save as new exception to master event + if ($add_exception) { + self::add_exception($master, $event, $old); + } + + $success = $storage->update_event($master); + break; + + default: // 'all' is the default + $event['id'] = $master['uid']; + $event['uid'] = $master['uid']; + + // use start date from master but try to be smart on time or duration changes + $old_start_date = $old['start']->format('Y-m-d'); + $old_start_time = !empty($old['allday']) ? '' : $old['start']->format('H:i'); + $old_duration = self::event_duration($old['start'], $old['end'], !empty($old['allday'])); + + $new_start_date = $event['start']->format('Y-m-d'); + $new_start_time = !empty($event['allday']) ? '' : $event['start']->format('H:i'); + $new_duration = self::event_duration($event['start'], $event['end'], !empty($event['allday'])); + + $diff = $old_start_date != $new_start_date || $old_start_time != $new_start_time || $old_duration != $new_duration; + $date_shift = $old['start']->diff($event['start']); + + // shifted or resized + if ($diff && ($old_start_date == $new_start_date || $old_duration == $new_duration)) { + $event['start'] = $master['start']->add($date_shift); + $event['end'] = clone $event['start']; + $event['end']->add(new DateInterval($new_duration)); + + // remove fixed weekday, will be re-set to the new weekday in kolab_calendar::update_event() + if ($old_start_date != $new_start_date && !empty($event['recurrence'])) { + if (!empty($event['recurrence']['BYDAY']) && strlen($event['recurrence']['BYDAY']) == 2) + unset($event['recurrence']['BYDAY']); + if (!empty($old['recurrence']['BYMONTH']) && $old['recurrence']['BYMONTH'] == $old['start']->format('n')) + unset($event['recurrence']['BYMONTH']); + } + } + // dates did not change, use the ones from master + else if ($new_start_date . $new_start_time == $old_start_date . $old_start_time) { + $event['start'] = $master['start']; + $event['end'] = $master['end']; + } + + // when saving an instance in 'all' mode, copy recurrence exceptions over + if (!empty($old['recurrence_id'])) { + $event['recurrence']['EXCEPTIONS'] = $master['recurrence']['EXCEPTIONS']; + $event['recurrence']['EXDATE'] = $master['recurrence']['EXDATE']; + } + else if (!empty($master['_instance'])) { + $event['_instance'] = $master['_instance']; + $event['recurrence_date'] = $master['recurrence_date']; + } + + // TODO: forward changes to exceptions (which do not yet have differing values stored) + if (!empty($event['recurrence']) && !empty($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) { + // determine added and removed attendees + $old_attendees = $current_attendees = $added_attendees = []; + + if (!empty($old['attendees'])) { + foreach ((array) $old['attendees'] as $attendee) { + $old_attendees[] = $attendee['email']; + } + } + + if (!empty($event['attendees'])) { + foreach ((array) $event['attendees'] as $attendee) { + $current_attendees[] = $attendee['email']; + if (!in_array($attendee['email'], $old_attendees)) { + $added_attendees[] = $attendee; + } + } + } + + $removed_attendees = array_diff($old_attendees, $current_attendees); + + foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { + calendar::merge_attendee_data($event['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); + } + + // adjust recurrence-id when start changed and therefore the entire recurrence chain changes + if ($old_start_date != $new_start_date || $old_start_time != $new_start_time) { + $recurrence_id_format = libcalendaring::recurrence_id_format($event); + + foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { + if (isset($exception['recurrence_date']) && is_a($exception['recurrence_date'], 'DateTime')) { + $recurrence_id = $exception['recurrence_date']; + } + else { + $recurrence_id = rcube_utils::anytodatetime($exception['_instance'], $old['start']->getTimezone()); + } + + if ($recurrence_id instanceof DateTime) { + $recurrence_id->add($date_shift); + $event['recurrence']['EXCEPTIONS'][$i]['recurrence_date'] = $recurrence_id; + $event['recurrence']['EXCEPTIONS'][$i]['_instance'] = $recurrence_id->format($recurrence_id_format); + } + } + } + + // set link to top-level exceptions + $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; + } + + // unset _dateonly flags in (cached) date objects + unset($event['start']->_dateonly, $event['end']->_dateonly); + + $success = $storage->update_event($event) ? $event['id'] : false; // return master UID + break; + } + + if ($success && $this->freebusy_trigger) { + $this->rc->output->command('plugin.ping_url', [ + 'action' => 'calendar/push-freebusy', + 'source' => $storage->id + ]); + } + + return $success; } - return $calendars; - } - - /** - * Get list of calendars according to specified filters - * - * @param integer Bitmask defining restrictions. See FILTER_* constants for possible values. - * - * @return array List of calendars - */ - protected function filter_calendars($filter) - { - $this->_read_calendars(); - - $calendars = array(); - - $plugin = $this->rc->plugins->exec_hook('calendar_list_filter', array( - 'list' => $this->calendars, - 'calendars' => $calendars, - 'filter' => $filter, - )); - - if ($plugin['abort']) { - return $plugin['calendars']; + /** + * Calculate event duration, returns string in DateInterval format + */ + protected static function event_duration($start, $end, $allday = false) + { + if ($allday) { + $diff = $start->diff($end); + return 'P' . $diff->days . 'D'; + } + + return 'PT' . ($end->format('U') - $start->format('U')) . 'S'; } - $personal = $filter & self::FILTER_PERSONAL; - $shared = $filter & self::FILTER_SHARED; - - foreach ($this->calendars as $cal) { - if (!$cal->ready) { - continue; - } - if (($filter & self::FILTER_WRITEABLE) && !$cal->editable) { - continue; - } - if (($filter & self::FILTER_INSERTABLE) && !$cal->editable) { - continue; - } - if (($filter & self::FILTER_ACTIVE) && !$cal->is_active()) { - continue; - } - if (($filter & self::FILTER_PRIVATE) && $cal->subtype != 'private') { - continue; - } - if (($filter & self::FILTER_CONFIDENTIAL) && $cal->subtype != 'confidential') { - continue; - } - if ($personal || $shared) { - $ns = $cal->get_namespace(); - if (!(($personal && $ns == 'personal') || ($shared && $ns == 'shared'))) { - continue; - } - } - - $calendars[$cal->id] = $cal; + /** + * Determine whether the current change affects scheduling and reset attendee status accordingly + */ + public function check_scheduling(&$event, $old, $update = true) + { + // skip this check when importing iCal/iTip events + if (isset($event['sequence']) || !empty($event['_method'])) { + return false; + } + + // iterate through the list of properties considered 'significant' for scheduling + $kolab_event = !empty($old['_formatobj']) ? $old['_formatobj'] : new kolab_format_event(); + $reschedule = $kolab_event->check_rescheduling($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; } - return $calendars; - } - - /** - * Get the kolab_calendar instance for the given calendar ID - * - * @param string Calendar identifier (encoded imap folder name) - * - * @return object kolab_calendar Object nor null if calendar doesn't exist - */ - public function get_calendar($id) - { - $this->_read_calendars(); - - // create calendar object if necesary - if (!$this->calendars[$id]) { - if (in_array($id, array(self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED))) { - return new kolab_invitation_calendar($id, $this->cal); - } - // for unsubscribed calendar folders - if ($id !== self::BIRTHDAY_CALENDAR_ID) { - $calendar = kolab_calendar::factory($id, $this->cal); - if ($calendar->ready) { - $this->calendars[$calendar->id] = $calendar; - } - } - } + /** + * Apply the given changes to already existing exceptions + */ + protected function update_recurrence_exceptions(&$master, $event, $old, $savemode) + { + $saved = false; + $existing = null; + + // determine added and removed attendees + $added_attendees = $removed_attendees = []; + + if ($savemode == 'future') { + $old_attendees = $current_attendees = []; + + if (!empty($old['attendees'])) { + foreach ((array) $old['attendees'] as $attendee) { + $old_attendees[] = $attendee['email']; + } + } + + if (!empty($event['attendees'])) { + foreach ((array) $event['attendees'] as $attendee) { + $current_attendees[] = $attendee['email']; + if (!in_array($attendee['email'], $old_attendees)) { + $added_attendees[] = $attendee; + } + } + } + + $removed_attendees = array_diff($old_attendees, $current_attendees); + } + + foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { + // update a specific instance + if ($exception['_instance'] == $old['_instance']) { + $existing = $i; + + // check savemode against existing exception mode. + // if matches, we can update this existing exception + $thisandfuture = !empty($exception['thisandfuture']); + if ($thisandfuture === ($savemode == 'future')) { + $event['_instance'] = $old['_instance']; + $event['thisandfuture'] = $old['thisandfuture']; + $event['recurrence_date'] = $old['recurrence_date']; + $master['recurrence']['EXCEPTIONS'][$i] = $event; + $saved = true; + } + } + + // merge the new event properties onto future exceptions + if ($savemode == 'future' && $exception['_instance'] >= $old['_instance']) { + unset($event['thisandfuture']); + self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event, ['attendees']); + + if (!empty($added_attendees) || !empty($removed_attendees)) { + calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); + } + } + } +/* + // we could not update the existing exception due to savemode mismatch... + if (!$saved && isset($existing) && !empty($master['recurrence']['EXCEPTIONS'][$existing]['thisandfuture'])) { + // ... try to move the existing this-and-future exception to the next occurrence + foreach ($this->get_recurring_events($master, $existing['start']) as $candidate) { + // our old this-and-future exception is obsolete + if (!empty($candidate['thisandfuture'])) { + unset($master['recurrence']['EXCEPTIONS'][$existing]); + $saved = true; + break; + } + // this occurrence doesn't yet have an exception + else if (empty($candidate['isexception'])) { + $event['_instance'] = $candidate['_instance']; + $event['recurrence_date'] = $candidate['recurrence_date']; + $master['recurrence']['EXCEPTIONS'][$i] = $event; + $saved = true; + break; + } + } + } +*/ + + // set link to top-level exceptions + $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; - return $this->calendars[$id]; - } - - /** - * Create a new calendar assigned to the current user - * - * @param array Hash array with calendar properties - * name: Calendar name - * color: The color of the calendar - * - * @return mixed ID of the calendar on success, False on error - */ - public function create_calendar($prop) - { - $prop['type'] = 'event'; - $prop['active'] = true; - $prop['subscribed'] = true; - - $folder = kolab_storage::folder_update($prop); - - if ($folder === false) { - $this->last_error = $this->cal->gettext(kolab_storage::$last_error); - return false; + // returning false here will add a new exception + return $saved; } - // create ID - $id = kolab_storage::folder_id($folder); - - // save color in user prefs (temp. solution) - $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); - - if (isset($prop['color'])) - $prefs['kolab_calendars'][$id]['color'] = $prop['color']; - if (isset($prop['showalarms'])) - $prefs['kolab_calendars'][$id]['showalarms'] = $prop['showalarms'] ? true : false; - - if ($prefs['kolab_calendars'][$id]) - $this->rc->user->save_prefs($prefs); + /** + * Add or update the given event as an exception to $master + */ + public static function add_exception(&$master, $event, $old = null) + { + if ($old) { + $event['_instance'] = $old['_instance']; + if (empty($event['recurrence_date'])) { + $event['recurrence_date'] = !empty($old['recurrence_date']) ? $old['recurrence_date'] : $old['start']; + } + } + else if (empty($event['recurrence_date'])) { + $event['recurrence_date'] = $event['start']; + } - return $id; - } + if (empty($event['_instance']) && is_a($event['recurrence_date'], 'DateTime')) { + $event['_instance'] = libcalendaring::recurrence_instance_identifier($event, !empty($master['allday'])); + } + if (!is_array($master['exceptions']) && isset($master['recurrence']['EXCEPTIONS'])) { + $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; + } - /** - * Update properties of an existing calendar - * - * @see calendar_driver::edit_calendar() - */ - public function edit_calendar($prop) - { - if ($prop['id'] && ($cal = $this->get_calendar($prop['id']))) { - $id = $cal->update($prop); - } - else { - $id = $prop['id']; - } + $existing = false; + foreach ((array) $master['exceptions'] as $i => $exception) { + if ($exception['_instance'] == $event['_instance']) { + $master['exceptions'][$i] = $event; + $existing = true; + } + } - // fallback to local prefs - $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); - unset($prefs['kolab_calendars'][$prop['id']]['color'], $prefs['kolab_calendars'][$prop['id']]['showalarms']); - - if (isset($prop['color'])) - $prefs['kolab_calendars'][$id]['color'] = $prop['color']; - - if (isset($prop['showalarms']) && $id == self::BIRTHDAY_CALENDAR_ID) - $prefs['calendar_birthdays_alarm_type'] = $prop['showalarms'] ? $this->alarm_types[0] : ''; - else if (isset($prop['showalarms'])) - $prefs['kolab_calendars'][$id]['showalarms'] = $prop['showalarms'] ? true : false; - - if (!empty($prefs['kolab_calendars'][$id])) - $this->rc->user->save_prefs($prefs); - - return true; - } - - - /** - * Set active/subscribed state of a calendar - * - * @see calendar_driver::subscribe_calendar() - */ - public function subscribe_calendar($prop) - { - if ($prop['id'] && ($cal = $this->get_calendar($prop['id'])) && is_object($cal->storage)) { - $ret = false; - if (isset($prop['permanent'])) - $ret |= $cal->storage->subscribe(intval($prop['permanent'])); - if (isset($prop['active'])) - $ret |= $cal->storage->activate(intval($prop['active'])); - - // apply to child folders, too - if ($prop['recursive']) { - foreach ((array)kolab_storage::list_folders($cal->storage->name, '*', 'event') as $subfolder) { - if (isset($prop['permanent'])) - ($prop['permanent'] ? kolab_storage::folder_subscribe($subfolder) : kolab_storage::folder_unsubscribe($subfolder)); - if (isset($prop['active'])) - ($prop['active'] ? kolab_storage::folder_activate($subfolder) : kolab_storage::folder_deactivate($subfolder)); - } - } - return $ret; - } - else { - // save state in local prefs - $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); - $prefs['kolab_calendars'][$prop['id']]['active'] = (bool)$prop['active']; - $this->rc->user->save_prefs($prefs); - return true; - } + if (!$existing) { + $master['exceptions'][] = $event; + } - return false; - } - - - /** - * Delete the given calendar with all its contents - * - * @see calendar_driver::delete_calendar() - */ - public function delete_calendar($prop) - { - if ($prop['id'] && ($cal = $this->get_calendar($prop['id']))) { - $folder = $cal->get_realname(); - // TODO: unsubscribe if no admin rights - if (kolab_storage::folder_delete($folder)) { - // remove color in user prefs (temp. solution) - $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); - unset($prefs['kolab_calendars'][$prop['id']]); - - $this->rc->user->save_prefs($prefs); return true; - } - else - $this->last_error = kolab_storage::$last_error; - } - - return false; - } - - - /** - * Search for shared or otherwise not listed calendars the user has access - * - * @param string Search string - * @param string Section/source to search - * @return array List of calendars - */ - public function search_calendars($query, $source) - { - if (!kolab_storage::setup()) - return array(); - - $this->calendars = array(); - $this->search_more_results = false; - - // find unsubscribed IMAP folders that have "event" type - if ($source == 'folders') { - foreach ((array)kolab_storage::search_folders('event', $query, array('other')) as $folder) { - $calendar = new kolab_calendar($folder->name, $this->cal); - $this->calendars[$calendar->id] = $calendar; - } - } - // find other user's virtual calendars - else if ($source == 'users') { - $limit = $this->rc->config->get('autocomplete_max', 15) * 2; // we have slightly more space, so display twice the number - foreach (kolab_storage::search_users($query, 0, array(), $limit, $count) as $user) { - $calendar = new kolab_user_calendar($user, $this->cal); - $this->calendars[$calendar->id] = $calendar; - - // search for calendar folders shared by this user - foreach (kolab_storage::list_user_folders($user, 'event', false) as $foldername) { - $cal = new kolab_calendar($foldername, $this->cal); - $this->calendars[$cal->id] = $cal; - $calendar->subscriptions = true; - } - } - - if ($count > $limit) { - $this->search_more_results = true; - } - } - - // don't list the birthday calendar - $this->rc->config->set('calendar_contact_birthdays', false); - $this->rc->config->set('kolab_invitation_calendars', false); - - return $this->list_calendars(); - } - - - /** - * Fetch a single event - * - * @see calendar_driver::get_event() - * @return array Hash array with event properties, false if not found - */ - public function get_event($event, $scope = 0, $full = false) - { - if (is_array($event)) { - $id = $event['id'] ?: $event['uid']; - $cal = $event['calendar']; - - // we're looking for a recurring instance: expand the ID to our internal convention for recurring instances - if (!$event['id'] && $event['_instance']) { - $id .= '-' . $event['_instance']; - } - } - else { - $id = $event; } - if ($cal) { - if ($storage = $this->get_calendar($cal)) { - $result = $storage->get_event($id); - return self::to_rcube_event($result); - } - // get event from the address books birthday calendar - else if ($cal == self::BIRTHDAY_CALENDAR_ID) { - return $this->get_birthday_event($id); - } - } - // iterate over all calendar folders and search for the event ID - else { - foreach ($this->filter_calendars($scope) as $calendar) { - if ($result = $calendar->get_event($id)) { - return self::to_rcube_event($result); + /** + * Remove the noreply flags from attendees + */ + public static function clear_attandee_noreply(&$event) + { + if (!empty($event['attendees'])) { + foreach ((array) $event['attendees'] as $i => $attendee) { + unset($event['attendees'][$i]['noreply']); + } } - } } - return false; - } + /** + * Merge certain properties from the overlay event to the base event object + * + * @param array The event object to be altered + * @param array The overlay event object to be merged over $event + * @param array List of properties not allowed to be overwritten + */ + public static function merge_exception_data(&$event, $overlay, $blacklist = null) + { + $forbidden = ['id','uid','recurrence','recurrence_date','thisandfuture','organizer','_attachments']; - /** - * Add a single event to the database - * - * @see calendar_driver::new_event() - */ - public function new_event($event) - { - if (!$this->validate($event)) - return false; + if (is_array($blacklist)) { + $forbidden = array_merge($forbidden, $blacklist); + } - $event = self::from_rcube_event($event); + foreach ($overlay as $prop => $value) { + if ($prop == 'start' || $prop == 'end') { + // handled by merge_exception_dates() below + } + else if ($prop == 'thisandfuture' && $overlay['_instance'] == $event['_instance']) { + $event[$prop] = $value; + } + else if ($prop[0] != '_' && !in_array($prop, $forbidden)) { + $event[$prop] = $value; + } + } - if (!$event['calendar']) { - $this->_read_calendars(); - $event['calendar'] = reset(array_keys($this->calendars)); + self::merge_exception_dates($event, $overlay); } - if ($storage = $this->get_calendar($event['calendar'])) { - // if this is a recurrence instance, append as exception to an already existing object for this UID - if (!empty($event['recurrence_date']) && ($master = $storage->get_event($event['uid']))) { - self::add_exception($master, $event); - $success = $storage->update_event($master); - } - else { - $success = $storage->insert_event($event); - } - - if ($success && $this->freebusy_trigger) { - $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); - $this->freebusy_trigger = false; // disable after first execution (#2355) - } - - return $success; - } + /** + * Merge start/end date from the overlay event to the base event object + * + * @param array The event object to be altered + * @param array The overlay event object to be merged over $event + */ + public static function merge_exception_dates(&$event, $overlay) + { + // compute date offset from the exception + if ($overlay['start'] instanceof DateTime && $overlay['recurrence_date'] instanceof DateTime) { + $date_offset = $overlay['recurrence_date']->diff($overlay['start']); + } - return false; - } - - /** - * Update an event entry with the given data - * - * @see calendar_driver::new_event() - * @return boolean True on success, False on error - */ - public function edit_event($event) - { - if (!($storage = $this->get_calendar($event['calendar']))) - return false; - - return $this->update_event(self::from_rcube_event($event, $storage->get_event($event['id']))); - } - - /** - * Extended event editing with possible changes to the argument - * - * @param array Hash array with event properties - * @param string New participant status - * @param array List of hash arrays with updated attendees - * @return boolean True on success, False on error - */ - public function edit_rsvp(&$event, $status, $attendees) - { - $update_event = $event; - - // apply changes to master (and all exceptions) - if ($event['_savemode'] == 'all' && $event['recurrence_id']) { - if ($storage = $this->get_calendar($event['calendar'])) { - $update_event = $storage->get_event($event['recurrence_id']); - $update_event['_savemode'] = $event['_savemode']; - $update_event['id'] = $update_event['uid']; - unset($update_event['recurrence_id']); - calendar::merge_attendee_data($update_event, $attendees); - } + foreach (['start', 'end'] as $prop) { + $value = $overlay[$prop]; + if (isset($event[$prop]) && $event[$prop] instanceof DateTime) { + // set date value if overlay is an exception of the current instance + if (substr($overlay['_instance'], 0, 8) == substr($event['_instance'], 0, 8)) { + $event[$prop]->setDate(intval($value->format('Y')), intval($value->format('n')), intval($value->format('j'))); + } + // apply date offset + else if (!empty($date_offset)) { + $event[$prop]->add($date_offset); + } + // adjust time of the recurring event instance + $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s'))); + } + } } - if ($ret = $this->update_attendees($update_event, $attendees)) { - // replace with master event (for iTip reply) - $event = self::to_rcube_event($update_event); - - // re-assign to the according (virtual) calendar - if ($this->rc->config->get('kolab_invitation_calendars')) { - if (strtoupper($status) == 'DECLINED') - $event['calendar'] = self::INVITATIONS_CALENDAR_DECLINED; - else if (strtoupper($status) == 'NEEDS-ACTION') - $event['calendar'] = self::INVITATIONS_CALENDAR_PENDING; - else if ($event['_folder_id']) - $event['calendar'] = $event['_folder_id']; - } - } + /** + * 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); + } - return $ret; - } - - /** - * Update the participant status for the given attendees - * - * @see calendar_driver::update_attendees() - */ - public function update_attendees(&$event, $attendees) - { - // for this-and-future updates, merge the updated attendees onto all exceptions in range - if (($event['_savemode'] == 'future' && $event['recurrence_id']) || (!empty($event['recurrence']) && !$event['recurrence_id'])) { - if (!($storage = $this->get_calendar($event['calendar']))) - return false; + $query = []; + $events = []; + $categories = []; - // load master event - $master = $event['recurrence_id'] ? $storage->get_event($event['recurrence_id']) : $event; + if ($modifiedsince) { + $query[] = ['changed', '>=', $modifiedsince]; + } - // apply attendee update to each existing exception - if ($master['recurrence'] && !empty($master['recurrence']['EXCEPTIONS'])) { - $saved = false; - foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { - // merge the new event properties onto future exceptions - if ($exception['_instance'] >= strval($event['_instance'])) { - calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $attendees); - } - // update a specific instance - if ($exception['_instance'] == $event['_instance'] && $exception['thisandfuture']) { - $saved = true; - } + 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 the given event as new exception - if (!$saved && $event['id'] != $master['id']) { - $event['thisandfuture'] = true; - $master['recurrence']['EXCEPTIONS'][] = $event; + // 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)); } - // set link to top-level exceptions - $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; + // 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); } + ); - return $this->update_event($master); - } - } + if (!empty($newcats)) { + foreach ($newcats as $category) { + $old_categories[$category] = ''; // no color set yet + } + $this->rc->user->save_prefs(['calendar_categories' => $old_categories]); + } - // just update the given event (instance) - return $this->update_event($event); - } - - /** - * Move a single event - * - * @see calendar_driver::move_event() - * @return boolean True on success, False on error - */ - public function move_event($event) - { - if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { - unset($ev['sequence']); - self::clear_attandee_noreply($ev); - return $this->update_event($event + $ev); - } + array_walk($events, 'kolab_driver::to_rcube_event'); - return false; - } - - /** - * Resize a single event - * - * @see calendar_driver::resize_event() - * @return boolean True on success, False on error - */ - public function resize_event($event) - { - if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { - unset($ev['sequence']); - self::clear_attandee_noreply($ev); - return $this->update_event($event + $ev); + return $events; } - return false; - } - - /** - * Remove a single event - * - * @param array Hash array with event properties: - * id: Event identifier - * @param boolean Remove record(s) irreversible (mark as deleted otherwise) - * - * @return boolean True on success, False on error - */ - public function remove_event($event, $force = true) - { - $ret = true; - $success = false; - $savemode = $event['_savemode']; - $decline = $event['_decline']; - - if (!$force) { - unset($event['attendees']); - $this->rc->session->remove('calendar_event_undo'); - $this->rc->session->remove('calendar_restore_event_data'); - $sess_data = $event; + /** + * Get number of events in the given calendar + * + * @param mixed List of calendar IDs to count events (either as array or comma-separated string) + * @param int Date range start (unix timestamp) + * @param int Date range end (unix timestamp) + * + * @return array Hash array with counts grouped by calendar ID + */ + public function count_events($calendars, $start, $end = null) + { + $counts = []; + + if ($calendars && is_string($calendars)) { + $calendars = explode(',', $calendars); + } + else if (!$calendars) { + $this->_read_calendars(); + $calendars = array_keys($this->calendars); + } + + foreach ($calendars as $cid) { + if ($storage = $this->get_calendar($cid)) { + $counts[$cid] = $storage->count_events($start, $end); + } + } + + return $counts; } - if (($storage = $this->get_calendar($event['calendar'])) && ($event = $storage->get_event($event['id']))) { - $event['_savemode'] = $savemode; - $savemode = 'all'; - $master = $event; + /** + * Get a list of pending alarms to be displayed to the user + * + * @see calendar_driver::pending_alarms() + */ + public function pending_alarms($time, $calendars = null) + { + $interval = 300; + $time -= $time % 60; - // read master if deleting a recurring event - if ($event['recurrence'] || $event['recurrence_id'] || $event['isexception']) { - $master = $storage->get_event($event['uid']); - $savemode = $event['_savemode'] ?: ($event['_instance'] || $event['isexception'] ? 'current' : 'all'); + $slot = $time; + $slot -= $slot % $interval; - // force 'current' mode for single occurrences stored as exception - if (!$event['recurrence'] && !$event['recurrence_id'] && $event['isexception']) - $savemode = 'current'; - } + $last = $time - max(60, $this->rc->config->get('refresh_interval', 0)); + $last -= $last % $interval; - // removing an exception instance - if (($event['recurrence_id'] || $event['isexception']) && is_array($master['exceptions'])) { - foreach ($master['exceptions'] as $i => $exception) { - if ($exception['_instance'] == $event['_instance']) { - unset($master['exceptions'][$i]); - // set event date back to the actual occurrence - if ($exception['recurrence_date']) - $event['start'] = $exception['recurrence_date']; - } + // only check for alerts once in 5 minutes + if ($last == $slot) { + return []; } - if (is_array($master['recurrence'])) { - $master['recurrence']['EXCEPTIONS'] = &$master['exceptions']; + if ($calendars && is_string($calendars)) { + $calendars = explode(',', $calendars); } - } - switch ($savemode) { - case 'current': - $_SESSION['calendar_restore_event_data'] = $master; + $time = $slot + $interval; - // remove the matching RDATE entry - if ($master['recurrence']['RDATE']) { - foreach ($master['recurrence']['RDATE'] as $j => $rdate) { - if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { - unset($master['recurrence']['RDATE'][$j]); - break; - } - } - } + $alarms = []; + $candidates = []; + $query = [['tags', '=', 'x-has-alarms']]; - // add exception to master event - $master['recurrence']['EXDATE'][] = $event['start']; + $this->_read_calendars(); - $success = $storage->update_event($master); - break; + foreach ($this->calendars as $cid => $calendar) { + // skip calendars with alarms disabled + if (!$calendar->alarms || ($calendars && !in_array($cid, $calendars))) { + continue; + } - case 'future': - $master['_instance'] = libcalendaring::recurrence_instance_identifier($master); - if ($master['_instance'] != $event['_instance']) { - $_SESSION['calendar_restore_event_data'] = $master; + foreach ($calendar->list_events($time, $time + 86400 * 365, null, 1, $query) as $e) { + // add to list if alarm is set + $alarm = libcalendaring::get_next_alarm($e); + if ($alarm && !empty($alarm['time']) && $alarm['time'] >= $last + && in_array($alarm['action'], $this->alarm_types) + ) { + $id = $alarm['id']; // use alarm-id as primary identifier + $candidates[$id] = [ + 'id' => $id, + 'title' => $e['title'], + 'location' => $e['location'], + 'start' => $e['start'], + 'end' => $e['end'], + 'notifyat' => $alarm['time'], + 'action' => $alarm['action'], + ]; + } + } + } - // set until-date on master event - $master['recurrence']['UNTIL'] = clone $event['start']; - $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); - unset($master['recurrence']['COUNT']); + // get alarm information stored in local database + if (!empty($candidates)) { + $dbdata = []; + $alarm_ids = array_map([$this->rc->db, 'quote'], array_keys($candidates)); - // if all future instances are deleted, remove recurrence rule entirely (bug #1677) - if ($master['recurrence']['UNTIL']->format('Ymd') == $master['start']->format('Ymd')) { - $master['recurrence'] = array(); + $result = $this->rc->db->query("SELECT *" + . " FROM " . $this->rc->db->table_name('kolab_alarms', true) + . " WHERE `alarm_id` IN (" . join(',', $alarm_ids) . ")" + . " AND `user_id` = ?", + $this->rc->user->ID + ); + + while ($result && ($e = $this->rc->db->fetch_assoc($result))) { + $dbdata[$e['alarm_id']] = $e; } - // remove matching RDATE entries - else if ($master['recurrence']['RDATE']) { - foreach ($master['recurrence']['RDATE'] as $j => $rdate) { - if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { - $master['recurrence']['RDATE'] = array_slice($master['recurrence']['RDATE'], 0, $j); - break; + + foreach ($candidates as $id => $alarm) { + // skip dismissed alarms + if ($dbdata[$id]['dismissed']) { + continue; } - } - } - $success = $storage->update_event($master); - $ret = $master['uid']; - break; - } - - default: // 'all' is default - // removing the master event with loose exceptions (not recurring though) - if (!empty($event['recurrence_date']) && empty($master['recurrence']) && !empty($master['exceptions'])) { - // make the first exception the new master - $newmaster = array_shift($master['exceptions']); - $newmaster['exceptions'] = $master['exceptions']; - $newmaster['_attachments'] = $master['_attachments']; - $newmaster['_mailbox'] = $master['_mailbox']; - $newmaster['_msguid'] = $master['_msguid']; - - $success = $storage->update_event($newmaster); - } - else if ($decline && $this->rc->config->get('kolab_invitation_calendars')) { - // don't delete but set PARTSTAT=DECLINED - if ($this->cal->lib->set_partstat($master, 'DECLINED')) { - $success = $storage->update_event($master); - } - } - - if (!$success) - $success = $storage->delete_event($master, $force); - break; - } - } + // snooze function may have shifted alarm time + $notifyat = $dbdata[$id]['notifyat'] ? strtotime($dbdata[$id]['notifyat']) : $alarm['notifyat']; + if ($notifyat <= $time) { + $alarms[] = $alarm; + } + } + } - if ($success && !$force) { - if ($master['_folder_id']) - $sess_data['_folder_id'] = $master['_folder_id']; - $_SESSION['calendar_event_undo'] = array('ts' => time(), 'data' => $sess_data); + return $alarms; } - if ($success && $this->freebusy_trigger) - $this->rc->output->command('plugin.ping_url', array( - 'action' => 'calendar/push-freebusy', - // _folder_id may be set by invitations calendar - 'source' => $master['_folder_id'] ?: $storage->id, - )); - - return $success ? $ret : false; - } - - /** - * Restore a single deleted event - * - * @param array Hash array with event properties: - * id: Event identifier - * calendar: Event calendar - * - * @return boolean True on success, False on error - */ - public function restore_event($event) - { - if ($storage = $this->get_calendar($event['calendar'])) { - if (!empty($_SESSION['calendar_restore_event_data'])) - $success = $storage->update_event($event = $_SESSION['calendar_restore_event_data']); - else - $success = $storage->restore_event($event); - - if ($success && $this->freebusy_trigger) - $this->rc->output->command('plugin.ping_url', array( - 'action' => 'calendar/push-freebusy', - // _folder_id may be set by invitations calendar - 'source' => $event['_folder_id'] ?: $storage->id, - )); - - return $success; - } + /** + * Feedback after showing/sending an alarm notification + * + * @see calendar_driver::dismiss_alarm() + */ + public function dismiss_alarm($alarm_id, $snooze = 0) + { + $alarms_table = $this->rc->db->table_name('kolab_alarms', true); - return false; - } + // delete old alarm entry + $this->rc->db->query("DELETE FROM $alarms_table" + . " WHERE `alarm_id` = ? AND `user_id` = ?", + $alarm_id, + $this->rc->user->ID + ); - /** - * Wrapper to update an event object depending on the given savemode - */ - private function update_event($event) - { - if (!($storage = $this->get_calendar($event['calendar']))) - return false; + // set new notifyat time or unset if not snoozed + $notifyat = $snooze > 0 ? date('Y-m-d H:i:s', time() + $snooze) : null; - // move event to another folder/calendar - if ($event['_fromcalendar'] && $event['_fromcalendar'] != $event['calendar']) { - if (!($fromcalendar = $this->get_calendar($event['_fromcalendar']))) - return false; + $query = $this->rc->db->query("INSERT INTO $alarms_table" + . " (`alarm_id`, `user_id`, `dismissed`, `notifyat`)" + . " VALUES (?, ?, ?, ?)", + $alarm_id, + $this->rc->user->ID, + $snooze > 0 ? 0 : 1, + $notifyat + ); - $old = $fromcalendar->get_event($event['id']); + return $this->rc->db->affected_rows($query); + } - if ($event['_savemode'] != 'new') { - if (!$fromcalendar->storage->move($old['uid'], $storage->storage)) { - return false; + /** + * List attachments from the given event + */ + public function list_attachments($event) + { + if (!($storage = $this->get_calendar($event['calendar']))) { + return false; } - $fromcalendar = $storage; - } - } - else - $fromcalendar = $storage; - - $success = false; - $savemode = 'all'; - $attachments = array(); - $old = $master = $storage->get_event($event['id']); - - if (!$old || !$old['start']) { - rcube::raise_error(array( - 'code' => 600, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Failed to load event object to update: id=" . $event['id']), - true, false); - return false; - } + $event = $storage->get_event($event['id']); - // modify a recurring event, check submitted savemode to do the right things - if ($old['recurrence'] || $old['recurrence_id'] || $old['isexception']) { - $master = $storage->get_event($old['uid']); - $savemode = $event['_savemode'] ?: ($old['recurrence_id'] || $old['isexception'] ? 'current' : 'all'); - - // this-and-future on the first instance equals to 'all' - if ($savemode == 'future' && $master['start'] && $old['_instance'] == libcalendaring::recurrence_instance_identifier($master)) - $savemode = 'all'; - // force 'current' mode for single occurrences stored as exception - else if (!$old['recurrence'] && !$old['recurrence_id'] && $old['isexception']) - $savemode = 'current'; - - // Stick to the master timezone for all occurrences (Bifrost#T104637) - $master_tz = $master['start']->getTimezone(); - $event_tz = $event['start']->getTimezone(); - - if ($master_tz->getName() != $event_tz->getName()) { - $event['start']->setTimezone($master_tz); - $event['end']->setTimezone($master_tz); - } + return $event['attachments']; } - // check if update affects scheduling and update attendee status accordingly - $reschedule = $this->check_scheduling($event, $old, true); - - // keep saved exceptions (not submitted by the client) - if ($old['recurrence']['EXDATE'] && !isset($event['recurrence']['EXDATE'])) - $event['recurrence']['EXDATE'] = $old['recurrence']['EXDATE']; - if (isset($event['recurrence']['EXCEPTIONS'])) - $with_exceptions = true; // exceptions already provided (e.g. from iCal import) - else if ($old['recurrence']['EXCEPTIONS']) - $event['recurrence']['EXCEPTIONS'] = $old['recurrence']['EXCEPTIONS']; - else if ($old['exceptions']) - $event['exceptions'] = $old['exceptions']; - - // remove some internal properties which should not be saved - unset($event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_owner'], - $event['_notify'], $event['_method'], $event['_sender'], $event['_sender_utf'], $event['_size']); - - switch ($savemode) { - case 'new': - // save submitted data as new (non-recurring) event - $event['recurrence'] = array(); - $event['_copyfrom'] = $master['_msguid']; - $event['_mailbox'] = $master['_mailbox']; - $event['uid'] = $this->cal->generate_uid(); - unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); - - // copy attachment metadata to new event - $event = self::from_rcube_event($event, $master); - - self::clear_attandee_noreply($event); - if ($success = $storage->insert_event($event)) - $success = $event['uid']; - break; - - case 'future': - // create a new recurring event - $event['_copyfrom'] = $master['_msguid']; - $event['_mailbox'] = $master['_mailbox']; - $event['uid'] = $this->cal->generate_uid(); - unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); - - // copy attachment metadata to new event - $event = self::from_rcube_event($event, $master); - - // remove recurrence exceptions on re-scheduling - if ($reschedule) { - unset($event['recurrence']['EXCEPTIONS'], $event['exceptions'], $master['recurrence']['EXDATE']); - } - else if (is_array($event['recurrence']['EXCEPTIONS'])) { - // only keep relevant exceptions - $event['recurrence']['EXCEPTIONS'] = array_filter($event['recurrence']['EXCEPTIONS'], function($exception) use ($event) { - return $exception['start'] > $event['start']; - }); - if (is_array($event['recurrence']['EXDATE'])) { - $event['recurrence']['EXDATE'] = array_filter($event['recurrence']['EXDATE'], function($exdate) use ($event) { - return $exdate > $event['start']; - }); - } - // set link to top-level exceptions - $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; + /** + * Get attachment properties + */ + public function get_attachment($id, $event) + { + if (!($storage = $this->get_calendar($event['calendar']))) { + return false; } - // compute remaining occurrences - if ($event['recurrence']['COUNT']) { - if (!$old['_count']) - $old['_count'] = $this->get_recurrence_count($master, $old['start']); - $event['recurrence']['COUNT'] -= intval($old['_count']); + // get old revision of event + if (!empty($event['rev'])) { + $event = $this->get_event_revison($event, $event['rev'], true); } - - // remove fixed weekday when date changed - if ($old['start']->format('Y-m-d') != $event['start']->format('Y-m-d')) { - if (strlen($event['recurrence']['BYDAY']) == 2) - unset($event['recurrence']['BYDAY']); - if ($old['recurrence']['BYMONTH'] == $old['start']->format('n')) - unset($event['recurrence']['BYMONTH']); + else { + $event = $storage->get_event($event['id']); } - // set until-date on master event - $master['recurrence']['UNTIL'] = clone $old['start']; - $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); - unset($master['recurrence']['COUNT']); - - // remove all exceptions after $event['start'] - if (is_array($master['recurrence']['EXCEPTIONS'])) { - $master['recurrence']['EXCEPTIONS'] = array_filter($master['recurrence']['EXCEPTIONS'], function($exception) use ($event) { - return $exception['start'] < $event['start']; - }); - // set link to top-level exceptions - $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; - } - if (is_array($master['recurrence']['EXDATE'])) { - $master['recurrence']['EXDATE'] = array_filter($master['recurrence']['EXDATE'], function($exdate) use ($event) { - return $exdate < $event['start']; - }); + if ($event) { + $attachments = isset($event['_attachments']) ? $event['_attachments'] : $event['attachments']; + foreach ((array) $attachments as $att) { + if ($att['id'] == $id) { + return $att; + } + } } + } - // save new event - if ($success = $storage->insert_event($event)) { - $success = $event['uid']; - - // update master event (no rescheduling!) - self::clear_attandee_noreply($master); - $storage->update_event($master); + /** + * Get attachment body + * @see calendar_driver::get_attachment_body() + */ + public function get_attachment_body($id, $event) + { + if (!($cal = $this->get_calendar($event['calendar']))) { + return false; } - break; - case 'current': - // recurring instances shall not store recurrence rules and attachments - $event['recurrence'] = array(); - $event['thisandfuture'] = $savemode == 'future'; - unset($event['attachments'], $event['id']); + // get old revision of event + if (!empty($event['rev'])) { + if (empty($this->bonnie_api)) { + return false; + } + + $cid = substr($id, 4); + + // call Bonnie API and get the raw mime message + list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); + if ($msg_raw = $this->bonnie_api->rawdata('event', $uid, $event['rev'], $mailbox, $msguid)) { + // parse the message and find the part with the matching content-id + $message = rcube_mime::parse_message($msg_raw); + foreach ((array) $message->parts as $part) { + if (!empty($part->headers['content-id']) && trim($part->headers['content-id'], '<>') == $cid) { + return $part->body; + } + } + } - // increment sequence of this instance if scheduling is affected - if ($reschedule) { - $event['sequence'] = max($old['sequence'], $master['sequence']) + 1; - } - else if (!isset($event['sequence'])) { - $event['sequence'] = $old['sequence'] ?: $master['sequence']; + return false; } - // save properties to a recurrence exception instance - if ($old['_instance'] && is_array($master['recurrence']['EXCEPTIONS'])) { - if ($this->update_recurrence_exceptions($master, $event, $old, $savemode)) { - $success = $storage->update_event($master, $old['id']); - break; - } - } - - $add_exception = true; - - // adjust matching RDATE entry if dates changed - if (is_array($master['recurrence']['RDATE']) && ($old_date = $old['start']->format('Ymd')) != $event['start']->format('Ymd')) { - foreach ($master['recurrence']['RDATE'] as $j => $rdate) { - if ($rdate->format('Ymd') == $old_date) { - $master['recurrence']['RDATE'][$j] = $event['start']; - sort($master['recurrence']['RDATE']); - $add_exception = false; - break; - } - } + return $cal->get_attachment_body($id, $event); + } + + /** + * Build a struct representing the given message reference + * + * @see calendar_driver::get_message_reference() + */ + public function get_message_reference($uri_or_headers, $folder = null) + { + if (is_object($uri_or_headers)) { + $uri_or_headers = kolab_storage_config::get_message_uri($uri_or_headers, $folder); } - // save as new exception to master event - if ($add_exception) { - self::add_exception($master, $event, $old); + if (is_string($uri_or_headers)) { + return kolab_storage_config::get_message_reference($uri_or_headers, 'event'); } - $success = $storage->update_event($master); - break; + return false; + } - default: // 'all' is default - $event['id'] = $master['uid']; - $event['uid'] = $master['uid']; + /** + * List availabale categories + * The default implementation reads them from config/user prefs + */ + public function list_categories() + { + // FIXME: complete list with categories saved in config objects (KEP:12) + return $this->rc->config->get('calendar_categories', $this->default_categories); + } + + /** + * Create instances of a recurring event + * + * @param array Hash array with event properties + * @param 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) + { + // load the given event data into a libkolabxml container + if (empty($event['_formatobj'])) { + $event_xml = new kolab_format_event(); + $event_xml->set($event); + $event['_formatobj'] = $event_xml; + } - // use start date from master but try to be smart on time or duration changes - $old_start_date = $old['start']->format('Y-m-d'); - $old_start_time = $old['allday'] ? '' : $old['start']->format('H:i'); - $old_duration = self::event_duration($old['start'], $old['end'], $old['allday']); + $this->_read_calendars(); + $storage = reset($this->calendars); - $new_start_date = $event['start']->format('Y-m-d'); - $new_start_time = $event['allday'] ? '' : $event['start']->format('H:i'); - $new_duration = self::event_duration($event['start'], $event['end'], $event['allday']); + return $storage->get_recurring_events($event, $start, $end); + } - $diff = $old_start_date != $new_start_date || $old_start_time != $new_start_time || $old_duration != $new_duration; - $date_shift = $old['start']->diff($event['start']); + /** + * + */ + private function get_recurrence_count($event, $dtstart) + { + // load the given event data into a libkolabxml container + if (empty($event['_formatobj'])) { + $event_xml = new kolab_format_event(); + $event_xml->set($event); + $event['_formatobj'] = $event_xml; + } - // shifted or resized - if ($diff && ($old_start_date == $new_start_date || $old_duration == $new_duration)) { - $event['start'] = $master['start']->add($date_shift); - $event['end'] = clone $event['start']; - $event['end']->add(new DateInterval($new_duration)); + // use libkolab to compute recurring events + $recurrence = new kolab_date_recurrence($event['_formatobj']); - // remove fixed weekday, will be re-set to the new weekday in kolab_calendar::update_event() - if ($old_start_date != $new_start_date && $event['recurrence']) { - if (strlen($event['recurrence']['BYDAY']) == 2) - unset($event['recurrence']['BYDAY']); - if ($old['recurrence']['BYMONTH'] == $old['start']->format('n')) - unset($event['recurrence']['BYMONTH']); - } - } - // dates did not change, use the ones from master - else if ($new_start_date . $new_start_time == $old_start_date . $old_start_time) { - $event['start'] = $master['start']; - $event['end'] = $master['end']; + $count = 0; + while (($next_event = $recurrence->next_instance()) && $next_event['start'] <= $dtstart && $count < 1000) { + $count++; } - // when saving an instance in 'all' mode, copy recurrence exceptions over - if ($old['recurrence_id']) { - $event['recurrence']['EXCEPTIONS'] = $master['recurrence']['EXCEPTIONS']; - $event['recurrence']['EXDATE'] = $master['recurrence']['EXDATE']; - } - else if ($master['_instance']) { - $event['_instance'] = $master['_instance']; - $event['recurrence_date'] = $master['recurrence_date']; + return $count; + } + + /** + * Fetch free/busy information from a person within the given range + */ + public function get_freebusy_list($email, $start, $end) + { + if (empty($email)/* || $end < time()*/) { + return false; } - // TODO: forward changes to exceptions (which do not yet have differing values stored) - if (is_array($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) { - // determine added and removed attendees - $old_attendees = $current_attendees = $added_attendees = array(); - foreach ((array)$old['attendees'] as $attendee) { - $old_attendees[] = $attendee['email']; - } - foreach ((array)$event['attendees'] as $attendee) { - $current_attendees[] = $attendee['email']; - if (!in_array($attendee['email'], $old_attendees)) { - $added_attendees[] = $attendee; + // map vcalendar fbtypes to internal values + $fbtypemap = [ + 'FREE' => calendar::FREEBUSY_FREE, + 'BUSY-TENTATIVE' => calendar::FREEBUSY_TENTATIVE, + 'X-OUT-OF-OFFICE' => calendar::FREEBUSY_OOF, + 'OOF' => calendar::FREEBUSY_OOF + ]; + + // ask kolab server first + try { + $request_config = [ + 'store_body' => true, + 'follow_redirects' => true, + ]; + + $request = libkolab::http_request(kolab_storage::get_freebusy_url($email), 'GET', $request_config); + $response = $request->send(); + + // authentication required + if ($response->getStatus() == 401) { + $request->setAuth($this->rc->user->get_username(), $this->rc->decrypt($_SESSION['password'])); + $response = $request->send(); } - } - $removed_attendees = array_diff($old_attendees, $current_attendees); - foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { - calendar::merge_attendee_data($event['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); - } - - // adjust recurrence-id when start changed and therefore the entire recurrence chain changes - if ($old_start_date != $new_start_date || $old_start_time != $new_start_time) { - $recurrence_id_format = libcalendaring::recurrence_id_format($event); - foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { - $recurrence_id = is_a($exception['recurrence_date'], 'DateTime') ? $exception['recurrence_date'] : - rcube_utils::anytodatetime($exception['_instance'], $old['start']->getTimezone()); - if (is_a($recurrence_id, 'DateTime')) { - $recurrence_id->add($date_shift); - $event['recurrence']['EXCEPTIONS'][$i]['recurrence_date'] = $recurrence_id; - $event['recurrence']['EXCEPTIONS'][$i]['_instance'] = $recurrence_id->format($recurrence_id_format); - } + if ($response->getStatus() == 200) { + $fbdata = $response->getBody(); } - } - - // set link to top-level exceptions - $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; - } - - // unset _dateonly flags in (cached) date objects - unset($event['start']->_dateonly, $event['end']->_dateonly); - - $success = $storage->update_event($event) ? $event['id'] : false; // return master UID - break; - } - if ($success && $this->freebusy_trigger) - $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); + unset($request, $response); + } + catch (Exception $e) { + PEAR::raiseError("Error fetching free/busy information: " . $e->getMessage()); + } - return $success; - } + // get free-busy url from contacts + if (empty($fbdata)) { + $fburl = null; + foreach ((array) $this->rc->config->get('autocomplete_addressbooks', 'sql') as $book) { + $abook = $this->rc->get_address_book($book); + + if ($result = $abook->search(['email'], $email, true, true, true/*, 'freebusyurl'*/)) { + while ($contact = $result->iterate()) { + if (!empty($contact['freebusyurl'])) { + $fbdata = @file_get_contents($contact['freebusyurl']); + break; + } + } + } - /** - * Calculate event duration, returns string in DateInterval format - */ - protected static function event_duration($start, $end, $allday = false) - { - if ($allday) { - $diff = $start->diff($end); - return 'P' . $diff->days . 'D'; - } + if (!empty($fbdata)) { + break; + } + } + } - return 'PT' . ($end->format('U') - $start->format('U')) . 'S'; - } - - /** - * Determine whether the current change affects scheduling and reset attendee status accordingly - */ - public function check_scheduling(&$event, $old, $update = true) - { - // skip this check when importing iCal/iTip events - if (isset($event['sequence']) || !empty($event['_method'])) { - return false; - } + // parse free-busy information using Horde classes + if (!empty($fbdata)) { + $ical = $this->cal->get_ical(); + $ical->import($fbdata); + if ($fb = $ical->freebusy) { + $result = []; + foreach ($fb['periods'] as $tuple) { + list($from, $to, $type) = $tuple; + $result[] = [ + $from->format('U'), + $to->format('U'), + isset($fbtypemap[$type]) ? $fbtypemap[$type] : calendar::FREEBUSY_BUSY + ]; + } - // iterate through the list of properties considered 'significant' for scheduling - $kolab_event = $old['_formatobj'] ?: new kolab_format_event(); - $reschedule = $kolab_event->check_rescheduling($event, $old); - - // reset all attendee status to needs-action (#4360) - if ($update && $reschedule && is_array($event['attendees'])) { - $is_organizer = false; - $emails = $this->cal->get_user_emails(); - $attendees = $event['attendees']; - foreach ($attendees as $i => $attendee) { - if ($attendee['role'] == 'ORGANIZER' && $attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { - $is_organizer = true; - } - else if ($attendee['role'] != 'ORGANIZER' && $attendee['role'] != 'NON-PARTICIPANT' && $attendee['status'] != 'DELEGATED') { - $attendees[$i]['status'] = 'NEEDS-ACTION'; - $attendees[$i]['rsvp'] = true; - } - } - - // update attendees only if I'm the organizer - if ($is_organizer || ($event['organizer'] && in_array(strtolower($event['organizer']['email']), $emails))) { - $event['attendees'] = $attendees; - } - } + // we take 'dummy' free-busy lists as "unknown" + if (empty($result) && !empty($fb['comment']) && stripos($fb['comment'], 'dummy')) { + return false; + } - return $reschedule; - } - - /** - * Apply the given changes to already existing exceptions - */ - protected function update_recurrence_exceptions(&$master, $event, $old, $savemode) - { - $saved = false; - $existing = null; - - // determine added and removed attendees - $added_attendees = $removed_attendees = array(); - if ($savemode == 'future') { - $old_attendees = $current_attendees = array(); - foreach ((array)$old['attendees'] as $attendee) { - $old_attendees[] = $attendee['email']; - } - foreach ((array)$event['attendees'] as $attendee) { - $current_attendees[] = $attendee['email']; - if (!in_array($attendee['email'], $old_attendees)) { - $added_attendees[] = $attendee; - } - } - $removed_attendees = array_diff($old_attendees, $current_attendees); - } + // set period from $start till the begin of the free-busy information as 'unknown' + if (!empty($fb['start']) && ($fbstart = $fb['start']->format('U')) && $start < $fbstart) { + array_unshift($result, [$start, $fbstart, calendar::FREEBUSY_UNKNOWN]); + } + // pad period till $end with status 'unknown' + if (!empty($fb['end']) && ($fbend = $fb['end']->format('U')) && $fbend < $end) { + $result[] = [$fbend, $end, calendar::FREEBUSY_UNKNOWN]; + } - foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { - // update a specific instance - if ($exception['_instance'] == $old['_instance']) { - $existing = $i; - - // check savemode against existing exception mode. - // if matches, we can update this existing exception - if ((bool)$exception['thisandfuture'] === ($savemode == 'future')) { - $event['_instance'] = $old['_instance']; - $event['thisandfuture'] = $old['thisandfuture']; - $event['recurrence_date'] = $old['recurrence_date']; - $master['recurrence']['EXCEPTIONS'][$i] = $event; - $saved = true; - } - } - // merge the new event properties onto future exceptions - if ($savemode == 'future' && $exception['_instance'] >= $old['_instance']) { - unset($event['thisandfuture']); - self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event, array('attendees')); - - if (!empty($added_attendees) || !empty($removed_attendees)) { - calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); - } - } - } -/* - // we could not update the existing exception due to savemode mismatch... - if (!$saved && $existing !== null && $master['recurrence']['EXCEPTIONS'][$existing]['thisandfuture']) { - // ... try to move the existing this-and-future exception to the next occurrence - foreach ($this->get_recurring_events($master, $existing['start']) as $candidate) { - // our old this-and-future exception is obsolete - if ($candidate['thisandfuture']) { - unset($master['recurrence']['EXCEPTIONS'][$existing]); - $saved = true; - break; - } - // this occurrence doesn't yet have an exception - else if (!$candidate['isexception']) { - $event['_instance'] = $candidate['_instance']; - $event['recurrence_date'] = $candidate['recurrence_date']; - $master['recurrence']['EXCEPTIONS'][$i] = $event; - $saved = true; - break; - } - } - } -*/ + return $result; + } + } - // set link to top-level exceptions - $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; - - // returning false here will add a new exception - return $saved; - } - - /** - * Add or update the given event as an exception to $master - */ - public static function add_exception(&$master, $event, $old = null) - { - if ($old) { - $event['_instance'] = $old['_instance']; - if (!$event['recurrence_date']) - $event['recurrence_date'] = $old['recurrence_date'] ?: $old['start']; - } - else if (!$event['recurrence_date']) { - $event['recurrence_date'] = $event['start']; + return false; } - if (!$event['_instance'] && is_a($event['recurrence_date'], 'DateTime')) { - $event['_instance'] = libcalendaring::recurrence_instance_identifier($event, $master['allday']); - } + /** + * Handler to push folder triggers when sent from client. + * Used to push free-busy changes asynchronously after updating an event + */ + public function push_freebusy() + { + // make shure triggering completes + set_time_limit(0); + ignore_user_abort(true); - if (!is_array($master['exceptions']) && is_array($master['recurrence']['EXCEPTIONS'])) { - $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; - } + $cal = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); + if (!($cal = $this->get_calendar($cal))) { + return false; + } - $existing = false; - foreach ((array)$master['exceptions'] as $i => $exception) { - if ($exception['_instance'] == $event['_instance']) { - $master['exceptions'][$i] = $event; - $existing = true; - } - } + // trigger updates on folder + $trigger = $cal->storage->trigger(); + if (is_object($trigger) && is_a($trigger, 'PEAR_Error')) { + rcube::raise_error([ + 'code' => 900, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Failed triggering folder. Error was " . $trigger->getMessage() + ], + true, false + ); + } - if (!$existing) { - $master['exceptions'][] = $event; + exit; } - return true; - } + /** + * Convert from driver format to external caledar app data + */ + public static function to_rcube_event(&$record) + { + if (!is_array($record)) { + return $record; + } - /** - * Remove the noreply flags from attendees - */ - public static function clear_attandee_noreply(&$event) - { - foreach ((array)$event['attendees'] as $i => $attendee) { - unset($event['attendees'][$i]['noreply']); - } - } - - /** - * Merge certain properties from the overlay event to the base event object - * - * @param array The event object to be altered - * @param array The overlay event object to be merged over $event - * @param array List of properties not allowed to be overwritten - */ - public static function merge_exception_data(&$event, $overlay, $blacklist = null) - { - $forbidden = array('id','uid','recurrence','recurrence_date','thisandfuture','organizer','_attachments'); - - if (is_array($blacklist)) - $forbidden = array_merge($forbidden, $blacklist); - - foreach ($overlay as $prop => $value) { - if ($prop == 'start' || $prop == 'end') { - // handled by merge_exception_dates() below - } - else if ($prop == 'thisandfuture' && $overlay['_instance'] == $event['_instance']) { - $event[$prop] = $value; - } - else if ($prop[0] != '_' && !in_array($prop, $forbidden)) - $event[$prop] = $value; - } + $record['id'] = $record['uid']; - self::merge_exception_dates($event, $overlay); - } - - /** - * Merge start/end date from the overlay event to the base event object - * - * @param array The event object to be altered - * @param array The overlay event object to be merged over $event - */ - public static function merge_exception_dates(&$event, $overlay) - { - // compute date offset from the exception - if ($overlay['start'] instanceof DateTime && $overlay['recurrence_date'] instanceof DateTime) { - $date_offset = $overlay['recurrence_date']->diff($overlay['start']); - } + if (!empty($record['_instance'])) { + $record['id'] .= '-' . $record['_instance']; - foreach (array('start', 'end') as $prop) { - $value = $overlay[$prop]; - if (is_object($event[$prop]) && $event[$prop] instanceof DateTime) { - // set date value if overlay is an exception of the current instance - if (substr($overlay['_instance'], 0, 8) == substr($event['_instance'], 0, 8)) { - $event[$prop]->setDate(intval($value->format('Y')), intval($value->format('n')), intval($value->format('j'))); - } - // apply date offset - else if ($date_offset) { - $event[$prop]->add($date_offset); + if (empty($record['recurrence_id']) && !empty($record['recurrence'])) { + $record['recurrence_id'] = $record['uid']; + } } - // adjust time of the recurring event instance - $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s'))); - } - } - } - - /** - * Get events from source. - * - * @param integer Event's new start (unix timestamp) - * @param integer Event's new end (unix timestamp) - * @param string Search query (optional) - * @param mixed List of calendar IDs to load events from (either as array or comma-separated string) - * @param boolean Include virtual events (optional) - * @param integer Only list events modified since this time (unix timestamp) - * @return array A list of event records - */ - public function load_events($start, $end, $search = null, $calendars = null, $virtual = 1, $modifiedsince = null) - { - if ($calendars && is_string($calendars)) - $calendars = explode(',', $calendars); - else if (!$calendars) { - $this->_read_calendars(); - $calendars = array_keys($this->calendars); - } - $query = array(); - if ($modifiedsince) - $query[] = array('changed', '>=', $modifiedsince); - - $events = $categories = array(); - foreach ($calendars as $cid) { - if ($storage = $this->get_calendar($cid)) { - $events = array_merge($events, $storage->list_events($start, $end, $search, $virtual, $query)); - $categories += $storage->categories; - } - } + // all-day events go from 12:00 - 13:00 + if (is_a($record['start'], 'DateTime') && $record['end'] <= $record['start'] && !empty($record['allday'])) { + $record['end'] = clone $record['start']; + $record['end']->add(new DateInterval('PT1H')); + } - // 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)); - } + // translate internal '_attachments' to external 'attachments' list + if (!empty($record['_attachments'])) { + foreach ($record['_attachments'] as $key => $attachment) { + if ($attachment !== false) { + if (empty($attachment['name'])) { + $attachment['name'] = $key; + } - // add new categories to user prefs - $old_categories = $this->rc->config->get('calendar_categories', $this->default_categories); - if ($newcats = array_udiff(array_keys($categories), array_keys($old_categories), function($a, $b){ return strcasecmp($a, $b); })) { - foreach ($newcats as $category) - $old_categories[$category] = ''; // no color set yet - $this->rc->user->save_prefs(array('calendar_categories' => $old_categories)); - } + unset($attachment['path'], $attachment['content']); + $attachments[] = $attachment; + } + } - array_walk($events, 'kolab_driver::to_rcube_event'); - return $events; - } - - /** - * Get number of events in the given calendar - * - * @param mixed List of calendar IDs to count events (either as array or comma-separated string) - * @param integer Date range start (unix timestamp) - * @param integer Date range end (unix timestamp) - * @return array Hash array with counts grouped by calendar ID - */ - public function count_events($calendars, $start, $end = null) - { - $counts = array(); - - if ($calendars && is_string($calendars)) - $calendars = explode(',', $calendars); - else if (!$calendars) { - $this->_read_calendars(); - $calendars = array_keys($this->calendars); - } - - foreach ($calendars as $cid) { - if ($storage = $this->get_calendar($cid)) { - $counts[$cid] = $storage->count_events($start, $end); - } - } - - return $counts; - } - - /** - * Get a list of pending alarms to be displayed to the user - * - * @see calendar_driver::pending_alarms() - */ - public function pending_alarms($time, $calendars = null) - { - $interval = 300; - $time -= $time % 60; - - $slot = $time; - $slot -= $slot % $interval; - - $last = $time - max(60, $this->rc->config->get('refresh_interval', 0)); - $last -= $last % $interval; - - // only check for alerts once in 5 minutes - if ($last == $slot) - return array(); - - if ($calendars && is_string($calendars)) - $calendars = explode(',', $calendars); - - $time = $slot + $interval; - - $candidates = array(); - $query = array(array('tags', '=', 'x-has-alarms')); - - $this->_read_calendars(); - - foreach ($this->calendars as $cid => $calendar) { - // skip calendars with alarms disabled - if (!$calendar->alarms || ($calendars && !in_array($cid, $calendars))) - continue; - - foreach ($calendar->list_events($time, $time + 86400 * 365, null, 1, $query) as $e) { - // add to list if alarm is set - $alarm = libcalendaring::get_next_alarm($e); - if ($alarm && $alarm['time'] && $alarm['time'] >= $last && in_array($alarm['action'], $this->alarm_types)) { - $id = $alarm['id']; // use alarm-id as primary identifier - $candidates[$id] = array( - 'id' => $id, - 'title' => $e['title'], - 'location' => $e['location'], - 'start' => $e['start'], - 'end' => $e['end'], - 'notifyat' => $alarm['time'], - 'action' => $alarm['action'], - ); - } - } - } + $record['attachments'] = $attachments; + } - // get alarm information stored in local database - if (!empty($candidates)) { - $alarm_ids = array_map(array($this->rc->db, 'quote'), array_keys($candidates)); - $result = $this->rc->db->query("SELECT *" - . " FROM " . $this->rc->db->table_name('kolab_alarms', true) - . " WHERE `alarm_id` IN (" . join(',', $alarm_ids) . ")" - . " AND `user_id` = ?", - $this->rc->user->ID - ); - - while ($result && ($e = $this->rc->db->fetch_assoc($result))) { - $dbdata[$e['alarm_id']] = $e; - } - } - - $alarms = array(); - foreach ($candidates as $id => $alarm) { - // skip dismissed alarms - if ($dbdata[$id]['dismissed']) - continue; - - // snooze function may have shifted alarm time - $notifyat = $dbdata[$id]['notifyat'] ? strtotime($dbdata[$id]['notifyat']) : $alarm['notifyat']; - if ($notifyat <= $time) - $alarms[] = $alarm; - } - - return $alarms; - } - - /** - * Feedback after showing/sending an alarm notification - * - * @see calendar_driver::dismiss_alarm() - */ - public function dismiss_alarm($alarm_id, $snooze = 0) - { - $alarms_table = $this->rc->db->table_name('kolab_alarms', true); - // delete old alarm entry - $this->rc->db->query("DELETE FROM $alarms_table" - . " WHERE `alarm_id` = ? AND `user_id` = ?", - $alarm_id, - $this->rc->user->ID - ); - - // set new notifyat time or unset if not snoozed - $notifyat = $snooze > 0 ? date('Y-m-d H:i:s', time() + $snooze) : null; - - $query = $this->rc->db->query("INSERT INTO $alarms_table" - . " (`alarm_id`, `user_id`, `dismissed`, `notifyat`)" - . " VALUES (?, ?, ?, ?)", - $alarm_id, - $this->rc->user->ID, - $snooze > 0 ? 0 : 1, - $notifyat - ); - - return $this->rc->db->affected_rows($query); - } - - /** - * List attachments from the given event - */ - public function list_attachments($event) - { - if (!($storage = $this->get_calendar($event['calendar']))) - return false; - - $event = $storage->get_event($event['id']); - - return $event['attachments']; - } - - /** - * Get attachment properties - */ - public function get_attachment($id, $event) - { - if (!($storage = $this->get_calendar($event['calendar']))) - return false; - - // get old revision of event - if ($event['rev']) { - $event = $this->get_event_revison($event, $event['rev'], true); - } - else { - $event = $storage->get_event($event['id']); - } + if (!empty($record['attendees'])) { + foreach ((array) $record['attendees'] as $i => $attendee) { + if (isset($attendee['delegated-from']) && is_array($attendee['delegated-from'])) { + $record['attendees'][$i]['delegated-from'] = join(', ', $attendee['delegated-from']); + } + if (isset($attendee['delegated-to']) && is_array($attendee['delegated-to'])) { + $record['attendees'][$i]['delegated-to'] = join(', ', $attendee['delegated-to']); + } + } + } - if ($event) { - $attachments = isset($event['_attachments']) ? $event['_attachments'] : $event['attachments']; - foreach ((array) $attachments as $att) { - if ($att['id'] == $id) { - return $att; + // Roundcube only supports one category assignment + if (!empty($record['categories']) && is_array($record['categories'])) { + $record['categories'] = $record['categories'][0]; } - } - } - } - - /** - * Get attachment body - * @see calendar_driver::get_attachment_body() - */ - public function get_attachment_body($id, $event) - { - if (!($cal = $this->get_calendar($event['calendar']))) - return false; - - // get old revision of event - if ($event['rev']) { - if (empty($this->bonnie_api)) { - return false; - } - $cid = substr($id, 4); + // the cancelled flag transltes into status=CANCELLED + if (!empty($record['cancelled'])) { + $record['status'] = 'CANCELLED'; + } - // call Bonnie API and get the raw mime message - list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); - if ($msg_raw = $this->bonnie_api->rawdata('event', $uid, $event['rev'], $mailbox, $msguid)) { - // parse the message and find the part with the matching content-id - $message = rcube_mime::parse_message($msg_raw); - foreach ((array)$message->parts as $part) { - if ($part->headers['content-id'] && trim($part->headers['content-id'], '<>') == $cid) { - return $part->body; - } + // The web client only supports DISPLAY type of alarms + if (!empty($record['alarms'])) { + $record['alarms'] = preg_replace('/:[A-Z]+$/', ':DISPLAY', $record['alarms']); } - } - return false; - } + // remove empty recurrence array + if (empty($record['recurrence'])) { + unset($record['recurrence']); + } + // clean up exception data + else if (!empty($record['recurrence']['EXCEPTIONS'])) { + array_walk($record['recurrence']['EXCEPTIONS'], function(&$exception) { + unset($exception['_mailbox'], $exception['_msguid'], + $exception['_formatobj'], $exception['_attachments'] + ); + }); + } - return $cal->get_attachment_body($id, $event); - } - - /** - * Build a struct representing the given message reference - * - * @see calendar_driver::get_message_reference() - */ - public function get_message_reference($uri_or_headers, $folder = null) - { - if (is_object($uri_or_headers)) { - $uri_or_headers = kolab_storage_config::get_message_uri($uri_or_headers, $folder); - } - - if (is_string($uri_or_headers)) { - return kolab_storage_config::get_message_reference($uri_or_headers, 'event'); - } - - return false; - } - - /** - * List availabale categories - * The default implementation reads them from config/user prefs - */ - public function list_categories() - { - // FIXME: complete list with categories saved in config objects (KEP:12) - return $this->rc->config->get('calendar_categories', $this->default_categories); - } - - /** - * Create instances of a recurring event - * - * @param array Hash array with event properties - * @param object DateTime Start date of the recurrence window - * @param object DateTime End date of the recurrence window - * @return array List of recurring event instances - */ - public function get_recurring_events($event, $start, $end = null) - { - // load the given event data into a libkolabxml container - if (!$event['_formatobj']) { - $event_xml = new kolab_format_event(); - $event_xml->set($event); - $event['_formatobj'] = $event_xml; - } + unset($record['_mailbox'], $record['_msguid'], $record['_type'], $record['_size'], + $record['_formatobj'], $record['_attachments'], $record['exceptions'], $record['x-custom'] + ); - $this->_read_calendars(); - $storage = reset($this->calendars); - return $storage->get_recurring_events($event, $start, $end); - } - - /** - * - */ - private function get_recurrence_count($event, $dtstart) - { - // load the given event data into a libkolabxml container - if (!$event['_formatobj']) { - $event_xml = new kolab_format_event(); - $event_xml->set($event); - $event['_formatobj'] = $event_xml; + return $record; } - // use libkolab to compute recurring events - $recurrence = new kolab_date_recurrence($event['_formatobj']); + /** + * + */ + public static function from_rcube_event($event, $old = []) + { + kolab_format::merge_attachments($event, $old); - $count = 0; - while (($next_event = $recurrence->next_instance()) && $next_event['start'] <= $dtstart && $count < 1000) { - $count++; + return $event; } - return $count; - } - - /** - * Fetch free/busy information from a person within the given range - */ - public function get_freebusy_list($email, $start, $end) - { - if (empty($email)/* || $end < time()*/) - return false; - - // map vcalendar fbtypes to internal values - $fbtypemap = array( - 'FREE' => calendar::FREEBUSY_FREE, - 'BUSY-TENTATIVE' => calendar::FREEBUSY_TENTATIVE, - 'X-OUT-OF-OFFICE' => calendar::FREEBUSY_OOF, - 'OOF' => calendar::FREEBUSY_OOF); - - // ask kolab server first - try { - $request_config = array( - 'store_body' => true, - 'follow_redirects' => true, - ); - $request = libkolab::http_request(kolab_storage::get_freebusy_url($email), 'GET', $request_config); - $response = $request->send(); - - // authentication required - if ($response->getStatus() == 401) { - $request->setAuth($this->rc->user->get_username(), $this->rc->decrypt($_SESSION['password'])); - $response = $request->send(); - } - - if ($response->getStatus() == 200) - $fbdata = $response->getBody(); - - unset($request, $response); - } - catch (Exception $e) { - PEAR::raiseError("Error fetching free/busy information: " . $e->getMessage()); - } - // get free-busy url from contacts - if (!$fbdata) { - $fburl = null; - foreach ((array)$this->rc->config->get('autocomplete_addressbooks', 'sql') as $book) { - $abook = $this->rc->get_address_book($book); + /** + * Set CSS class according to the event's attendde partstat + */ + public static function add_partstat_class($event, $partstats, $user = null) + { + // set classes according to PARTSTAT + if (!empty($event['attendees'])) { + $user_emails = libcalendaring::get_instance()->get_user_emails($user); + $partstat = 'UNKNOWN'; - if ($result = $abook->search(array('email'), $email, true, true, true/*, 'freebusyurl'*/)) { - while ($contact = $result->iterate()) { - if ($fburl = $contact['freebusyurl']) { - $fbdata = @file_get_contents($fburl); - break; + foreach ($event['attendees'] as $attendee) { + if (in_array($attendee['email'], $user_emails)) { + $partstat = $attendee['status']; + break; + } } - } - } - if ($fbdata) - break; - } - } + if (in_array($partstat, $partstats)) { + $event['className'] = trim($event['className'] . ' fc-invitation-' . strtolower($partstat)); + } + } - // parse free-busy information using Horde classes - if ($fbdata) { - $ical = $this->cal->get_ical(); - $ical->import($fbdata); - if ($fb = $ical->freebusy) { - $result = array(); - foreach ($fb['periods'] as $tuple) { - list($from, $to, $type) = $tuple; - $result[] = array($from->format('U'), $to->format('U'), isset($fbtypemap[$type]) ? $fbtypemap[$type] : calendar::FREEBUSY_BUSY); + return $event; + } + + /** + * Provide a list of revisions for the given event + * + * @param array $event Hash array with event properties + * + * @return array List of changes, each as a hash array + * @see calendar_driver::get_event_changelog() + */ + public function get_event_changelog($event) + { + if (empty($this->bonnie_api)) { + return false; } - // we take 'dummy' free-busy lists as "unknown" - if (empty($result) && !empty($fb['comment']) && stripos($fb['comment'], 'dummy')) - return false; + list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); - // set period from $start till the begin of the free-busy information as 'unknown' - if ($fb['start'] && ($fbstart = $fb['start']->format('U')) && $start < $fbstart) { - array_unshift($result, array($start, $fbstart, calendar::FREEBUSY_UNKNOWN)); + $result = $this->bonnie_api->changelog('event', $uid, $mailbox, $msguid); + if (is_array($result) && $result['uid'] == $uid) { + return $result['changes']; } - // pad period till $end with status 'unknown' - if ($fb['end'] && ($fbend = $fb['end']->format('U')) && $fbend < $end) { - $result[] = array($fbend, $end, calendar::FREEBUSY_UNKNOWN); - } - - return $result; - } - } - return false; - } - - /** - * Handler to push folder triggers when sent from client. - * Used to push free-busy changes asynchronously after updating an event - */ - public function push_freebusy() - { - // make shure triggering completes - set_time_limit(0); - ignore_user_abort(true); - - $cal = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); - if (!($cal = $this->get_calendar($cal))) - return false; - - // trigger updates on folder - $trigger = $cal->storage->trigger(); - if (is_object($trigger) && is_a($trigger, 'PEAR_Error')) { - rcube::raise_error(array( - 'code' => 900, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Failed triggering folder. Error was " . $trigger->getMessage()), - true, false); + return false; } - exit; - } - + /** + * Get a list of property changes beteen two revisions of an event + * + * @param array $event Hash array with event properties + * @param mixed $rev1 Old Revision + * @param mixed $rev2 New Revision + * + * @return array List of property changes, each as a hash array + * @see calendar_driver::get_event_diff() + */ + public function get_event_diff($event, $rev1, $rev2) + { + if (empty($this->bonnie_api)) { + return false; + } - /** - * Convert from driver format to external caledar app data - */ - public static function to_rcube_event(&$record) - { - if (!is_array($record)) - return $record; + list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); + + // get diff for the requested recurrence instance + $instance_id = $event['id'] != $uid ? substr($event['id'], strlen($uid) + 1) : null; + + // call Bonnie API + $result = $this->bonnie_api->diff('event', $uid, $rev1, $rev2, $mailbox, $msguid, $instance_id); + + if (is_array($result) && $result['uid'] == $uid) { + $result['rev1'] = $rev1; + $result['rev2'] = $rev2; + + $keymap = [ + 'dtstart' => 'start', + 'dtend' => 'end', + 'dstamp' => 'changed', + 'summary' => 'title', + 'alarm' => 'alarms', + 'attendee' => 'attendees', + 'attach' => 'attachments', + 'rrule' => 'recurrence', + 'transparency' => 'free_busy', + 'classification' => 'sensitivity', + 'lastmodified-date' => 'changed', + ]; + + $prop_keymaps = [ + 'attachments' => ['fmttype' => 'mimetype', 'label' => 'name'], + 'attendees' => ['partstat' => 'status'], + ]; + + $special_changes = []; + + // map kolab event properties to keys the client expects + array_walk($result['changes'], function(&$change, $i) use ($keymap, $prop_keymaps, $special_changes) { + if (array_key_exists($change['property'], $keymap)) { + $change['property'] = $keymap[$change['property']]; + } + // translate free_busy values + if ($change['property'] == 'free_busy') { + $change['old'] = !empty($old['old']) ? 'free' : 'busy'; + $change['new'] = !empty($old['new']) ? 'free' : 'busy'; + } - $record['id'] = $record['uid']; + // map alarms trigger value + if ($change['property'] == 'alarms') { + if (!empty($change['old']['trigger'])) { + $change['old']['trigger'] = $change['old']['trigger']['value']; + } + if (!empty($change['new']['trigger'])) { + $change['new']['trigger'] = $change['new']['trigger']['value']; + } + } - if ($record['_instance']) { - $record['id'] .= '-' . $record['_instance']; + // make all property keys uppercase + if ($change['property'] == 'recurrence') { + $special_changes['recurrence'] = $i; + foreach (['old', 'new'] as $m) { + if (!empty($change[$m])) { + $props = []; + foreach ($change[$m] as $k => $v) { + $props[strtoupper($k)] = $v; + } + $change[$m] = $props; + } + } + } - if (!$record['recurrence_id'] && !empty($record['recurrence'])) - $record['recurrence_id'] = $record['uid']; - } + // map property keys names + if (!empty($prop_keymaps[$change['property']])) { + foreach ($prop_keymaps[$change['property']] as $k => $dest) { + if (!empty($change['old']) && array_key_exists($k, $change['old'])) { + $change['old'][$dest] = $change['old'][$k]; + unset($change['old'][$k]); + } + if (!empty($change['new']) && array_key_exists($k, $change['new'])) { + $change['new'][$dest] = $change['new'][$k]; + unset($change['new'][$k]); + } + } + } - // all-day events go from 12:00 - 13:00 - if (is_a($record['start'], 'DateTime') && $record['end'] <= $record['start'] && $record['allday']) { - $record['end'] = clone $record['start']; - $record['end']->add(new DateInterval('PT1H')); - } + if ($change['property'] == 'exdate') { + $special_changes['exdate'] = $i; + } + else if ($change['property'] == 'rdate') { + $special_changes['rdate'] = $i; + } + }); - // translate internal '_attachments' to external 'attachments' list - if (!empty($record['_attachments'])) { - foreach ($record['_attachments'] as $key => $attachment) { - if ($attachment !== false) { - if (!$attachment['name']) - $attachment['name'] = $key; + // merge some recurrence changes + foreach (['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] = ['property' => 'recurrence', 'old' => [], 'new' => []]; + $recurrence = &$result['changes'][$i]['recurrence']; + } + $key = strtoupper($prop); + $recurrence['old'][$key] = $exdate['old']; + $recurrence['new'][$key] = $exdate['new']; + unset($result['changes'][$special_changes[$prop]]); + } + } - unset($attachment['path'], $attachment['content']); - $attachments[] = $attachment; + return $result; } - } - $record['attachments'] = $attachments; + return false; } - if (!empty($record['attendees'])) { - foreach ((array)$record['attendees'] as $i => $attendee) { - if (is_array($attendee['delegated-from'])) { - $record['attendees'][$i]['delegated-from'] = join(', ', $attendee['delegated-from']); - } - if (is_array($attendee['delegated-to'])) { - $record['attendees'][$i]['delegated-to'] = join(', ', $attendee['delegated-to']); + /** + * Return full data of a specific revision of an event + * + * @param array Hash array with event properties + * @param mixed $rev Revision number + * + * @return array Event object as hash array + * @see calendar_driver::get_event_revison() + */ + public function get_event_revison($event, $rev, $internal = false) + { + if (empty($this->bonnie_api)) { + return false; } - } - } - // Roundcube only supports one category assignment - if (is_array($record['categories'])) - $record['categories'] = $record['categories'][0]; + $eventid = $event['id']; + $calid = $event['calendar']; - // the cancelled flag transltes into status=CANCELLED - if ($record['cancelled']) - $record['status'] = 'CANCELLED'; + list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); - // The web client only supports DISPLAY type of alarms - if (!empty($record['alarms'])) - $record['alarms'] = preg_replace('/:[A-Z]+$/', ':DISPLAY', $record['alarms']); + // call Bonnie API + $result = $this->bonnie_api->get('event', $uid, $rev, $mailbox, $msguid); + if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { + $format = kolab_format::factory('event'); + $format->load($result['xml']); + $event = $format->to_array(); + $format->get_attachments($event, true); - // remove empty recurrence array - if (empty($record['recurrence'])) - unset($record['recurrence']); + // get the right instance from a recurring event + if ($eventid != $event['uid']) { + $instance_id = substr($eventid, strlen($event['uid']) + 1); - // clean up exception data - if (is_array($record['recurrence']['EXCEPTIONS'])) { - array_walk($record['recurrence']['EXCEPTIONS'], function(&$exception) { - unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj'], $exception['_attachments']); - }); - } + // check for recurrence exception first + if ($instance = $format->get_instance($instance_id)) { + $event = $instance; + } + else { + // not a exception, compute recurrence... + $event['_formatobj'] = $format; + $recurrence_date = rcube_utils::anytodatetime($instance_id, $event['start']->getTimezone()); + foreach ($this->get_recurring_events($event, $event['start'], $recurrence_date) as $instance) { + if ($instance['id'] == $eventid) { + $event = $instance; + break; + } + } + } + } - unset($record['_mailbox'], $record['_msguid'], $record['_type'], $record['_size'], - $record['_formatobj'], $record['_attachments'], $record['exceptions'], $record['x-custom']); - - return $record; - } - - /** - * - */ - public static function from_rcube_event($event, $old = array()) - { - kolab_format::merge_attachments($event, $old); - - return $event; - } - - - /** - * Set CSS class according to the event's attendde partstat - */ - public static function add_partstat_class($event, $partstats, $user = null) - { - // set classes according to PARTSTAT - if (is_array($event['attendees'])) { - $user_emails = libcalendaring::get_instance()->get_user_emails($user); - $partstat = 'UNKNOWN'; - foreach ($event['attendees'] as $attendee) { - if (in_array($attendee['email'], $user_emails)) { - $partstat = $attendee['status']; - break; - } - } - - if (in_array($partstat, $partstats)) { - $event['className'] = trim($event['className'] . ' fc-invitation-' . strtolower($partstat)); - } - } + if ($format->is_valid()) { + $event['calendar'] = $calid; + $event['rev'] = $result['rev']; - return $event; - } - - /** - * Provide a list of revisions for the given event - * - * @param array $event Hash array with event properties - * - * @return array List of changes, each as a hash array - * @see calendar_driver::get_event_changelog() - */ - public function get_event_changelog($event) - { - if (empty($this->bonnie_api)) { - return false; + return $internal ? $event : self::to_rcube_event($event); + } + } + + return false; } - list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); + /** + * Command the backend to restore a certain revision of an event. + * This shall replace the current event with an older version. + * + * @param mixed $event UID string or hash array with event properties: + * id: Event identifier + * calendar: Calendar identifier + * @param mixed $rev Revision number + * + * @return bool True on success, False on failure + */ + public function restore_event_revision($event, $rev) + { + if (empty($this->bonnie_api)) { + return false; + } - $result = $this->bonnie_api->changelog('event', $uid, $mailbox, $msguid); - if (is_array($result) && $result['uid'] == $uid) { - return $result['changes']; - } + list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); - return false; - } - - /** - * Get a list of property changes beteen two revisions of an event - * - * @param array $event Hash array with event properties - * @param mixed $rev1 Old Revision - * @param mixed $rev2 New Revision - * - * @return array List of property changes, each as a hash array - * @see calendar_driver::get_event_diff() - */ - public function get_event_diff($event, $rev1, $rev2) - { - if (empty($this->bonnie_api)) { - return false; - } + $calendar = $this->get_calendar($event['calendar']); + $success = false; - list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); - - // get diff for the requested recurrence instance - $instance_id = $event['id'] != $uid ? substr($event['id'], strlen($uid) + 1) : null; - - // call Bonnie API - $result = $this->bonnie_api->diff('event', $uid, $rev1, $rev2, $mailbox, $msguid, $instance_id); - if (is_array($result) && $result['uid'] == $uid) { - $result['rev1'] = $rev1; - $result['rev2'] = $rev2; - - $keymap = array( - 'dtstart' => 'start', - 'dtend' => 'end', - 'dstamp' => 'changed', - 'summary' => 'title', - 'alarm' => 'alarms', - 'attendee' => 'attendees', - 'attach' => 'attachments', - 'rrule' => 'recurrence', - 'transparency' => 'free_busy', - 'classification' => 'sensitivity', - 'lastmodified-date' => 'changed', - ); - $prop_keymaps = array( - 'attachments' => array('fmttype' => 'mimetype', 'label' => 'name'), - 'attendees' => array('partstat' => 'status'), - ); - $special_changes = array(); - - // map kolab event properties to keys the client expects - array_walk($result['changes'], function(&$change, $i) use ($keymap, $prop_keymaps, $special_changes) { - if (array_key_exists($change['property'], $keymap)) { - $change['property'] = $keymap[$change['property']]; - } - // translate free_busy values - if ($change['property'] == 'free_busy') { - $change['old'] = $old['old'] ? 'free' : 'busy'; - $change['new'] = $old['new'] ? 'free' : 'busy'; - } - // map alarms trigger value - if ($change['property'] == 'alarms') { - if (is_array($change['old']) && is_array($change['old']['trigger'])) - $change['old']['trigger'] = $change['old']['trigger']['value']; - if (is_array($change['new']) && is_array($change['new']['trigger'])) - $change['new']['trigger'] = $change['new']['trigger']['value']; - } - // make all property keys uppercase - if ($change['property'] == 'recurrence') { - $special_changes['recurrence'] = $i; - foreach (array('old','new') as $m) { - if (is_array($change[$m])) { - $props = array(); - foreach ($change[$m] as $k => $v) - $props[strtoupper($k)] = $v; - $change[$m] = $props; - } - } - } - // map property keys names - if (is_array($prop_keymaps[$change['property']])) { - foreach ($prop_keymaps[$change['property']] as $k => $dest) { - if (is_array($change['old']) && array_key_exists($k, $change['old'])) { - $change['old'][$dest] = $change['old'][$k]; - unset($change['old'][$k]); - } - if (is_array($change['new']) && array_key_exists($k, $change['new'])) { - $change['new'][$dest] = $change['new'][$k]; - unset($change['new'][$k]); - } - } - } - - if ($change['property'] == 'exdate') { - $special_changes['exdate'] = $i; - } - else if ($change['property'] == 'rdate') { - $special_changes['rdate'] = $i; - } - }); - - // merge some recurrence changes - foreach (array('exdate','rdate') as $prop) { - if (array_key_exists($prop, $special_changes)) { - $exdate = $result['changes'][$special_changes[$prop]]; - if (array_key_exists('recurrence', $special_changes)) { - $recurrence = &$result['changes'][$special_changes['recurrence']]; - } - else { - $i = count($result['changes']); - $result['changes'][$i] = array('property' => 'recurrence', 'old' => array(), 'new' => array()); - $recurrence = &$result['changes'][$i]['recurrence']; - } - $key = strtoupper($prop); - $recurrence['old'][$key] = $exdate['old']; - $recurrence['new'][$key] = $exdate['new']; - unset($result['changes'][$special_changes[$prop]]); - } - } - - return $result; - } + if ($calendar && $calendar->storage && $calendar->editable) { + if ($raw_msg = $this->bonnie_api->rawdata('event', $uid, $rev, $mailbox)) { + $imap = $this->rc->get_storage(); + + // insert $raw_msg as new message + if ($imap->save_message($calendar->storage->name, $raw_msg, null, false)) { + $success = true; + + // delete old revision from imap and cache + $imap->delete_message($msguid, $calendar->storage->name); + $calendar->storage->cache->set($msguid, false); + } + } + } - return false; - } - - /** - * Return full data of a specific revision of an event - * - * @param array Hash array with event properties - * @param mixed $rev Revision number - * - * @return array Event object as hash array - * @see calendar_driver::get_event_revison() - */ - public function get_event_revison($event, $rev, $internal = false) - { - if (empty($this->bonnie_api)) { - return false; + return $success; } - $eventid = $event['id']; - $calid = $event['calendar']; - list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); + /** + * Helper method to resolved the given event identifier into uid and folder + * + * @return array (uid,folder,msguid) tuple + */ + private function _resolve_event_identity($event) + { + $mailbox = $msguid = null; - // call Bonnie API - $result = $this->bonnie_api->get('event', $uid, $rev, $mailbox, $msguid); - if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { - $format = kolab_format::factory('event'); - $format->load($result['xml']); - $event = $format->to_array(); - $format->get_attachments($event, true); + if (is_array($event)) { + $uid = !empty($event['uid']) ? $event['uid'] : $event['id']; - // get the right instance from a recurring event - if ($eventid != $event['uid']) { - $instance_id = substr($eventid, strlen($event['uid']) + 1); + if (($cal = $this->get_calendar($event['calendar'])) && !($cal instanceof kolab_invitation_calendar)) { + $mailbox = $cal->get_mailbox_id(); - // check for recurrence exception first - if ($instance = $format->get_instance($instance_id)) { - $event = $instance; + // get event object from storage in order to get the real object uid an msguid + if ($ev = $cal->get_event($event['id'])) { + $msguid = $ev['_msguid']; + $uid = $ev['uid']; + } + } } else { - // not a exception, compute recurrence... - $event['_formatobj'] = $format; - $recurrence_date = rcube_utils::anytodatetime($instance_id, $event['start']->getTimezone()); - foreach ($this->get_recurring_events($event, $event['start'], $recurrence_date) as $instance) { - if ($instance['id'] == $eventid) { - $event = $instance; - break; - } - } - } - } - - if ($format->is_valid()) { - $event['calendar'] = $calid; - $event['rev'] = $result['rev']; - return $internal ? $event : self::to_rcube_event($event); - } - } + $uid = $event; - return false; - } - - /** - * Command the backend to restore a certain revision of an event. - * This shall replace the current event with an older version. - * - * @param mixed UID string or hash array with event properties: - * id: Event identifier - * calendar: Calendar identifier - * @param mixed $rev Revision number - * - * @return boolean True on success, False on failure - */ - public function restore_event_revision($event, $rev) - { - if (empty($this->bonnie_api)) { - return false; - } - - list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); - $calendar = $this->get_calendar($event['calendar']); - $success = false; + // get event object from storage in order to get the real object uid an msguid + if ($ev = $this->get_event($event)) { + $mailbox = $ev['_mailbox']; + $msguid = $ev['_msguid']; + $uid = $ev['uid']; + } + } - if ($calendar && $calendar->storage && $calendar->editable) { - if ($raw_msg = $this->bonnie_api->rawdata('event', $uid, $rev, $mailbox)) { - $imap = $this->rc->get_storage(); + return array($uid, $mailbox, $msguid); + } + + /** + * Callback function to produce driver-specific calendar create/edit form + * + * @param string Request action 'form-edit|form-new' + * @param array Calendar properties (e.g. id, color) + * @param array Edit form fields + * + * @return string HTML content of the form + */ + public function calendar_form($action, $calendar, $formfields) + { + $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']); + } - // insert $raw_msg as new message - if ($imap->save_message($calendar->storage->name, $raw_msg, null, false)) { - $success = true; + // General tab + $form['props'] = [ + 'name' => $this->rc->gettext('properties'), + 'fields' => $formfields, + ]; - // delete old revision from imap and cache - $imap->delete_message($msguid, $calendar->storage->name); - $calendar->storage->cache->set($msguid, false); + return kolab_utils::folder_form($form, '', 'calendar'); } - } - } - return $success; - } - - /** - * Helper method to resolved the given event identifier into uid and folder - * - * @return array (uid,folder,msguid) tuple - */ - private function _resolve_event_identity($event) - { - $mailbox = $msguid = null; - if (is_array($event)) { - $uid = $event['uid'] ?: $event['id']; - if (($cal = $this->get_calendar($event['calendar'])) && !($cal instanceof kolab_invitation_calendar)) { - $mailbox = $cal->get_mailbox_id(); - - // get event object from storage in order to get the real object uid an msguid - if ($ev = $cal->get_event($event['id'])) { - $msguid = $ev['_msguid']; - $uid = $ev['uid']; - } - } - } - else { - $uid = $event; - - // get event object from storage in order to get the real object uid an msguid - if ($ev = $this->get_event($event)) { - $mailbox = $ev['_mailbox']; - $msguid = $ev['_msguid']; - $uid = $ev['uid']; - } - } + $this->_read_calendars(); - return array($uid, $mailbox, $msguid); - } - - /** - * Callback function to produce driver-specific calendar create/edit form - * - * @param string Request action 'form-edit|form-new' - * @param array Calendar properties (e.g. id, color) - * @param array Edit form fields - * - * @return string HTML content of the form - */ - public function calendar_form($action, $calendar, $formfields) - { - // show default dialog for birthday calendar - if (in_array($calendar['id'], array(self::BIRTHDAY_CALENDAR_ID, self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED))) { - if ($calendar['id'] != self::BIRTHDAY_CALENDAR_ID) - unset($formfields['showalarms']); - - // General tab - $form['props'] = array( - 'name' => $this->rc->gettext('properties'), - 'fields' => $formfields, - ); - - return kolab_utils::folder_form($form, '', 'calendar'); - } + if (!empty($calendar['id']) && ($cal = $this->calendars[$calendar['id']])) { + $folder = $cal->get_realname(); // UTF7 + $color = $cal->get_color(); + } + else { + $folder = ''; + $color = ''; + } - $this->_read_calendars(); + $hidden_fields[] = ['name' => 'oldname', 'value' => $folder]; - if ($calendar['id'] && ($cal = $this->calendars[$calendar['id']])) { - $folder = $cal->get_realname(); // UTF7 - $color = $cal->get_color(); - } - else { - $folder = ''; - $color = ''; - } + $storage = $this->rc->get_storage(); + $delim = $storage->get_hierarchy_delimiter(); + $form = []; - $hidden_fields[] = array('name' => 'oldname', 'value' => $folder); + if (strlen($folder)) { + $path_imap = explode($delim, $folder); + array_pop($path_imap); // pop off name part + $path_imap = implode($delim, $path_imap); - $storage = $this->rc->get_storage(); - $delim = $storage->get_hierarchy_delimiter(); - $form = array(); + $options = $storage->folder_info($folder); + } + else { + $path_imap = ''; + } - if (strlen($folder)) { - $path_imap = explode($delim, $folder); - array_pop($path_imap); // pop off name part - $path_imap = implode($delim, $path_imap); + // General tab + $form['props'] = [ + 'name' => $this->rc->gettext('properties'), + 'fields' => [], + ]; + + $protected = !empty($options) && (!empty($options['norename']) || !empty($options['protected'])); + // Disable folder name input + if ($protected) { + $input_name = new html_hiddenfield(['name' => 'name', 'id' => 'calendar-name']); + $formfields['name']['value'] = kolab_storage::object_name($folder) + . $input_name->show($folder); + } - $options = $storage->folder_info($folder); - } - else { - $path_imap = ''; - } + // calendar name (default field) + $form['props']['fields']['location'] = $formfields['name']; - // General tab - $form['props'] = array( - 'name' => $this->rc->gettext('properties'), - 'fields' => array(), - ); - - // Disable folder name input - if (!empty($options) && ($options['norename'] || $options['protected'])) { - $input_name = new html_hiddenfield(array('name' => 'name', 'id' => 'calendar-name')); - $formfields['name']['value'] = kolab_storage::object_name($folder) - . $input_name->show($folder); - } + if ($protected) { + // prevent user from moving folder + $hidden_fields[] = ['name' => 'parent', 'value' => $path_imap]; + } + else { + $select = kolab_storage::folder_selector('event', ['name' => 'parent', 'id' => 'calendar-parent'], $folder); - // calendar name (default field) - $form['props']['fields']['location'] = $formfields['name']; + $form['props']['fields']['path'] = [ + 'id' => 'calendar-parent', + 'label' => $this->cal->gettext('parentcalendar'), + 'value' => $select->show(strlen($folder) ? $path_imap : ''), + ]; + } - if (!empty($options) && ($options['norename'] || $options['protected'])) { - // prevent user from moving folder - $hidden_fields[] = array('name' => 'parent', 'value' => $path_imap); - } - else { - $select = kolab_storage::folder_selector('event', array('name' => 'parent', 'id' => 'calendar-parent'), $folder); - $form['props']['fields']['path'] = array( - 'id' => 'calendar-parent', - 'label' => $this->cal->gettext('parentcalendar'), - 'value' => $select->show(strlen($folder) ? $path_imap : ''), - ); + // calendar color (default field) + $form['props']['fields']['color'] = $formfields['color']; + $form['props']['fields']['alarms'] = $formfields['showalarms']; + + return kolab_utils::folder_form($form, $folder, 'calendar', $hidden_fields); } - // calendar color (default field) - $form['props']['fields']['color'] = $formfields['color']; - $form['props']['fields']['alarms'] = $formfields['showalarms']; - - return kolab_utils::folder_form($form, $folder, 'calendar', $hidden_fields); - } - - /** - * Handler for user_delete plugin hook - */ - public function user_delete($args) - { - $db = $this->rc->get_dbh(); - foreach (array('kolab_alarms', 'itipinvitations') as $table) { - $db->query("DELETE FROM " . $this->rc->db->table_name($table, true) - . " WHERE `user_id` = ?", $args['user']->ID); + /** + * Handler for user_delete plugin hook + */ + public function user_delete($args) + { + $db = $this->rc->get_dbh(); + foreach (['kolab_alarms', 'itipinvitations'] as $table) { + $db->query("DELETE FROM " . $this->rc->db->table_name($table, true) + . " WHERE `user_id` = ?", $args['user']->ID); + } } - } } diff --git a/plugins/calendar/drivers/kolab/kolab_invitation_calendar.php b/plugins/calendar/drivers/kolab/kolab_invitation_calendar.php index adb73e3c..cae04552 100644 --- a/plugins/calendar/drivers/kolab/kolab_invitation_calendar.php +++ b/plugins/calendar/drivers/kolab/kolab_invitation_calendar.php @@ -1,411 +1,424 @@ * * Copyright (C) 2014-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_invitation_calendar { - public $id = '__invitation__'; - public $ready = true; - public $alarms = false; - public $rights = 'lrsv'; - public $editable = false; - public $attachments = false; - public $subscriptions = false; - public $partstats = array('unknown'); - public $categories = array(); - public $name = 'Invitations'; - - - /** - * Default constructor - */ - public function __construct($id, $calendar) - { - $this->cal = $calendar; - $this->id = $id; - - switch ($this->id) { - case kolab_driver::INVITATIONS_CALENDAR_PENDING: - $this->partstats = array('NEEDS-ACTION'); - $this->name = $this->cal->gettext('invitationspending'); - if (!empty($_REQUEST['_quickview'])) - $this->partstats[] = 'TENTATIVE'; - break; - - case kolab_driver::INVITATIONS_CALENDAR_DECLINED: - $this->partstats = array('DECLINED'); - $this->name = $this->cal->gettext('invitationsdeclined'); - break; + public $id = '__invitation__'; + public $ready = true; + public $alarms = false; + public $rights = 'lrsv'; + public $editable = false; + public $attachments = false; + public $subscriptions = false; + public $partstats = ['unknown']; + public $categories = []; + public $name = 'Invitations'; + + + /** + * Default constructor + */ + public function __construct($id, $calendar) + { + $this->cal = $calendar; + $this->id = $id; + + switch ($this->id) { + case kolab_driver::INVITATIONS_CALENDAR_PENDING: + $this->partstats = ['NEEDS-ACTION']; + $this->name = $this->cal->gettext('invitationspending'); + + if (!empty($_REQUEST['_quickview'])) { + $this->partstats[] = 'TENTATIVE'; + } + break; + + case kolab_driver::INVITATIONS_CALENDAR_DECLINED: + $this->partstats = ['DECLINED']; + $this->name = $this->cal->gettext('invitationsdeclined'); + break; + } + + // user-specific alarms settings win + $prefs = $this->cal->rc->config->get('kolab_calendars', []); + if (isset($prefs[$this->id]['showalarms'])) { + $this->alarms = $prefs[$this->id]['showalarms']; + } + } + + /** + * Getter for a nice and human readable name for this calendar + * + * @return string Name of this calendar + */ + public function get_name() + { + return $this->name; } - // user-specific alarms settings win - $prefs = $this->cal->rc->config->get('kolab_calendars', array()); - if (isset($prefs[$this->id]['showalarms'])) - $this->alarms = $prefs[$this->id]['showalarms']; - } - - /** - * Getter for a nice and human readable name for this calendar - * - * @return string Name of this calendar - */ - public function get_name() - { - return $this->name; - } - - /** - * Getter for the IMAP folder owner - * - * @return string Name of the folder owner - */ - public function get_owner() - { - return $this->cal->rc->get_user_name(); - } - - /** - * - */ - public function get_title() - { - return $this->get_name(); - } - - /** - * Getter for the name of the namespace to which the IMAP folder belongs - * - * @return string Name of the namespace (personal, other, shared) - */ - public function get_namespace() - { - return 'x-special'; - } - - /** - * Getter for the top-end calendar folder name (not the entire path) - * - * @return string Name of this calendar - */ - public function get_foldername() - { - return $this->get_name(); - } - - /** - * Getter for the Cyrus mailbox identifier corresponding to this folder - * - * @return string Mailbox ID - */ - public function get_mailbox_id() - { - // this is a virtual collection and has no concrete mailbox ID - return null; - } - - /** - * Return color to display this calendar - */ - public function get_color() - { - // calendar color is stored in local user prefs - $prefs = $this->cal->rc->config->get('kolab_calendars', array()); - - if (!empty($prefs[$this->id]) && !empty($prefs[$this->id]['color'])) - return $prefs[$this->id]['color']; - - return 'ffffff'; - } - - /** - * Compose an URL for CalDAV access to this calendar (if configured) - */ - public function get_caldav_url() - { - return false; - } - - /** - * Check activation status of this folder - * - * @return boolean True if enabled, false if not - */ - public function is_active() - { - $prefs = $this->cal->rc->config->get('kolab_calendars', array()); // read local prefs - return (bool)$prefs[$this->id]['active']; - } - - /** - * Update properties of this calendar folder - * - * @see calendar_driver::edit_calendar() - */ - public function update(&$prop) - { - // don't change anything. - // let kolab_driver save props in local prefs - return $prop['id']; - } - - /** - * Getter for a single event object - */ - public function get_event($id) - { - // redirect call to kolab_driver::get_event() - $event = $this->cal->driver->get_event($id, calendar_driver::FILTER_WRITEABLE); - - if (is_array($event)) { - $event = $this->_mod_event($event, $event['calendar']); + /** + * Getter for the IMAP folder owner + * + * @return string Name of the folder owner + */ + public function get_owner() + { + return $this->cal->rc->get_user_name(); } - return $event; - } - - /** - * Create instances of a recurring event - * - * @see kolab_calendar::get_recurring_events() - */ - public function get_recurring_events($event, $start, $end = null, $event_id = null, $limit = null) - { - // forward call to the actual storage folder - if ($event['_folder_id']) { - $cal = $this->cal->driver->get_calendar($event['_folder_id']); - if ($cal && $cal->ready) { - return $cal->get_recurring_events($event, $start, $end, $event_id, $limit); - } + /** + * + */ + public function get_title() + { + return $this->get_name(); } - } - - /** - * Get attachment body - * - * @see calendar_driver::get_attachment_body() - */ - public function get_attachment_body($id, $event) - { - // find the actual folder this event resides in - if (!empty($event['_folder_id'])) { - $cal = $this->cal->driver->get_calendar($event['_folder_id']); + + /** + * Getter for the name of the namespace to which the IMAP folder belongs + * + * @return string Name of the namespace (personal, other, shared) + */ + public function get_namespace() + { + return 'x-special'; } - else { - $cal = null; - foreach (kolab_storage::list_folders('', '*', 'event', null) as $foldername) { - $cal = $this->_get_calendar($foldername); - if ($cal->ready && $cal->storage && $cal->get_event($event['id'])) { - break; + + /** + * Getter for the top-end calendar folder name (not the entire path) + * + * @return string Name of this calendar + */ + public function get_foldername() + { + return $this->get_name(); + } + + /** + * Getter for the Cyrus mailbox identifier corresponding to this folder + * + * @return string Mailbox ID + */ + public function get_mailbox_id() + { + // this is a virtual collection and has no concrete mailbox ID + return null; + } + + /** + * Return color to display this calendar + */ + public function get_color() + { + // calendar color is stored in local user prefs + $prefs = $this->cal->rc->config->get('kolab_calendars', []); + + if (!empty($prefs[$this->id]) && !empty($prefs[$this->id]['color'])) { + return $prefs[$this->id]['color']; } - } + + return 'ffffff'; } - if ($cal && $cal->storage) { - return $cal->get_attachment_body($id, $event); + /** + * Compose an URL for CalDAV access to this calendar (if configured) + */ + public function get_caldav_url() + { + return false; } - return false; - } - - /** - * @param integer Event's new start (unix timestamp) - * @param integer Event's new end (unix timestamp) - * @param string Search query (optional) - * @param boolean Include virtual events (optional) - * @param array Additional parameters to query storage - * - * @return array A list of event records - */ - public function list_events($start, $end, $search = null, $virtual = 1, $query = array()) - { - // get email addresses of the current user - $user_emails = $this->cal->get_user_emails(); - $subquery = array(); - foreach ($user_emails as $email) { - foreach ($this->partstats as $partstat) { - $subquery[] = array('tags', '=', 'x-partstat:' . $email . ':' . strtolower($partstat)); - } + /** + * Check activation status of this folder + * + * @return bool True if enabled, false if not + */ + public function is_active() + { + $prefs = $this->cal->rc->config->get('kolab_calendars', []); // read local prefs + return !empty($prefs[$this->id]['active']); } - // aggregate events from all calendar folders - $events = array(); - foreach (kolab_storage::list_folders('', '*', 'event', null) as $foldername) { - $cal = $this->_get_calendar($foldername); - if (!$cal || $cal->get_namespace() == 'other') - continue; - - foreach ($cal->list_events($start, $end, $search, 1, $query, array(array($subquery, 'OR'))) as $event) { - $match = false; - - // post-filter events to match out partstats - if (is_array($event['attendees'])) { - foreach ($event['attendees'] as $attendee) { - if (in_array($attendee['email'], $user_emails) && in_array($attendee['status'], $this->partstats)) { - $match = true; - break; + /** + * Update properties of this calendar folder + * + * @see calendar_driver::edit_calendar() + */ + public function update(&$prop) + { + // don't change anything. + // let kolab_driver save props in local prefs + return $prop['id']; + } + + /** + * Getter for a single event object + */ + public function get_event($id) + { + // redirect call to kolab_driver::get_event() + $event = $this->cal->driver->get_event($id, calendar_driver::FILTER_WRITEABLE); + + if (is_array($event)) { + $event = $this->_mod_event($event, $event['calendar']); + } + + return $event; + } + + /** + * Create instances of a recurring event + * + * @see kolab_calendar::get_recurring_events() + */ + public function get_recurring_events($event, $start, $end = null, $event_id = null, $limit = null) + { + // forward call to the actual storage folder + if (!empty($event['_folder_id'])) { + $cal = $this->cal->driver->get_calendar($event['_folder_id']); + if ($cal && $cal->ready) { + return $cal->get_recurring_events($event, $start, $end, $event_id, $limit); } - } } + } - if ($match) { - $events[$event['id'] ?: $event['uid']] = $this->_mod_event($event, $cal->id); + /** + * Get attachment body + * + * @see calendar_driver::get_attachment_body() + */ + public function get_attachment_body($id, $event) + { + // find the actual folder this event resides in + if (!empty($event['_folder_id'])) { + $cal = $this->cal->driver->get_calendar($event['_folder_id']); + } + else { + $cal = null; + foreach (kolab_storage::list_folders('', '*', 'event', null) as $foldername) { + $cal = $this->_get_calendar($foldername); + if ($cal->ready && $cal->storage && $cal->get_event($event['id'])) { + break; + } + } } - } - // merge list of event categories (really?) - $this->categories += $cal->categories; + if ($cal && $cal->storage) { + return $cal->get_attachment_body($id, $event); + } + + return false; } - return $events; - } - - /** - * Get number of events in the given calendar - * - * @param integer Date range start (unix timestamp) - * @param integer Date range end (unix timestamp) - * @param array Additional query to filter events - * - * @return integer Count - */ - public function count_events($start, $end = null, $filter = null) - { - // get email addresses of the current user - $user_emails = $this->cal->get_user_emails(); - $subquery = array(); - foreach ($user_emails as $email) { - foreach ($this->partstats as $partstat) { - $subquery[] = array('tags', '=', 'x-partstat:' . $email . ':' . strtolower($partstat)); - } + /** + * @param int Event's new start (unix timestamp) + * @param int Event's new end (unix timestamp) + * @param string Search query (optional) + * @param bool Include virtual events (optional) + * @param array Additional parameters to query storage + * + * @return array A list of event records + */ + public function list_events($start, $end, $search = null, $virtual = 1, $query = []) + { + // get email addresses of the current user + $user_emails = $this->cal->get_user_emails(); + $subquery = []; + + foreach ($user_emails as $email) { + foreach ($this->partstats as $partstat) { + $subquery[] = ['tags', '=', 'x-partstat:' . $email . ':' . strtolower($partstat)]; + } + } + + $events = []; + + // aggregate events from all calendar folders + foreach (kolab_storage::list_folders('', '*', 'event', null) as $foldername) { + $cal = $this->_get_calendar($foldername); + if (!$cal || $cal->get_namespace() == 'other') { + continue; + } + + foreach ($cal->list_events($start, $end, $search, 1, $query, [[$subquery, 'OR']]) as $event) { + $match = false; + + // post-filter events to match out partstats + if (!empty($event['attendees'])) { + foreach ($event['attendees'] as $attendee) { + if ( + in_array($attendee['email'], $user_emails) + && in_array($attendee['status'], $this->partstats) + ) { + $match = true; + break; + } + } + } + + if ($match) { + $uid = !empty($event['id']) ? $event['id'] : $event['uid']; + $events[$uid] = $this->_mod_event($event, $cal->id); + } + } + + // merge list of event categories (really?) + $this->categories += $cal->categories; + } + + return $events; } - $filter = array( - array('tags','!=','x-status:cancelled'), - array($subquery, 'OR') - ); + /** + * Get number of events in the given calendar + * + * @param int Date range start (unix timestamp) + * @param int Date range end (unix timestamp) + * @param array Additional query to filter events + * + * @return int Count + */ + public function count_events($start, $end = null, $filter = null) + { + // get email addresses of the current user + $user_emails = $this->cal->get_user_emails(); + $subquery = []; + + foreach ($user_emails as $email) { + foreach ($this->partstats as $partstat) { + $subquery[] = ['tags', '=', 'x-partstat:' . $email . ':' . strtolower($partstat)]; + } + } - // aggregate counts from all calendar folders - $count = 0; - foreach (kolab_storage::list_folders('', '*', 'event', null) as $foldername) { - $cal = $this->_get_calendar($foldername); - if (!$cal || $cal->get_namespace() == 'other') - continue; + $filter = [ + ['tags', '!=', 'x-status:cancelled'], + [$subquery, 'OR'] + ]; + + // aggregate counts from all calendar folders + $count = 0; + foreach (kolab_storage::list_folders('', '*', 'event', null) as $foldername) { + $cal = $this->_get_calendar($foldername); + if (!$cal || $cal->get_namespace() == 'other') { + continue; + } - $count += $cal->count_events($start, $end, $filter); + $count += $cal->count_events($start, $end, $filter); + } + + return $count; } - return $count; - } - - /** - * Get calendar object instance (that maybe already initialized) - */ - private function _get_calendar($folder_name) - { - $id = kolab_storage::folder_id($folder_name, true); - return $this->cal->driver->get_calendar($id); - } - - /** - * Helper method to modify some event properties - */ - private function _mod_event($event, $calendar_id = null) - { - // set classes according to PARTSTAT - $event = kolab_driver::add_partstat_class($event, $this->partstats); - - if (strpos($event['className'], 'fc-invitation-') !== false) { - $event['calendar'] = $this->id; + /** + * Get calendar object instance (that maybe already initialized) + */ + private function _get_calendar($folder_name) + { + $id = kolab_storage::folder_id($folder_name, true); + return $this->cal->driver->get_calendar($id); } - // add pointer to original calendar folder - if ($calendar_id) { - $event['_folder_id'] = $calendar_id; + /** + * Helper method to modify some event properties + */ + private function _mod_event($event, $calendar_id = null) + { + // set classes according to PARTSTAT + $event = kolab_driver::add_partstat_class($event, $this->partstats); + + if (strpos($event['className'], 'fc-invitation-') !== false) { + $event['calendar'] = $this->id; + } + + // add pointer to original calendar folder + if ($calendar_id) { + $event['_folder_id'] = $calendar_id; + } + + return $event; } - return $event; - } - - /** - * Create a new event record - * - * @see kolab_calendar::insert_event() - */ - public function insert_event($event) - { - return false; - } - - /** - * Update a specific event record - * - * @see kolab_calendar::update_event() - */ - public function update_event($event, $exception_id = null) - { - // forward call to the actual storage folder - if ($event['_folder_id']) { - $cal = $this->cal->driver->get_calendar($event['_folder_id']); - if ($cal && $cal->ready) { - return $cal->update_event($event, $exception_id); - } + /** + * Create a new event record + * + * @see kolab_calendar::insert_event() + */ + public function insert_event($event) + { + return false; } - return false; - } - - /** - * Delete an event record - * - * @see kolab_calendar::delete_event() - */ - public function delete_event($event, $force = true) - { - // forward call to the actual storage folder - if ($event['_folder_id']) { - $cal = $this->cal->driver->get_calendar($event['_folder_id']); - if ($cal && $cal->ready) { - return $cal->delete_event($event, $force); - } + /** + * Update a specific event record + * + * @see kolab_calendar::update_event() + */ + public function update_event($event, $exception_id = null) + { + // forward call to the actual storage folder + if (!empty($event['_folder_id'])) { + $cal = $this->cal->driver->get_calendar($event['_folder_id']); + if ($cal && $cal->ready) { + return $cal->update_event($event, $exception_id); + } + } + + return false; } - return false; - } - - /** - * Restore deleted event record - * - * @see kolab_calendar::restore_event() - */ - public function restore_event($event) - { - // forward call to the actual storage folder - if ($event['_folder_id']) { - $cal = $this->cal->driver->get_calendar($event['_folder_id']); - if ($cal && $cal->ready) { - return $cal->restore_event($event); - } + /** + * Delete an event record + * + * @see kolab_calendar::delete_event() + */ + public function delete_event($event, $force = true) + { + // forward call to the actual storage folder + if (!empty($event['_folder_id'])) { + $cal = $this->cal->driver->get_calendar($event['_folder_id']); + if ($cal && $cal->ready) { + return $cal->delete_event($event, $force); + } + } + + return false; } - return false; - } + /** + * Restore deleted event record + * + * @see kolab_calendar::restore_event() + */ + public function restore_event($event) + { + // forward call to the actual storage folder + if (!empty($event['_folder_id'])) { + $cal = $this->cal->driver->get_calendar($event['_folder_id']); + if ($cal && $cal->ready) { + return $cal->restore_event($event); + } + } + + return false; + } } diff --git a/plugins/calendar/drivers/kolab/kolab_user_calendar.php b/plugins/calendar/drivers/kolab/kolab_user_calendar.php index b3c1cd1a..98eaca67 100644 --- a/plugins/calendar/drivers/kolab/kolab_user_calendar.php +++ b/plugins/calendar/drivers/kolab/kolab_user_calendar.php @@ -1,424 +1,445 @@ * * Copyright (C) 2014-2016, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_user_calendar extends kolab_calendar { - public $id = 'unknown'; - public $ready = false; - public $editable = false; - public $attachments = false; - public $subscriptions = false; - - protected $userdata = array(); - protected $timeindex = array(); - - - /** - * Default constructor - */ - public function __construct($user_or_folder, $calendar) - { - $this->cal = $calendar; - $this->imap = $calendar->rc->get_storage(); - - // full user record is provided - if (is_array($user_or_folder)) { - $this->userdata = $user_or_folder; - $this->storage = new kolab_storage_folder_user($this->userdata['kolabtargetfolder'], '', $this->userdata); + public $id = 'unknown'; + public $ready = false; + public $editable = false; + public $attachments = false; + public $subscriptions = false; + + protected $userdata = []; + protected $timeindex = []; + + + /** + * Default constructor + */ + public function __construct($user_or_folder, $calendar) + { + $this->cal = $calendar; + $this->imap = $calendar->rc->get_storage(); + + // full user record is provided + if (is_array($user_or_folder)) { + $this->userdata = $user_or_folder; + $this->storage = new kolab_storage_folder_user($this->userdata['kolabtargetfolder'], '', $this->userdata); + } + else if ($user_or_folder instanceof kolab_storage_folder_user) { + $this->storage = $user_or_folder; + $this->userdata = $this->storage->ldaprec; + } + else { + // get user record from LDAP + $this->storage = new kolab_storage_folder_user($user_or_folder); + $this->userdata = $this->storage->ldaprec; + } + + $this->ready = !empty($this->userdata['kolabtargetfolder']); + $this->storage->type = 'event'; + + if ($this->ready) { + // ID is derrived from the user's kolabtargetfolder attribute + $this->id = kolab_storage::folder_id($this->userdata['kolabtargetfolder'], true); + $this->imap_folder = $this->userdata['kolabtargetfolder']; + $this->name = $this->storage->name; + $this->parent = ''; // user calendars are top level + + // user-specific alarms settings win + $prefs = $this->cal->rc->config->get('kolab_calendars', []); + if (isset($prefs[$this->id]['showalarms'])) { + $this->alarms = $prefs[$this->id]['showalarms']; + } + } } - else if ($user_or_folder instanceof kolab_storage_folder_user) { - $this->storage = $user_or_folder; - $this->userdata = $this->storage->ldaprec; + + /** + * Getter for a nice and human readable name for this calendar + * + * @return string Name of this calendar + */ + public function get_name() + { + if (!empty($this->userdata['displayname'])) { + return $this->userdata['displayname']; + } + + return !empty($this->userdata['name']) ? $this->userdata['name'] : $this->userdata['mail']; } - else { // get user record from LDAP - $this->storage = new kolab_storage_folder_user($user_or_folder); - $this->userdata = $this->storage->ldaprec; + + /** + * Getter for the IMAP folder owner + * + * @param bool Return a fully qualified owner name (unused) + * + * @return string Name of the folder owner + */ + public function get_owner($fully_qualified = false) + { + return $this->userdata['mail']; } - $this->ready = !empty($this->userdata['kolabtargetfolder']); - $this->storage->type = 'event'; + /** + * + */ + public function get_title() + { + $title = []; + + if (!empty($this->userdata['displayname'])) { + $title[] = $this->userdata['displayname']; + } + + $title[] = $this->userdata['mail']; - if ($this->ready) { - // ID is derrived from the user's kolabtargetfolder attribute - $this->id = kolab_storage::folder_id($this->userdata['kolabtargetfolder'], true); - $this->imap_folder = $this->userdata['kolabtargetfolder']; - $this->name = $this->storage->name; - $this->parent = ''; // user calendars are top level + return implode('; ', $title); + } - // user-specific alarms settings win - $prefs = $this->cal->rc->config->get('kolab_calendars', array()); - if (isset($prefs[$this->id]['showalarms'])) - $this->alarms = $prefs[$this->id]['showalarms']; + /** + * Getter for the name of the namespace to which the IMAP folder belongs + * + * @return string Name of the namespace (personal, other, shared) + */ + public function get_namespace() + { + return 'other user'; } - } - - /** - * Getter for a nice and human readable name for this calendar - * - * @return string Name of this calendar - */ - public function get_name() - { - return $this->userdata['displayname'] ?: ($this->userdata['name'] ?: $this->userdata['mail']); - } - - /** - * Getter for the IMAP folder owner - * - * @param bool Return a fully qualified owner name (unused) - * - * @return string Name of the folder owner - */ - public function get_owner($fully_qualified = false) - { - return $this->userdata['mail']; - } - - /** - * - */ - public function get_title() - { - return trim($this->userdata['displayname'] . '; ' . $this->userdata['mail'], '; '); - } - - /** - * Getter for the name of the namespace to which the IMAP folder belongs - * - * @return string Name of the namespace (personal, other, shared) - */ - public function get_namespace() - { - return 'other user'; - } - - /** - * Getter for the top-end calendar folder name (not the entire path) - * - * @return string Name of this calendar - */ - public function get_foldername() - { - return $this->get_name(); - } - - /** - * Return color to display this calendar - */ - public function get_color($default = null) - { - // calendar color is stored in local user prefs - $prefs = $this->cal->rc->config->get('kolab_calendars', array()); - - if (!empty($prefs[$this->id]) && !empty($prefs[$this->id]['color'])) - return $prefs[$this->id]['color']; - - return $default ?: 'cc0000'; - } - - /** - * Compose an URL for CalDAV access to this calendar (if configured) - */ - public function get_caldav_url() - { - return false; - } - - /** - * Check subscription status of this folder - * - * @return boolean True if subscribed, false if not - */ - public function is_subscribed() - { - return $this->storage->is_subscribed(); - } - - /** - * Update properties of this calendar folder - * - * @see calendar_driver::edit_calendar() - */ - public function update(&$prop) - { - // don't change anything. - // let kolab_driver save props in local prefs - return $prop['id']; - } - - /** - * Getter for a single event object - */ - public function get_event($id) - { - // TODO: implement this - return $this->events[$id]; - } - - /** - * Get attachment body - * @see calendar_driver::get_attachment_body() - */ - public function get_attachment_body($id, $event) - { - if (!$event['calendar'] && ($ev = $this->get_event($event['id']))) { - $event['calendar'] = $ev['calendar']; + + /** + * Getter for the top-end calendar folder name (not the entire path) + * + * @return string Name of this calendar + */ + public function get_foldername() + { + return $this->get_name(); } - if ($event['calendar'] && ($cal = $this->cal->get_calendar($event['calendar']))) { - return $cal->get_attachment_body($id, $event); + /** + * Return color to display this calendar + */ + public function get_color($default = null) + { + // calendar color is stored in local user prefs + $prefs = $this->cal->rc->config->get('kolab_calendars', []); + + if (!empty($prefs[$this->id]) && !empty($prefs[$this->id]['color'])) { + return $prefs[$this->id]['color']; + } + + return $default ?: 'cc0000'; } - return false; - } - - /** - * @param integer Event's new start (unix timestamp) - * @param integer Event's new end (unix timestamp) - * @param string Search query (optional) - * @param boolean Include virtual events (optional) - * @param array Additional parameters to query storage - * @param array Additional query to filter events - * - * @return array A list of event records - */ - public function list_events($start, $end, $search = null, $virtual = 1, $query = array(), $filter_query = null) - { - // convert to DateTime for comparisons - try { - $start_dt = new DateTime('@'.$start); + /** + * Compose an URL for CalDAV access to this calendar (if configured) + */ + public function get_caldav_url() + { + return false; } - catch (Exception $e) { - $start_dt = new DateTime('@0'); + + /** + * Check subscription status of this folder + * + * @return boolean True if subscribed, false if not + */ + public function is_subscribed() + { + return $this->storage->is_subscribed(); } - try { - $end_dt = new DateTime('@'.$end); + + /** + * Update properties of this calendar folder + * + * @see calendar_driver::edit_calendar() + */ + public function update(&$prop) + { + // don't change anything. + // let kolab_driver save props in local prefs + return $prop['id']; } - catch (Exception $e) { - $end_dt = new DateTime('today +10 years'); + + /** + * Getter for a single event object + */ + public function get_event($id) + { + // TODO: implement this + return isset($this->events[$id]) ? $this->events[$id] : null; } - $limit_changed = null; - if (!empty($query)) { - foreach ($query as $q) { - if ($q[0] == 'changed' && $q[1] == '>=') { - try { $limit_changed = new DateTime('@'.$q[2]); } - catch (Exception $e) { /* ignore */ } + /** + * Get attachment body + * @see calendar_driver::get_attachment_body() + */ + public function get_attachment_body($id, $event) + { + if (empty($event['calendar']) && ($ev = $this->get_event($event['id']))) { + $event['calendar'] = $ev['calendar']; + } + + if (!empty($event['calendar']) && ($cal = $this->cal->get_calendar($event['calendar']))) { + return $cal->get_attachment_body($id, $event); } - } + + return false; } - // aggregate all calendar folders the user shares (but are not activated) - foreach (kolab_storage::list_user_folders($this->userdata, 'event', 2) as $foldername) { - $cal = new kolab_calendar($foldername, $this->cal); - foreach ($cal->list_events($start, $end, $search, 1) as $event) { - $uid = $event['id'] ?: $event['uid']; - $this->events[$uid] = $event; - $this->timeindex[$this->time_key($event)] = $uid; - } + /** + * @param int Event's new start (unix timestamp) + * @param int Event's new end (unix timestamp) + * @param string Search query (optional) + * @param bool Include virtual events (optional) + * @param array Additional parameters to query storage + * @param array Additional query to filter events + * + * @return array A list of event records + */ + public function list_events($start, $end, $search = null, $virtual = 1, $query = [], $filter_query = null) + { + // convert to DateTime for comparisons + try { + $start_dt = new DateTime('@'.$start); + } + catch (Exception $e) { + $start_dt = new DateTime('@0'); + } + try { + $end_dt = new DateTime('@'.$end); + } + catch (Exception $e) { + $end_dt = new DateTime('today +10 years'); + } + + $limit_changed = null; + + if (!empty($query)) { + foreach ($query as $q) { + if ($q[0] == 'changed' && $q[1] == '>=') { + try { $limit_changed = new DateTime('@'.$q[2]); } + catch (Exception $e) { /* ignore */ } + } + } + } + + // aggregate all calendar folders the user shares (but are not activated) + foreach (kolab_storage::list_user_folders($this->userdata, 'event', 2) as $foldername) { + $cal = new kolab_calendar($foldername, $this->cal); + foreach ($cal->list_events($start, $end, $search, 1) as $event) { + $uid = !empty($event['id']) ? $event['id'] : $event['uid']; + $this->events[$uid] = $event; + $this->timeindex[$this->time_key($event)] = $uid; + } + } + + // get events from the user's free/busy feed (for quickview only) + $fbview = $this->cal->rc->config->get('calendar_include_freebusy_data', 1); + if ($fbview && ($fbview == 1 || !empty($_REQUEST['_quickview'])) && empty($search)) { + $this->fetch_freebusy($limit_changed); + } + + $events = []; + foreach ($this->events as $event) { + // list events in requested time window + if ( + $event['start'] <= $end_dt + && $event['end'] >= $start_dt + && (!$limit_changed || empty($event['changed']) || $event['changed'] >= $limit_changed) + ) { + $events[] = $event; + } + } + + // avoid session race conditions that will loose temporary subscriptions + $this->cal->rc->session->nowrite = true; + + return $events; } - // get events from the user's free/busy feed (for quickview only) - $fbview = $this->cal->rc->config->get('calendar_include_freebusy_data', 1); - if ($fbview && ($fbview == 1 || !empty($_REQUEST['_quickview'])) && empty($search)) { - $this->fetch_freebusy($limit_changed); + /** + * Get number of events in the given calendar + * + * @param int Date range start (unix timestamp) + * @param int Date range end (unix timestamp) + * @param array Additional query to filter events + * + * @return integer Count + */ + public function count_events($start, $end = null, $filter_query = null) + { + // not implemented + return 0; } - $events = array(); - foreach ($this->events as $event) { - // list events in requested time window - if ($event['start'] <= $end_dt && $event['end'] >= $start_dt && - (!$limit_changed || !$event['changed'] || $event['changed'] >= $limit_changed)) { - $events[] = $event; - } + /** + * Helper method to fetch free/busy data for the user and turn it into calendar data + */ + private function fetch_freebusy($limit_changed = null) + { + // ask kolab server first + try { + $request_config = [ + 'store_body' => true, + 'follow_redirects' => true, + ]; + $request = libkolab::http_request(kolab_storage::get_freebusy_url($this->userdata['mail']), 'GET', $request_config); + $response = $request->send(); + + // authentication required + if ($response->getStatus() == 401) { + $request->setAuth($this->cal->rc->user->get_username(), $this->cal->rc->decrypt($_SESSION['password'])); + $response = $request->send(); + } + + if ($response->getStatus() == 200) { + $fbdata = $response->getBody(); + } + + unset($request, $response); + } + catch (Exception $e) { + rcube::raise_error([ + 'code' => 900, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error fetching free/busy information: " . $e->getMessage() + ], + true, false + ); + + return false; + } + + $statusmap = [ + 'FREE' => 'free', + 'BUSY' => 'busy', + 'BUSY-TENTATIVE' => 'tentative', + 'X-OUT-OF-OFFICE' => 'outofoffice', + 'OOF' => 'outofoffice', + ]; + + $titlemap = [ + 'FREE' => $this->cal->gettext('availfree'), + 'BUSY' => $this->cal->gettext('availbusy'), + 'BUSY-TENTATIVE' => $this->cal->gettext('availtentative'), + 'X-OUT-OF-OFFICE' => $this->cal->gettext('availoutofoffice'), + ]; + + // rcube::console('_fetch_freebusy', kolab_storage::get_freebusy_url($this->userdata['mail']), $fbdata); + + $count = 0; + + // parse free-busy information + if (!empty($fbdata)) { + $ical = $this->cal->get_ical(); + $ical->import($fbdata); + if ($fb = $ical->freebusy) { + // consider 'changed >= X' queries + if ($limit_changed && !empty($fb['created']) && $fb['created'] < $limit_changed) { + return 0; + } + + foreach ($fb['periods'] as $tuple) { + list($from, $to, $type) = $tuple; + $event = [ + 'uid' => md5($this->id . $from->format('U') . '/' . $to->format('U')), + 'calendar' => $this->id, + 'changed' => !empty($fb['created']) ? $fb['created'] : new DateTime(), + 'title' => $this->get_name() . ' ' . (!empty($titlemap[$type]) ? $titlemap[$type] : $type), + 'start' => $from, + 'end' => $to, + 'free_busy' => !empty($statusmap[$type]) ? $statusmap[$type] : 'busy', + 'className' => 'fc-type-freebusy', + 'organizer' => [ + 'email' => $this->userdata['mail'], + 'name' => isset($this->userdata['displayname']) ? $this->userdata['displayname'] : null, + ], + ]; + + // avoid duplicate entries + $key = $this->time_key($event); + if (empty($this->timeindex[$key])) { + $this->events[$event['uid']] = $event; + $this->timeindex[$key] = $event['uid']; + $count++; + } + } + } + } + + return $count; } - // avoid session race conditions that will loose temporary subscriptions - $this->cal->rc->session->nowrite = true; - - return $events; - } - - /** - * Get number of events in the given calendar - * - * @param integer Date range start (unix timestamp) - * @param integer Date range end (unix timestamp) - * @param array Additional query to filter events - * - * @return integer Count - */ - public function count_events($start, $end = null, $filter_query = null) - { - // not implemented - return 0; - } - - /** - * Helper method to fetch free/busy data for the user and turn it into calendar data - */ - private function fetch_freebusy($limit_changed = null) - { - // ask kolab server first - try { - $request_config = array( - 'store_body' => true, - 'follow_redirects' => true, - ); - $request = libkolab::http_request(kolab_storage::get_freebusy_url($this->userdata['mail']), 'GET', $request_config); - $response = $request->send(); - - // authentication required - if ($response->getStatus() == 401) { - $request->setAuth($this->cal->rc->user->get_username(), $this->cal->rc->decrypt($_SESSION['password'])); - $response = $request->send(); - } - - if ($response->getStatus() == 200) - $fbdata = $response->getBody(); - - unset($request, $response); + /** + * Helper to build a key for the absolute time slot the given event convers + */ + private function time_key($event) + { + return sprintf('%s/%s', $event['start']->format('U'), is_object($event['end']) ? $event['end']->format('U') : '0'); } - catch (Exception $e) { - rcube::raise_error(array( - 'code' => 900, - 'type' => 'php', - 'file' => __FILE__, - 'line' => __LINE__, - 'message' => "Error fetching free/busy information: " . $e->getMessage()), - true, false); - - return false; + + /** + * Create a new event record + * + * @see calendar_driver::new_event() + * + * @return mixed The created record ID on success, False on error + */ + public function insert_event($event) + { + return false; } - $statusmap = array( - 'FREE' => 'free', - 'BUSY' => 'busy', - 'BUSY-TENTATIVE' => 'tentative', - 'X-OUT-OF-OFFICE' => 'outofoffice', - 'OOF' => 'outofoffice', - ); - $titlemap = array( - 'FREE' => $this->cal->gettext('availfree'), - 'BUSY' => $this->cal->gettext('availbusy'), - 'BUSY-TENTATIVE' => $this->cal->gettext('availtentative'), - 'X-OUT-OF-OFFICE' => $this->cal->gettext('availoutofoffice'), - ); - - // rcube::console('_fetch_freebusy', kolab_storage::get_freebusy_url($this->userdata['mail']), $fbdata); - - // parse free-busy information - $count = 0; - if ($fbdata) { - $ical = $this->cal->get_ical(); - $ical->import($fbdata); - if ($fb = $ical->freebusy) { - // consider 'changed >= X' queries - if ($limit_changed && $fb['created'] && $fb['created'] < $limit_changed) { - return 0; - } + /** + * Update a specific event record + * + * @see calendar_driver::new_event() + * @return bool True on success, False on error + */ + public function update_event($event, $exception_id = null) + { + return false; + } - foreach ($fb['periods'] as $tuple) { - list($from, $to, $type) = $tuple; - $event = array( - 'uid' => md5($this->id . $from->format('U') . '/' . $to->format('U')), - 'calendar' => $this->id, - 'changed' => $fb['created'] ?: new DateTime(), - 'title' => $this->get_name() . ' ' . ($titlemap[$type] ?: $type), - 'start' => $from, - 'end' => $to, - 'free_busy' => $statusmap[$type] ?: 'busy', - 'className' => 'fc-type-freebusy', - 'organizer' => array( - 'email' => $this->userdata['mail'], - 'name' => $this->userdata['displayname'], - ), - ); - - // avoid duplicate entries - $key = $this->time_key($event); - if (!$this->timeindex[$key]) { - $this->events[$event['uid']] = $event; - $this->timeindex[$key] = $event['uid']; - $count++; - } - } - } + /** + * Delete an event record + * + * @see calendar_driver::remove_event() + * @return bool True on success, False on error + */ + public function delete_event($event, $force = true) + { + return false; } - return $count; - } - - /** - * Helper to build a key for the absolute time slot the given event convers - */ - private function time_key($event) - { - return sprintf('%s/%s', $event['start']->format('U'), is_object($event['end']) ? $event['end']->format('U') : '0'); - } - - /** - * Create a new event record - * - * @see calendar_driver::new_event() - * - * @return mixed The created record ID on success, False on error - */ - public function insert_event($event) - { - return false; - } - - /** - * Update a specific event record - * - * @see calendar_driver::new_event() - * @return boolean True on success, False on error - */ - public function update_event($event, $exception_id = null) - { - return false; - } - - /** - * Delete an event record - * - * @see calendar_driver::remove_event() - * @return boolean True on success, False on error - */ - public function delete_event($event, $force = true) - { - return false; - } - - /** - * Restore deleted event record - * - * @see calendar_driver::undelete_event() - * @return boolean True on success, False on error - */ - public function restore_event($event) - { - return false; - } + /** + * Restore deleted event record + * + * @see calendar_driver::undelete_event() + * @return bool True on success, False on error + */ + public function restore_event($event) + { + return false; + } } diff --git a/plugins/calendar/drivers/ldap/resources_driver_ldap.php b/plugins/calendar/drivers/ldap/resources_driver_ldap.php index bd2d610c..0be41261 100644 --- a/plugins/calendar/drivers/ldap/resources_driver_ldap.php +++ b/plugins/calendar/drivers/ldap/resources_driver_ldap.php @@ -1,154 +1,157 @@ * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /** * LDAP-based resource directory implementation */ class resources_driver_ldap extends resources_driver { private $rc; private $ldap; /** * Default constructor */ function __construct($cal) { $this->cal = $cal; $this->rc = $cal->rc; } /** * Fetch resource objects to be displayed for booking * - * @param string Search query (optional) - * @return array List of resource records available for booking + * @param string $query Search query (optional) + * @param int $num Max size of the result + * + * @return array List of resource records available for booking */ public function load_resources($query = null, $num = 5000) { - if (!($ldap = $this->connect())) { - return array(); - } - - // TODO: apply paging - $ldap->set_pagesize($num); - - if (isset($query)) { - $results = $ldap->search('*', $query, 0, true, true); - } - else { - $results = $ldap->list_records(); - } - - if ($results instanceof ArrayAccess) { - foreach ($results as $i => $rec) { - $results[$i] = $this->decode_resource($rec); + if (!($ldap = $this->connect())) { + return []; + } + + // TODO: apply paging + $ldap->set_pagesize($num); + + if (isset($query)) { + $results = $ldap->search('*', $query, 0, true, true); + } + else { + $results = $ldap->list_records(); } - } - return $results; + if ($results instanceof ArrayAccess) { + foreach ($results as $i => $rec) { + $results[$i] = $this->decode_resource($rec); + } + } + + return $results; } /** * Return properties of a single resource * - * @param string Unique resource identifier + * @param string $id Unique resource identifier + * * @return array Resource object as hash array */ public function get_resource($dn) { - $rec = null; + $rec = null; - if ($ldap = $this->connect()) { - $rec = $ldap->get_record(rcube_ldap::dn_encode($dn), true); + if ($ldap = $this->connect()) { + $rec = $ldap->get_record(rcube_ldap::dn_encode($dn), true); - if (!empty($rec)) { - $rec = $this->decode_resource($rec); + if (!empty($rec)) { + $rec = $this->decode_resource($rec); + } } - } - return $rec; + return $rec; } /** * Return properties of a resource owner * - * @param string Owner identifier - * @return array Resource object as hash array + * @param string $dn Owner identifier + * + * @return array Resource object as hash array */ public function get_resource_owner($dn) { - $owner = null; + $owner = null; - if ($ldap = $this->connect()) { - $owner = $ldap->get_record(rcube_ldap::dn_encode($dn), true); - $owner['ID'] = rcube_ldap::dn_decode($owner['ID']); - unset($owner['_raw_attrib'], $owner['_type']); - } + if ($ldap = $this->connect()) { + $owner = $ldap->get_record(rcube_ldap::dn_encode($dn), true); + $owner['ID'] = rcube_ldap::dn_decode($owner['ID']); + unset($owner['_raw_attrib'], $owner['_type']); + } - return $owner; + return $owner; } /** * Extract JSON-serialized attributes */ private function decode_resource($rec) { - $rec['ID'] = rcube_ldap::dn_decode($rec['ID']); - - $attributes = array(); - - foreach ((array) $rec['attributes'] as $sattr) { - $sattr = trim($sattr); - if ($sattr && $sattr[0] === '{') { - $attr = @json_decode($sattr, true); - $attributes += $attr; + $rec['ID'] = rcube_ldap::dn_decode($rec['ID']); + + $attributes = []; + + foreach ((array) $rec['attributes'] as $sattr) { + $sattr = trim($sattr); + if (!empty($sattr) && $sattr[0] === '{') { + $attr = @json_decode($sattr, true); + $attributes += $attr; + } + else if (!empty($sattr) && empty($rec['description'])) { + $rec['description'] = $sattr; + } } - else if ($sattr && empty($rec['description'])) { - $rec['description'] = $sattr; - } - } - $rec['attributes'] = $attributes; + $rec['attributes'] = $attributes; - // force $rec['members'] to be an array - if (!empty($rec['members']) && !is_array($rec['members'])) { - $rec['members'] = array($rec['members']); - } + // force $rec['members'] to be an array + if (!empty($rec['members']) && !is_array($rec['members'])) { + $rec['members'] = [$rec['members']]; + } - // remove unused cruft - unset($rec['_raw_attrib']); + // remove unused cruft + unset($rec['_raw_attrib']); - return $rec; + return $rec; } private function connect() { - if (!isset($this->ldap)) { - $this->ldap = new rcube_ldap($this->rc->config->get('calendar_resources_directory'), true); - } + if (!isset($this->ldap)) { + $this->ldap = new rcube_ldap($this->rc->config->get('calendar_resources_directory'), true); + } - return $this->ldap->ready ? $this->ldap : null; + return $this->ldap->ready ? $this->ldap : null; } - -} \ No newline at end of file +} diff --git a/plugins/calendar/drivers/resources_driver.php b/plugins/calendar/drivers/resources_driver.php index 4c141cdf..a81a0fff 100644 --- a/plugins/calendar/drivers/resources_driver.php +++ b/plugins/calendar/drivers/resources_driver.php @@ -1,112 +1,118 @@ * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /** * Interface definition for a resources directory driver classe */ abstract class resources_driver { - protected $cal; + protected $cal; - /** - * Default constructor - */ - function __construct($cal) - { - $this->cal = $cal; - } + /** + * Default constructor + */ + function __construct($cal) + { + $this->cal = $cal; + } - /** - * Fetch resource objects to be displayed for booking - * - * @param string Search query (optional) - * @return array List of resource records available for booking - */ - abstract public function load_resources($query = null); + /** + * Fetch resource objects to be displayed for booking + * + * @param string $query Search query (optional) + * + * @return array List of resource records available for booking + */ + abstract public function load_resources($query = null); - /** - * Return properties of a single resource - * - * @param string Unique resource identifier - * @return array Resource object as hash array - */ - abstract public function get_resource($id); + /** + * Return properties of a single resource + * + * @param string $id Unique resource identifier + * + * @return array Resource object as hash array + */ + abstract public function get_resource($id); - /** - * Return properties of a resource owner - * - * @param string Owner identifier - * @return array Resource object as hash array - */ - public function get_resource_owner($id) - { - return null; - } + /** + * Return properties of a resource owner + * + * @param string $id Owner identifier + * + * @return array Resource object as hash array + */ + public function get_resource_owner($id) + { + return null; + } - /** - * Get event data to display a resource's calendar - * - * The default implementation extracts the resource's email address - * and fetches free-busy data using the calendar backend driver. - * - * @param integer Event's new start (unix timestamp) - * @param integer Event's new end (unix timestamp) - * @return array A list of event objects (see calendar_driver specification) - */ - public function get_resource_calendar($id, $start, $end) - { - $events = array(); - $rec = $this->get_resource($id); - if ($rec && !empty($rec['email']) && $this->cal->driver) { - $fbtypemap = array( - calendar::FREEBUSY_BUSY => 'busy', - calendar::FREEBUSY_TENTATIVE => 'tentative', - calendar::FREEBUSY_OOF => 'outofoffice', - ); + /** + * Get event data to display a resource's calendar + * + * The default implementation extracts the resource's email address + * and fetches free-busy data using the calendar backend driver. + * + * @param string $id Calendar identifier + * @param int $start Event's new start (unix timestamp) + * @param int $end Event's new end (unix timestamp) + * + * @return array A list of event objects (see calendar_driver specification) + */ + public function get_resource_calendar($id, $start, $end) + { + $events = []; + $rec = $this->get_resource($id); - // if the backend has free-busy information - $fblist = $this->cal->driver->get_freebusy_list($rec['email'], $start, $end); - if (is_array($fblist)) { - foreach ($fblist as $slot) { - list($from, $to, $type) = $slot; - if ($type == calendar::FREEBUSY_FREE || $type == calendar::FREEBUSY_UNKNOWN) { - continue; - } - if ($from < $end && $to > $start) { - $event = array( - 'id' => sha1($id . $from . $to), - 'title' => $rec['name'], - 'start' => new DateTime('@' . $from), - 'end' => new DateTime('@' . $to), - 'status' => $fbtypemap[$type], - 'calendar' => '_resource', - ); - $events[] = $event; - } - } - } - } + if ($rec && !empty($rec['email']) && !empty($this->cal->driver)) { + $fbtypemap = [ + calendar::FREEBUSY_BUSY => 'busy', + calendar::FREEBUSY_TENTATIVE => 'tentative', + calendar::FREEBUSY_OOF => 'outofoffice', + ]; - return $events; - } + // if the backend has free-busy information + $fblist = $this->cal->driver->get_freebusy_list($rec['email'], $start, $end); + if (is_array($fblist)) { + foreach ($fblist as $slot) { + list($from, $to, $type) = $slot; + if ($type == calendar::FREEBUSY_FREE || $type == calendar::FREEBUSY_UNKNOWN) { + continue; + } + + if ($from < $end && $to > $start) { + $events[] = [ + 'id' => sha1($id . $from . $to), + 'title' => $rec['name'], + 'start' => new DateTime('@' . $from), + 'end' => new DateTime('@' . $to), + 'status' => $fbtypemap[$type], + 'calendar' => '_resource', + ]; + } + } + } + } + + return $events; + } } diff --git a/plugins/calendar/lib/calendar_itip.php b/plugins/calendar/lib/calendar_itip.php index e2a2402b..4f7f382f 100644 --- a/plugins/calendar/lib/calendar_itip.php +++ b/plugins/calendar/lib/calendar_itip.php @@ -1,240 +1,258 @@ * @package @package_name@ * * Copyright (C) 2011, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class calendar_itip extends libcalendaring_itip { - /** - * Constructor to set text domain to calendar - */ - function __construct($plugin, $domain = 'calendar') - { - parent::__construct($plugin, $domain); - - $this->db_itipinvitations = $this->rc->db->table_name('itipinvitations', true); - } - - /** - * Handler for calendar/itip-status requests - */ - public function get_itip_status($event, $existing = null) - { - $status = parent::get_itip_status($event, $existing); - - // don't ask for deleting events when declining - if ($this->rc->config->get('kolab_invitation_calendars')) - $status['saved'] = false; - - return $status; - } - - /** - * Find invitation record by token - * - * @param string Invitation token - * @return mixed Invitation record as hash array or False if not found - */ - public function get_invitation($token) - { - if ($parts = $this->decode_token($token)) { - $result = $this->rc->db->query("SELECT * FROM $this->db_itipinvitations WHERE `token` = ?", $parts['base']); - if ($result && ($rec = $this->rc->db->fetch_assoc($result))) { - $rec['event'] = unserialize($rec['event']); - $rec['attendee'] = $parts['attendee']; - return $rec; - } + /** + * Constructor to set text domain to calendar + */ + function __construct($plugin, $domain = 'calendar') + { + parent::__construct($plugin, $domain); + + $this->db_itipinvitations = $this->rc->db->table_name('itipinvitations', true); + } + + /** + * Handler for calendar/itip-status requests + */ + public function get_itip_status($event, $existing = null) + { + $status = parent::get_itip_status($event, $existing); + + // don't ask for deleting events when declining + if ($this->rc->config->get('kolab_invitation_calendars')) { + $status['saved'] = false; + } + + return $status; } - - return false; - } - - /** - * Update the attendee status of the given invitation record - * - * @param array Invitation record as fetched with calendar_itip::get_invitation() - * @param string Attendee email address - * @param string New attendee status - */ - public function update_invitation($invitation, $email, $newstatus) - { - if (is_string($invitation)) - $invitation = $this->get_invitation($invitation); - - if ($invitation['token'] && $invitation['event']) { - // update attendee record in event data - foreach ($invitation['event']['attendees'] as $i => $attendee) { - if ($attendee['role'] == 'ORGANIZER') { - $organizer = $attendee; + + /** + * Find invitation record by token + * + * @param string $token Invitation token + * + * @return mixed Invitation record as hash array or False if not found + */ + public function get_invitation($token) + { + if ($parts = $this->decode_token($token)) { + $result = $this->rc->db->query("SELECT * FROM $this->db_itipinvitations WHERE `token` = ?", $parts['base']); + if ($result && ($rec = $this->rc->db->fetch_assoc($result))) { + $rec['event'] = unserialize($rec['event']); + $rec['attendee'] = $parts['attendee']; + + return $rec; + } + } + + return false; + } + + /** + * Update the attendee status of the given invitation record + * + * @param array $invitation Invitation record as fetched with calendar_itip::get_invitation() + * @param string $email Attendee email address + * @param string $newstatus New attendee status + */ + public function update_invitation($invitation, $email, $newstatus) + { + if (is_string($invitation)) { + $invitation = $this->get_invitation($invitation); + } + + if (!empty($invitation['token']) && !empty($invitation['event'])) { + // update attendee record in event data + foreach ($invitation['event']['attendees'] as $i => $attendee) { + if ($attendee['role'] == 'ORGANIZER') { + $organizer = $attendee; + } + else if ($attendee['email'] == $email) { + // nothing to be done here + if ($attendee['status'] == $newstatus) { + return true; + } + + $invitation['event']['attendees'][$i]['status'] = $newstatus; + $this->sender = $attendee; + } + } + + $invitation['event']['changed'] = new DateTime(); + + // send iTIP REPLY message to organizer + if (!empty($organizer)) { + $status = strtolower($newstatus); + if ($this->send_itip_message($invitation['event'], 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status)) { + $mailto = !empty($organizer['name']) ? $organizer['name'] : $organizer['email']; + $message = $this->plugin->gettext([ + 'name' => 'sentresponseto', + 'vars' => ['mailto' => $mailto] + ]); + $this->rc->output->command('display_message', $message, 'confirmation'); + } + else { + $this->rc->output->command('display_message', $this->plugin->gettext('itipresponseerror'), 'error'); + } + } + + // update record in DB + $query = $this->rc->db->query( + "UPDATE $this->db_itipinvitations SET `event` = ? WHERE `token` = ?", + self::serialize_event($invitation['event']), + $invitation['token'] + ); + + if ($this->rc->db->affected_rows($query)) { + return true; + } + } + + return false; + } + + /** + * Create iTIP invitation token for later replies via URL + * + * @param array $event Hash array with event properties + * @param string $attendee Attendee email address + * + * @return string Invitation token + */ + public function store_invitation($event, $attendee) + { + static $stored = []; + + if (empty($event['uid']) || !$attendee) { + return false; + } + + // generate token for this invitation + $token = $this->generate_token($event, $attendee); + $base = substr($token, 0, 40); + + // already stored this + if (!empty($stored[$base])) { + return $token; } - else if ($attendee['email'] == $email) { - // nothing to be done here - if ($attendee['status'] == $newstatus) - return true; - - $invitation['event']['attendees'][$i]['status'] = $newstatus; - $this->sender = $attendee; + + // delete old entry + $this->rc->db->query("DELETE FROM $this->db_itipinvitations WHERE `token` = ?", $base); + + $event_uid = $event['uid'] . (!empty($event['_instance']) ? '-' . $event['_instance'] : ''); + + $query = $this->rc->db->query( + "INSERT INTO $this->db_itipinvitations" + . " (`token`, `event_uid`, `user_id`, `event`, `expires`)" + . " VALUES(?, ?, ?, ?, ?)", + $base, + $event_uid, + $this->rc->user->ID, + self::serialize_event($event), + date('Y-m-d H:i:s', $event['end']->format('U') + 86400 * 2) + ); + + if ($this->rc->db->affected_rows($query)) { + $stored[$base] = 1; + return $token; } - } - $invitation['event']['changed'] = new DateTime(); - - // send iTIP REPLY message to organizer - if ($organizer) { - $status = strtolower($newstatus); - if ($this->send_itip_message($invitation['event'], 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status)) - $this->rc->output->command('display_message', $this->plugin->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ? $organizer['name'] : $organizer['email']))), 'confirmation'); - else - $this->rc->output->command('display_message', $this->plugin->gettext('itipresponseerror'), 'error'); - } - - // update record in DB - $query = $this->rc->db->query( - "UPDATE $this->db_itipinvitations - SET `event` = ? - WHERE `token` = ?", - self::serialize_event($invitation['event']), - $invitation['token'] - ); - - if ($this->rc->db->affected_rows($query)) - return true; + + return false; } - - return false; - } - - - /** - * Create iTIP invitation token for later replies via URL - * - * @param array Hash array with event properties - * @param string Attendee email address - * @return string Invitation token - */ - public function store_invitation($event, $attendee) - { - static $stored = array(); - - if (!$event['uid'] || !$attendee) - return false; - - // generate token for this invitation - $token = $this->generate_token($event, $attendee); - $base = substr($token, 0, 40); - - // already stored this - if ($stored[$base]) - return $token; - - // delete old entry - $this->rc->db->query("DELETE FROM $this->db_itipinvitations WHERE `token` = ?", $base); - - $event_uid = $event['uid'] . ($event['_instance'] ? '-' . $event['_instance'] : ''); - - $query = $this->rc->db->query( - "INSERT INTO $this->db_itipinvitations - (`token`, `event_uid`, `user_id`, `event`, `expires`) - VALUES(?, ?, ?, ?, ?)", - $base, - $event_uid, - $this->rc->user->ID, - self::serialize_event($event), - date('Y-m-d H:i:s', $event['end']->format('U') + 86400 * 2) - ); - - if ($this->rc->db->affected_rows($query)) { - $stored[$base] = 1; - return $token; + + /** + * Mark invitations for the given event as cancelled + * + * @param array $event Hash array with event properties + */ + public function cancel_itip_invitation($event) + { + $event_uid = $event['uid'] . (!empty($event['_instance']) ? '-' . $event['_instance'] : ''); + + // flag invitation record as cancelled + $this->rc->db->query( + "UPDATE $this->db_itipinvitations SET `cancelled` = 1" + . " WHERE `event_uid` = ? AND `user_id` = ?", + $event_uid, + $this->rc->user->ID + ); } - - return false; - } - - /** - * Mark invitations for the given event as cancelled - * - * @param array Hash array with event properties - */ - public function cancel_itip_invitation($event) - { - $event_uid = $event['uid'] . ($event['_instance'] ? '-' . $event['_instance'] : ''); - - // flag invitation record as cancelled - $this->rc->db->query( - "UPDATE $this->db_itipinvitations - SET `cancelled` = 1 - WHERE `event_uid` = ? AND `user_id` = ?", - $event_uid, - $this->rc->user->ID - ); - } - - /** - * Generate an invitation request token for the given event and attendee - * - * @param array Event hash array - * @param string Attendee email address - */ - public function generate_token($event, $attendee) - { - $event_uid = $event['uid'] . ($event['_instance'] ? '-' . $event['_instance'] : ''); - $base = sha1($event_uid . ';' . $this->rc->user->ID); - $mail = base64_encode($attendee); - $hash = substr(md5($base . $mail . $this->rc->config->get('des_key')), 0, 6); - - return "$base.$mail.$hash"; - } - - /** - * Decode the given iTIP request token and return its parts - * - * @param string Request token to decode - * @return mixed Hash array with parts or False if invalid - */ - public function decode_token($token) - { - list($base, $mail, $hash) = explode('.', $token); - - // validate and return parts - if ($mail && $hash && $hash == substr(md5($base . $mail . $this->rc->config->get('des_key')), 0, 6)) { - return array('base' => $base, 'attendee' => base64_decode($mail)); + + /** + * Generate an invitation request token for the given event and attendee + * + * @param array $event Event hash array + * @param string $attendee Attendee email address + */ + public function generate_token($event, $attendee) + { + $event_uid = $event['uid'] . (!empty($event['_instance']) ? '-' . $event['_instance'] : ''); + $base = sha1($event_uid . ';' . $this->rc->user->ID); + $mail = base64_encode($attendee); + $hash = substr(md5($base . $mail . $this->rc->config->get('des_key')), 0, 6); + + return "$base.$mail.$hash"; + } + + /** + * Decode the given iTIP request token and return its parts + * + * @param string $token Request token to decode + * + * @return mixed Hash array with parts or False if invalid + */ + public function decode_token($token) + { + list($base, $mail, $hash) = explode('.', $token); + + // validate and return parts + if ($mail && $hash && $hash == substr(md5($base . $mail . $this->rc->config->get('des_key')), 0, 6)) { + return ['base' => $base, 'attendee' => base64_decode($mail)]; + } + + return false; } - - return false; - } - - /** - * Helper method to serialize the given event for storing in invitations table - */ - private static function serialize_event($event) - { - $ev = $event; - $ev['description'] = abbreviate_string($ev['description'], 100); - unset($ev['attachments']); - return serialize($ev); - } + /** + * Helper method to serialize the given event for storing in invitations table + */ + private static function serialize_event($event) + { + $ev = $event; + + if (!empty($ev['description'])) { + $ev['description'] = abbreviate_string($ev['description'], 100); + } + + unset($ev['attachments']); + + return serialize($ev); + } } diff --git a/plugins/calendar/lib/calendar_recurrence.php b/plugins/calendar/lib/calendar_recurrence.php index c0d7c793..4888fe18 100644 --- a/plugins/calendar/lib/calendar_recurrence.php +++ b/plugins/calendar/lib/calendar_recurrence.php @@ -1,88 +1,89 @@ * * Copyright (C) 2012-2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class calendar_recurrence extends libcalendaring_recurrence { - private $event; - private $duration; + private $event; + private $duration; - /** - * Default constructor - * - * @param object calendar The calendar plugin instance - * @param array The event object to operate on - */ - function __construct($cal, $event) - { - parent::__construct($cal->lib); + /** + * Default constructor + * + * @param calendar $cal The calendar plugin instance + * @param array $event The event object to operate on + */ + function __construct($cal, $event) + { + parent::__construct($cal->lib); - $this->event = $event; + $this->event = $event; - if (is_object($event['start']) && is_object($event['end'])) - $this->duration = $event['start']->diff($event['end']); + if (is_object($event['start']) && is_object($event['end'])) { + $this->duration = $event['start']->diff($event['end']); + } - $event['start']->_dateonly |= $event['allday']; - $this->init($event['recurrence'], $event['start']); - } + $event['start']->_dateonly = !empty($event['allday']); - /** - * Alias of libcalendaring_recurrence::next() - * - * @return mixed DateTime object or False if recurrence ended - */ - public function next_start() - { - return $this->next(); - } + $this->init($event['recurrence'], $event['start']); + } - /** - * Get the next recurring instance of this event - * - * @return mixed Array with event properties or False if recurrence ended - */ - public function next_instance() - { - if ($next_start = $this->next()) { - $next = $this->event; - $next['start'] = $next_start; + /** + * Alias of libcalendaring_recurrence::next() + * + * @return mixed DateTime object or False if recurrence ended + */ + public function next_start() + { + return $this->next(); + } - if ($this->duration) { - $next['end'] = clone $next_start; - $next['end']->add($this->duration); - } + /** + * Get the next recurring instance of this event + * + * @return mixed Array with event properties or False if recurrence ended + */ + public function next_instance() + { + if ($next_start = $this->next()) { + $next = $this->event; + $next['start'] = $next_start; - $next['recurrence_date'] = clone $next_start; - $next['_instance'] = libcalendaring::recurrence_instance_identifier($next, $this->event['allday']); + if ($this->duration) { + $next['end'] = clone $next_start; + $next['end']->add($this->duration); + } - unset($next['_formatobj']); + $next['recurrence_date'] = clone $next_start; + $next['_instance'] = libcalendaring::recurrence_instance_identifier($next, $this->event['allday']); - return $next; - } + unset($next['_formatobj']); - return false; - } + return $next; + } + return false; + } } diff --git a/plugins/calendar/lib/calendar_ui.php b/plugins/calendar/lib/calendar_ui.php index 63ab9c3b..0d1b1297 100644 --- a/plugins/calendar/lib/calendar_ui.php +++ b/plugins/calendar/lib/calendar_ui.php @@ -1,843 +1,1025 @@ * @author Thomas Bruederli * * Copyright (C) 2010, Lazlo Westerhof * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class calendar_ui { - private $rc; - private $cal; - private $ready = false; - public $screen; - - function __construct($cal) - { - $this->cal = $cal; - $this->rc = $cal->rc; - $this->screen = $this->rc->task == 'calendar' ? ($this->rc->action ? $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(array( - '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', array($this, 'calendar_css')); - $this->cal->register_handler('plugin.calendar_list', array($this, 'calendar_list')); - $this->cal->register_handler('plugin.calendar_select', array($this, 'calendar_select')); - $this->cal->register_handler('plugin.identity_select', array($this, 'identity_select')); - $this->cal->register_handler('plugin.category_select', array($this, 'category_select')); - $this->cal->register_handler('plugin.status_select', array($this, 'status_select')); - $this->cal->register_handler('plugin.freebusy_select', array($this, 'freebusy_select')); - $this->cal->register_handler('plugin.priority_select', array($this, 'priority_select')); - $this->cal->register_handler('plugin.sensitivity_select', array($this, 'sensitivity_select')); - $this->cal->register_handler('plugin.alarm_select', array($this, 'alarm_select')); - $this->cal->register_handler('plugin.recurrence_form', array($this->cal->lib, 'recurrence_form')); - $this->cal->register_handler('plugin.attendees_list', array($this, 'attendees_list')); - $this->cal->register_handler('plugin.attendees_form', array($this, 'attendees_form')); - $this->cal->register_handler('plugin.resources_form', array($this, 'resources_form')); - $this->cal->register_handler('plugin.resources_list', array($this, 'resources_list')); - $this->cal->register_handler('plugin.resources_searchform', array($this, 'resources_search_form')); - $this->cal->register_handler('plugin.resource_info', array($this, 'resource_info')); - $this->cal->register_handler('plugin.resource_calendar', array($this, 'resource_calendar')); - $this->cal->register_handler('plugin.attendees_freebusy_table', array($this, 'attendees_freebusy_table')); - $this->cal->register_handler('plugin.edit_attendees_notify', array($this, 'edit_attendees_notify')); - $this->cal->register_handler('plugin.edit_recurrence_sync', array($this, 'edit_recurrence_sync')); - $this->cal->register_handler('plugin.edit_recurring_warning', array($this, 'recurring_event_warning')); - $this->cal->register_handler('plugin.event_rsvp_buttons', array($this, 'event_rsvp_buttons')); - $this->cal->register_handler('plugin.agenda_options', array($this, 'agenda_options')); - $this->cal->register_handler('plugin.events_import_form', array($this, 'events_import_form')); - $this->cal->register_handler('plugin.events_export_form', array($this, 'events_export_form')); - $this->cal->register_handler('plugin.object_changelog_table', array('libkolab', 'object_changelog_table')); - $this->cal->register_handler('plugin.searchform', array($this->rc->output, 'search_form')); // use generic method from rcube_template - - kolab_attachments_handler::ui(); - } - - /** - * Adds CSS stylesheets to the page header - */ - public function addCSS() - { - $skin_path = $this->cal->local_skin_path(); + private $rc; + private $cal; + private $ready = false; + + public $screen; + + 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.sensitivity_select', [$this, 'sensitivity_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, array('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'); + 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
    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 (!empty($prop['subscribed'])) { + $actions .= html::a([ + 'href' => '#', + 'class' => 'subscribed', + 'title' => $this->cal->gettext('calendarsubscribe'), + 'role' => 'checkbox', + 'aria-checked' => !empty($prop['subscribed']) ? 'true' : 'false' + ], ' ' + ); + } + + $content .= html::tag('input', [ + 'type' => 'checkbox', + 'name' => '_cal[]', + 'value' => $id, + 'checked' => !empty($prop['active']), + 'aria-labelledby' => $label_id + ]) + . html::span('actions', $actions) + . html::span(['class' => 'handle', 'style' => "background-color: #$color"], ' '); + } + + $content = html::div(join(' ', $classes), $content); + } + + return $content; + } + + /** + * Render a HTML for agenda options form + */ + function agenda_options($attrib = []) + { + $attrib += ['id' => 'agendaoptions']; + $attrib['style'] = 'display:none'; + + $select_range = new html_select(['name' => 'listrange', 'id' => 'agenda-listrange', 'class' => 'form-control custom-select']); + $select_range->add(1 . ' ' . preg_replace('/\(.+\)/', '', $this->cal->lib->gettext('days')), ''); + + foreach ([2,5,7,14,30,60,90,180,365] as $days) { + $select_range->add($days . ' ' . preg_replace('/\(|\)/', '', $this->cal->lib->gettext('days')), $days); + } + + $html = html::span('input-group', + html::label(['for' => 'agenda-listrange', 'class' => 'input-group-prepend'], + html::span('input-group-text', $this->cal->gettext('listrange')) + ) + . $select_range->show($this->rc->config->get('calendar_agenda_range', $this->cal->defaults['calendar_agenda_range'])) + ); + + return html::div($attrib, $html); } - } - - /** - * 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'); + /** + * 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); } - 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(); + + /** + * 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); } - } - /** - * - */ - function calendar_css($attrib = array()) - { - $categories = $this->cal->driver->list_categories(); - $js_categories = array(); - $mode = $this->rc->config->get('calendar_event_coloring', $this->cal->defaults['calendar_event_coloring']); - $css = "\n"; + /** + * Render a HTML select box to select an event category + */ + function category_select($attrib = []) + { + $attrib['name'] = 'categories'; - foreach ((array)$categories as $class => $color) { - if (!empty($color)) { - $js_categories[$class] = $color; + $select = new html_select($attrib); + $select->add('---', ''); + foreach (array_keys((array) $this->cal->driver->list_categories()) as $cat) { + $select->add($cat, $cat); + } - $color = ltrim($color, '#'); - $class = 'cat-' . asciiwords(strtolower($class), true); - $css .= ".$class { color: #$color; }\n"; - } + return $select->show(null); } - $this->rc->output->set_env('calendar_categories', $js_categories); + /** + * 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'); - $calendars = $this->cal->driver->list_calendars(); - foreach ((array)$calendars as $id => $prop) { - if ($prop['color']) { - $css .= $this->calendar_css_classes($id, $prop, $mode, $attrib); - } + return $select->show(null); } - return html::tag('style', array('type' => 'text/css'), $css); - } + /** + * 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); + } - /** - * - */ - public function calendar_css_classes($id, $prop, $mode, $attrib = array()) - { - $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 = str_replace('$class', $class, $attrib['folder-class']) ?: "li .$class"; - $css .= " { color: #$folder_color; }\n"; + /** + * 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); + } - return $css . ".$class .handle { background-color: #$color; }\n"; - } + /** + * Render HTML input for sensitivity selection + */ + function sensitivity_select($attrib = []) + { + $attrib['name'] = 'sensitivity'; + + $select = new html_select($attrib); + $select->add($this->cal->gettext('public'), 'public'); + $select->add($this->cal->gettext('private'), 'private'); + $select->add($this->cal->gettext('confidential'), 'confidential'); + + 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'))); + } - /** - * - */ - function calendar_list($attrib = array(), $js_only = false) - { - $html = ''; - $jsenv = array(); - $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 ($bdaycal = $calendars[calendar_driver::BIRTHDAY_CALENDAR_ID]) { - $calendars = array(calendar_driver::BIRTHDAY_CALENDAR_ID => $bdaycal); - } - else { - $calendars = array(); // clear array for flat listing - } - } - else { - // fall-back to flat folder listing - $attrib['class'] .= ' flat'; - } - - foreach ((array)$calendars as $id => $prop) { - if ($attrib['activeonly'] && !$prop['active']) - continue; - - $html .= html::tag('li', array('id' => 'rcmlical' . $id, 'class' => $prop['group']), - $content = $this->calendar_list_item($id, $prop, $jsenv, $attrib['activeonly']) - ); - } - - $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', $attrib['id'] ?: 'unknown'); - - return html::tag('ul', $attrib, $html, html::$common_attrib); - } - - /** - * Return html for a structured list
      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, $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' => 'rcmlical' . rcube_utils::html_identifier($id), - 'class' => $prop['group'] . ($prop['virtual'] ? ' virtual' : ''), - ), - $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 (!$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(array('_cal' => $this->cal->ical_feed_hash($id) . '.ics', 'action' => 'feed')); - - $jsenv[$id] = $prop; - } - - $classes = array('calendar', 'cal-' . asciiwords($id, true)); - $title = $prop['title'] ?: ($prop['name'] != $prop['listname'] || strlen($prop['name']) > 25 ? - html_entity_decode($prop['name'], ENT_COMPAT, RCUBE_CHARSET) : ''); - - if ($prop['virtual']) - $classes[] = 'virtual'; - else if (!$prop['editable']) - $classes[] = 'readonly'; - if ($prop['subscribed']) - $classes[] = 'subscribed'; - if ($prop['subscribed'] === 2) - $classes[] = 'partial'; - if ($prop['class']) - $classes[] = $prop['class']; - - $content = ''; - if (!$activeonly || $prop['active']) { - $label_id = 'cl:' . $id; - $content = html::div(join(' ', $classes), - html::a(array('class' => 'calname', 'id' => $label_id, 'title' => $title, 'href' => '#'), rcube::Q($prop['editname'] ?: $prop['listname'])) - . ($prop['virtual'] ? '' : - html::tag('input', array('type' => 'checkbox', 'name' => '_cal[]', 'value' => $id, 'checked' => $prop['active'], 'aria-labelledby' => $label_id)) . - html::span('actions', - ($prop['removable'] ? html::a(array('href' => '#', 'class' => 'remove', 'title' => $this->cal->gettext('removelist')), ' ') : '') . - html::a(array('href' => '#', 'class' => 'quickview', 'title' => $this->cal->gettext('quickview'), 'role' => 'checkbox', 'aria-checked' => 'false'), '') . - (isset($prop['subscribed']) ? html::a(array('href' => '#', 'class' => 'subscribed', 'title' => $this->cal->gettext('calendarsubscribe'), 'role' => 'checkbox', 'aria-checked' => $prop['subscribed'] ? 'true' : 'false'), ' ') : '') - ) . - html::span(array('class' => 'handle', 'style' => "background-color: #" . ($prop['color'] ?: 'f00')), ' ') - ) - ); - } - - return $content; - } - - /** - * Render a HTML for agenda options form - */ - function agenda_options($attrib = array()) - { - $attrib += array('id' => 'agendaoptions'); - $attrib['style'] .= 'display:none'; - - $select_range = new html_select(array('name' => 'listrange', 'id' => 'agenda-listrange', 'class' => 'form-control custom-select')); - $select_range->add(1 . ' ' . preg_replace('/\(.+\)/', '', $this->cal->lib->gettext('days')), $days); - foreach (array(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(array('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 = array()) - { - $attrib['name'] = 'calendar'; - $attrib['is_escaped'] = true; - $select = new html_select($attrib); - - foreach ((array)$this->cal->driver->list_calendars() as $id => $prop) { - if ($prop['editable'] || 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 = array()) - { - $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 = array()) - { - $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 = array()) - { - $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 = array()) - { - $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 = array()) - { - $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 input for sensitivity selection - */ - function sensitivity_select($attrib = array()) - { - $attrib['name'] = 'sensitivity'; - $select = new html_select($attrib); - $select->add($this->cal->gettext('public'), 'public'); - $select->add($this->cal->gettext('private'), 'private'); - $select->add($this->cal->gettext('confidential'), 'confidential'); - return $select->show(null); - } - - /** - * Render HTML form for alarm configuration - */ - function alarm_select($attrib = array()) - { - 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 = 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->cal->gettext('sendnotifications'))); - } - - /** - * Render HTML for recurrence option to align start date with the recurrence rule - */ - function edit_recurrence_sync($attrib = array()) - { - $checkbox = new html_checkbox(array('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 = array()) - { - $attrib['id'] = 'edit-recurring-warning'; - - $radio = new html_radiobutton(array('name' => '_savemode', 'class' => 'edit-recurring-savemode')); - $form = html::label(null, $radio->show('', array('value' => 'current')) . $this->cal->gettext('currentevent')) . ' ' . - html::label(null, $radio->show('', array('value' => 'future')) . $this->cal->gettext('futurevents')) . ' ' . - html::label(null, $radio->show('all', array('value' => 'all')) . $this->cal->gettext('allevents')) . ' ' . - html::label(null, $radio->show('', array('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 = array()) - { - if (!$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' => $attrib['uploadfieldsize'], - 'accept' => $accept - )); - - $select = new html_select(array('name' => '_range', 'id' => 'event-import-range')); - $select->add(array( - $this->cal->gettext('onemonthback'), - $this->cal->gettext(array('name' => 'nmonthsback', 'vars' => array('nr'=>2))), - $this->cal->gettext(array('name' => 'nmonthsback', 'vars' => array('nr'=>3))), - $this->cal->gettext(array('name' => 'nmonthsback', 'vars' => array('nr'=>6))), - $this->cal->gettext(array('name' => 'nmonthsback', 'vars' => array('nr'=>12))), - $this->cal->gettext('all'), - ), - array('1','2','3','6','12',0)); - - $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' => 'event-import-calendar', 'class' => 'col-form-label col-sm-4'), $this->cal->gettext('calendar')) - . html::div('col-sm-8', $this->calendar_select(array('name' => 'calendar', 'id' => 'event-import-calendar'))) - ); - - $html .= html::div('form-section form-group row', - html::label(array('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', array( - 'action' => $this->rc->url(array('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 = array()) - { - if (!$attrib['id']) - $attrib['id'] = 'rcmExportForm'; - - $html = html::div('form-section form-group row', - html::label(array('for' => 'event-export-calendar', 'class' => 'col-sm-4 col-form-label'), $this->cal->gettext('calendar')) - . html::div('col-sm-8', $this->calendar_select(array('name' => 'calendar', 'id' => 'event-export-calendar', 'class' => 'form-control custom-select')))); - - $select = new html_select(array('name' => 'range', 'id' => 'event-export-range', 'class' => 'form-control custom-select rounded-right')); - $select->add(array( - $this->cal->gettext('all'), - $this->cal->gettext('onemonthback'), - $this->cal->gettext(array('name' => 'nmonthsback', 'vars' => array('nr'=>2))), - $this->cal->gettext(array('name' => 'nmonthsback', 'vars' => array('nr'=>3))), - $this->cal->gettext(array('name' => 'nmonthsback', 'vars' => array('nr'=>6))), - $this->cal->gettext(array('name' => 'nmonthsback', 'vars' => array('nr'=>12))), - $this->cal->gettext('customdate'), - ), - array(0,'1','2','3','6','12','custom')); - - $startdate = new html_inputfield(array('name' => 'start', 'size' => 11, 'id' => 'event-export-startdate', 'style' => 'display:none')); - - $html .= html::div('form-section form-group row', - html::label(array('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(array('name' => 'attachments', 'id' => 'event-export-attachments', 'value' => 1, 'class' => 'form-check-input pretty-checkbox')); - $html .= html::div('form-section form-check row', - html::label(array('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 + array( - 'action' => $this->rc->url(array('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 = array()) - { - $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', array($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(array('name' => 'name', 'id' => 'calendar-name', 'size' => 20)); - $input_color = new html_inputfield(array('name' => 'color', 'id' => 'calendar-color', 'size' => 7, 'class' => 'colors')); - - $formfields = array( - 'name' => array( - 'label' => $this->cal->gettext('name'), - 'value' => $input_name->show($calendar['name']), - 'id' => 'calendar-name', - ), - 'color' => array( - 'label' => $this->cal->gettext('color'), - 'value' => $input_color->show($calendar['color']), - 'id' => 'calendar-color', - ), - ); - - if ($this->cal->driver->alarms) { - $checkbox = new html_checkbox(array('name' => 'showalarms', 'id' => 'calendar-showalarms', 'value' => 1)); - $formfields['showalarms'] = array( - 'label' => $this->cal->gettext('showalarms'), - 'value' => $checkbox->show($this->calendar['showalarms'] ? 1 :0), - 'id' => 'calendar-showalarms', - ); - } - - // allow driver to extend or replace the form content - return html::tag('form', $attrib + array('action' => "#", 'method' => "get", 'id' => 'calendarpropform'), - $this->cal->driver->calendar_form($this->action, $this->calendar, $formfields) - ); - } - - /** - * - */ - 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' => 5 + intval($invitations), 'border' => 0, 'cellpadding' => 0, 'class' => 'rectable')); - - $table->add_header('role', $this->cal->gettext('role')); - $table->add_header('name', $this->cal->gettext($attrib['coltitle'] ?: 'attendee')); - $table->add_header('availability', $this->cal->gettext('availability')); - $table->add_header('confirmstate', $this->cal->gettext('confirmstate')); - if ($invitations) { - $table->add_header(array('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', 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', 'class' => 'form-control')); - $textarea = new html_textarea(array('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', array('type' => 'button', 'class' => 'button', 'id' => 'edit-attendee-add', 'value' => $this->cal->gettext('addattendee'))) . " " . - html::tag('input', array('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()) - ); - } - - /** - * - */ - function resources_form($attrib = array()) - { - $input = new html_inputfield(array('name' => 'resource', 'id' => 'edit-resource-name', 'class' => 'form-control')); - - return html::div($attrib, - html::div('form-searchbar', $input->show() . " " . - html::tag('input', array('type' => 'button', 'class' => 'button', 'id' => 'edit-resource-add', 'value' => $this->cal->gettext('addresource'))) . " " . - html::tag('input', array('type' => 'button', 'class' => 'button', 'id' => 'edit-resource-find', 'value' => $this->cal->gettext('findresources').'...'))) - ); - } - - /** - * - */ - function resources_list($attrib = array()) - { - $attrib += array('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 = array()) - { - $attrib += array('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 = array('id','class','style','width','summary','cellpadding','cellspacing','border'); - - return html::tag('table', $attrib, - html::tag('tbody', null, ''), $table_attrib) . - - html::tag('table', array('id' => $attrib['id'] . '-owner', 'style' => 'display:none') + $attrib, - html::tag('thead', null, - html::tag('tr', null, - html::tag('td', array('colspan' => 2), rcube::Q($this->cal->gettext('resourceowner'))) - ) - ) . - html::tag('tbody', null, ''), - $table_attrib); - } - - /** - * - */ - public function resource_calendar($attrib = array()) - { - $attrib += array('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 Named parameters - * @return string HTML code for the gui object - */ - function resources_search_form($attrib) - { - $attrib += array( - '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 = array()) - { - $table = new html_table(array('cols' => 2, 'border' => 0, 'cellspacing' => 0)); - $table->add('attendees', - html::tag('h3', 'boxtitle', $this->cal->gettext('tabattendees')) . - html::div('timesheader', ' ') . - html::div(array('id' => 'schedule-attendees-list', 'class' => 'attendees-list'), '') - ); - $table->add('times', - html::div('scroll', - html::tag('table', array('id' => 'schedule-freebusy-times', 'border' => 0, 'cellspacing' => 0), html::tag('thead') . html::tag('tbody')) . - html::div(array('id' => 'schedule-event-time', 'style' => 'display:none'), ' ') - ) - ); - - return $table->show($attrib); - } - - /** - * - */ - function event_invitebox($attrib = array()) - { - if ($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 = array()) - { - $actions = array('accepted','tentative','declined'); - if ($attrib['delegate'] !== 'false') - $actions[] = 'delegated'; - - return $this->cal->itip->itip_rsvp_buttons($attrib, $actions); - } + /** + * Generate the form for recurrence settings + */ + function recurring_event_warning($attrib = []) + { + $attrib['id'] = 'edit-recurring-warning'; + $radio = new html_radiobutton(['name' => '_savemode', 'class' => 'edit-recurring-savemode']); + + $form = html::label(null, $radio->show('', ['value' => 'current']) . $this->cal->gettext('currentevent')) . ' ' + . html::label(null, $radio->show('', ['value' => 'future']) . $this->cal->gettext('futurevents')) . ' ' + . html::label(null, $radio->show('all', ['value' => 'all']) . $this->cal->gettext('allevents')) . ' ' + . html::label(null, $radio->show('', ['value' => 'new']) . $this->cal->gettext('saveasnew')); + + return html::div($attrib, + html::div('message', $this->cal->gettext('changerecurringeventwarning')) + . html::div('savemode', $form) + ); + } + + /** + * Form for uploading and importing events + */ + function events_import_form($attrib = []) + { + if (empty($attrib['id'])) { + $attrib['id'] = 'rcmImportForm'; + } + + // Get max filesize, enable upload progress bar + $max_filesize = $this->rc->upload_init(); + + $accept = '.ics, text/calendar, text/x-vcalendar, application/ics'; + if (class_exists('ZipArchive', false)) { + $accept .= ', .zip, application/zip'; + } + + $input = new html_inputfield([ + 'id' => 'importfile', + 'type' => 'file', + 'name' => '_data', + 'size' => !empty($attrib['uploadfieldsize']) ? $attrib['uploadfieldsize'] : null, + 'accept' => $accept + ]); + + $select = new html_select(['name' => '_range', 'id' => 'event-import-range']); + $select->add([ + $this->cal->gettext('onemonthback'), + $this->cal->gettext(['name' => 'nmonthsback', 'vars' => ['nr'=>2]]), + $this->cal->gettext(['name' => 'nmonthsback', 'vars' => ['nr'=>3]]), + $this->cal->gettext(['name' => 'nmonthsback', 'vars' => ['nr'=>6]]), + $this->cal->gettext(['name' => 'nmonthsback', 'vars' => ['nr'=>12]]), + $this->cal->gettext('all'), + ], + ['1','2','3','6','12',0] + ); + + $html = html::div('form-section form-group row', + html::label(['class' => 'col-sm-4 col-form-label', 'for' => 'importfile'], + rcube::Q($this->rc->gettext('importfromfile')) + ) + . html::div('col-sm-8', $input->show() + . html::div('hint', $this->rc->gettext(['name' => 'maxuploadsize', 'vars' => ['size' => $max_filesize]])) + ) + ); + + $html .= html::div('form-section form-group row', + html::label(['for' => 'event-import-calendar', 'class' => 'col-form-label col-sm-4'], + $this->cal->gettext('calendar') + ) + . html::div('col-sm-8', $this->calendar_select(['name' => 'calendar', 'id' => 'event-import-calendar'])) + ); + + $html .= html::div('form-section form-group row', + html::label(['for' => 'event-import-range', 'class' => 'col-form-label col-sm-4'], + $this->cal->gettext('importrange') + ) + . html::div('col-sm-8', $select->show(1)) + ); + + $this->rc->output->add_gui_object('importform', $attrib['id']); + $this->rc->output->add_label('import'); + + return html::tag('p', null, $this->cal->gettext('importtext')) + . html::tag('form', [ + 'action' => $this->rc->url(['task' => 'calendar', 'action' => 'import_events']), + 'method' => 'post', + 'enctype' => 'multipart/form-data', + 'id' => $attrib['id'] + ], $html + ); + } + + /** + * Form to select options for exporting events + */ + function events_export_form($attrib = []) + { + if (empty($attrib['id'])) { + $attrib['id'] = 'rcmExportForm'; + } + + $html = html::div('form-section form-group row', + html::label(['for' => 'event-export-calendar', 'class' => 'col-sm-4 col-form-label'], + $this->cal->gettext('calendar') + ) + . html::div('col-sm-8', $this->calendar_select(['name' => 'calendar', 'id' => 'event-export-calendar', 'class' => 'form-control custom-select'])) + ); + + $select = new html_select([ + 'name' => 'range', + 'id' => 'event-export-range', + 'class' => 'form-control custom-select rounded-right' + ]); + + $select->add([ + $this->cal->gettext('all'), + $this->cal->gettext('onemonthback'), + $this->cal->gettext(['name' => 'nmonthsback', 'vars' => ['nr' => 2]]), + $this->cal->gettext(['name' => 'nmonthsback', 'vars' => ['nr' => 3]]), + $this->cal->gettext(['name' => 'nmonthsback', 'vars' => ['nr' => 6]]), + $this->cal->gettext(['name' => 'nmonthsback', 'vars' => ['nr' => 12]]), + $this->cal->gettext('customdate'), + ], + [0,'1','2','3','6','12','custom'] + ); + + $startdate = new html_inputfield([ + 'name' => 'start', + 'size' => 11, + 'id' => 'event-export-startdate', + 'style' => 'display:none' + ]); + + $html .= html::div('form-section form-group row', + html::label(['for' => 'event-export-range', 'class' => 'col-sm-4 col-form-label'], + $this->cal->gettext('exportrange') + ) + . html::div('col-sm-8 input-group', $select->show(0) . $startdate->show()) + ); + + $checkbox = new html_checkbox([ + 'name' => 'attachments', + 'id' => 'event-export-attachments', + 'value' => 1, + 'class' => 'form-check-input pretty-checkbox' + ]); + + $html .= html::div('form-section form-check row', + html::label(['for' => 'event-export-attachments', 'class' => 'col-sm-4 col-form-label'], + $this->cal->gettext('exportattachments') + ) + . html::div('col-sm-8', $checkbox->show(1)) + ); + + $this->rc->output->add_gui_object('exportform', $attrib['id']); + + return html::tag('form', $attrib + [ + 'action' => $this->rc->url(['task' => 'calendar', 'action' => 'export_events']), + 'method' => 'post', + 'id' => $attrib['id'] + ], + $html + ); + } + + /** + * Handler for calendar form template. + * The form content could be overriden by the driver + */ + function calendar_editform($action, $calendar = []) + { + $this->action = $action; + $this->calendar = $calendar; + + // load miniColors js/css files + jqueryui::miniColors(); + + $this->rc->output->set_env('pagetitle', $this->cal->gettext('calendarprops')); + $this->rc->output->add_handler('folderform', [$this, 'calendarform']); + $this->rc->output->send('libkolab.folderform'); + } + + /** + * Handler for calendar form template. + * The form content could be overriden by the driver + */ + function calendarform($attrib) + { + // compose default calendar form fields + $input_name = new html_inputfield(['name' => 'name', 'id' => 'calendar-name', 'size' => 20]); + $input_color = new html_inputfield(['name' => 'color', 'id' => 'calendar-color', 'size' => 7, 'class' => 'colors']); + + $formfields = [ + 'name' => [ + 'label' => $this->cal->gettext('name'), + 'value' => $input_name->show(isset($this->calendar['name']) ? $this->calendar['name'] : ''), + 'id' => 'calendar-name', + ], + 'color' => [ + 'label' => $this->cal->gettext('color'), + 'value' => $input_color->show(isset($this->calendar['color']) ? $this->calendar['color'] : ''), + 'id' => 'calendar-color', + ], + ]; + + if (!empty($this->cal->driver->alarms)) { + $checkbox = new html_checkbox(['name' => 'showalarms', 'id' => 'calendar-showalarms', 'value' => 1]); + + $formfields['showalarms'] = [ + 'label' => $this->cal->gettext('showalarms'), + 'value' => $checkbox->show(!empty($this->calendar['showalarms']) ? 1 : 0), + 'id' => 'calendar-showalarms', + ]; + } + + // allow driver to extend or replace the form content + return html::tag('form', $attrib + ['action' => '#', 'method' => 'get', 'id' => 'calendarpropform'], + $this->cal->driver->calendar_form($this->action, $this->calendar, $formfields) + ); + } + + /** + * Render HTML for attendees table + */ + function attendees_list($attrib = []) + { + // add "noreply" checkbox to attendees table only + $invitations = strpos($attrib['id'], 'attend') !== false; + + $invite = new html_checkbox(['value' => 1, 'id' => 'edit-attendees-invite']); + $table = new html_table(['cols' => 5 + intval($invitations), 'border' => 0, 'cellpadding' => 0, 'class' => 'rectable']); + + $table->add_header('role', $this->cal->gettext('role')); + $table->add_header('name', $this->cal->gettext(!empty($attrib['coltitle']) ? $attrib['coltitle'] : 'attendee')); + $table->add_header('availability', $this->cal->gettext('availability')); + $table->add_header('confirmstate', $this->cal->gettext('confirmstate')); + + if ($invitations) { + $table->add_header(['class' => 'invite', 'title' => $this->cal->gettext('sendinvitations')], + $invite->show(1) + . html::label('edit-attendees-invite', html::span('inner', $this->cal->gettext('sendinvitations'))) + ); + } + + $table->add_header('options', ''); + + // hide invite column if disabled by config + $itip_notify = (int)$this->rc->config->get('calendar_itip_send_option', $this->cal->defaults['calendar_itip_send_option']); + if ($invitations && !($itip_notify & 2)) { + $css = sprintf('#%s td.invite, #%s th.invite { display:none !important }', $attrib['id'], $attrib['id']); + $this->rc->output->add_footer(html::tag('style', ['type' => 'text/css'], $css)); + } + + return $table->show($attrib); + } + + /** + * Render HTML for attendees adding form + */ + function attendees_form($attrib = []) + { + $input = new html_inputfield([ + 'name' => 'participant', + 'id' => 'edit-attendee-name', + 'class' => 'form-control' + ]); + $textarea = new html_textarea([ + 'name' => 'comment', + 'id' => 'edit-attendees-comment', + 'class' => 'form-control', + 'rows' => 4, + 'cols' => 55, + 'title' => $this->cal->gettext('itipcommenttitle') + ]); + + return html::div($attrib, + html::div('form-searchbar', + $input->show() + . ' ' . + html::tag('input', [ + 'type' => 'button', + 'class' => 'button', + 'id' => 'edit-attendee-add', + 'value' => $this->cal->gettext('addattendee') + ]) + . ' ' . + html::tag('input', [ + 'type' => 'button', + 'class' => 'button', + 'id' => 'edit-attendee-schedule', + 'value' => $this->cal->gettext('scheduletime') . '...' + ]) + ) + . html::p('attendees-commentbox', html::label('edit-attendees-comment', $this->cal->gettext('itipcomment')) . $textarea->show()) + ); + } + + /** + * Render HTML for resources adding form + */ + function resources_form($attrib = []) + { + $input = new html_inputfield(['name' => 'resource', 'id' => 'edit-resource-name', 'class' => 'form-control']); + + return html::div($attrib, + html::div('form-searchbar', + $input->show() + . ' ' . + html::tag('input', [ + 'type' => 'button', + 'class' => 'button', + 'id' => 'edit-resource-add', + 'value' => $this->cal->gettext('addresource') + ]) + . ' ' . + html::tag('input', [ + 'type' => 'button', + 'class' => 'button', + 'id' => 'edit-resource-find', + 'value' => $this->cal->gettext('findresources') . '...' + ]) + ) + ); + } + + /** + * Render HTML for resources list + */ + function resources_list($attrib = []) + { + $attrib += ['id' => 'calendar-resources-list']; + + $this->rc->output->add_gui_object('resourceslist', $attrib['id']); + + return html::tag('ul', $attrib, '', html::$common_attrib); + } + + /** + * + */ + public function resource_info($attrib = []) + { + $attrib += ['id' => 'calendar-resources-info']; + + $this->rc->output->add_gui_object('resourceinfo', $attrib['id']); + $this->rc->output->add_gui_object('resourceownerinfo', $attrib['id'] . '-owner'); + + // copy address book labels for owner details to client + $this->rc->output->add_label('name','firstname','surname','department','jobtitle','email','phone','address'); + + $table_attrib = ['id','class','style','width','summary','cellpadding','cellspacing','border']; + + return html::tag('table', $attrib, html::tag('tbody', null, ''), $table_attrib) + . html::tag('table', ['id' => $attrib['id'] . '-owner', 'style' => 'display:none'] + $attrib, + html::tag('thead', null, + html::tag('tr', null, + html::tag('td', ['colspan' => 2], rcube::Q($this->cal->gettext('resourceowner'))) + ) + ) + . html::tag('tbody', null, ''), + $table_attrib + ); + } + + /** + * + */ + public function resource_calendar($attrib = []) + { + $attrib += ['id' => 'calendar-resources-calendar']; + + $this->rc->output->add_gui_object('resourceinfocalendar', $attrib['id']); + + return html::div($attrib, ''); + } + + /** + * GUI object 'searchform' for the resource finder dialog + * + * @param array $attrib Named parameters + * + * @return string HTML code for the gui object + */ + function resources_search_form($attrib) + { + $attrib += [ + 'command' => 'search-resource', + 'reset-command' => 'reset-resource-search', + 'id' => 'rcmcalresqsearchbox', + 'autocomplete' => 'off', + 'form-name' => 'rcmcalresoursqsearchform', + 'gui-object' => 'resourcesearchform', + ]; + + // add form tag around text field + return $this->rc->output->search_form($attrib); + } + + /** + * + */ + function attendees_freebusy_table($attrib = []) + { + $table = new html_table(['cols' => 2, 'border' => 0, 'cellspacing' => 0]); + $table->add('attendees', + html::tag('h3', 'boxtitle', $this->cal->gettext('tabattendees')) + . html::div('timesheader', ' ') + . html::div(['id' => 'schedule-attendees-list', 'class' => 'attendees-list'], '') + ); + $table->add('times', + html::div('scroll', + html::tag('table', ['id' => 'schedule-freebusy-times', 'border' => 0, 'cellspacing' => 0], + html::tag('thead') . html::tag('tbody') + ) + . html::div(['id' => 'schedule-event-time', 'style' => 'display:none'], ' ') + ) + ); + + return $table->show($attrib); + } + + /** + * + */ + function event_invitebox($attrib = []) + { + if (!empty($this->cal->event)) { + return html::div($attrib, + $this->cal->itip->itip_object_details_table($this->cal->event, $this->cal->itip->gettext('itipinvitation')) + . $this->cal->invitestatus + ); + } + + return ''; + } + + function event_rsvp_buttons($attrib = []) + { + $actions = ['accepted', 'tentative', 'declined']; + + if (empty($attrib['delegate']) || $attrib['delegate'] !== 'false') { + $actions[] = 'delegated'; + } + + return $this->cal->itip->itip_rsvp_buttons($attrib, $actions); + } } diff --git a/plugins/kolab_addressbook/kolab_addressbook.php b/plugins/kolab_addressbook/kolab_addressbook.php index aeb88d44..923b75a5 100644 --- a/plugins/kolab_addressbook/kolab_addressbook.php +++ b/plugins/kolab_addressbook/kolab_addressbook.php @@ -1,1200 +1,1199 @@ * @author Aleksander Machniak * * Copyright (C) 2011-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_addressbook extends rcube_plugin { public $task = '?(?!logout).*'; private $sources; private $folders; private $rc; private $ui; public $bonnie_api = false; const GLOBAL_FIRST = 0; const PERSONAL_FIRST = 1; const GLOBAL_ONLY = 2; const PERSONAL_ONLY = 3; /** * Startup method of a Roundcube plugin */ public function init() { require_once(dirname(__FILE__) . '/lib/rcube_kolab_contacts.php'); $this->rc = rcube::get_instance(); // load required plugin $this->require_plugin('libkolab'); // register hooks $this->add_hook('addressbooks_list', array($this, 'address_sources')); $this->add_hook('addressbook_get', array($this, 'get_address_book')); $this->add_hook('config_get', array($this, 'config_get')); if ($this->rc->task == 'addressbook') { $this->add_texts('localization'); $this->add_hook('contact_form', array($this, 'contact_form')); $this->add_hook('contact_photo', array($this, 'contact_photo')); $this->add_hook('template_object_directorylist', array($this, 'directorylist_html')); // Plugin actions $this->register_action('plugin.book', array($this, 'book_actions')); $this->register_action('plugin.book-save', array($this, 'book_save')); $this->register_action('plugin.book-search', array($this, 'book_search')); $this->register_action('plugin.book-subscribe', array($this, 'book_subscribe')); $this->register_action('plugin.contact-changelog', array($this, 'contact_changelog')); $this->register_action('plugin.contact-diff', array($this, 'contact_diff')); $this->register_action('plugin.contact-restore', array($this, 'contact_restore')); // get configuration for the Bonnie API $this->bonnie_api = libkolab::get_bonnie_api(); // Load UI elements if ($this->api->output->type == 'html') { $this->load_config(); require_once($this->home . '/lib/kolab_addressbook_ui.php'); $this->ui = new kolab_addressbook_ui($this); if ($this->bonnie_api) { $this->add_button(array( 'command' => 'contact-history-dialog', 'class' => 'history contact-history disabled', 'classact' => 'history contact-history active', 'innerclass' => 'icon inner', 'label' => 'kolab_addressbook.showhistory', 'type' => 'link-menuitem' ), 'contactmenu'); } } } else if ($this->rc->task == 'settings') { $this->add_texts('localization'); $this->add_hook('preferences_list', array($this, 'prefs_list')); $this->add_hook('preferences_save', array($this, 'prefs_save')); } $this->add_hook('folder_delete', array($this, 'prefs_folder_delete')); $this->add_hook('folder_rename', array($this, 'prefs_folder_rename')); $this->add_hook('folder_update', array($this, 'prefs_folder_update')); } /** * Handler for the addressbooks_list hook. * * This will add all instances of available Kolab-based address books * to the list of address sources of Roundcube. * This will also hide some addressbooks according to kolab_addressbook_prio setting. * * @param array $p Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function address_sources($p) { $abook_prio = $this->addressbook_prio(); // Disable all global address books // Assumes that all non-kolab_addressbook sources are global if ($abook_prio == self::PERSONAL_ONLY) { $p['sources'] = array(); } $sources = array(); foreach ($this->_list_sources() as $abook_id => $abook) { // register this address source $sources[$abook_id] = $this->abook_prop($abook_id, $abook); // flag folders with 'i' right as writeable if ($this->rc->action == 'add' && strpos($abook->rights, 'i') !== false) { $sources[$abook_id]['readonly'] = false; } } // Add personal address sources to the list if ($abook_prio == self::PERSONAL_FIRST) { // $p['sources'] = array_merge($sources, $p['sources']); // Don't use array_merge(), because if you have folders name // that resolve to numeric identifier it will break output array keys foreach ($p['sources'] as $idx => $value) $sources[$idx] = $value; $p['sources'] = $sources; } else { // $p['sources'] = array_merge($p['sources'], $sources); foreach ($sources as $idx => $value) $p['sources'][$idx] = $value; } return $p; } /** * Helper method to build a hash array of address book properties */ protected function abook_prop($id, $abook) { if ($abook->virtual) { return array( 'id' => $id, 'name' => $abook->get_name(), 'listname' => $abook->get_foldername(), 'group' => $abook instanceof kolab_storage_folder_user ? 'user' : $abook->get_namespace(), 'readonly' => true, 'rights' => 'l', 'kolab' => true, 'virtual' => true, ); } else { return array( 'id' => $id, 'name' => $abook->get_name(), 'listname' => $abook->get_foldername(), 'readonly' => $abook->readonly, 'rights' => $abook->rights, 'groups' => $abook->groups, 'undelete' => $abook->undelete && $this->rc->config->get('undo_timeout'), 'realname' => rcube_charset::convert($abook->get_realname(), 'UTF7-IMAP'), // IMAP folder name 'group' => $abook->get_namespace(), 'subscribed' => $abook->is_subscribed(), 'carddavurl' => $abook->get_carddav_url(), 'removable' => true, 'kolab' => true, 'audittrail' => !empty($this->bonnie_api), ); } } /** * */ public function directorylist_html($args) { $out = ''; $jsdata = array(); $sources = (array)$this->rc->get_address_sources(); // list all non-kolab sources first (also exclude hidden sources) $filter = function($source){ return empty($source['kolab']) && empty($source['hidden']); }; foreach (array_filter($sources, $filter) as $j => $source) { $id = strval(strlen($source['id']) ? $source['id'] : $j); $out .= $this->addressbook_list_item($id, $source, $jsdata) . ''; } // render a hierarchical list of kolab contact folders kolab_storage::folder_hierarchy($this->folders, $tree); if ($tree && !empty($tree->children)) { $out .= $this->folder_tree_html($tree, $sources, $jsdata); } $this->rc->output->set_env('contactgroups', array_filter($jsdata, function($src){ return $src['type'] == 'group'; })); $this->rc->output->set_env('address_sources', array_filter($jsdata, function($src){ return $src['type'] != 'group'; })); $args['content'] = html::tag('ul', $args, $out, html::$common_attrib); return $args; } /** * Return html for a structured list
        for the folder tree */ public function folder_tree_html($node, $data, &$jsdata) { $out = ''; foreach ($node->children as $folder) { $id = $folder->id; $source = $data[$id]; $is_collapsed = strpos($this->rc->config->get('collapsed_abooks',''), '&'.rawurlencode($id).'&') !== false; if ($folder->virtual) { $source = $this->abook_prop($folder->id, $folder); } else if (empty($source)) { $this->sources[$id] = new rcube_kolab_contacts($folder->name); $source = $this->abook_prop($id, $this->sources[$id]); } $content = $this->addressbook_list_item($id, $source, $jsdata); if (!empty($folder->children)) { $child_html = $this->folder_tree_html($folder, $data, $jsdata); // copy group items... if (preg_match('!]*>(.*)
      \n*$!Ums', $content, $m)) { $child_html = $m[1] . $child_html; $content = substr($content, 0, -strlen($m[0]) - 1); } // ... and re-create the subtree if (!empty($child_html)) { $content .= html::tag('ul', array('class' => 'groups', 'style' => ($is_collapsed ? "display:none;" : null)), $child_html); } } $out .= $content . ''; } return $out; } /** * */ protected function addressbook_list_item($id, $source, &$jsdata, $search_mode = false) { $current = rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC); if (!$source['virtual']) { $jsdata[$id] = $source; $jsdata[$id]['name'] = html_entity_decode($source['name'], ENT_NOQUOTES, RCUBE_CHARSET); } // set class name(s) $classes = array('addressbook'); if ($source['group']) $classes[] = $source['group']; if ($current === $id) $classes[] = 'selected'; if ($source['readonly']) $classes[] = 'readonly'; if ($source['virtual']) $classes[] = 'virtual'; if ($source['class_name']) $classes[] = $source['class_name']; $name = !empty($source['listname']) ? $source['listname'] : (!empty($source['name']) ? $source['name'] : $id); $label_id = 'kabt:' . $id; $inner = ($source['virtual'] ? html::a(array('tabindex' => '0'), $name) : html::a(array( 'href' => $this->rc->url(array('_source' => $id)), 'rel' => $source['id'], 'id' => $label_id, 'onclick' => "return " . rcmail_output::JS_OBJECT_NAME.".command('list','" . rcube::JQ($id) . "',this)", ), $name) ); if (isset($source['subscribed'])) { $inner .= html::span(array( 'class' => 'subscribed', 'title' => $this->gettext('foldersubscribe'), 'role' => 'checkbox', 'aria-checked' => $source['subscribed'] ? 'true' : 'false', ), ''); } // don't wrap in
    • but add a checkbox for search results listing if ($search_mode) { $jsdata[$id]['group'] = join(' ', $classes); if (!$source['virtual']) { $inner .= html::tag('input', array( 'type' => 'checkbox', 'name' => '_source[]', 'value' => $id, 'checked' => false, 'aria-labelledby' => $label_id, )); } return html::div(null, $inner); } $out .= html::tag('li', array( 'id' => 'rcmli' . rcube_utils::html_identifier($id, true), 'class' => join(' ', $classes), 'noclose' => true, ), html::div($source['subscribed'] ? 'subscribed' : null, $inner) ); $groupdata = array('out' => '', 'jsdata' => $jsdata, 'source' => $id); if ($source['groups'] && function_exists('rcmail_contact_groups')) { $groupdata = rcmail_contact_groups($groupdata); } $jsdata = $groupdata['jsdata']; $out .= $groupdata['out']; return $out; } /** * Sets autocomplete_addressbooks option according to * kolab_addressbook_prio setting extending list of address sources * to be used for autocompletion. */ public function config_get($args) { if ($args['name'] != 'autocomplete_addressbooks' || $this->recurrent) { return $args; } $abook_prio = $this->addressbook_prio(); // Get the original setting, use temp flag to prevent from an infinite recursion $this->recurrent = true; $sources = $this->rc->config->get('autocomplete_addressbooks'); $this->recurrent = false; // Disable all global address books // Assumes that all non-kolab_addressbook sources are global if ($abook_prio == self::PERSONAL_ONLY) { $sources = array(); } if (!is_array($sources)) { $sources = array(); } $kolab_sources = array(); foreach (array_keys($this->_list_sources()) as $abook_id) { if (!in_array($abook_id, $sources)) $kolab_sources[] = $abook_id; } // Add personal address sources to the list if (!empty($kolab_sources)) { if ($abook_prio == self::PERSONAL_FIRST) { $sources = array_merge($kolab_sources, $sources); } else { $sources = array_merge($sources, $kolab_sources); } } $args['result'] = $sources; return $args; } /** * Getter for the rcube_addressbook instance * * @param array $p Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function get_address_book($p) { if ($p['id']) { $id = kolab_storage::id_decode($p['id']); $folder = kolab_storage::get_folder($id); // try with unencoded (old-style) identifier if ((!$folder || $folder->type != 'contact') && $id != $p['id']) { $folder = kolab_storage::get_folder($p['id']); } if ($folder && $folder->type == 'contact') { $p['instance'] = new rcube_kolab_contacts($folder->name); // flag source as writeable if 'i' right is given if ($p['writeable'] && $this->rc->action == 'save' && strpos($p['instance']->rights, 'i') !== false) { $p['instance']->readonly = false; } else if ($this->rc->action == 'delete' && strpos($p['instance']->rights, 't') !== false) { $p['instance']->readonly = false; } } } return $p; } private function _list_sources() { // already read sources if (isset($this->sources)) return $this->sources; kolab_storage::$encode_ids = true; $this->sources = array(); $this->folders = array(); $abook_prio = $this->addressbook_prio(); // Personal address source(s) disabled? if ($abook_prio == self::GLOBAL_ONLY) { return $this->sources; } // get all folders that have "contact" type $folders = kolab_storage::sort_folders(kolab_storage::get_folders('contact')); if (PEAR::isError($folders)) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed to list contact folders from Kolab server:" . $folders->getMessage()), true, false); } else { // we need at least one folder to prevent from errors in Roundcube core // when there's also no sql nor ldap addressbook (Bug #2086) if (empty($folders)) { if ($folder = kolab_storage::create_default_folder('contact')) { $folders = array(new kolab_storage_folder($folder, 'contact')); } } // convert to UTF8 and sort foreach ($folders as $folder) { // create instance of rcube_contacts $abook_id = $folder->id; $abook = new rcube_kolab_contacts($folder->name); $this->sources[$abook_id] = $abook; $this->folders[$abook_id] = $folder; } } return $this->sources; } /** * Plugin hook called before rendering the contact form or detail view * * @param array $p Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function contact_form($p) { // none of our business if (!is_object($GLOBALS['CONTACTS']) || !is_a($GLOBALS['CONTACTS'], 'rcube_kolab_contacts')) return $p; // extend the list of contact fields to be displayed in the 'personal' section if (is_array($p['form']['personal'])) { $p['form']['personal']['content']['profession'] = array('size' => 40); $p['form']['personal']['content']['children'] = array('size' => 40); $p['form']['personal']['content']['freebusyurl'] = array('size' => 40); $p['form']['personal']['content']['pgppublickey'] = array('size' => 70); $p['form']['personal']['content']['pkcs7publickey'] = array('size' => 70); // re-order fields according to the coltypes list $p['form']['contact']['content'] = $this->_sort_form_fields($p['form']['contact']['content'], $GLOBALS['CONTACTS']); $p['form']['personal']['content'] = $this->_sort_form_fields($p['form']['personal']['content'], $GLOBALS['CONTACTS']); /* define a separate section 'settings' $p['form']['settings'] = array( 'name' => $this->gettext('settings'), 'content' => array( 'freebusyurl' => array('size' => 40, 'visible' => true), 'pgppublickey' => array('size' => 70, 'visible' => true), 'pkcs7publickey' => array('size' => 70, 'visible' => false), ) ); */ } if ($this->bonnie_api && $this->rc->action == 'show' && empty($p['record']['rev'])) { $this->rc->output->set_env('kolab_audit_trail', true); } return $p; } /** * Plugin hook for the contact photo image */ public function contact_photo($p) { // add photo data from old revision inline as data url if (!empty($p['record']['rev']) && !empty($p['data'])) { $p['url'] = 'data:image/gif;base64,' . base64_encode($p['data']); } return $p; } /** * Handler for contact audit trail changelog requests */ public function contact_changelog() { if (empty($this->bonnie_api)) { return false; } $contact = rcube_utils::get_input_value('cid', rcube_utils::INPUT_POST, true); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST); list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($contact, $source); $result = $uid && $mailbox ? $this->bonnie_api->changelog('contact', $uid, $mailbox, $msguid) : null; if (is_array($result) && $result['uid'] == $uid) { if (is_array($result['changes'])) { $rcmail = $this->rc; $dtformat = $this->rc->config->get('date_format') . ' ' . $this->rc->config->get('time_format'); array_walk($result['changes'], function(&$change) use ($rcmail, $dtformat) { if ($change['date']) { $dt = rcube_utils::anytodatetime($change['date']); if ($dt instanceof DateTime) { $change['date'] = $rcmail->format_date($dt, $dtformat); } } }); } $this->rc->output->command('contact_render_changelog', $result['changes']); } else { $this->rc->output->command('contact_render_changelog', false); } $this->rc->output->send(); } /** * Handler for audit trail diff view requests */ public function contact_diff() { if (empty($this->bonnie_api)) { return false; } $contact = rcube_utils::get_input_value('cid', rcube_utils::INPUT_POST, true); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST); $rev1 = rcube_utils::get_input_value('rev1', rcube_utils::INPUT_POST); $rev2 = rcube_utils::get_input_value('rev2', rcube_utils::INPUT_POST); list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($contact, $source); $result = $this->bonnie_api->diff('contact', $uid, $rev1, $rev2, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid) { $result['rev1'] = $rev1; $result['rev2'] = $rev2; $result['cid'] = $contact; // convert some properties, similar to rcube_kolab_contacts::_to_rcube_contact() $keymap = array( 'lastmodified-date' => 'changed', 'additional' => 'middlename', 'fn' => 'name', 'tel' => 'phone', 'url' => 'website', 'bday' => 'birthday', 'note' => 'notes', 'role' => 'profession', 'title' => 'jobtitle', ); $propmap = array('email' => 'address', 'website' => 'url', 'phone' => 'number'); $date_format = $this->rc->config->get('date_format', 'Y-m-d'); // map kolab object properties to keys and values the client expects array_walk($result['changes'], function(&$change, $i) use ($keymap, $propmap, $date_format) { if (array_key_exists($change['property'], $keymap)) { $change['property'] = $keymap[$change['property']]; } // format date-time values if ($change['property'] == 'created' || $change['property'] == 'changed') { if ($old_ = rcube_utils::anytodatetime($change['old'])) { $change['old_'] = $this->rc->format_date($old_); } if ($new_ = rcube_utils::anytodatetime($change['new'])) { $change['new_'] = $this->rc->format_date($new_); } } // format dates else if ($change['property'] == 'birthday' || $change['property'] == 'anniversary') { if ($old_ = rcube_utils::anytodatetime($change['old'])) { $change['old_'] = $this->rc->format_date($old_, $date_format); } if ($new_ = rcube_utils::anytodatetime($change['new'])) { $change['new_'] = $this->rc->format_date($new_, $date_format); } } // convert email, website, phone values else if (array_key_exists($change['property'], $propmap)) { $propname = $propmap[$change['property']]; foreach (array('old','new') as $k) { $k_ = $k . '_'; if (!empty($change[$k])) { $change[$k_] = html::quote($change[$k][$propname] ?: '--'); if ($change[$k]['type']) { $change[$k_] .= ' ' . html::span('subtype', rcmail_get_type_label($change[$k]['type'])); } $change['ishtml'] = true; } } } // serialize address structs if ($change['property'] == 'address') { foreach (array('old','new') as $k) { $k_ = $k . '_'; $change[$k]['zipcode'] = $change[$k]['code']; $template = $this->rc->config->get('address_template', '{'.join('} {', array_keys($change[$k])).'}'); $composite = array(); foreach ($change[$k] as $p => $val) { if (strlen($val)) $composite['{'.$p.'}'] = $val; } $change[$k_] = preg_replace('/\{\w+\}/', '', strtr($template, $composite)); if ($change[$k]['type']) { $change[$k_] .= html::div('subtype', rcmail_get_type_label($change[$k]['type'])); } $change['ishtml'] = true; } $change['diff_'] = libkolab::html_diff($change['old_'], $change['new_'], true); } // localize gender values else if ($change['property'] == 'gender') { if ($change['old']) $change['old_'] = $this->rc->gettext($change['old']); if ($change['new']) $change['new_'] = $this->rc->gettext($change['new']); } // translate 'key' entries in individual properties else if ($change['property'] == 'key') { $p = $change['old'] ?: $change['new']; $t = $p['type']; $change['property'] = $t . 'publickey'; $change['old'] = $change['old'] ? $change['old']['key'] : ''; $change['new'] = $change['new'] ? $change['new']['key'] : ''; } // compute a nice diff of notes else if ($change['property'] == 'notes') { $change['diff_'] = libkolab::html_diff($change['old'], $change['new'], false); } }); $this->rc->output->command('contact_show_diff', $result); } else { $this->rc->output->command('display_message', $this->gettext('objectdiffnotavailable'), 'error'); } $this->rc->output->send(); } /** * Handler for audit trail revision restore requests */ public function contact_restore() { if (empty($this->bonnie_api)) { return false; } $success = false; $contact = rcube_utils::get_input_value('cid', rcube_utils::INPUT_POST, true); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST); $rev = rcube_utils::get_input_value('rev', rcube_utils::INPUT_POST); list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($contact, $source, $folder); if ($folder && ($raw_msg = $this->bonnie_api->rawdata('contact', $uid, $rev, $mailbox))) { $imap = $this->rc->get_storage(); // insert $raw_msg as new message if ($imap->save_message($folder->name, $raw_msg, null, false)) { $success = true; // delete old revision from imap and cache $imap->delete_message($msguid, $folder->name); $folder->cache->set($msguid, false); $this->cache = array(); } } if ($success) { $this->rc->output->command('display_message', $this->gettext(array('name' => 'objectrestoresuccess', 'vars' => array('rev' => $rev))), 'confirmation'); $this->rc->output->command('close_contact_history_dialog', $contact); } else { $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error'); } $this->rc->output->send(); } /** * Get a previous revision of the given contact record from the Bonnie API */ public function get_revision($cid, $source, $rev) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_contact_identity($cid, $source); // call Bonnie API $result = $this->bonnie_api->get('contact', $uid, $rev, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { $format = kolab_format::factory('contact'); $format->load($result['xml']); $rec = $format->to_array(); if ($format->is_valid()) { $rec['rev'] = $result['rev']; return $rec; } } return false; } /** * Helper method to resolved the given contact identifier into uid and mailbox * * @return array (uid,mailbox,msguid) tuple */ private function _resolve_contact_identity($id, $abook, &$folder = null) { $mailbox = $msguid = null; $source = $this->get_address_book(array('id' => $abook)); if ($source['instance']) { $uid = $source['instance']->id2uid($id); $list = kolab_storage::id_decode($abook); } else { return array(null, $mailbox, $msguid); } // get resolve message UID and mailbox identifier if ($folder = kolab_storage::get_folder($list)) { $mailbox = $folder->get_mailbox_id(); $msguid = $folder->cache->uid2msguid($uid); } return array($uid, $mailbox, $msguid); } /** * */ private function _sort_form_fields($contents, $source) { $block = array(); foreach (array_keys($source->coltypes) as $col) { if (isset($contents[$col])) $block[$col] = $contents[$col]; } return $block; } /** * Handler for user preferences form (preferences_list hook) * * @param array $args Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function prefs_list($args) { if ($args['section'] != 'addressbook') { return $args; } $ldap_public = $this->rc->config->get('ldap_public'); // Hide option if there's no global addressbook if (empty($ldap_public)) { return $args; } // Check that configuration is not disabled $dont_override = (array) $this->rc->config->get('dont_override', array()); $prio = $this->addressbook_prio(); if (!in_array('kolab_addressbook_prio', $dont_override)) { // Load localization $this->add_texts('localization'); $field_id = '_kolab_addressbook_prio'; $select = new html_select(array('name' => $field_id, 'id' => $field_id)); $select->add($this->gettext('globalfirst'), self::GLOBAL_FIRST); $select->add($this->gettext('personalfirst'), self::PERSONAL_FIRST); $select->add($this->gettext('globalonly'), self::GLOBAL_ONLY); $select->add($this->gettext('personalonly'), self::PERSONAL_ONLY); $args['blocks']['main']['options']['kolab_addressbook_prio'] = array( 'title' => html::label($field_id, rcube::Q($this->gettext('addressbookprio'))), 'content' => $select->show($prio), ); } return $args; } /** * Handler for user preferences save (preferences_save hook) * * @param array $args Hash array with hook parameters * * @return array Hash array with modified hook parameters */ public function prefs_save($args) { if ($args['section'] != 'addressbook') { return $args; } // Check that configuration is not disabled $dont_override = (array) $this->rc->config->get('dont_override', array()); $key = 'kolab_addressbook_prio'; if (!in_array('kolab_addressbook_prio', $dont_override) || !isset($_POST['_'.$key])) { $args['prefs'][$key] = (int) rcube_utils::get_input_value('_'.$key, rcube_utils::INPUT_POST); } return $args; } /** * Handler for plugin actions */ public function book_actions() { $action = trim(rcube_utils::get_input_value('_act', rcube_utils::INPUT_GPC)); if ($action == 'create') { $this->ui->book_edit(); } else if ($action == 'edit') { $this->ui->book_edit(); } else if ($action == 'delete') { $this->book_delete(); } } /** * Handler for address book create/edit form submit */ public function book_save() { $prop = array( 'name' => trim(rcube_utils::get_input_value('_name', rcube_utils::INPUT_POST)), 'oldname' => trim(rcube_utils::get_input_value('_oldname', rcube_utils::INPUT_POST, true)), // UTF7-IMAP 'parent' => trim(rcube_utils::get_input_value('_parent', rcube_utils::INPUT_POST, true)), // UTF7-IMAP 'type' => 'contact', 'subscribed' => true, ); $result = $error = false; $type = strlen($prop['oldname']) ? 'update' : 'create'; $prop = $this->rc->plugins->exec_hook('addressbook_'.$type, $prop); if (!$prop['abort']) { if ($newfolder = kolab_storage::folder_update($prop)) { $folder = $newfolder; $result = true; } else { $error = kolab_storage::$last_error; } } else { $result = $prop['result']; $folder = $prop['name']; } if ($result) { $kolab_folder = kolab_storage::get_folder($folder); // get folder/addressbook properties $abook = new rcube_kolab_contacts($folder); $props = $this->abook_prop(kolab_storage::folder_id($folder, true), $abook); $props['parent'] = kolab_storage::folder_id($kolab_folder->get_parent(), true); $this->rc->output->show_message('kolab_addressbook.book'.$type.'d', 'confirmation'); $this->rc->output->command('book_update', $props, kolab_storage::folder_id($prop['oldname'], true)); } else { if (!$error) { $error = $plugin['message'] ? $plugin['message'] : 'kolab_addressbook.book'.$type.'error'; } $this->rc->output->show_message($error, 'error'); } $this->rc->output->send('iframe'); } /** * */ public function book_search() { $results = array(); $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); kolab_storage::$encode_ids = true; $search_more_results = false; $this->sources = array(); $this->folders = array(); // find unsubscribed IMAP folders that have "event" type if ($source == 'folders') { foreach ((array)kolab_storage::search_folders('contact', $query, array('other')) as $folder) { $this->folders[$folder->id] = $folder; $this->sources[$folder->id] = new rcube_kolab_contacts($folder->name); } } // search other user's namespace via LDAP else if ($source == 'users') { $limit = $this->rc->config->get('autocomplete_max', 15) * 2; // we have slightly more space, so display twice the number foreach (kolab_storage::search_users($query, 0, array(), $limit * 10) as $user) { $folders = array(); // search for contact folders shared by this user foreach (kolab_storage::list_user_folders($user, 'contact', false) as $foldername) { $folders[] = new kolab_storage_folder($foldername, 'contact'); } if (count($folders)) { $userfolder = new kolab_storage_folder_user($user['kolabtargetfolder'], '', $user); $this->folders[$userfolder->id] = $userfolder; $this->sources[$userfolder->id] = $userfolder; foreach ($folders as $folder) { $this->folders[$folder->id] = $folder; $this->sources[$folder->id] = new rcube_kolab_contacts($folder->name);; $count++; } } if ($count >= $limit) { $search_more_results = true; break; } } } $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); // build results list foreach ($this->sources as $id => $source) { $folder = $this->folders[$id]; $imap_path = explode($delim, $folder->name); // find parent do { array_pop($imap_path); $parent_id = kolab_storage::folder_id(join($delim, $imap_path)); } while (count($imap_path) > 1 && !$this->folders[$parent_id]); // restore "real" parent ID if ($parent_id && !$this->folders[$parent_id]) { $parent_id = kolab_storage::folder_id($folder->get_parent()); } $prop = $this->abook_prop($id, $source); $prop['parent'] = $parent_id; $html = $this->addressbook_list_item($id, $prop, $jsdata, true); unset($prop['group']); $prop += (array)$jsdata[$id]; $prop['html'] = $html; $results[] = $prop; } // report more results available if ($search_more_results) { $this->rc->output->show_message('autocompletemore', 'notice'); } $this->rc->output->command('multi_thread_http_response', $results, rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC)); } /** * */ public function book_subscribe() { $success = false; $id = rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC); if ($id && ($folder = kolab_storage::get_folder(kolab_storage::id_decode($id)))) { if (isset($_POST['_permanent'])) $success |= $folder->subscribe(intval($_POST['_permanent'])); if (isset($_POST['_active'])) $success |= $folder->activate(intval($_POST['_active'])); // list groups for this address book if (!empty($_POST['_groups'])) { $abook = new rcube_kolab_contacts($folder->name); foreach ((array)$abook->list_groups() as $prop) { $prop['source'] = $id; $prop['id'] = $prop['ID']; unset($prop['ID']); $this->rc->output->command('insert_contact_group', $prop); } } } if ($success) { $this->rc->output->show_message('successfullysaved', 'confirmation'); } else { $this->rc->output->show_message($this->gettext('errorsaving'), 'error'); } $this->rc->output->send(); } /** * Handler for address book delete action (AJAX) */ private function book_delete() { $folder = trim(rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC, true, 'UTF7-IMAP')); if (kolab_storage::folder_delete($folder)) { $storage = $this->rc->get_storage(); $delimiter = $storage->get_hierarchy_delimiter(); $this->rc->output->show_message('kolab_addressbook.bookdeleted', 'confirmation'); $this->rc->output->set_env('pagecount', 0); $this->rc->output->command('set_rowcount', rcmail_get_rowcount_text(new rcube_result_set())); $this->rc->output->command('set_env', 'delimiter', $delimiter); $this->rc->output->command('list_contacts_clear'); $this->rc->output->command('book_delete_done', kolab_storage::folder_id($folder, true)); } else { $this->rc->output->show_message('kolab_addressbook.bookdeleteerror', 'error'); } $this->rc->output->send(); } /** * Returns value of kolab_addressbook_prio setting */ private function addressbook_prio() { // Load configuration if (!$this->config_loaded) { $this->load_config(); $this->config_loaded = true; } $abook_prio = (int) $this->rc->config->get('kolab_addressbook_prio'); // Make sure any global addressbooks are defined if ($abook_prio == 0 || $abook_prio == 2) { $ldap_public = $this->rc->config->get('ldap_public'); if (empty($ldap_public)) { $abook_prio = 1; } } return $abook_prio; } /** * Hook for (contact) folder deletion */ function prefs_folder_delete($args) { // ignore... if ($args['abort'] && !$args['result']) { return $args; } $this->_contact_folder_rename($args['name'], false); } /** * Hook for (contact) folder renaming */ function prefs_folder_rename($args) { // ignore... if ($args['abort'] && !$args['result']) { return $args; } $this->_contact_folder_rename($args['oldname'], $args['newname']); } /** * Hook for (contact) folder updates. Forward to folder_rename handler if name was changed */ function prefs_folder_update($args) { // ignore... if ($args['abort'] && !$args['result']) { return $args; } if ($args['record']['name'] != $args['record']['oldname']) { $this->_contact_folder_rename($args['record']['oldname'], $args['record']['name']); } } /** * Apply folder renaming or deletion to the registered birthday calendar address books */ private function _contact_folder_rename($oldname, $newname = false) { $update = false; $delimiter = $this->rc->get_storage()->get_hierarchy_delimiter(); $bday_addressbooks = (array)$this->rc->config->get('calendar_birthday_adressbooks', array()); foreach ($bday_addressbooks as $i => $id) { $folder_name = kolab_storage::id_decode($id); if ($oldname === $folder_name || strpos($folder_name, $oldname.$delimiter) === 0) { if ($newname) { // rename $new_folder = $newname . substr($folder_name, strlen($oldname)); $bday_addressbooks[$i] = kolab_storage::id_encode($new_folder); } else { // delete unset($bday_addressbooks[$i]); } $update = true; } } if ($update) { $this->rc->user->save_prefs(array('calendar_birthday_adressbooks' => $bday_addressbooks)); } } - } diff --git a/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php b/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php index f6805eb0..1f9ac291 100644 --- a/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php +++ b/plugins/kolab_addressbook/lib/rcube_kolab_contacts.php @@ -1,1340 +1,1400 @@ * @author Aleksander Machniak * * Copyright (C) 2011, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * @see rcube_addressbook */ class rcube_kolab_contacts extends rcube_addressbook { public $primary_key = 'ID'; public $rights = 'lrs'; public $readonly = true; public $undelete = true; public $groups = true; public $coltypes = array( - 'name' => array('limit' => 1), - 'firstname' => array('limit' => 1), - 'surname' => array('limit' => 1), - 'middlename' => array('limit' => 1), - 'prefix' => array('limit' => 1), - 'suffix' => array('limit' => 1), - 'nickname' => array('limit' => 1), - 'jobtitle' => array('limit' => 1), - 'organization' => array('limit' => 1), - 'department' => array('limit' => 1), - 'email' => array('subtypes' => array('home','work','other')), - 'phone' => array(), - 'address' => array('subtypes' => array('home','work','office')), - 'website' => array('subtypes' => array('homepage','blog')), - 'im' => array('subtypes' => null), - 'gender' => array('limit' => 1), - 'birthday' => array('limit' => 1), - 'anniversary' => array('limit' => 1), - 'profession' => array('type' => 'text', 'size' => 40, 'maxlength' => 80, 'limit' => 1, - 'label' => 'kolab_addressbook.profession', 'category' => 'personal'), - 'manager' => array('limit' => null), - 'assistant' => array('limit' => null), - 'spouse' => array('limit' => 1), - 'children' => array('type' => 'text', 'size' => 40, 'maxlength' => 80, 'limit' => null, - 'label' => 'kolab_addressbook.children', 'category' => 'personal'), - 'freebusyurl' => array('type' => 'text', 'size' => 40, 'limit' => 1, - 'label' => 'kolab_addressbook.freebusyurl'), - 'pgppublickey' => array('type' => 'textarea', 'size' => 70, 'rows' => 10, 'limit' => 1, - 'label' => 'kolab_addressbook.pgppublickey'), - 'pkcs7publickey' => array('type' => 'textarea', 'size' => 70, 'rows' => 10, 'limit' => 1, - 'label' => 'kolab_addressbook.pkcs7publickey'), - 'notes' => array('limit' => 1), - 'photo' => array('limit' => 1), - // TODO: define more Kolab-specific fields such as: language, latitude, longitude, crypto settings + 'name' => array('limit' => 1), + 'firstname' => array('limit' => 1), + 'surname' => array('limit' => 1), + 'middlename' => array('limit' => 1), + 'prefix' => array('limit' => 1), + 'suffix' => array('limit' => 1), + 'nickname' => array('limit' => 1), + 'jobtitle' => array('limit' => 1), + 'organization' => array('limit' => 1), + 'department' => array('limit' => 1), + 'email' => array('subtypes' => array('home','work','other')), + 'phone' => array(), + 'address' => array('subtypes' => array('home','work','office')), + 'website' => array('subtypes' => array('homepage','blog')), + 'im' => array('subtypes' => null), + 'gender' => array('limit' => 1), + 'birthday' => array('limit' => 1), + 'anniversary' => array('limit' => 1), + 'profession' => array( + 'type' => 'text', + 'size' => 40, + 'maxlength' => 80, + 'limit' => 1, + 'label' => 'kolab_addressbook.profession', + 'category' => 'personal' + ), + 'manager' => array('limit' => null), + 'assistant' => array('limit' => null), + 'spouse' => array('limit' => 1), + 'children' => array( + 'type' => 'text', + 'size' => 40, + 'maxlength' => 80, + 'limit' => null, + 'label' => 'kolab_addressbook.children', + 'category' => 'personal' + ), + 'freebusyurl' => array( + 'type' => 'text', + 'size' => 40, + 'limit' => 1, + 'label' => 'kolab_addressbook.freebusyurl' + ), + 'pgppublickey' => array( + 'type' => 'textarea', + 'size' => 70, + 'rows' => 10, + 'limit' => 1, + 'label' => 'kolab_addressbook.pgppublickey' + ), + 'pkcs7publickey' => array( + 'type' => 'textarea', + 'size' => 70, + 'rows' => 10, + 'limit' => 1, + 'label' => 'kolab_addressbook.pkcs7publickey' + ), + 'notes' => array('limit' => 1), + 'photo' => array('limit' => 1), + // TODO: define more Kolab-specific fields such as: language, latitude, longitude, crypto settings ); /** * vCard additional fields mapping */ public $vcard_map = array( - 'profession' => 'X-PROFESSION', - 'officelocation' => 'X-OFFICE-LOCATION', - 'initials' => 'X-INITIALS', - 'children' => 'X-CHILDREN', - 'freebusyurl' => 'X-FREEBUSY-URL', - 'pgppublickey' => 'KEY', + 'profession' => 'X-PROFESSION', + 'officelocation' => 'X-OFFICE-LOCATION', + 'initials' => 'X-INITIALS', + 'children' => 'X-CHILDREN', + 'freebusyurl' => 'X-FREEBUSY-URL', + 'pgppublickey' => 'KEY', ); /** * List of date type fields */ public $date_cols = array('birthday', 'anniversary'); private $gid; private $storagefolder; private $dataset; private $sortindex; private $contacts; private $distlists; private $groupmembers; private $filter; private $result; private $namespace; private $imap_folder = 'INBOX/Contacts'; private $action; // list of fields used for searching in "All fields" mode private $search_fields = array( - 'name', - 'firstname', - 'surname', - 'middlename', - 'prefix', - 'suffix', - 'nickname', - 'jobtitle', - 'organization', - 'department', - 'email', - 'phone', - 'address', - 'profession', - 'manager', - 'assistant', - 'spouse', - 'children', - 'notes', + 'name', + 'firstname', + 'surname', + 'middlename', + 'prefix', + 'suffix', + 'nickname', + 'jobtitle', + 'organization', + 'department', + 'email', + 'phone', + 'address', + 'profession', + 'manager', + 'assistant', + 'spouse', + 'children', + 'notes', ); public function __construct($imap_folder = null) { if ($imap_folder) { $this->imap_folder = $imap_folder; } // extend coltypes configuration $format = kolab_format::factory('contact'); - $this->coltypes['phone']['subtypes'] = array_keys($format->phonetypes); + + $this->coltypes['phone']['subtypes'] = array_keys($format->phonetypes); $this->coltypes['address']['subtypes'] = array_keys($format->addresstypes); $rcube = rcube::get_instance(); // set localized labels for proprietary cols foreach ($this->coltypes as $col => $prop) { - if (is_string($prop['label'])) + if (is_string($prop['label'])) { $this->coltypes[$col]['label'] = $rcube->gettext($prop['label']); + } } // fetch objects from the given IMAP folder $this->storagefolder = kolab_storage::get_folder($this->imap_folder); $this->ready = $this->storagefolder && !PEAR::isError($this->storagefolder); // Set readonly and rights flags according to folder permissions if ($this->ready) { if ($this->storagefolder->get_owner() == $_SESSION['username']) { $this->readonly = false; $this->rights = 'lrswikxtea'; } else { $rights = $this->storagefolder->get_myrights(); if ($rights && !PEAR::isError($rights)) { $this->rights = $rights; - if (strpos($rights, 'i') !== false && strpos($rights, 't') !== false) + if (strpos($rights, 'i') !== false && strpos($rights, 't') !== false) { $this->readonly = false; + } } } } $this->action = rcube::get_instance()->action; } /** * Getter for the address book name to be displayed * * @return string Name of this address book */ public function get_name() { return $this->storagefolder->get_name(); } /** * Wrapper for kolab_storage_folder::get_foldername() */ public function get_foldername() { return $this->storagefolder->get_foldername(); } /** * Getter for the IMAP folder name * * @return string Name of the IMAP folder */ public function get_realname() { return $this->imap_folder; } /** * Getter for the name of the namespace to which the IMAP folder belongs * * @return string Name of the namespace (personal, other, shared) */ public function get_namespace() { if ($this->namespace === null && $this->ready) { $this->namespace = $this->storagefolder->get_namespace(); } return $this->namespace; } /** * Getter for parent folder path * * @return string Full path to parent folder */ public function get_parent() { return $this->storagefolder->get_parent(); } /** * Check subscription status of this folder * * @return boolean True if subscribed, false if not */ public function is_subscribed() { return kolab_storage::folder_is_subscribed($this->imap_folder); } /** * Compose an URL for CardDAV access to this address book (if configured) */ public function get_carddav_url() { - $rcmail = rcmail::get_instance(); - if ($template = $rcmail->config->get('kolab_addressbook_carddav_url', null)) { - return strtr($template, array( - '%h' => $_SERVER['HTTP_HOST'], - '%u' => urlencode($rcmail->get_user_name()), - '%i' => urlencode($this->storagefolder->get_uid()), - '%n' => urlencode($this->imap_folder), - )); - } - - return false; + $rcmail = rcmail::get_instance(); + if ($template = $rcmail->config->get('kolab_addressbook_carddav_url', null)) { + return strtr($template, array( + '%h' => $_SERVER['HTTP_HOST'], + '%u' => urlencode($rcmail->get_user_name()), + '%i' => urlencode($this->storagefolder->get_uid()), + '%n' => urlencode($this->imap_folder), + )); + } + + return false; } /** * Setter for the current group */ public function set_group($gid) { $this->gid = $gid; } - /** * Save a search string for future listings * * @param mixed Search params to use in listing method, obtained by get_search_set() */ public function set_search_set($filter) { $this->filter = $filter; } - /** * Getter for saved search properties * * @return mixed Search properties used by this class */ public function get_search_set() { return $this->filter; } - /** * Reset saved results and search parameters */ public function reset() { $this->result = null; $this->filter = null; } - /** * List all active contact groups of this source * * @param string Optional search string to match group name * @param int Search mode. Sum of self::SEARCH_* * - * @return array Indexed list of contact groups, each a hash array + * @return array Indexed list of contact groups, each a hash array */ function list_groups($search = null, $mode = 0) { $this->_fetch_groups(); $groups = array(); foreach ((array)$this->distlists as $group) { if (!$search || strstr(mb_strtolower($group['name']), mb_strtolower($search))) { $groups[$group['ID']] = array('ID' => $group['ID'], 'name' => $group['name']); } } // sort groups by name uasort($groups, function($a, $b) { return strcoll($a['name'], $b['name']); }); return array_values($groups); } - /** * List the current set of contact records * * @param array List of cols to show - * @param int Only return this number of records, use negative values for tail - * @param boolean True to skip the count query (select only) + * @param int Only return this number of records, use negative values for tail + * @param bool True to skip the count query (select only) * - * @return array Indexed list of contact records, each a hash array + * @return array Indexed list of contact records, each a hash array */ public function list_records($cols = null, $subset = 0, $nocount = false) { $this->result = new rcube_result_set(0, ($this->list_page-1) * $this->page_size); $fetch_all = false; $fast_mode = !empty($cols) && is_array($cols); // list member of the selected group if ($this->gid) { $this->_fetch_groups(); $this->sortindex = array(); $this->contacts = array(); $local_sortindex = array(); $uids = array(); // get members with email specified foreach ((array)$this->distlists[$this->gid]['member'] as $member) { // skip member that don't match the search filter if (!empty($this->filter['ids']) && array_search($member['ID'], $this->filter['ids']) === false) { continue; } if (!empty($member['uid'])) { $uids[] = $member['uid']; } else if (!empty($member['email'])) { $this->contacts[$member['ID']] = $member; $local_sortindex[$member['ID']] = $this->_sort_string($member); $fetch_all = true; } } // get members by UID if (!empty($uids)) { $this->_fetch_contacts($query = array(array('uid', '=', $uids)), $fetch_all ? false : count($uids), $fast_mode); $this->sortindex = array_merge($this->sortindex, $local_sortindex); } } else if (is_array($this->filter['ids'])) { $ids = $this->filter['ids']; if (count($ids)) { $uids = array_map(array($this, 'id2uid'), $this->filter['ids']); $this->_fetch_contacts($query = array(array('uid', '=', $uids)), count($ids), $fast_mode); } } else { $this->_fetch_contacts($query = 'contact', true, $fast_mode); } if ($fetch_all) { // sort results (index only) asort($this->sortindex, SORT_LOCALE_STRING); $ids = array_keys($this->sortindex); // fill contact data into the current result set $this->result->count = count($ids); $start_row = $subset < 0 ? $this->result->first + $this->page_size + $subset : $this->result->first; $last_row = min($subset != 0 ? $start_row + abs($subset) : $this->result->first + $this->page_size, $this->result->count); for ($i = $start_row; $i < $last_row; $i++) { if (array_key_exists($i, $ids)) { $idx = $ids[$i]; $this->result->add($this->contacts[$idx] ?: $this->_to_rcube_contact($this->dataset[$idx])); } } } else if (!empty($this->dataset)) { // get all records count, skip the query if possible if (!isset($query) || count($this->dataset) < $this->page_size) { $this->result->count = count($this->dataset) + $this->page_size * ($this->list_page - 1); } else { $this->result->count = $this->storagefolder->count($query); } $start_row = $subset < 0 ? $this->page_size + $subset : 0; $last_row = min($subset != 0 ? $start_row + abs($subset) : $this->page_size, $this->result->count); for ($i = $start_row; $i < $last_row; $i++) { $this->result->add($this->_to_rcube_contact($this->dataset[$i])); } } return $this->result; } - /** * Search records * - * @param mixed $fields The field name of array of field names to search in - * @param mixed $value Search value (or array of values when $fields is array) - * @param int $mode Matching mode: - * 0 - partial (*abc*), - * 1 - strict (=), - * 2 - prefix (abc*) - * 4 - include groups (if supported) - * @param boolean $select True if results are requested, False if count only - * @param boolean $nocount True to skip the count query (select only) - * @param array $required List of fields that cannot be empty + * @param mixed $fields The field name of array of field names to search in + * @param mixed $value Search value (or array of values when $fields is array) + * @param int $mode Matching mode: + * 0 - partial (*abc*), + * 1 - strict (=), + * 2 - prefix (abc*) + * 4 - include groups (if supported) + * @param bool $select True if results are requested, False if count only + * @param bool $nocount True to skip the count query (select only) + * @param array $required List of fields that cannot be empty * - * @return object rcube_result_set List of contact records and 'count' value + * @return rcube_result_set List of contact records and 'count' value */ public function search($fields, $value, $mode=0, $select=true, $nocount=false, $required=array()) { // search by ID if ($fields == $this->primary_key) { $ids = !is_array($value) ? explode(',', $value) : $value; $result = new rcube_result_set(); foreach ($ids as $id) { if ($rec = $this->get_record($id, true)) { $result->add($rec); $result->count++; } } return $result; } else if ($fields == '*') { $fields = $this->search_fields; } - if (!is_array($fields)) + if (!is_array($fields)) { $fields = array($fields); - if (!is_array($required) && !empty($required)) + } + if (!is_array($required) && !empty($required)) { $required = array($required); + } // advanced search if (is_array($value)) { $advanced = true; $value = array_map('mb_strtolower', $value); } - else + else { $value = mb_strtolower($value); + } $scount = count($fields); // build key name regexp $regexp = '/^(' . implode('|', $fields) . ')(?:.*)$/'; // pass query to storage if only indexed cols are involved // NOTE: this is only some rough pre-filtering but probably includes false positives $squery = $this->_search_query($fields, $value, $mode); // add magic selector to select contacts with birthday dates only if (in_array('birthday', $required)) { $squery[] = array('tags', '=', 'x-has-birthday'); } $squery[] = array('type', '=', 'contact'); // get all/matching records $this->_fetch_contacts($squery); // save searching conditions $this->filter = array('fields' => $fields, 'value' => $value, 'mode' => $mode, 'ids' => array()); // search by iterating over all records in dataset foreach ($this->dataset as $record) { $contact = $this->_to_rcube_contact($record); $id = $contact['ID']; // check if current contact has required values, otherwise skip it if ($required) { foreach ($required as $f) { // required field might be 'email', but contact might contain 'email:home' if (!($v = rcube_addressbook::get_col_values($f, $contact, true)) || empty($v)) { continue 2; } } } $found = array(); $contents = ''; foreach (preg_grep($regexp, array_keys($contact)) as $col) { $pos = strpos($col, ':'); $colname = $pos ? substr($col, 0, $pos) : $col; foreach ((array)$contact[$col] as $val) { if ($advanced) { $found[$colname] = $this->compare_search_value($colname, $val, $value[array_search($colname, $fields)], $mode); } else { $contents .= ' ' . join(' ', (array)$val); } } } // compare matches if (($advanced && count($found) >= $scount) || (!$advanced && rcube_utils::words_match(mb_strtolower($contents), $value))) { $this->filter['ids'][] = $id; } } // dummy result with contacts count if (!$select) { return new rcube_result_set(count($this->filter['ids']), ($this->list_page-1) * $this->page_size); } // list records (now limited by $this->filter) return $this->list_records(); } - /** * Refresh saved search results after data has changed */ public function refresh_search() { - if ($this->filter) + if ($this->filter) { $this->search($this->filter['fields'], $this->filter['value'], $this->filter['mode']); + } return $this->get_search_set(); } - /** * Count number of available contacts in database * * @return rcube_result_set Result set with values for 'count' and 'first' */ public function count() { if ($this->gid) { $this->_fetch_groups(); $count = count($this->distlists[$this->gid]['member']); } else if (is_array($this->filter['ids'])) { $count = count($this->filter['ids']); } else { $count = $this->storagefolder->count('contact'); } return new rcube_result_set($count, ($this->list_page-1) * $this->page_size); } - /** * Return the last result set * * @return rcube_result_set Current result set or NULL if nothing selected yet */ public function get_result() { return $this->result; } - /** * Get a specific contact record * - * @param mixed record identifier(s) - * @param boolean True to return record as associative array, otherwise a result set is returned + * @param mixed Record identifier(s) + * @param bool True to return record as associative array, otherwise a result set is returned + * * @return mixed Result object with all record fields or False if not found */ - public function get_record($id, $assoc=false) + public function get_record($id, $assoc = false) { $rec = null; $uid = $this->id2uid($id); $rev = rcube_utils::get_input_value('_rev', rcube_utils::INPUT_GPC); if (strpos($uid, 'mailto:') === 0) { $this->_fetch_groups(true); $rec = $this->contacts[$id]; $this->readonly = true; // set source to read-only } else if (!empty($rev)) { $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->get_plugin('kolab_addressbook'); if ($plugin && ($object = $plugin->get_revision($id, kolab_storage::id_encode($this->imap_folder), $rev))) { $rec = $this->_to_rcube_contact($object); $rec['rev'] = $rev; } $this->readonly = true; // set source to read-only } else if ($object = $this->storagefolder->get_object($uid)) { $rec = $this->_to_rcube_contact($object); } if ($rec) { $this->result = new rcube_result_set(1); $this->result->add($rec); return $assoc ? $rec : $this->result; } return false; } - /** * Get group assignments of a specific contact record * * @param mixed Record identifier + * * @return array List of assigned groups as ID=>Name pairs */ public function get_record_groups($id) { $out = array(); $this->_fetch_groups(); - foreach ((array)$this->groupmembers[$id] as $gid) { - if ($group = $this->distlists[$gid]) - $out[$gid] = $group['name']; + if (!empty($this->groupmembers[$id])) { + foreach ((array) $this->groupmembers[$id] as $gid) { + if (!empty($this->distlists[$gid])) { + $group = $this->distlists[$gid]; + $out[$gid] = $group['name']; + } + } } return $out; } - /** * Create a new contact record * - * @param array Assoziative array with save data + * @param array Associative array with save data * Keys: Field name with optional section in the form FIELD:SECTION * Values: Field value. Can be either a string or an array of strings for multiple values - * @param boolean True to check for duplicates first + * @param bool True to check for duplicates first + * * @return mixed The created record ID on success, False on error */ public function insert($save_data, $check=false) { - if (!is_array($save_data)) + if (!is_array($save_data)) { return false; + } $insert_id = $existing = false; // check for existing records by e-mail comparison if ($check) { foreach ($this->get_col_values('email', $save_data, true) as $email) { if (($res = $this->search('email', $email, true, false)) && $res->count) { $existing = true; break; } } } if (!$existing) { // remove existing id attributes (#1101) unset($save_data['ID'], $save_data['uid']); // generate new Kolab contact item $object = $this->_from_rcube_contact($save_data); $saved = $this->storagefolder->save($object, 'contact'); if (!$saved) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving contact object to Kolab server"), true, false); } else { $insert_id = $this->uid2id($object['uid']); } } return $insert_id; } - /** * Update a specific contact record * * @param mixed Record identifier - * @param array Assoziative array with save data + * @param array Associative array with save data * Keys: Field name with optional section in the form FIELD:SECTION * Values: Field value. Can be either a string or an array of strings for multiple values - * @return boolean True on success, False on error + * + * @return bool True on success, False on error */ public function update($id, $save_data) { $updated = false; if ($old = $this->storagefolder->get_object($this->id2uid($id))) { $object = $this->_from_rcube_contact($save_data, $old); if (!$this->storagefolder->save($object, 'contact', $old['uid'])) { rcube::raise_error(array( - 'code' => 600, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving contact object to Kolab server"), - true, false); + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error saving contact object to Kolab server" + ), + true, false + ); } else { $updated = true; // TODO: update data in groups this contact is member of } } return $updated; } - /** * Mark one or more contact records as deleted * - * @param array Record identifiers - * @param boolean Remove record(s) irreversible (mark as deleted otherwise) + * @param array Record identifiers + * @param bool Remove record(s) irreversible (mark as deleted otherwise) * * @return int Number of records deleted */ public function delete($ids, $force=true) { $this->_fetch_groups(); - if (!is_array($ids)) + if (!is_array($ids)) { $ids = explode(',', $ids); + } $count = 0; foreach ($ids as $id) { if ($uid = $this->id2uid($id)) { $is_mailto = strpos($uid, 'mailto:') === 0; $deleted = $is_mailto || $this->storagefolder->delete($uid, $force); if (!$deleted) { rcube::raise_error(array( - 'code' => 600, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error deleting a contact object $uid from the Kolab server"), - true, false); + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error deleting a contact object $uid from the Kolab server" + ), + true, false + ); } else { // remove from distribution lists - foreach ((array)$this->groupmembers[$id] as $gid) { - if (!$is_mailto || $gid == $this->gid) + foreach ((array) $this->groupmembers[$id] as $gid) { + if (!$is_mailto || $gid == $this->gid) { $this->remove_from_group($gid, $id); + } } // clear internal cache unset($this->groupmembers[$id]); $count++; } } } return $count; } - /** * Undelete one or more contact records. * Only possible just after delete (see 2nd argument of delete() method). * - * @param array Record identifiers + * @param array Record identifiers * * @return int Number of records restored */ public function undelete($ids) { - if (!is_array($ids)) + if (!is_array($ids)) { $ids = explode(',', $ids); + } $count = 0; foreach ($ids as $id) { $uid = $this->id2uid($id); if ($this->storagefolder->undelete($uid)) { $count++; } else { rcube::raise_error(array( - 'code' => 600, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error undeleting a contact object $uid from the Kolab server"), - true, false); + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error undeleting a contact object $uid from the Kolab server" + ), + true, false + ); } } return $count; } - /** * Remove all records from the database * * @param bool $with_groups Remove also groups */ public function delete_all($with_groups = false) { if ($this->storagefolder->delete_all()) { $this->contacts = array(); $this->sortindex = array(); $this->dataset = null; $this->result = null; } } - /** * Close connection to source * Called on script shutdown */ public function close() { } - /** * Create a contact group with the given name * * @param string The group name + * * @return mixed False on error, array with record props in success */ function create_group($name) { $this->_fetch_groups(); $result = false; $list = array( 'name' => $name, 'member' => array(), ); $saved = $this->storagefolder->save($list, 'distribution-list'); if (!$saved) { rcube::raise_error(array( - 'code' => 600, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving distribution-list object to Kolab server"), - true, false); + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error saving distribution-list object to Kolab server" + ), + true, false + ); return false; } else { $id = $this->uid2id($list['uid']); $this->distlists[$id] = $list; $result = array('id' => $id, 'name' => $name); } return $result; } /** * Delete the given group and all linked group members * * @param string Group identifier - * @return boolean True on success, false if no data was changed + * + * @return bool True on success, false if no data was changed */ function delete_group($gid) { $this->_fetch_groups(); $result = false; if ($list = $this->distlists[$gid]) { $deleted = $this->storagefolder->delete($list['uid']); } if (!$deleted) { rcube::raise_error(array( - 'code' => 600, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error deleting distribution-list object from the Kolab server"), - true, false); + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error deleting distribution-list object from the Kolab server" + ), + true, false + ); } else { $result = true; } return $result; } /** * Rename a specific contact group * * @param string Group identifier * @param string New name to set for this group * @param string New group identifier (if changed, otherwise don't set) * - * @return boolean New name on success, false if no data was changed + * @return bool New name on success, false if no data was changed */ function rename_group($gid, $newname, &$newid) { $this->_fetch_groups(); $list = $this->distlists[$gid]; if ($newname != $list['name']) { $list['name'] = $newname; $saved = $this->storagefolder->save($list, 'distribution-list', $list['uid']); } if (!$saved) { rcube::raise_error(array( - 'code' => 600, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving distribution-list object to Kolab server"), - true, false); + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error saving distribution-list object to Kolab server" + ), + true, false + ); return false; } return $newname; } /** * Add the given contact records the a certain group * - * @param string Group identifier - * @param array List of contact identifiers to be added - * @return int Number of contacts added + * @param string Group identifier + * @param array List of contact identifiers to be added + * @return int Number of contacts added */ function add_to_group($gid, $ids) { if (!is_array($ids)) { $ids = explode(',', $ids); } $this->_fetch_groups(true); $list = $this->distlists[$gid]; $added = 0; $uids = array(); $exists = array(); foreach ((array)$list['member'] as $member) { $exists[] = $member['ID']; } // substract existing assignments from list $ids = array_unique(array_diff($ids, $exists)); // add mailto: members foreach ($ids as $contact_id) { $uid = $this->id2uid($contact_id); if (strpos($uid, 'mailto:') === 0 && ($contact = $this->contacts[$contact_id])) { $list['member'][] = array( 'email' => $contact['email'], 'name' => $contact['name'], ); $this->groupmembers[$contact_id][] = $gid; $added++; } else { $uids[$uid] = $contact_id; } } // add members with UID if (!empty($uids)) { foreach ($uids as $uid => $contact_id) { $list['member'][] = array('uid' => $uid); $this->groupmembers[$contact_id][] = $gid; $added++; } } - if ($added) + if ($added) { $saved = $this->storagefolder->save($list, 'distribution-list', $list['uid']); - else + } + else { $saved = true; + } if (!$saved) { rcube::raise_error(array( - 'code' => 600, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving distribution-list to Kolab server"), - true, false); + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error saving distribution-list to Kolab server" + ), + true, false + ); + $added = false; $this->set_error(self::ERROR_SAVING, 'errorsaving'); } else { $this->distlists[$gid] = $list; } return $added; } /** * Remove the given contact records from a certain group * - * @param string Group identifier - * @param array List of contact identifiers to be removed - * @return int Number of deleted group members + * @param string Group identifier + * @param array List of contact identifiers to be removed + * @return int Number of deleted group members */ function remove_from_group($gid, $ids) { - if (!is_array($ids)) + if (!is_array($ids)) { $ids = explode(',', $ids); + } $this->_fetch_groups(); - if (!($list = $this->distlists[$gid])) + if (!($list = $this->distlists[$gid])) { return false; + } $new_member = array(); foreach ((array)$list['member'] as $member) { - if (!in_array($member['ID'], $ids)) + if (!in_array($member['ID'], $ids)) { $new_member[] = $member; + } } // write distribution list back to server $list['member'] = $new_member; $saved = $this->storagefolder->save($list, 'distribution-list', $list['uid']); if (!$saved) { rcube::raise_error(array( - 'code' => 600, 'type' => 'php', - 'file' => __FILE__, 'line' => __LINE__, - 'message' => "Error saving distribution-list object to Kolab server"), - true, false); + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error saving distribution-list object to Kolab server" + ), + true, false + ); } else { // remove group assigments in local cache foreach ($ids as $id) { $j = array_search($gid, $this->groupmembers[$id]); unset($this->groupmembers[$id][$j]); } $this->distlists[$gid] = $list; return true; } return false; } /** * Check the given data before saving. * If input not valid, the message to display can be fetched using get_error() * * @param array Associative array with contact data to save * @param bool Attempt to fix/complete data automatically * - * @return boolean True if input is valid, False if not. + * @return bool True if input is valid, False if not. */ public function validate(&$save_data, $autofix = false) { // validate e-mail addresses $valid = parent::validate($save_data); // require at least one e-mail address if there's no name // (syntax check is already done) if ($valid) { if (!strlen($save_data['name']) && !strlen($save_data['organization']) && !array_filter($this->get_col_values('email', $save_data, true)) ) { $this->set_error('warning', 'kolab_addressbook.noemailnamewarning'); $valid = false; } } return $valid; } /** * Query storage layer and store records in private member var */ private function _fetch_contacts($query = array(), $limit = false, $fast_mode = false) { if (!isset($this->dataset) || !empty($query)) { if ($limit) { $size = is_int($limit) && $limit < $this->page_size ? $limit : $this->page_size; $this->storagefolder->set_order_and_limit($this->_sort_columns(), $size, ($this->list_page-1) * $this->page_size); } $this->sortindex = array(); $this->dataset = $this->storagefolder->select($query, $fast_mode); foreach ($this->dataset as $idx => $record) { $contact = $this->_to_rcube_contact($record); $this->sortindex[$idx] = $this->_sort_string($contact); } } } /** * Extract a string for sorting from the given contact record */ private function _sort_string($rec) { $str = ''; switch ($this->sort_col) { case 'name': $str = $rec['name'] . $rec['prefix']; case 'firstname': $str .= $rec['firstname'] . $rec['middlename'] . $rec['surname']; break; case 'surname': $str = $rec['surname'] . $rec['firstname'] . $rec['middlename']; break; default: $str = $rec[$this->sort_col]; break; } $str .= is_array($rec['email']) ? $rec['email'][0] : $rec['email']; return mb_strtolower($str); } /** * Return the cache table columns to order by */ private function _sort_columns() { $sortcols = array(); switch ($this->sort_col) { case 'name': $sortcols[] = 'name'; case 'firstname': $sortcols[] = 'firstname'; break; case 'surname': $sortcols[] = 'surname'; break; } $sortcols[] = 'email'; return $sortcols; } /** * Read distribution-lists AKA groups from server */ private function _fetch_groups($with_contacts = false) { if (!isset($this->distlists)) { $this->distlists = $this->groupmembers = array(); foreach ($this->storagefolder->select('distribution-list', true) as $record) { $record['ID'] = $this->uid2id($record['uid']); foreach ((array)$record['member'] as $i => $member) { $mid = $this->uid2id($member['uid'] ? $member['uid'] : 'mailto:' . $member['email']); $record['member'][$i]['ID'] = $mid; $record['member'][$i]['readonly'] = empty($member['uid']); $this->groupmembers[$mid][] = $record['ID']; if ($with_contacts && empty($member['uid'])) { $this->contacts[$mid] = $record['member'][$i]; } } $this->distlists[$record['ID']] = $record; } } } /** * Encode object UID into a safe identifier */ public function uid2id($uid) { return rtrim(strtr(base64_encode($uid), '+/', '-_'), '='); } /** * Convert Roundcube object identifier back into the original UID */ public function id2uid($id) { return base64_decode(str_pad(strtr($id, '-_', '+/'), strlen($id) % 4, '=', STR_PAD_RIGHT)); } /** * Build SQL query for fulltext matches */ private function _search_query($fields, $value, $mode) { $query = array(); $cols = array(); // $fulltext_cols might contain composite field names e.g. 'email:address' while $fields not foreach (kolab_format_contact::$fulltext_cols as $col) { if ($pos = strpos($col, ':')) { $col = substr($col, 0, $pos); } if (in_array($col, $fields)) { $cols[] = $col; } } if (count($cols) == count($fields)) { if ($mode & rcube_addressbook::SEARCH_STRICT) { $prefix = '^'; $suffix = '$'; } else if ($mode & rcube_addressbook::SEARCH_PREFIX) { $prefix = '^'; $suffix = ''; } else { $prefix = ''; $suffix = ''; } $search_string = is_array($value) ? join(' ', $value) : $value; foreach (rcube_utils::normalize_string($search_string, true) as $word) { $query[] = array('words', 'LIKE', $prefix . $word . $suffix); } } return $query; } /** * Map fields from internal Kolab_Format to Roundcube contact format */ private function _to_rcube_contact($record) { $record['ID'] = $this->uid2id($record['uid']); // convert email, website, phone values foreach (array('email'=>'address', 'website'=>'url', 'phone'=>'number') as $col => $propname) { if (is_array($record[$col])) { $values = $record[$col]; unset($record[$col]); foreach ((array)$values as $i => $val) { $key = $col . ($val['type'] ? ':' . $val['type'] : ''); $record[$key][] = $val[$propname]; } } } if (is_array($record['address'])) { $addresses = $record['address']; unset($record['address']); foreach ($addresses as $i => $adr) { $key = 'address' . ($adr['type'] ? ':' . $adr['type'] : ''); $record[$key][] = array( 'street' => $adr['street'], 'locality' => $adr['locality'], 'zipcode' => $adr['code'], 'region' => $adr['region'], 'country' => $adr['country'], ); } } // photo is stored as separate attachment - if ($record['photo'] && strlen($record['photo']) < 255 && ($att = $record['_attachments'][$record['photo']])) { + if ($record['photo'] && strlen($record['photo']) < 255 && !empty($record['_attachments'][$record['photo']])) { + $att = $record['_attachments'][$record['photo']]; // only fetch photo content if requested - if ($this->action == 'photo') - $record['photo'] = $att['content'] ? $att['content'] : $this->storagefolder->get_attachment($record['uid'], $att['id']); + if ($this->action == 'photo') { + if (!empty($att['content'])) { + $record['photo'] = $att['content']; + } + else { + $record['photo'] = $this->storagefolder->get_attachment($record['uid'], $att['id']); + } + } } // truncate publickey value for display - if ($record['pgppublickey'] && $this->action == 'show') + if (!empty($record['pgppublickey']) && $this->action == 'show') { $record['pgppublickey'] = substr($record['pgppublickey'], 0, 140) . '...'; + } // remove empty fields $record = array_filter($record); // remove kolab_storage internal data unset($record['_msguid'], $record['_formatobj'], $record['_mailbox'], $record['_type'], $record['_size']); return $record; } /** * Map fields from Roundcube format to internal kolab_format_contact properties */ private function _from_rcube_contact($contact, $old = array()) { - if (!$contact['uid'] && $contact['ID']) + if (!$contact['uid'] && $contact['ID']) { $contact['uid'] = $this->id2uid($contact['ID']); - else if (!$contact['uid'] && $old['uid']) + } + else if (!$contact['uid'] && $old['uid']) { $contact['uid'] = $old['uid']; + } $contact['im'] = array_filter($this->get_col_values('im', $contact, true)); // convert email, website, phone values foreach (array('email'=>'address', 'website'=>'url', 'phone'=>'number') as $col => $propname) { $col_values = $this->get_col_values($col, $contact); $contact[$col] = array(); foreach ($col_values as $type => $values) { foreach ((array)$values as $val) { if (!empty($val)) { $contact[$col][] = array($propname => $val, 'type' => $type); } } unset($contact[$col.':'.$type]); } } $addresses = array(); foreach ($this->get_col_values('address', $contact) as $type => $values) { foreach ((array)$values as $adr) { // skip empty address $adr = array_filter($adr); - if (empty($adr)) + if (empty($adr)) { continue; + } $addresses[] = array( 'type' => $type, 'street' => $adr['street'], 'locality' => $adr['locality'], 'code' => $adr['zipcode'], 'region' => $adr['region'], 'country' => $adr['country'], ); } unset($contact['address:'.$type]); } $contact['address'] = $addresses; // categories are not supported in the web client but should be preserved (#2608) $contact['categories'] = $old['categories']; // copy meta data (starting with _) from old object foreach ((array)$old as $key => $val) { - if (!isset($contact[$key]) && $key[0] == '_') + if (!isset($contact[$key]) && $key[0] == '_') { $contact[$key] = $val; + } } // convert one-item-array elements into string element // this is needed e.g. to properly import birthday field foreach ($this->coltypes as $type => $col_def) { if ($col_def['limit'] == 1 && is_array($contact[$type])) { $contact[$type] = array_shift(array_filter($contact[$type])); } } // When importing contacts 'vcard' data is added, we don't need it (Bug #1711) unset($contact['vcard']); // add empty values for some fields which can be removed in the UI - return array_filter($contact) + array('nickname' => '', 'birthday' => '', 'anniversary' => '', 'freebusyurl' => '', 'photo' => $contact['photo']); + return array_filter($contact) + array( + 'nickname' => '', + 'birthday' => '', + 'anniversary' => '', + 'freebusyurl' => '', + 'photo' => $contact['photo'] + ); } - } diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php index 42b6106d..93e0a26e 100644 --- a/plugins/libcalendaring/lib/libcalendaring_itip.php +++ b/plugins/libcalendaring/lib/libcalendaring_itip.php @@ -1,999 +1,1011 @@ * * Copyright (C) 2011-2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class libcalendaring_itip { protected $rc; protected $lib; protected $plugin; protected $sender; protected $domain; protected $itip_send = false; protected $rsvp_actions = array('accepted','tentative','declined','delegated'); protected $rsvp_status = array('accepted','tentative','declined','delegated'); function __construct($plugin, $domain = 'libcalendaring') { $this->plugin = $plugin; $this->rc = rcube::get_instance(); $this->lib = libcalendaring::get_instance(); $this->domain = $domain; $hook = $this->rc->plugins->exec_hook('calendar_load_itip', array('identity' => $this->rc->user->list_emails(true))); $this->sender = $hook['identity']; $this->plugin->add_hook('smtp_connect', array($this, 'smtp_connect_hook')); } public function set_sender_email($email) { if (!empty($email)) $this->sender['email'] = $email; } public function set_rsvp_actions($actions) { $this->rsvp_actions = (array)$actions; $this->rsvp_status = array_merge($this->rsvp_actions, array('delegated')); } public function set_rsvp_status($status) { $this->rsvp_status = $status; } /** * Wrapper for rcube_plugin::gettext() * Checking for a label in different domains * * @see rcube::gettext() */ public function gettext($p) { $label = is_array($p) ? $p['name'] : $p; $domain = $this->domain; if (!$this->rc->text_exists($label, $domain)) { $domain = 'libcalendaring'; } return $this->rc->gettext($p, $domain); } /** * Send an iTip mail message * * @param array Event object to send * @param string iTip method (REQUEST|REPLY|CANCEL) * @param array Hash array with recipient data (name, email) * @param string Mail subject * @param string Mail body text label * @param object Mail_mime object with message data * @param boolean Request RSVP * @return boolean True on success, false on failure */ public function send_itip_message($event, $method, $recipient, $subject, $bodytext, $message = null, $rsvp = true) { if (!$this->sender['name']) { $this->sender['name'] = $this->sender['email']; } if (!$message) { libcalendaring::identify_recurrence_instance($event); $message = $this->compose_itip_message($event, $method, $rsvp); } $mailto = rcube_utils::idn_to_ascii($recipient['email']); $headers = $message->headers(); $headers['To'] = format_email_recipient($mailto, $recipient['name']); $headers['Subject'] = $this->gettext(array( 'name' => $subject, 'vars' => array( 'title' => $event['title'], 'name' => $this->sender['name'], ) )); // compose a list of all event attendees $attendees_list = array(); foreach ((array)$event['attendees'] as $attendee) { - $attendees_list[] = ($attendee['name'] && $attendee['email']) ? + $attendees_list[] = (!empty($attendee['name']) && !empty($attendee['email'])) ? $attendee['name'] . ' <' . $attendee['email'] . '>' : - ($attendee['name'] ? $attendee['name'] : $attendee['email']); + (!empty($attendee['name']) ? $attendee['name'] : $attendee['email']); } $recurrence_info = ''; if (!empty($event['recurrence_id'])) { - $recurrence_info = "\n\n** " . $this->gettext($event['thisandfuture'] ? 'itipmessagefutureoccurrence' : 'itipmessagesingleoccurrence') . ' **'; + $msg = $this->gettext(!empty($event['thisandfuture']) ? 'itipmessagefutureoccurrence' : 'itipmessagesingleoccurrence'); + $recurrence_info = "\n\n** $msg **"; } else if (!empty($event['recurrence'])) { $recurrence_info = sprintf("\n%s: %s", $this->gettext('recurring'), $this->lib->recurrence_text($event['recurrence'])); } $mailbody = $this->gettext(array( 'name' => $bodytext, 'vars' => array( 'title' => $event['title'], 'date' => $this->lib->event_date_text($event, true) . $recurrence_info, 'attendees' => join(",\n ", $attendees_list), 'sender' => $this->sender['name'], 'organizer' => $this->sender['name'], - 'description' => $event['description'], + 'description' => isset($event['description']) ? $event['description'] : '', ) )); // remove redundant empty lines (e.g. when an event description is empty) $mailbody = preg_replace('/\n{3,}/', "\n\n", $mailbody); // if (!empty($event['comment'])) { // $mailbody .= "\n\n" . $this->gettext('itipsendercomment') . $event['comment']; // } // append links for direct invitation replies if ($method == 'REQUEST' && $rsvp && $this->rc->config->get('calendar_itip_smtp_server') && ($token = $this->store_invitation($event, $recipient['email'])) ) { $mailbody .= "\n\n" . $this->gettext(array( 'name' => 'invitationattendlinks', 'vars' => array('url' => $this->plugin->get_url(array('action' => 'attend', 't' => $token))), )); } else if ($method == 'CANCEL' && $event['cancelled']) { $this->cancel_itip_invitation($event); } $message->headers($headers, true); $message->setTXTBody(rcube_mime::format_flowed($mailbody, 79)); if ($this->rc->config->get('libcalendaring_itip_debug', false)) { rcube::console('iTip ' . $method, $message->txtHeaders() . "\r\n" . $message->get()); } // finally send the message $this->itip_send = true; $sent = $this->rc->deliver_message($message, $headers['X-Sender'], $mailto, $smtp_error); $this->itip_send = false; return $sent; } /** * Plugin hook to alter SMTP authentication. * This is used if iTip messages are to be sent from an unauthenticated session */ public function smtp_connect_hook($p) { // replace smtp auth settings if we're not in an authenticated session if ($this->itip_send && !$this->rc->user->ID) { foreach (array('smtp_server', 'smtp_user', 'smtp_pass') as $prop) { $p[$prop] = $this->rc->config->get("calendar_itip_$prop", $p[$prop]); } } return $p; } /** * Helper function to build a Mail_mime object to send an iTip message * * @param array Event object to send * @param string iTip method (REQUEST|REPLY|CANCEL) * @param boolean Request RSVP * @return object Mail_mime object with message data */ public function compose_itip_message($event, $method, $rsvp = true) { $from = rcube_utils::idn_to_ascii($this->sender['email']); $from_utf = rcube_utils::idn_to_utf8($from); $sender = format_email_recipient($from, $this->sender['name']); // truncate list attendees down to the recipient of the iTip Reply. // constraints for a METHOD:REPLY according to RFC 5546 if ($method == 'REPLY') { $replying_attendee = null; $reply_attendees = array(); foreach ($event['attendees'] as $attendee) { if ($attendee['role'] == 'ORGANIZER') { $reply_attendees[] = $attendee; } else if (strcasecmp($attendee['email'], $from) == 0 || strcasecmp($attendee['email'], $from_utf) == 0) { $replying_attendee = $attendee; if ($attendee['status'] != 'DELEGATED') { unset($replying_attendee['rsvp']); // unset the RSVP attribute } } // include attendees relevant for delegation (RFC 5546, Section 4.2.5) else if ((!empty($attendee['delegated-to']) && (strcasecmp($attendee['delegated-to'], $from) == 0 || strcasecmp($attendee['delegated-to'], $from_utf) == 0)) || (!empty($attendee['delegated-from']) && (strcasecmp($attendee['delegated-from'], $from) == 0 || strcasecmp($attendee['delegated-from'], $from_utf) == 0))) { $reply_attendees[] = $attendee; } } if ($replying_attendee) { array_unshift($reply_attendees, $replying_attendee); $event['attendees'] = $reply_attendees; } if ($event['recurrence']) { unset($event['recurrence']['EXCEPTIONS']); } } // set RSVP for every attendee else if ($method == 'REQUEST') { foreach ($event['attendees'] as $i => $attendee) { - if (($rsvp || !isset($attendee['rsvp'])) && ($attendee['status'] != 'DELEGATED' && $attendee['role'] != 'NON-PARTICIPANT')) { - $event['attendees'][$i]['rsvp']= (bool)$rsvp; + if ( + ($rsvp || !isset($attendee['rsvp'])) + && ( + (empty($attendee['status']) || $attendee['status'] != 'DELEGATED') + && $attendee['role'] != 'NON-PARTICIPANT' + ) + ) { + $event['attendees'][$i]['rsvp']= (bool) $rsvp; } } } else if ($method == 'CANCEL') { if ($event['recurrence']) { unset($event['recurrence']['EXCEPTIONS']); } } // Set SENT-BY property if the sender is not the organizer if ($method == 'CANCEL' || $method == 'REQUEST') { foreach ((array)$event['attendees'] as $idx => $attendee) { if ($attendee['role'] == 'ORGANIZER' && $attendee['email'] && strcasecmp($attendee['email'], $from) != 0 && strcasecmp($attendee['email'], $from_utf) != 0 ) { $attendee['sent-by'] = 'mailto:' . $from_utf; $event['organizer'] = $event['attendees'][$idx] = $attendee; break; } } } // compose multipart message using PEAR:Mail_Mime $message = new Mail_mime("\r\n"); $message->setParam('text_encoding', 'quoted-printable'); $message->setParam('head_encoding', 'quoted-printable'); $message->setParam('head_charset', RCUBE_CHARSET); $message->setParam('text_charset', RCUBE_CHARSET . ";\r\n format=flowed"); $message->setContentType('multipart/alternative'); // compose common headers array $headers = array( 'From' => $sender, 'Date' => $this->rc->user_date(), 'Message-ID' => $this->rc->gen_message_id(), 'X-Sender' => $from, ); if ($agent = $this->rc->config->get('useragent')) { $headers['User-Agent'] = $agent; } $message->headers($headers); // attach ics file for this event $ical = libcalendaring::get_ical(); $ics = $ical->export(array($event), $method, false, $method == 'REQUEST' && $this->plugin->driver ? array($this->plugin->driver, 'get_attachment_body') : false); - $filename = $event['_type'] == 'task' ? 'todo.ics' : 'event.ics'; + $filename = !empty($event['_type']) && $event['_type'] == 'task' ? 'todo.ics' : 'event.ics'; $message->addAttachment($ics, 'text/calendar', $filename, false, '8bit', '', RCUBE_CHARSET . "; method=" . $method); return $message; } /** * Forward the given iTip event as delegation to another person * * @param array Event object to delegate * @param mixed Delegatee as string or hash array with keys 'name' and 'mailto' * @param boolean The delegator's RSVP flag * @param array List with indexes of new/updated attendees * @return boolean True on success, False on failure */ public function delegate_to(&$event, $delegate, $rsvp = false, &$attendees = array()) { if (is_string($delegate)) { $delegates = rcube_mime::decode_address_list($delegate, 1, false); if (count($delegates) > 0) { $delegate = reset($delegates); } } $emails = $this->lib->get_user_emails(); $me = $this->rc->user->list_emails(true); // find/create the delegate attendee $delegate_attendee = array( 'email' => $delegate['mailto'], 'name' => $delegate['name'], 'role' => 'REQ-PARTICIPANT', ); $delegate_index = count($event['attendees']); foreach ($event['attendees'] as $i => $attendee) { // set myself the DELEGATED-TO parameter if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $event['attendees'][$i]['delegated-to'] = $delegate['mailto']; $event['attendees'][$i]['status'] = 'DELEGATED'; $event['attendees'][$i]['role'] = 'NON-PARTICIPANT'; $event['attendees'][$i]['rsvp'] = $rsvp; $me['email'] = $attendee['email']; $delegate_attendee['role'] = $attendee['role']; } // the disired delegatee is already listed as an attendee else if (stripos($delegate['mailto'], $attendee['email']) !== false && $attendee['role'] != 'ORGANIZER') { $delegate_attendee = $attendee; $delegate_index = $i; break; } // TODO: remove previous delegatee (i.e. attendee that has DELEGATED-FROM == $me) } // set/add delegate attendee with RSVP=TRUE and DELEGATED-FROM parameter $delegate_attendee['rsvp'] = true; $delegate_attendee['status'] = 'NEEDS-ACTION'; $delegate_attendee['delegated-from'] = $me['email']; $event['attendees'][$delegate_index] = $delegate_attendee; $attendees[] = $delegate_index; $this->set_sender_email($me['email']); return $this->send_itip_message($event, 'REQUEST', $delegate_attendee, 'itipsubjectdelegatedto', 'itipmailbodydelegatedto'); } /** * Handler for calendar/itip-status requests */ public function get_itip_status($event, $existing = null) { $action = $event['rsvp'] ? 'rsvp' : ''; $status = $event['fallback']; $latest = $rescheduled = false; $html = ''; if (is_numeric($event['changed'])) { $event['changed'] = new DateTime('@'.$event['changed']); } // check if the given itip object matches the last state if ($existing) { $latest = (isset($event['sequence']) && intval($existing['sequence']) == intval($event['sequence'])) || (!isset($event['sequence']) && $existing['changed'] && $existing['changed'] >= $event['changed']); } // determine action for REQUEST if ($event['method'] == 'REQUEST') { $html = html::div('rsvp-status', $this->gettext('acceptinvitation')); if ($existing) { $rsvp = $event['rsvp']; $emails = $this->lib->get_user_emails(); foreach ($existing['attendees'] as $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $status = strtoupper($attendee['status']); break; } } } else { $rsvp = $event['rsvp'] && $this->rc->config->get('calendar_allow_itip_uninvited', true); } $status_lc = strtolower($status); if ($status_lc == 'unknown' && !$this->rc->config->get('calendar_allow_itip_uninvited', true)) { $html = html::div('rsvp-status', $this->gettext('notanattendee')); $action = 'import'; } else if (in_array($status_lc, $this->rsvp_status)) { $status_text = $this->gettext(($latest ? 'youhave' : 'youhavepreviously') . $status_lc); if ($existing && ($existing['sequence'] > $event['sequence'] || (!isset($event['sequence']) && $existing['changed'] && $existing['changed'] > $event['changed'])) ) { $action = ''; // nothing to do here, outdated invitation if ($status_lc == 'needs-action') { $status_text = $this->gettext('outdatedinvitation'); } } else if (!$existing && !$rsvp) { $action = 'import'; } else { if ($latest) { $diff = $this->get_itip_diff($event, $existing); // Detect re-scheduling // FIXME: This is probably to simplistic, or maybe we should just check // attendee's RSVP flag in the new event? $rescheduled = !empty($diff['start']) || !empty($diff['end']); unset($diff['start'], $diff['end']); } if ($rescheduled) { $action = 'rsvp'; $latest = false; } else if ($status_lc != 'needs-action') { // check if there are any changes if ($latest) { $latest = empty($diff); } $action = !$latest ? 'update' : ''; } } $html = html::div('rsvp-status ' . $status_lc, $status_text); } } // determine action for REPLY else if ($event['method'] == 'REPLY') { // check whether the sender already is an attendee if ($existing) { // Relax checking if that is a reply to the latest version of the event // We accept versions with older SEQUENCE but no significant changes (Bifrost#T78144) if (!$latest) { $num = $got = 0; foreach (array('start', 'end', 'due', 'allday', 'recurrence', 'location') as $key) { if (isset($existing[$key])) { if ($key == 'allday') { $event[$key] = $event[$key] == 'true'; } $value = $existing[$key] instanceof DateTime ? $existing[$key]->format('c') : $existing[$key]; $num++; $got += intval($value == $event[$key]); } } $latest = $num === $got; } $action = $this->rc->config->get('calendar_allow_itip_uninvited', true) ? 'accept' : ''; $listed = false; foreach ($existing['attendees'] as $attendee) { if ($attendee['role'] != 'ORGANIZER' && strcasecmp($attendee['email'], $event['attendee']) == 0) { $status_lc = strtolower($status); if (in_array($status_lc, $this->rsvp_status)) { $html = html::div('rsvp-status ' . $status_lc, $this->gettext(array( 'name' => 'attendee' . $status_lc, 'vars' => array( 'delegatedto' => rcube::Q($event['delegated-to'] ?: ($attendee['delegated-to'] ?: '?')), ) ))); } $action = $attendee['status'] == $status || !$latest ? '' : 'update'; $listed = true; break; } } if (!$listed) { $html = html::div('rsvp-status', $this->gettext('itipnewattendee')); } } else { $html = html::div('rsvp-status hint', $this->gettext('itipobjectnotfound')); $action = ''; } } else if ($event['method'] == 'CANCEL') { if (!$existing) { $html = html::div('rsvp-status hint', $this->gettext('itipobjectnotfound')); $action = ''; } } return array( 'uid' => $event['uid'], 'id' => asciiwords($event['uid'], true), 'existing' => $existing ? true : false, 'saved' => $existing ? true : false, 'latest' => $latest, 'status' => $status, 'action' => $action, 'rescheduled' => $rescheduled, 'html' => $html, ); } protected function get_itip_diff($event, $existing) { - if (empty($event) || empty($existing) || empty($event['message_uid'])) { + if (empty($event) || empty($existing) || empty($event['message_uid']) || empty($event['mime_id'])) { return; } $itip = $this->lib->mail_get_itip_object($event['mbox'], $event['message_uid'], $event['mime_id'], $event['task'] == 'calendar' ? 'event' : 'task'); if ($itip) { // List of properties that could change without SEQUENCE bump $attrs = array('description', 'title', 'location', 'url'); $diff = array(); foreach ($attrs as $attr) { if (isset($itip[$attr]) && $itip[$attr] != $existing[$attr]) { $diff[$attr] = array( 'new' => $itip[$attr], 'old' => $existing[$attr] ); } } $status = array(); $itip_attendees = array(); $existing_attendees = array(); $emails = $this->lib->get_user_emails(); // Compare list of attendees (ignoring current user status) foreach ((array) $existing['attendees'] as $idx => $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $status[strtolower($attendee['email'])] = $attendee['status']; } if ($attendee['role'] == 'ORGANIZER') { $attendee['status'] = 'ACCEPTED'; // sometimes is not set for exceptions $existing['attendees'][$idx] = $attendee; } - $existing_attendees[] = $attendee['email'].$attendee['name']; + $existing_attendees[] = $attendee['email'] . (isset($attendee['name']) ? $attendee['name'] : ''); } foreach ((array) $itip['attendees'] as $idx => $attendee) { - if ($attendee['email'] && ($_status = $status[strtolower($attendee['email'])])) { - $attendee['status'] = $_status; + if (!empty($attendee['email']) && !empty($status[strtolower($attendee['email'])])) { + $attendee['status'] = $status[strtolower($attendee['email'])]; $itip['attendees'][$idx] = $attendee; } - $itip_attendees[] = $attendee['email'].$attendee['name']; + $itip_attendees[] = $attendee['email'] . (isset($attendee['name']) ? $attendee['name'] : ''); } if ($itip_attendees != $existing_attendees) { $diff['attendees'] = array( 'new' => $itip['attendees'], 'old' => $existing['attendees'] ); } if ($existing['start'] != $itip['start']) { $diff['start'] = array( 'new' => $itip['start'], 'old' => $existing['start'], ); } if ($existing['end'] != $itip['end']) { $diff['end'] = array( 'new' => $itip['end'], 'old' => $existing['end'], ); } return $diff; } } /** * Build inline UI elements for iTip messages */ public function mail_itip_inline_ui($event, $method, $mime_id, $task, $message_date = null, $preview_url = null) { $buttons = array(); - $dom_id = asciiwords($event['uid'], true); - $rsvp_status = 'unknown'; + $dom_id = asciiwords($event['uid'], true); + + $rsvp_status = 'unknown'; + $rsvp_buttons = ''; // pass some metadata about the event and trigger the asynchronous status check $changed = is_object($event['changed']) ? $event['changed'] : $message_date; $metadata = array( 'uid' => $event['uid'], - '_instance' => $event['_instance'], + '_instance' => isset($event['_instance']) ? $event['_instance'] : null, 'changed' => $changed ? $changed->format('U') : 0, 'sequence' => intval($event['sequence']), 'method' => $method, 'task' => $task, 'mime_id' => $mime_id, ); // create buttons to be activated from async request checking existence of this event in local calendars $buttons[] = html::div(array('id' => 'loading-'.$dom_id, 'class' => 'rsvp-status loading'), $this->gettext('loading')); // on iTip REPLY we have two options: if ($method == 'REPLY') { $title = $this->gettext('itipreply'); foreach ($event['attendees'] as $attendee) { if (!empty($attendee['email']) && $attendee['role'] != 'ORGANIZER') { if (empty($event['_sender']) || self::compare_email($attendee['email'], $event['_sender'], $event['_sender_utf'])) { $metadata['attendee'] = $attendee['email']; $rsvp_status = strtoupper($attendee['status']); if ($attendee['delegated-to']) { $metadata['delegated-to'] = $attendee['delegated-to']; } break; } } } // It may happen that sender's address is different in From: and the attached iTip // In such case use the ATTENDEE entry with the address from From: header if (empty($metadata['attendee']) && !empty($event['_sender'])) { // remove the organizer $itip_attendees = array_filter($event['attendees'], function($item) { return $item['role'] != 'ORGANIZER'; }); // there must be only one attendee if (is_array($itip_attendees) && count($itip_attendees) == 1) { $event_attendee = $itip_attendees[key($itip_attendees)]; $metadata['attendee'] = $event['_sender']; $rsvp_status = strtoupper($event_attendee['status']); } } // 1. update the attendee status on our copy $update_button = html::tag('input', array( 'type' => 'button', 'class' => 'button', 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('updateattendeestatus'), )); // 2. accept or decline a new or delegate attendee $accept_buttons = html::tag('input', array( 'type' => 'button', 'class' => "button accept", 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('acceptattendee'), )); $accept_buttons .= html::tag('input', array( 'type' => 'button', 'class' => "button decline", 'onclick' => "rcube_libcalendaring.decline_attendee_reply('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('declineattendee'), )); $buttons[] = html::div(array('id' => 'update-'.$dom_id, 'style' => 'display:none'), $update_button); $buttons[] = html::div(array('id' => 'accept-'.$dom_id, 'style' => 'display:none'), $accept_buttons); // For replies we need more metadata foreach (array('start', 'end', 'due', 'allday', 'recurrence', 'location') as $key) { if (isset($event[$key])) { $metadata[$key] = $event[$key] instanceof DateTime ? $event[$key]->format('c') : $event[$key]; } } } // when receiving iTip REQUEST messages: else if ($method == 'REQUEST') { $emails = $this->lib->get_user_emails(); $title = $event['sequence'] > 0 ? $this->gettext('itipupdate') : $this->gettext('itipinvitation'); $metadata['rsvp'] = true; $metadata['sensitivity'] = $event['sensitivity']; if (is_object($event['start'])) { $metadata['date'] = $event['start']->format('U'); } // check for X-KOLAB-INVITATIONTYPE property and only show accept/decline buttons if (self::get_custom_property($event, 'X-KOLAB-INVITATIONTYPE') == 'CONFIRMATION') { $this->rsvp_actions = array('accepted','declined'); $metadata['nosave'] = true; } // 1. display RSVP buttons (if the user was invited) foreach ($this->rsvp_actions as $method) { $rsvp_buttons .= html::tag('input', array( 'type' => 'button', 'class' => "button $method", 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task', '$method', '$dom_id')", 'value' => $this->gettext('itip' . $method), )); } // add button to open calendar/preview if (!empty($preview_url)) { $msgref = $this->lib->ical_message->folder . '/' . $this->lib->ical_message->uid . '#' . $mime_id; $rsvp_buttons .= html::tag('input', array( 'type' => 'button', // TODO: Temp. disable this button on small screen in Elastic (Bifrost#T105747) 'class' => "button preview hidden-phone hidden-small", 'onclick' => "rcube_libcalendaring.open_itip_preview('" . rcube::JQ($preview_url) . "', '" . rcube::JQ($msgref) . "')", 'value' => $this->gettext('openpreview'), )); } // 2. update the local copy with minor changes $update_button = html::tag('input', array( 'type' => 'button', 'class' => 'button', 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('updatemycopy'), )); // 3. Simply import the event without replying $import_button = html::tag('input', array( 'type' => 'button', 'class' => 'button', 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('importtocalendar'), )); // check my status as an attendee foreach ($event['attendees'] as $attendee) { if ($attendee['email'] && $attendee['role'] != 'ORGANIZER' && in_array(strtolower($attendee['email']), $emails)) { $metadata['attendee'] = $attendee['email']; $metadata['rsvp'] = $attendee['rsvp'] || $attendee['role'] != 'NON-PARTICIPANT'; $rsvp_status = !empty($attendee['status']) ? strtoupper($attendee['status']) : 'NEEDS-ACTION'; break; } } // add itip reply message controls - $rsvp_buttons .= html::div('itip-reply-controls', $this->itip_rsvp_options_ui($dom_id, $metadata['nosave'])); + $rsvp_buttons .= html::div('itip-reply-controls', $this->itip_rsvp_options_ui($dom_id, !empty($metadata['nosave']))); $buttons[] = html::div(array('id' => 'rsvp-'.$dom_id, 'class' => 'rsvp-buttons', 'style' => 'display:none'), $rsvp_buttons); $buttons[] = html::div(array('id' => 'update-'.$dom_id, 'style' => 'display:none'), $update_button); // prepare autocompletion for delegation dialog if (in_array('delegated', $this->rsvp_actions)) { $this->rc->autocomplete_init(); } } // for CANCEL messages, we can: else if ($method == 'CANCEL') { $title = $this->gettext('itipcancellation'); $event_prop = array_filter(array( 'uid' => $event['uid'], - '_instance' => $event['_instance'], - '_savemode' => $event['_savemode'], + '_instance' => isset($event['_instance']) ? $event['_instance'] : null, + '_savemode' => isset($event['_savemode']) ? $event['_savemode'] : null, )); // 1. remove the event from our calendar $button_remove = html::tag('input', array( 'type' => 'button', 'class' => 'button', 'onclick' => "rcube_libcalendaring.remove_from_itip(" . rcube_output::json_serialize($event_prop) . ", '$task', '" . rcube::JQ($event['title']) . "')", 'value' => $this->gettext('removefromcalendar'), )); // 2. update our copy with status=cancelled $button_update = html::tag('input', array( 'type' => 'button', 'class' => 'button', 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('updatemycopy'), )); $buttons[] = html::div(array('id' => 'rsvp-'.$dom_id, 'style' => 'display:none'), $button_remove . $button_update); $rsvp_status = 'CANCELLED'; $metadata['rsvp'] = true; } // append generic import button - if ($import_button) { + if (!empty($import_button)) { $buttons[] = html::div(array('id' => 'import-'.$dom_id, 'style' => 'display:none'), $import_button); } // pass some metadata about the event and trigger the asynchronous status check $metadata['fallback'] = $rsvp_status; $metadata['rsvp'] = intval($metadata['rsvp']); $this->rc->output->add_script("rcube_libcalendaring.fetch_itip_object_status(" . rcube_output::json_serialize($metadata) . ")", 'docready'); // get localized texts from the right domain foreach (array('savingdata','deleteobjectconfirm','declinedeleteconfirm','declineattendee', 'cancel','itipdelegated','declineattendeeconfirm','itipcomment','delegateinvitation', 'delegateto','delegatersvpme','delegateinvalidaddress') as $label) { $this->rc->output->command('add_label', "itip.$label", $this->gettext($label)); } // show event details with buttons return $this->itip_object_details_table($event, $title) . html::div(array('class' => 'itip-buttons', 'id' => 'itip-buttons-' . asciiwords($metadata['uid'], true)), join('', $buttons)); } /** * Render an RSVP UI widget with buttons to respond on iTip invitations */ function itip_rsvp_buttons($attrib = array(), $actions = null) { $attrib += array('type' => 'button'); - if (!$actions) + if (!$actions) { $actions = $this->rsvp_actions; + } + + $buttons = ''; foreach ($actions as $method) { $buttons .= html::tag('input', array( 'type' => $attrib['type'], - 'name' => $attrib['iname'], + 'name' => !empty($attrib['iname']) ? $attrib['iname'] : null, 'class' => 'button', 'rel' => $method, 'value' => $this->gettext('itip' . $method), )); } // add localized texts for the delegation dialog if (in_array('delegated', $actions)) { foreach (array('itipdelegated','itipcomment','delegateinvitation', 'delegateto','delegatersvpme','delegateinvalidaddress','cancel') as $label) { $this->rc->output->command('add_label', "itip.$label", $this->gettext($label)); } } foreach (array('all','current','future') as $mode) { $this->rc->output->command('add_label', "rsvpmode$mode", $this->gettext("rsvpmode$mode")); } $savemode_radio = new html_radiobutton(array('name' => '_rsvpmode', 'class' => 'rsvp-replymode')); return html::div($attrib, html::div('label', $this->gettext('acceptinvitation')) . html::div('rsvp-buttons itip-buttons', $buttons . html::div('itip-reply-controls', $this->itip_rsvp_options_ui($attrib['id'])) ) ); } /** * Render UI elements to control iTip reply message sending */ public function itip_rsvp_options_ui($dom_id, $disable = false) { $itip_sending = $this->rc->config->get('calendar_itip_send_option', 3); // itip sending is entirely disabled if ($itip_sending === 0) { return ''; } // add checkbox to suppress itip reply message else if ($itip_sending >= 2) { $toggle_attrib = array( 'type' => 'checkbox', 'id' => 'noreply-'.$dom_id, 'value' => 1, 'disabled' => $disable, 'checked' => ($itip_sending & 1) == 0, 'class' => 'pretty-checkbox', ); $rsvp_additions = html::label(array('class' => 'noreply-toggle'), html::tag('input', $toggle_attrib) . ' ' . $this->gettext('itipsuppressreply') ); } // add input field for reply comment $toggle_attrib = array( 'href' => '#toggle', 'class' => 'reply-comment-toggle', 'onclick' => '$(this).hide().parent().find(\'textarea\').show().focus()' ); $textarea_attrib = array( 'id' => 'reply-comment-' . $dom_id, 'name' => '_comment', 'cols' => 40, 'rows' => 4, 'class' => 'form-control', 'style' => 'display:none', 'placeholder' => $this->gettext('itipcomment') ); $rsvp_additions .= html::a($toggle_attrib, $this->gettext('itipeditresponse')) . html::div('itip-reply-comment', html::tag('textarea', $textarea_attrib, '')); return $rsvp_additions; } /** * Render event/task details in a table */ function itip_object_details_table($event, $title) { $table = new html_table(array('cols' => 2, 'border' => 0, 'class' => 'calendar-eventdetails')); $table->add('ititle', $title); $table->add('title', rcube::Q(trim($event['title']))); if ($event['start'] && $event['end']) { $table->add('label', $this->gettext('date')); $table->add('date', rcube::Q($this->lib->event_date_text($event))); } else if ($event['due'] && $event['_type'] == 'task') { $table->add('label', $this->gettext('date')); $table->add('date', rcube::Q($this->lib->event_date_text($event))); } if (!empty($event['recurrence_date'])) { $table->add('label', ''); $table->add('recurrence-id', $this->gettext($event['thisandfuture'] ? 'itipfutureoccurrence' : 'itipsingleoccurrence')); } else if (!empty($event['recurrence'])) { $table->add('label', $this->gettext('recurring')); $table->add('recurrence', $this->lib->recurrence_text($event['recurrence'])); } - if ($location = trim($event['location'])) { + if (isset($event['location']) && ($location = trim($event['location']))) { $table->add('label', $this->gettext('location')); $table->add('location', rcube::Q($location)); } if (($sensitivity = trim($event['sensitivity'])) && !preg_match('/^(x-|public$)/i', $sensitivity)) { $table->add('label', $this->gettext('sensitivity')); $table->add('sensitivity', ucfirst($this->gettext($sensitivity)) . '!'); } - if ($event['status'] == 'COMPLETED' || $event['status'] == 'CANCELLED') { + if (!empty($event['status']) && ($event['status'] == 'COMPLETED' || $event['status'] == 'CANCELLED')) { $table->add('label', $this->gettext('status')); $table->add('status', $this->gettext('status-' . strtolower($event['status']))); } - if ($comment = trim($event['comment'])) { + if (isset($event['comment']) && ($comment = trim($event['comment']))) { $table->add('label', $this->gettext('comment')); $table->add('location', rcube::Q($comment)); } return $table->show(); } /** * Create iTIP invitation token for later replies via URL * * @param array Hash array with event properties * @param string Attendee email address * @return string Invitation token */ public function store_invitation($event, $attendee) { // empty stub return false; } /** * Mark invitations for the given event as cancelled * * @param array Hash array with event properties */ public function cancel_itip_invitation($event) { // empty stub return false; } /** * Utility function to get the value of a custom property */ public static function get_custom_property($event, $name) { $ret = false; if (is_array($event['x-custom'])) { array_walk($event['x-custom'], function($prop, $i) use ($name, &$ret) { if (strcasecmp($prop[0], $name) === 0) { $ret = $prop[1]; } }); } return $ret; } /** * Compare email address */ public static function compare_email($value, $email, $email_utf = null) { $v1 = !empty($email) && strcasecmp($value, $email) === 0; $v2 = !empty($email_utf) && strcasecmp($value, $email_utf) === 0; return $v1 || $v2; } } diff --git a/plugins/libcalendaring/lib/libcalendaring_recurrence.php b/plugins/libcalendaring/lib/libcalendaring_recurrence.php index f83d024c..4eebd68d 100644 --- a/plugins/libcalendaring/lib/libcalendaring_recurrence.php +++ b/plugins/libcalendaring/lib/libcalendaring_recurrence.php @@ -1,234 +1,235 @@ * * Copyright (C) 2012-2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class libcalendaring_recurrence { protected $lib; protected $start; protected $next; protected $engine; protected $recurrence; protected $dateonly = false; protected $hour = 0; /** * Default constructor * * @param object calendar The calendar plugin instance */ function __construct($lib) { // use Horde classes to compute recurring instances // TODO: replace with something that has less than 6'000 lines of code require_once(__DIR__ . '/Horde_Date_Recurrence.php'); $this->lib = $lib; } /** * Initialize recurrence engine * * @param array The recurrence properties * @param object DateTime The recurrence start date */ public function init($recurrence, $start = null) { $this->recurrence = $recurrence; $this->engine = new Horde_Date_Recurrence($start); $this->engine->fromRRule20(libcalendaring::to_rrule($recurrence)); $this->set_start($start); - if (is_array($recurrence['EXDATE'])) { - foreach ($recurrence['EXDATE'] as $exdate) { + if (!empty($recurrence['EXDATE'])) { + foreach ((array) $recurrence['EXDATE'] as $exdate) { if (is_a($exdate, 'DateTime')) { $this->engine->addException($exdate->format('Y'), $exdate->format('n'), $exdate->format('j')); } } } - if (is_array($recurrence['RDATE'])) { - foreach ($recurrence['RDATE'] as $rdate) { + if (!empty($recurrence['RDATE'])) { + foreach ((array) $recurrence['RDATE'] as $rdate) { if (is_a($rdate, 'DateTime')) { $this->engine->addRDate($rdate->format('Y'), $rdate->format('n'), $rdate->format('j')); } } } } /** * Setter for (new) recurrence start date * * @param object DateTime The recurrence start date */ public function set_start($start) { $this->start = $start; $this->dateonly = $start->_dateonly; $this->next = new Horde_Date($start, $this->lib->timezone->getName()); $this->hour = $this->next->hour; $this->engine->setRecurStart($this->next); } /** * Get date/time of the next occurence of this event * * @return mixed DateTime object or False if recurrence ended */ public function next() { $time = false; $after = clone $this->next; $after->mday = $after->mday + 1; if ($this->next && ($next = $this->engine->nextActiveRecurrence($after))) { // avoid endless loops if recurrence computation fails if (!$next->after($this->next)) { return false; } // fix time for all-day events if ($this->dateonly) { $next->hour = $this->hour; $next->min = 0; } $time = $next->toDateTime(); $this->next = $next; } return $time; } /** * Get the end date of the occurence of this recurrence cycle * * @return DateTime|bool End datetime of the last occurence or False if recurrence exceeds limit */ public function end() { // recurrence end date is given if ($this->recurrence['UNTIL'] instanceof DateTime) { return $this->recurrence['UNTIL']; } // take the last RDATE entry if set if (is_array($this->recurrence['RDATE']) && !empty($this->recurrence['RDATE'])) { $last = end($this->recurrence['RDATE']); if ($last instanceof DateTime) { return $last; } } // run through all items till we reach the end if ($this->recurrence['COUNT']) { $last = $this->start; $this->next = new Horde_Date($this->start, $this->lib->timezone->getName()); while (($next = $this->next()) && $c < 1000) { $last = $next; $c++; } } return $last; } /** * Find date/time of the first occurrence (excluding start date) */ public function first_occurrence() { $start = clone $this->start; $orig_start = clone $this->start; $r = $this->recurrence; - $interval = intval($r['INTERVAL'] ?: 1); + $interval = !empty($r['INTERVAL']) ? intval($r['INTERVAL']) : 1; + $frequency = isset($this->recurrence['FREQ']) ? $this->recurrence['FREQ'] : null; - switch ($this->recurrence['FREQ']) { + switch ($frequency) { case 'WEEKLY': if (empty($this->recurrence['BYDAY'])) { return $start; } $start->sub(new DateInterval("P{$interval}W")); break; case 'MONTHLY': if (empty($this->recurrence['BYDAY']) && empty($this->recurrence['BYMONTHDAY'])) { return $start; } $start->sub(new DateInterval("P{$interval}M")); break; case 'YEARLY': if (empty($this->recurrence['BYDAY']) && empty($this->recurrence['BYMONTH'])) { return $start; } $start->sub(new DateInterval("P{$interval}Y")); break; default: return $start; } $r = $this->recurrence; $r['INTERVAL'] = $interval; - if ($r['COUNT']) { + if (!empty($r['COUNT'])) { // Increase count so we do not stop the loop to early $r['COUNT'] += 100; } // Create recurrence that starts in the past $recurrence = new self($this->lib); $recurrence->init($r, $start); // find the first occurrence $found = false; while ($next = $recurrence->next()) { $start = $next; if ($next >= $orig_start) { $found = true; break; } } if (!$found) { rcube::raise_error(array( 'file' => __FILE__, 'line' => __LINE__, 'message' => sprintf("Failed to find a first occurrence. Start: %s, Recurrence: %s", $orig_start->format(DateTime::ISO8601), json_encode($r)), ), true); return null; } if ($start Instanceof Horde_Date) { $start = $start->toDateTime(); } $start->_dateonly = $this->dateonly; return $start; } } diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php index 379bd945..1d25c380 100644 --- a/plugins/libcalendaring/libcalendaring.php +++ b/plugins/libcalendaring/libcalendaring.php @@ -1,1551 +1,1556 @@ * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class libcalendaring extends rcube_plugin { public $rc; public $timezone; public $gmt_offset; public $dst_active; public $timezone_offset; public $ical_parts = array(); public $ical_message; public $defaults = array( - 'calendar_date_format' => "Y-m-d", - 'calendar_date_short' => "M-j", - 'calendar_date_long' => "F j Y", - 'calendar_date_agenda' => "l M-d", - 'calendar_time_format' => "H:m", - 'calendar_first_day' => 1, - 'calendar_first_hour' => 6, - 'calendar_date_format_sets' => array( - 'Y-m-d' => array('d M Y', 'm-d', 'l m-d'), - 'Y/m/d' => array('d M Y', 'm/d', 'l m/d'), - 'Y.m.d' => array('d M Y', 'm.d', 'l m.d'), - 'd-m-Y' => array('d M Y', 'd-m', 'l d-m'), - 'd/m/Y' => array('d M Y', 'd/m', 'l d/m'), - 'd.m.Y' => array('d M Y', 'd.m', 'l d.m'), - 'j.n.Y' => array('d M Y', 'd.m', 'l d.m'), - 'm/d/Y' => array('M d Y', 'm/d', 'l m/d'), - ), + 'calendar_date_format' => "Y-m-d", + 'calendar_date_short' => "M-j", + 'calendar_date_long' => "F j Y", + 'calendar_date_agenda' => "l M-d", + 'calendar_time_format' => "H:m", + 'calendar_first_day' => 1, + 'calendar_first_hour' => 6, + 'calendar_date_format_sets' => array( + 'Y-m-d' => array('d M Y', 'm-d', 'l m-d'), + 'Y/m/d' => array('d M Y', 'm/d', 'l m/d'), + 'Y.m.d' => array('d M Y', 'm.d', 'l m.d'), + 'd-m-Y' => array('d M Y', 'd-m', 'l d-m'), + 'd/m/Y' => array('d M Y', 'd/m', 'l d/m'), + 'd.m.Y' => array('d M Y', 'd.m', 'l d.m'), + 'j.n.Y' => array('d M Y', 'd.m', 'l d.m'), + 'm/d/Y' => array('M d Y', 'm/d', 'l m/d'), + ), ); private static $instance; private $mail_ical_parser; /** * Singleton getter to allow direct access from other plugins */ public static function get_instance() { if (!self::$instance) { self::$instance = new libcalendaring(rcube::get_instance()->plugins); self::$instance->init_instance(); } return self::$instance; } /** * Initializes class properties */ public function init_instance() { $this->rc = rcube::get_instance(); // set user's timezone try { $this->timezone = new DateTimeZone($this->rc->config->get('timezone', 'GMT')); } catch (Exception $e) { $this->timezone = new DateTimeZone('GMT'); } $now = new DateTime('now', $this->timezone); $this->gmt_offset = $now->getOffset(); $this->dst_active = $now->format('I'); $this->timezone_offset = $this->gmt_offset / 3600 - $this->dst_active; $this->add_texts('localization/', false); } /** * Required plugin startup method */ public function init() { self::$instance = $this; $this->rc = rcube::get_instance(); $this->init_instance(); // include client scripts and styles if ($this->rc->output) { // add hook to display alarms $this->add_hook('refresh', array($this, 'refresh')); $this->register_action('plugin.alarms', array($this, 'alarms_action')); $this->register_action('plugin.expand_attendee_group', array($this, 'expand_attendee_group')); } // proceed initialization in startup hook $this->add_hook('startup', array($this, 'startup')); } /** * Startup hook */ public function startup($args) { if ($this->rc->output && $this->rc->output->type == 'html') { $this->rc->output->set_env('libcal_settings', $this->load_settings()); $this->include_script('libcalendaring.js'); $this->include_stylesheet($this->local_skin_path() . '/libcal.css'); $this->add_label( 'itipaccepted', 'itiptentative', 'itipdeclined', 'itipdelegated', 'expandattendeegroup', 'expandattendeegroupnodata', 'statusorganizer', 'statusaccepted', 'statusdeclined', 'statusdelegated', 'statusunknown', 'statusneeds-action', 'statustentative', 'statuscompleted', 'statusin-process', 'delegatedto', 'delegatedfrom', 'showmore' ); } if ($args['task'] == 'mail') { if ($args['action'] == 'show' || $args['action'] == 'preview') { $this->add_hook('message_load', array($this, 'mail_message_load')); } } } /** * Load iCalendar functions */ public static function get_ical() { $self = self::get_instance(); require_once __DIR__ . '/libvcalendar.php'; return new libvcalendar(); } /** * Load iTip functions */ public static function get_itip($domain = 'libcalendaring') { $self = self::get_instance(); require_once __DIR__ . '/lib/libcalendaring_itip.php'; return new libcalendaring_itip($self, $domain); } /** * Load recurrence computation engine */ public static function get_recurrence() { $self = self::get_instance(); require_once __DIR__ . '/lib/libcalendaring_recurrence.php'; return new libcalendaring_recurrence($self); } /** * Shift dates into user's current timezone * * @param mixed Any kind of a date representation (DateTime object, string or unix timestamp) * @return object DateTime object in user's timezone */ public function adjust_timezone($dt, $dateonly = false) { - if (is_numeric($dt)) + if (is_numeric($dt)) { $dt = new DateTime('@'.$dt); - else if (is_string($dt)) + } + else if (is_string($dt)) { $dt = rcube_utils::anytodatetime($dt); + } - if ($dt instanceof DateTime && !($dt->_dateonly || $dateonly)) { + if ($dt instanceof DateTime && empty($dt->_dateonly) && !$dateonly) { $dt->setTimezone($this->timezone); } return $dt; } - /** * */ public function load_settings() { $this->date_format_defaults(); $settings = array(); $keys = array('date_format', 'time_format', 'date_short', 'date_long', 'date_agenda'); foreach ($keys as $key) { $settings[$key] = (string)$this->rc->config->get('calendar_' . $key, $this->defaults['calendar_' . $key]); $settings[$key] = self::from_php_date_format($settings[$key]); } $settings['dates_long'] = $settings['date_long']; $settings['first_day'] = (int)$this->rc->config->get('calendar_first_day', $this->defaults['calendar_first_day']); $settings['timezone'] = $this->timezone_offset; $settings['dst'] = $this->dst_active; // localization $settings['days'] = array( $this->rc->gettext('sunday'), $this->rc->gettext('monday'), $this->rc->gettext('tuesday'), $this->rc->gettext('wednesday'), $this->rc->gettext('thursday'), $this->rc->gettext('friday'), $this->rc->gettext('saturday') ); $settings['days_short'] = array( $this->rc->gettext('sun'), $this->rc->gettext('mon'), $this->rc->gettext('tue'), $this->rc->gettext('wed'), $this->rc->gettext('thu'), $this->rc->gettext('fri'), $this->rc->gettext('sat') ); $settings['months'] = array( $this->rc->gettext('longjan'), $this->rc->gettext('longfeb'), $this->rc->gettext('longmar'), $this->rc->gettext('longapr'), $this->rc->gettext('longmay'), $this->rc->gettext('longjun'), $this->rc->gettext('longjul'), $this->rc->gettext('longaug'), $this->rc->gettext('longsep'), $this->rc->gettext('longoct'), $this->rc->gettext('longnov'), $this->rc->gettext('longdec') ); $settings['months_short'] = array( $this->rc->gettext('jan'), $this->rc->gettext('feb'), $this->rc->gettext('mar'), $this->rc->gettext('apr'), $this->rc->gettext('may'), $this->rc->gettext('jun'), $this->rc->gettext('jul'), $this->rc->gettext('aug'), $this->rc->gettext('sep'), $this->rc->gettext('oct'), $this->rc->gettext('nov'), $this->rc->gettext('dec') ); $settings['today'] = $this->rc->gettext('today'); return $settings; } /** * Helper function to set date/time format according to config and user preferences */ private function date_format_defaults() { static $defaults = array(); // nothing to be done if (isset($defaults['date_format'])) return; $defaults['date_format'] = $this->rc->config->get('calendar_date_format', $this->rc->config->get('date_format')); $defaults['time_format'] = $this->rc->config->get('calendar_time_format', $this->rc->config->get('time_format')); // override defaults if ($defaults['date_format']) $this->defaults['calendar_date_format'] = $defaults['date_format']; if ($defaults['time_format']) $this->defaults['calendar_time_format'] = $defaults['time_format']; // derive format variants from basic date format $format_sets = $this->rc->config->get('calendar_date_format_sets', $this->defaults['calendar_date_format_sets']); if ($format_set = $format_sets[$this->defaults['calendar_date_format']]) { $this->defaults['calendar_date_long'] = $format_set[0]; $this->defaults['calendar_date_short'] = $format_set[1]; $this->defaults['calendar_date_agenda'] = $format_set[2]; } } /** * Compose a date string for the given event */ public function event_date_text($event, $tzinfo = false) { - $fromto = '--'; + $fromto = '--'; + $is_task = !empty($event['_type']) && $event['_type'] == 'task'; // handle task objects - if ($event['_type'] == 'task' && is_object($event['due'])) { - $date_format = $event['due']->_dateonly ? self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format'])) : null; + if ($is_task && !empty($event['due']) && is_object($event['due'])) { + $date_format = !empty($event['due']->_dateonly) ? self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format'])) : null; $fromto = $this->rc->format_date($event['due'], $date_format, false); // add timezone information if ($fromto && $tzinfo && ($tzname = $this->timezone->getName())) { $fromto .= ' (' . strtr($tzname, '_', ' ') . ')'; } return $fromto; } // abort if no valid event dates are given if (!is_object($event['start']) || !is_a($event['start'], 'DateTime') || !is_object($event['end']) || !is_a($event['end'], 'DateTime')) { return $fromto; } $duration = $event['start']->diff($event['end'])->format('s'); $this->date_format_defaults(); $date_format = self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format'])); $time_format = self::to_php_date_format($this->rc->config->get('calendar_time_format', $this->defaults['calendar_time_format'])); if ($event['allday']) { $fromto = $this->rc->format_date($event['start'], $date_format, false); if (($todate = $this->rc->format_date($event['end'], $date_format, false)) != $fromto) $fromto .= ' - ' . $todate; } else if ($duration < 86400 && $event['start']->format('d') == $event['end']->format('d')) { $fromto = $this->rc->format_date($event['start'], $date_format) . ' ' . $this->rc->format_date($event['start'], $time_format) . ' - ' . $this->rc->format_date($event['end'], $time_format); } else { $fromto = $this->rc->format_date($event['start'], $date_format) . ' ' . $this->rc->format_date($event['start'], $time_format) . ' - ' . $this->rc->format_date($event['end'], $date_format) . ' ' . $this->rc->format_date($event['end'], $time_format); } // add timezone information if ($tzinfo && ($tzname = $this->timezone->getName())) { $fromto .= ' (' . strtr($tzname, '_', ' ') . ')'; } return $fromto; } /** * Render HTML form for alarm configuration */ public function alarm_select($attrib, $alarm_types, $absolute_time = true) { unset($attrib['name']); $input_value = new html_inputfield(array('name' => 'alarmvalue[]', 'class' => 'edit-alarm-value form-control', 'size' => 3)); $input_date = new html_inputfield(array('name' => 'alarmdate[]', 'class' => 'edit-alarm-date form-control', 'size' => 10)); $input_time = new html_inputfield(array('name' => 'alarmtime[]', 'class' => 'edit-alarm-time form-control', 'size' => 6)); $select_type = new html_select(array('name' => 'alarmtype[]', 'class' => 'edit-alarm-type form-control', 'id' => $attrib['id'])); $select_offset = new html_select(array('name' => 'alarmoffset[]', 'class' => 'edit-alarm-offset form-control')); $select_related = new html_select(array('name' => 'alarmrelated[]', 'class' => 'edit-alarm-related form-control')); - $object_type = $attrib['_type'] ?: 'event'; + $object_type = !empty($attrib['_type']) ? $attrib['_type'] : 'event'; $select_type->add($this->gettext('none'), ''); - foreach ($alarm_types as $type) + foreach ($alarm_types as $type) { $select_type->add($this->gettext(strtolower("alarm{$type}option")), $type); + } - foreach (array('-M','-H','-D','+M','+H','+D') as $trigger) + foreach (array('-M','-H','-D','+M','+H','+D') as $trigger) { $select_offset->add($this->gettext('trigger' . $trigger), $trigger); + } $select_offset->add($this->gettext('trigger0'), '0'); - if ($absolute_time) + if ($absolute_time) { $select_offset->add($this->gettext('trigger@'), '@'); + } $select_related->add($this->gettext('relatedstart'), 'start'); $select_related->add($this->gettext('relatedend' . $object_type), 'end'); // pre-set with default values from user settings $preset = self::parse_alarm_value($this->rc->config->get('calendar_default_alarm_offset', '-15M')); $hidden = array('style' => 'display:none'); return html::span('edit-alarm-set', $select_type->show($this->rc->config->get('calendar_default_alarm_type', '')) . ' ' . html::span(array('class' => 'edit-alarm-values input-group', 'style' => 'display:none'), $input_value->show($preset[0]) . ' ' . $select_offset->show($preset[1]) . ' ' . $select_related->show() . ' ' . $input_date->show('', $hidden) . ' ' . $input_time->show('', $hidden) ) ); } /** * Get a list of email addresses of the given user (from login and identities) * * @param string User Email (default to current user) * * @return array Email addresses related to the user */ public function get_user_emails($user = null) { static $_emails = array(); if (empty($user)) { $user = $this->rc->user->get_username(); } // return cached result - if (is_array($_emails[$user])) { + if (isset($_emails[$user])) { return $_emails[$user]; } $emails = array($user); $plugin = $this->rc->plugins->exec_hook('calendar_user_emails', array('emails' => $emails)); $emails = array_map('strtolower', $plugin['emails']); // add all emails from the current user's identities if (!$plugin['abort'] && ($user == $this->rc->user->get_username())) { foreach ($this->rc->user->list_emails() as $identity) { $emails[] = strtolower($identity['email']); } } $_emails[$user] = array_unique($emails); return $_emails[$user]; } /** * Set the given participant status to the attendee matching the current user's identities * Unsets 'rsvp' flag too. * * @param array &$event Event data * @param string $status The PARTSTAT value to set * @param bool $recursive Recurive call * * @return mixed Email address of the updated attendee or False if none matching found */ public function set_partstat(&$event, $status, $recursive = true) { $success = false; $emails = $this->get_user_emails(); foreach ((array)$event['attendees'] as $i => $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $event['attendees'][$i]['status'] = strtoupper($status); unset($event['attendees'][$i]['rsvp']); $success = $attendee['email']; } } // apply partstat update to each existing exception if ($event['recurrence'] && is_array($event['recurrence']['EXCEPTIONS'])) { foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { $this->set_partstat($event['recurrence']['EXCEPTIONS'][$i], $status, false); } // set link to top-level exceptions $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; } return $success; } /********* Alarms handling *********/ /** * Helper function to convert alarm trigger strings * into two-field values (e.g. "-45M" => 45, "-M") */ public static function parse_alarm_value($val) { if ($val[0] == '@') { return array(new DateTime($val)); } else if (preg_match('/([+-]?)P?(T?\d+[HMSDW])+/', $val, $m) && preg_match_all('/T?(\d+)([HMSDW])/', $val, $m2, PREG_SET_ORDER)) { if ($m[1] == '') $m[1] = '+'; foreach ($m2 as $seg) { $prefix = $seg[2] == 'D' || $seg[2] == 'W' ? 'P' : 'PT'; if ($seg[1] > 0) { // ignore zero values // convert seconds to minutes if ($seg[2] == 'S') { $seg[2] = 'M'; $seg[1] = max(1, round($seg[1]/60)); } return array($seg[1], $m[1].$seg[2], $m[1].$seg[1].$seg[2], $m[1].$prefix.$seg[1].$seg[2]); } } // return zero value nevertheless return array($seg[1], $m[1].$seg[2], $m[1].$seg[1].$seg[2], $m[1].$prefix.$seg[1].$seg[2]); } return false; } /** * Convert the alarms list items to be processed on the client */ public static function to_client_alarms($valarms) { return array_map(function($alarm){ if ($alarm['trigger'] instanceof DateTime) { $alarm['trigger'] = '@' . $alarm['trigger']->format('U'); } else if ($trigger = libcalendaring::parse_alarm_value($alarm['trigger'])) { $alarm['trigger'] = $trigger[2]; } return $alarm; }, (array)$valarms); } /** * Process the alarms values submitted by the client */ public static function from_client_alarms($valarms) { return array_map(function($alarm){ if ($alarm['trigger'][0] == '@') { try { $alarm['trigger'] = new DateTime($alarm['trigger']); $alarm['trigger']->setTimezone(new DateTimeZone('UTC')); } catch (Exception $e) { /* handle this ? */ } } else if ($trigger = libcalendaring::parse_alarm_value($alarm['trigger'])) { $alarm['trigger'] = $trigger[3]; } return $alarm; }, (array)$valarms); } /** * Render localized text for alarm settings */ public static function alarms_text($alarms) { if (is_array($alarms) && is_array($alarms[0])) { $texts = array(); foreach ($alarms as $alarm) { if ($text = self::alarm_text($alarm)) $texts[] = $text; } return join(', ', $texts); } else { return self::alarm_text($alarms); } } /** * Render localized text for a single alarm property */ public static function alarm_text($alarm) { if (is_string($alarm)) { list($trigger, $action) = explode(':', $alarm); } else { $trigger = $alarm['trigger']; $action = $alarm['action']; $related = $alarm['related']; } $text = ''; $rcube = rcube::get_instance(); switch ($action) { case 'EMAIL': $text = $rcube->gettext('libcalendaring.alarmemail'); break; case 'DISPLAY': $text = $rcube->gettext('libcalendaring.alarmdisplay'); break; case 'AUDIO': $text = $rcube->gettext('libcalendaring.alarmaudio'); break; } if ($trigger instanceof DateTime) { $text .= ' ' . $rcube->gettext(array( 'name' => 'libcalendaring.alarmat', 'vars' => array('datetime' => $rcube->format_date($trigger)) )); } else if (preg_match('/@(\d+)/', $trigger, $m)) { $text .= ' ' . $rcube->gettext(array( 'name' => 'libcalendaring.alarmat', 'vars' => array('datetime' => $rcube->format_date($m[1])) )); } else if ($val = self::parse_alarm_value($trigger)) { $r = strtoupper($related ?: 'start') == 'END' ? 'end' : ''; // TODO: for all-day events say 'on date of event at XX' ? if ($val[0] == 0) { $text .= ' ' . $rcube->gettext('libcalendaring.triggerattime' . $r); } else { $label = 'libcalendaring.trigger' . $r . $val[1]; $text .= ' ' . intval($val[0]) . ' ' . $rcube->gettext($label); } } else { return false; } return $text; } /** * Get the next alarm (time & action) for the given event * * @param array Record data * @return array Hash array with alarm time/type or null if no alarms are configured */ public static function get_next_alarm($rec, $type = 'event') { if ( (empty($rec['valarms']) && empty($rec['alarms'])) || !empty($rec['cancelled']) || (!empty($rec['status']) && $rec['status'] == 'CANCELLED') ) { return null; } if ($type == 'task') { $timezone = self::get_instance()->timezone; if (!empty($rec['startdate'])) { $time = !empty($rec['starttime']) ? $rec['starttime'] : '12:00'; $rec['start'] = new DateTime($rec['startdate'] . ' ' . $time, $timezone); } if (!empty($rec['date'])) { $time = !empty($rec['time']) ? $rec['time'] : '12:00'; $rec[!empty($rec['start']) ? 'end' : 'start'] = new DateTime($rec['date'] . ' ' . $time, $timezone); } } if (empty($rec['end'])) { $rec['end'] = $rec['start']; } // support legacy format if (empty($rec['valarms'])) { list($trigger, $action) = explode(':', $rec['alarms'], 2); if ($alarm = self::parse_alarm_value($trigger)) { $rec['valarms'] = array(array('action' => $action, 'trigger' => $alarm[3] ?: $alarm[0])); } } // alarm ID eq. record ID by default to keep backwards compatibility $alarm_id = isset($rec['id']) ? $rec['id'] : null; $alarm_prop = null; $expires = new DateTime('now - 12 hours'); $notify_at = null; // handle multiple alarms foreach ($rec['valarms'] as $alarm) { $notify_time = null; if ($alarm['trigger'] instanceof DateTime) { $notify_time = $alarm['trigger']; } else if (is_string($alarm['trigger'])) { $refdate = !empty($alarm['related']) && $alarm['related'] == 'END' ? $rec['end'] : $rec['start']; // abort if no reference date is available to compute notification time if (!is_a($refdate, 'DateTime')) { continue; } // TODO: for all-day events, take start @ 00:00 as reference date ? try { $interval = new DateInterval(trim($alarm['trigger'], '+-')); $interval->invert = $alarm['trigger'][0] == '-'; $notify_time = clone $refdate; $notify_time->add($interval); } catch (Exception $e) { rcube::raise_error($e, true); continue; } } if ($notify_time && (!$notify_at || ($notify_time > $notify_at && $notify_time > $expires))) { $notify_at = $notify_time; $action = isset($alarm['action']) ? $alarm['action'] : null; $alarm_prop = $alarm; // generate a unique alarm ID if multiple alarms are set if (count($rec['valarms']) > 1) { $rec_id = substr(md5(isset($rec['id']) ? $rec['id'] : 'none'), 0, 16); $alarm_id = $rec_id . '-' . $notify_at->format('Ymd\THis'); } } } return !$notify_at ? null : array( 'time' => $notify_at->format('U'), 'action' => !empty($action) ? strtoupper($action) : 'DISPLAY', 'id' => $alarm_id, 'prop' => $alarm_prop, ); } /** * Handler for keep-alive requests * This will check for pending notifications and pass them to the client */ public function refresh($attr) { // collect pending alarms from all providers (e.g. calendar, tasks) $plugin = $this->rc->plugins->exec_hook('pending_alarms', array( 'time' => time(), 'alarms' => array(), )); if (!$plugin['abort'] && !empty($plugin['alarms'])) { // make sure texts and env vars are available on client $this->add_texts('localization/', true); $this->rc->output->add_label('close'); $this->rc->output->set_env('snooze_select', $this->snooze_select()); $this->rc->output->command('plugin.display_alarms', $this->_alarms_output($plugin['alarms'])); } } /** * Handler for alarm dismiss/snooze requests */ public function alarms_action() { // $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); $data['ids'] = explode(',', $data['id']); $plugin = $this->rc->plugins->exec_hook('dismiss_alarms', $data); if (!empty($plugin['success'])) { $this->rc->output->show_message('successfullysaved', 'confirmation'); } else { $this->rc->output->show_message('calendar.errorsaving', 'error'); } } /** * Generate reduced and streamlined output for pending alarms */ private function _alarms_output($alarms) { $out = array(); foreach ($alarms as $alarm) { $out[] = array( 'id' => $alarm['id'], 'start' => !empty($alarm['start']) ? $this->adjust_timezone($alarm['start'])->format('c') : '', 'end' => !empty($alarm['end'])? $this->adjust_timezone($alarm['end'])->format('c') : '', 'allDay' => !empty($alarm['allday']), 'action' => $alarm['action'], 'title' => $alarm['title'], 'location' => $alarm['location'], 'calendar' => $alarm['calendar'], ); } return $out; } /** * Render a dropdown menu to choose snooze time */ private function snooze_select($attrib = array()) { $steps = array( 5 => 'repeatinmin', 10 => 'repeatinmin', 15 => 'repeatinmin', 20 => 'repeatinmin', 30 => 'repeatinmin', 60 => 'repeatinhr', 120 => 'repeatinhrs', 1440 => 'repeattomorrow', 10080 => 'repeatinweek', ); $items = array(); foreach ($steps as $n => $label) { $items[] = html::tag('li', null, html::a(array('href' => "#" . ($n * 60), 'class' => 'active'), $this->gettext(array('name' => $label, 'vars' => array('min' => $n % 60, 'hrs' => intval($n / 60)))))); } return html::tag('ul', $attrib + array('class' => 'toolbarmenu menu'), join("\n", $items), html::$common_attrib); } /********* Recurrence rules handling ********/ /** * Render localized text describing the recurrence rule of an event */ public function recurrence_text($rrule) { $limit = 10; $exdates = array(); $format = $this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format']); $format = self::to_php_date_format($format); $format_fn = function($dt) use ($format) { return rcmail::get_instance()->format_date($dt, $format); }; - if (is_array($rrule['EXDATE']) && !empty($rrule['EXDATE'])) { + if (!empty($rrule['EXDATE']) && is_array($rrule['EXDATE'])) { $exdates = array_map($format_fn, $rrule['EXDATE']); } if (empty($rrule['FREQ']) && !empty($rrule['RDATE'])) { $rdates = array_map($format_fn, $rrule['RDATE']); + $more = false; if (!empty($exdates)) { $rdates = array_diff($rdates, $exdates); } if (count($rdates) > $limit) { $rdates = array_slice($rdates, 0, $limit); $more = true; } - return $this->gettext('ondate') . ' ' . join(', ', $rdates) - . ($more ? '...' : ''); + return $this->gettext('ondate') . ' ' . join(', ', $rdates) . ($more ? '...' : ''); } $output = sprintf('%s %d ', $this->gettext('every'), $rrule['INTERVAL'] ?: 1); switch ($rrule['FREQ']) { case 'DAILY': $output .= $this->gettext('days'); break; case 'WEEKLY': $output .= $this->gettext('weeks'); break; case 'MONTHLY': $output .= $this->gettext('months'); break; case 'YEARLY': $output .= $this->gettext('years'); break; } - if ($rrule['COUNT']) { + if (!empty($rrule['COUNT'])) { $until = $this->gettext(array('name' => 'forntimes', 'vars' => array('nr' => $rrule['COUNT']))); } - else if ($rrule['UNTIL']) { + else if (!empty($rrule['UNTIL'])) { $until = $this->gettext('recurrencend') . ' ' . $this->rc->format_date($rrule['UNTIL'], $format); } else { $until = $this->gettext('forever'); } $output .= ', ' . $until; if (!empty($exdates)) { + $more = false; if (count($exdates) > $limit) { $exdates = array_slice($exdates, 0, $limit); $more = true; } - $output .= '; ' . $this->gettext('except') . ' ' . join(', ', $exdates) - . ($more ? '...' : ''); + $output .= '; ' . $this->gettext('except') . ' ' . join(', ', $exdates) . ($more ? '...' : ''); } return $output; } /** * Generate the form for recurrence settings */ public function recurrence_form($attrib = array()) { switch ($attrib['part']) { // frequency selector case 'frequency': $select = new html_select(array('name' => 'frequency', 'id' => 'edit-recurrence-frequency', 'class' => 'form-control')); $select->add($this->gettext('never'), ''); $select->add($this->gettext('daily'), 'DAILY'); $select->add($this->gettext('weekly'), 'WEEKLY'); $select->add($this->gettext('monthly'), 'MONTHLY'); $select->add($this->gettext('yearly'), 'YEARLY'); $select->add($this->gettext('rdate'), 'RDATE'); $html = html::label(array('for' => 'edit-recurrence-frequency', 'class' => 'col-form-label col-sm-2'), $this->gettext('frequency')) . html::div('col-sm-10', $select->show('')); break; // daily recurrence case 'daily': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval form-control', 'id' => 'edit-recurrence-interval-daily')); $html = html::div($attrib, html::label(array('for' => 'edit-recurrence-interval-daily', 'class' => 'col-form-label col-sm-2'), $this->gettext('every')) . html::div('col-sm-10 input-group', $select->show(1) . html::span('label-after input-group-append', html::span('input-group-text', $this->gettext('days'))))); break; // weekly recurrence form case 'weekly': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval form-control', 'id' => 'edit-recurrence-interval-weekly')); $html = html::div($attrib, html::label(array('for' => 'edit-recurrence-interval-weekly', 'class' => 'col-form-label col-sm-2'), $this->gettext('every')) . html::div('col-sm-10 input-group', $select->show(1) . html::span('label-after input-group-append', html::span('input-group-text', $this->gettext('weeks'))))); // weekday selection $daymap = array('sun','mon','tue','wed','thu','fri','sat'); $checkbox = new html_checkbox(array('name' => 'byday', 'class' => 'edit-recurrence-weekly-byday')); $first = $this->rc->config->get('calendar_first_day', 1); for ($weekdays = '', $j = $first; $j <= $first+6; $j++) { $d = $j % 7; $weekdays .= html::label(array('class' => 'weekday'), $checkbox->show('', array('value' => strtoupper(substr($daymap[$d], 0, 2)))) . $this->gettext($daymap[$d]) ) . ' '; } $html .= html::div($attrib, html::label(array('class' => 'col-form-label col-sm-2'), $this->gettext('bydays')) . html::div('col-sm-10 form-control-plaintext', $weekdays)); break; // monthly recurrence form case 'monthly': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval form-control', 'id' => 'edit-recurrence-interval-monthly')); $html = html::div($attrib, html::label(array('for' => 'edit-recurrence-interval-monthly', 'class' => 'col-form-label col-sm-2'), $this->gettext('every')) . html::div('col-sm-10 input-group', $select->show(1) . html::span('label-after input-group-append', html::span('input-group-text', $this->gettext('months'))))); $checkbox = new html_checkbox(array('name' => 'bymonthday', 'class' => 'edit-recurrence-monthly-bymonthday')); for ($monthdays = '', $d = 1; $d <= 31; $d++) { $monthdays .= html::label(array('class' => 'monthday'), $checkbox->show('', array('value' => $d)) . $d); $monthdays .= $d % 7 ? ' ' : html::br(); } // rule selectors $radio = new html_radiobutton(array('name' => 'repeatmode', 'class' => 'edit-recurrence-monthly-mode')); $table = new html_table(array('cols' => 2, 'border' => 0, 'cellpadding' => 0, 'class' => 'formtable')); $table->add('label', html::label(null, $radio->show('BYMONTHDAY', array('value' => 'BYMONTHDAY')) . ' ' . $this->gettext('each'))); $table->add(null, $monthdays); $table->add('label', html::label(null, $radio->show('', array('value' => 'BYDAY')) . ' ' . $this->gettext('every'))); $table->add('recurrence-onevery', $this->rrule_selectors($attrib['part'])); $html .= html::div($attrib, html::label(array('class' => 'col-form-label col-sm-2'), $this->gettext('bydays')) . html::div('col-sm-10 form-control-plaintext', $table->show())); break; // annually recurrence form case 'yearly': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval form-control', 'id' => 'edit-recurrence-interval-yearly')); $html = html::div($attrib, html::label(array('for' => 'edit-recurrence-interval-yearly', 'class' => 'col-form-label col-sm-2'), $this->gettext('every')) . html::div('col-sm-10 input-group', $select->show(1) . html::span('label-after input-group-append', html::span('input-group-text', $this->gettext('years'))))); // month selector $monthmap = array('','jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'); $checkbox = new html_checkbox(array('name' => 'bymonth', 'class' => 'edit-recurrence-yearly-bymonth')); for ($months = '', $m = 1; $m <= 12; $m++) { $months .= html::label(array('class' => 'month'), $checkbox->show(null, array('value' => $m)) . $this->gettext($monthmap[$m])); $months .= $m % 4 ? ' ' : html::br(); } $html .= html::div($attrib, html::label(array('class' => 'col-form-label col-sm-2'), $this->gettext('bymonths')) . html::div('col-sm-10 form-control-plaintext', html::div(array('id' => 'edit-recurrence-yearly-bymonthblock'), $months) . html::div('recurrence-onevery', $this->rrule_selectors($attrib['part'], '---')) )); break; // end of recurrence form case 'until': $radio = new html_radiobutton(array('name' => 'repeat', 'class' => 'edit-recurrence-until')); $select = $this->interval_selector(array('name' => 'times', 'id' => 'edit-recurrence-repeat-times', 'class' => 'form-control')); $input = new html_inputfield(array('name' => 'untildate', 'id' => 'edit-recurrence-enddate', 'size' => '10', 'class' => 'form-control datepicker')); $html = html::div('line first', $radio->show('', array('value' => '', 'id' => 'edit-recurrence-repeat-forever')) . ' ' . html::label('edit-recurrence-repeat-forever', $this->gettext('forever')) ); $label = $this->gettext('ntimes'); if (strpos($label, '$') === 0) { $label = str_replace('$n', '', $label); $group = $select->show(1) . html::span('input-group-append', html::span('input-group-text', rcube::Q($label))); } else { $label = str_replace('$n', '', $label); $group = html::span('input-group-prepend', html::span('input-group-text', rcube::Q($label))) . $select->show(1); } $html .= html::div('line', $radio->show('', array('value' => 'count', 'id' => 'edit-recurrence-repeat-count')) . ' ' . html::label('edit-recurrence-repeat-count', $this->gettext('for')) . ' ' . html::span('input-group', $group) ); $html .= html::div('line', $radio->show('', array('value' => 'until', 'id' => 'edit-recurrence-repeat-until', 'aria-label' => $this->gettext('untilenddate'))) . ' ' . html::label('edit-recurrence-repeat-until', $this->gettext('untildate')) . ' ' . $input->show('', array('aria-label' => $this->gettext('untilenddate'))) ); $html = html::div($attrib, html::label(array('class' => 'col-form-label col-sm-2'), ucfirst($this->gettext('recurrencend'))) . html::div('col-sm-10', $html)); break; case 'rdate': $ul = html::tag('ul', array('id' => 'edit-recurrence-rdates', 'class' => 'recurrence-rdates'), ''); $input = new html_inputfield(array('name' => 'rdate', 'id' => 'edit-recurrence-rdate-input', 'size' => "10", 'class' => 'form-control datepicker')); $button = new html_inputfield(array('type' => 'button', 'class' => 'button add', 'value' => $this->gettext('addrdate'))); $html = html::div($attrib, html::label(array('class' => 'col-form-label col-sm-2', 'for' => 'edit-recurrence-rdate-input'), $this->gettext('bydates')) . html::div('col-sm-10', $ul . html::div('inputform', $input->show() . $button->show()))); break; } return $html; } /** * Input field for interval selection */ private function interval_selector($attrib) { $select = new html_select($attrib); $select->add(range(1,30), range(1,30)); return $select; } /** * Drop-down menus for recurrence rules like "each last sunday of" */ private function rrule_selectors($part, $noselect = null) { // rule selectors $select_prefix = new html_select(array('name' => 'bydayprefix', 'id' => "edit-recurrence-$part-prefix", 'class' => 'form-control')); if ($noselect) $select_prefix->add($noselect, ''); $select_prefix->add(array( $this->gettext('first'), $this->gettext('second'), $this->gettext('third'), $this->gettext('fourth'), $this->gettext('last') ), array(1, 2, 3, 4, -1)); $select_wday = new html_select(array('name' => 'byday', 'id' => "edit-recurrence-$part-byday", 'class' => 'form-control')); if ($noselect) $select_wday->add($noselect, ''); $daymap = array('sunday','monday','tuesday','wednesday','thursday','friday','saturday'); $first = $this->rc->config->get('calendar_first_day', 1); for ($j = $first; $j <= $first+6; $j++) { $d = $j % 7; $select_wday->add($this->gettext($daymap[$d]), strtoupper(substr($daymap[$d], 0, 2))); } return $select_prefix->show() . ' ' . $select_wday->show(); } /** * Convert the recurrence settings to be processed on the client */ public function to_client_recurrence($recurrence, $allday = false) { - if ($recurrence['UNTIL']) { + if (!empty($recurrence['UNTIL'])) { $recurrence['UNTIL'] = $this->adjust_timezone($recurrence['UNTIL'], $allday)->format('c'); } // format RDATE values - if (is_array($recurrence['RDATE'])) { + if (!empty($recurrence['RDATE'])) { $libcal = $this; $recurrence['RDATE'] = array_map(function($rdate) use ($libcal) { return $libcal->adjust_timezone($rdate, true)->format('c'); - }, $recurrence['RDATE']); + }, (array) $recurrence['RDATE']); } unset($recurrence['EXCEPTIONS']); return $recurrence; } /** * Process the alarms values submitted by the client */ public function from_client_recurrence($recurrence, $start = null) { if (is_array($recurrence) && !empty($recurrence['UNTIL'])) { $recurrence['UNTIL'] = new DateTime($recurrence['UNTIL'], $this->timezone); } - if (is_array($recurrence) && is_array($recurrence['RDATE'])) { + if (is_array($recurrence) && !empty($recurrence['RDATE'])) { $tz = $this->timezone; $recurrence['RDATE'] = array_map(function($rdate) use ($tz, $start) { try { $dt = new DateTime($rdate, $tz); if (is_a($start, 'DateTime')) $dt->setTime($start->format('G'), $start->format('i')); return $dt; } catch (Exception $e) { return null; } }, $recurrence['RDATE']); } return $recurrence; } /********* iTip message detection *********/ /** * Check mail message structure of there are .ics files attached */ public function mail_message_load($p) { $this->ical_message = $p['object']; $itip_part = null; // check all message parts for .ics files foreach ((array)$this->ical_message->mime_parts as $part) { if (self::part_is_vcalendar($part, $this->ical_message)) { if ($part->ctype_parameters['method']) $itip_part = $part->mime_id; else $this->ical_parts[] = $part->mime_id; } } // priorize part with method parameter if ($itip_part) { $this->ical_parts = array($itip_part); } } /** * Getter for the parsed iCal objects attached to the current email message * * @return object libvcalendar parser instance with the parsed objects */ public function get_mail_ical_objects() { // create parser and load ical objects if (!$this->mail_ical_parser) { $this->mail_ical_parser = $this->get_ical(); foreach ($this->ical_parts as $mime_id) { $part = $this->ical_message->mime_parts[$mime_id]; $charset = $part->ctype_parameters['charset'] ?: RCUBE_CHARSET; $this->mail_ical_parser->import($this->ical_message->get_part_body($mime_id, true), $charset); // check if the parsed object is an instance of a recurring event/task array_walk($this->mail_ical_parser->objects, 'libcalendaring::identify_recurrence_instance'); // stop on the part that has an iTip method specified if (count($this->mail_ical_parser->objects) && $this->mail_ical_parser->method) { $this->mail_ical_parser->message_date = $this->ical_message->headers->date; $this->mail_ical_parser->mime_id = $mime_id; // store the message's sender address for comparisons $from = rcube_mime::decode_address_list($this->ical_message->headers->from, 1, true, null, true); $this->mail_ical_parser->sender = !empty($from) ? $from[1] : ''; if (!empty($this->mail_ical_parser->sender)) { foreach ($this->mail_ical_parser->objects as $i => $object) { $this->mail_ical_parser->objects[$i]['_sender'] = $this->mail_ical_parser->sender; $this->mail_ical_parser->objects[$i]['_sender_utf'] = rcube_utils::idn_to_utf8($this->mail_ical_parser->sender); } } break; } } } return $this->mail_ical_parser; } /** * Read the given mime message from IMAP and parse ical data * * @param string Mailbox name * @param string Message UID * @param string Message part ID and object index (e.g. '1.2:0') * @param string Object type filter (optional) * * @return array Hash array with the parsed iCal */ public function mail_get_itip_object($mbox, $uid, $mime_id, $type = null) { $charset = RCUBE_CHARSET; // establish imap connection $imap = $this->rc->get_storage(); $imap->set_folder($mbox); if ($uid && $mime_id) { list($mime_id, $index) = explode(':', $mime_id); $part = $imap->get_message_part($uid, $mime_id); $headers = $imap->get_message_headers($uid); $parser = $this->get_ical(); - if ($part->ctype_parameters['charset']) { + if (!empty($part->ctype_parameters['charset'])) { $charset = $part->ctype_parameters['charset']; } if ($part) { $objects = $parser->import($part, $charset); } } // successfully parsed events/tasks? if (!empty($objects) && ($object = $objects[$index]) && (!$type || $object['_type'] == $type)) { if ($parser->method) $object['_method'] = $parser->method; // store the message's sender address for comparisons $from = rcube_mime::decode_address_list($headers->from, 1, true, null, true); $object['_sender'] = !empty($from) ? $from[1] : ''; $object['_sender_utf'] = rcube_utils::idn_to_utf8($object['_sender']); // check if this is an instance of a recurring event/task self::identify_recurrence_instance($object); return $object; } return null; } /** * Checks if specified message part is a vcalendar data * * @param rcube_message_part Part object * @param rcube_message Message object * * @return boolean True if part is of type vcard */ public static function part_is_vcalendar($part, $message = null) { // First check if the message is "valid" (i.e. not multipart/report) if ($message) { $level = explode('.', $part->mime_id); while (array_pop($level) !== null) { - $parent = $message->mime_parts[join('.', $level) ?: 0]; - if ($parent->mimetype == 'multipart/report') { + $id = join('.', $level) ?: 0; + $parent = !empty($message->mime_parts[$id]) ? $message->mime_parts[$id] : null; + if ($parent && $parent->mimetype == 'multipart/report') { return false; } } } return ( in_array($part->mimetype, array('text/calendar', 'text/x-vcalendar', 'application/ics')) || // Apple sends files as application/x-any (!?) - ($part->mimetype == 'application/x-any' && $part->filename && preg_match('/\.ics$/i', $part->filename)) + ($part->mimetype == 'application/x-any' && !empty($part->filename) && preg_match('/\.ics$/i', $part->filename)) ); } /** * Single occourrences of recurring events are identified by their RECURRENCE-ID property * in iCal which is represented as 'recurrence_date' in our internal data structure. * * Check if such a property exists and derive the '_instance' identifier and '_savemode' * attributes which are used in the storage backend to identify the nested exception item. */ public static function identify_recurrence_instance(&$object) { // for savemode=all, remove recurrence instance identifiers if (!empty($object['_savemode']) && $object['_savemode'] == 'all' && $object['recurrence']) { unset($object['_instance'], $object['recurrence_date']); } // set instance and 'savemode' according to recurrence-id else if (!empty($object['recurrence_date']) && is_a($object['recurrence_date'], 'DateTime')) { $object['_instance'] = self::recurrence_instance_identifier($object); $object['_savemode'] = $object['thisandfuture'] ? 'future' : 'current'; } else if (!empty($object['recurrence_id']) && !empty($object['_instance'])) { if (strlen($object['_instance']) > 4) { $object['recurrence_date'] = rcube_utils::anytodatetime($object['_instance'], $object['start']->getTimezone()); } else { $object['recurrence_date'] = clone $object['start']; } } } /** * Return a date() format string to render identifiers for recurrence instances * * @param array Hash array with event properties * @return string Format string */ public static function recurrence_id_format($event) { - return $event['allday'] ? 'Ymd' : 'Ymd\THis'; + return !empty($event['allday']) ? 'Ymd' : 'Ymd\THis'; } /** * Return the identifer for the given instance of a recurring event * * @param array Hash array with event properties * @param bool All-day flag from the main event * * @return mixed Format string or null if identifier cannot be generated */ public static function recurrence_instance_identifier($event, $allday = null) { - $instance_date = $event['recurrence_date'] ?: $event['start']; + $instance_date = !empty($event['recurrence_date']) ? $event['recurrence_date'] : $event['start']; - if ($instance_date && is_a($instance_date, 'DateTime')) { + if ($instance_date instanceof DateTime) { // According to RFC5545 (3.8.4.4) RECURRENCE-ID format should // be date/date-time depending on the main event type, not the exception if ($allday === null) { - $allday = $event['allday']; + $allday = !empty($event['allday']); } return $instance_date->format($allday ? 'Ymd' : 'Ymd\THis'); } } /********* Attendee handling functions *********/ /** * Handler for attendee group expansion requests */ public function expand_attendee_group() { $id = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST); $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); $result = array('id' => $id, 'members' => array()); $maxnum = 500; // iterate over all autocomplete address books (we don't know the source of the group) foreach ((array)$this->rc->config->get('autocomplete_addressbooks', 'sql') as $abook_id) { if (($abook = $this->rc->get_address_book($abook_id)) && $abook->groups) { foreach ($abook->list_groups($data['name'], 1) as $group) { // this is the matching group to expand if (in_array($data['email'], (array)$group['email'])) { $abook->set_pagesize($maxnum); $abook->set_group($group['ID']); // get all members $res = $abook->list_records($this->rc->config->get('contactlist_fields')); // handle errors (e.g. sizelimit, timelimit) if ($abook->get_error()) { $result['error'] = $this->rc->gettext('expandattendeegrouperror', 'libcalendaring'); $res = false; } // check for maximum number of members (we don't wanna bloat the UI too much) else if ($res->count > $maxnum) { $result['error'] = $this->rc->gettext('expandattendeegroupsizelimit', 'libcalendaring'); $res = false; } while ($res && ($member = $res->iterate())) { $emails = (array)$abook->get_col_values('email', $member, true); if (!empty($emails) && ($email = array_shift($emails))) { $result['members'][] = array( 'email' => $email, 'name' => rcube_addressbook::compose_list_name($member), ); } } break 2; } } } } $this->rc->output->command('plugin.expand_attendee_callback', $result); } /** * Merge attendees of the old and new event version * with keeping current user and his delegatees status * * @param array &$new New object data * @param array $old Old object data * @param bool $status New status of the current user */ public function merge_attendees(&$new, $old, $status = null) { if (empty($status)) { $emails = $this->get_user_emails(); $delegates = array(); $attendees = array(); // keep attendee status of the current user foreach ((array) $new['attendees'] as $i => $attendee) { if (empty($attendee['email'])) { continue; } $attendees[] = $email = strtolower($attendee['email']); if (in_array($email, $emails)) { foreach ($old['attendees'] as $_attendee) { if ($attendee['email'] == $_attendee['email']) { $new['attendees'][$i] = $_attendee; if ($_attendee['status'] == 'DELEGATED' && ($email = $_attendee['delegated-to'])) { $delegates[] = strtolower($email); } break; } } } } // make sure delegated attendee is not lost foreach ($delegates as $delegatee) { if (!in_array($delegatee, $attendees)) { foreach ((array) $old['attendees'] as $attendee) { if ($attendee['email'] && ($email = strtolower($attendee['email'])) && $email == $delegatee) { $new['attendees'][] = $attendee; break; } } } } } // We also make sure that status of any attendee // is not overriden by NEEDS-ACTION if it was already set // which could happen if you work with shared events foreach ((array) $new['attendees'] as $i => $attendee) { if ($attendee['email'] && $attendee['status'] == 'NEEDS-ACTION') { foreach ($old['attendees'] as $_attendee) { if ($attendee['email'] == $_attendee['email']) { $new['attendees'][$i]['status'] = $_attendee['status']; unset($new['attendees'][$i]['rsvp']); break; } } } } } /********* Static utility functions *********/ /** * Convert the internal structured data into a vcalendar rrule 2.0 string */ public static function to_rrule($recurrence, $allday = false) { if (is_string($recurrence)) { return $recurrence; } $rrule = ''; foreach ((array)$recurrence as $k => $val) { $k = strtoupper($k); switch ($k) { case 'UNTIL': // convert to UTC according to RFC 5545 if (is_a($val, 'DateTime')) { if (!$allday && empty($val->_dateonly)) { $until = clone $val; $until->setTimezone(new DateTimeZone('UTC')); $val = $until->format('Ymd\THis\Z'); } else { $val = $val->format('Ymd'); } } break; case 'RDATE': case 'EXDATE': foreach ((array)$val as $i => $ex) { if (is_a($ex, 'DateTime')) { $val[$i] = $ex->format('Ymd\THis'); } } $val = join(',', (array)$val); break; case 'EXCEPTIONS': continue 2; } if (strlen($val)) { $rrule .= $k . '=' . $val . ';'; } } return rtrim($rrule, ';'); } /** * Convert from fullcalendar date format to PHP date() format string */ public static function to_php_date_format($from) { // "dd.MM.yyyy HH:mm:ss" => "d.m.Y H:i:s" return strtr(strtr($from, array( 'YYYY' => 'Y', 'YY' => 'y', 'yyyy' => 'Y', 'yy' => 'y', 'MMMM' => 'F', 'MMM' => 'M', 'MM' => 'm', 'M' => 'n', 'dddd' => 'l', 'ddd' => 'D', 'DD' => 'd', 'D' => 'j', 'HH' => '**', 'hh' => '%%', 'H' => 'G', 'h' => 'g', 'mm' => 'i', 'ss' => 's', 'TT' => 'A', 'tt' => 'a', 'T' => 'A', 't' => 'a', 'u' => 'c', )), array( '**' => 'H', '%%' => 'h', )); } /** * Convert from PHP date() format to fullcalendar (MomentJS) format string */ public static function from_php_date_format($from) { // "d.m.Y H:i:s" => "dd.MM.yyyy HH:mm:ss" return strtr($from, array( 'y' => 'YY', 'Y' => 'YYYY', 'M' => 'MMM', 'F' => 'MMMM', 'm' => 'MM', 'n' => 'M', 'j' => 'D', 'd' => 'DD', 'D' => 'ddd', 'l' => 'dddd', 'H' => 'HH', 'h' => 'hh', 'G' => 'H', 'g' => 'h', 'i' => 'mm', 's' => 'ss', 'c' => '', )); } - } diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php index 3dda65d4..0bf58456 100644 --- a/plugins/libcalendaring/libvcalendar.php +++ b/plugins/libcalendaring/libvcalendar.php @@ -1,1532 +1,1532 @@ * * Copyright (C) 2013-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ use \Sabre\VObject; use \Sabre\VObject\DateTimeParser; /** * Class to parse and build vCalendar (iCalendar) files * * Uses the Sabre VObject library, version 3.x. * */ class libvcalendar implements Iterator { private $timezone; private $attach_uri = null; private $prodid = '-//Roundcube libcalendaring//Sabre//Sabre VObject//EN'; 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'; } /** * 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 boolean 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 array(); } /** * Read iCalendar events from a file * * @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 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 'CLASS': case 'X-CALENDARSERVER-ACCESS': $event['sensitivity'] = strtolower($value); 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 ($attendee['email'] != $event['organizer']['email']) { + 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 (strlen($value) && strtoupper($params['VALUE']) == 'BINARY') { $attachment = self::map_keys($params, array('FMTTYPE' => 'mimetype', 'X-LABEL' => 'name', 'X-APPLE-FILENAME' => 'name')); $attachment['data'] = $value; $attachment['size'] = strlen($value); $event['attachments'][] = $attachment; } break; default: if (substr($prop->name, 0, 2) == 'X-') $event['x-custom'][] = array($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 DateTime) { $this->_apply_timezone($event['start']); } else { unset($event['start']); } if (!empty($event['end']) && $event['end'] instanceof DateTime) { $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 ($date->_dateonly) { + if (!empty($date->_dateonly)) { $dt = new 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 = array('_type' => 'freebusy', 'periods' => array()); $seen = array(); 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 = array( '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'][] = array($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 ? array() : null; } else if ($prop instanceof VObject\Property\iCalendar\DateTime) { if (count($prop->getDateTimes()) > 1) { $dt = array(); $dateonly = !$prop->hasTime(); foreach ($prop->getDateTimes() as $item) { $item->_dateonly = $dateonly; $dt[] = $item; } } else { $dt = $prop->getDateTime(); if (!$prop->hasTime()) { $dt->_dateonly = true; } } } else if ($prop instanceof VObject\Property\iCalendar\Period) { $dt = array(); 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[] = array($start, $end); } catch (Exception $e) { // ignore single date parse errors } } } else if ($prop instanceof \DateTime) { $dt = $prop; } // force return value to array if requested if ($as_array && !is_array($dt)) { $dt = empty($dt) ? array() : array($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(); } } /** * 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'] = clone $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 DateTime) { $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); } $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 DateTime) { $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['sensitivity'])) $ve->add('CLASS', strtoupper($event['sensitivity'])); 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 DateTime) { $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 DateTime) { $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 $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) && $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'], $event))) { // embed attachments for iCal $ve->add('ATTACH', $data, array_filter(array('VALUE' => 'BINARY', 'ENCODING' => 'BASE64', 'FMTTYPE' => $attach['mimetype'], 'X-LABEL' => $attach['name']))); unset($data); // attempt to free memory } // list attachments as absolute URIs else if (!empty($this->attach_uri)) { $ve->add('ATTACH', strtr($this->attach_uri, array( '{{id}}' => urlencode($attach['id']), '{{name}}' => urlencode($attach['name']), '{{mimetype}}' => urlencode($attach['mimetype']), )), array('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'); } $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; } /*** Implement PHP 5 Iterator interface to make foreach work ***/ function current() { return $this->objects[$this->iteratorkey]; } function key() { return $this->iteratorkey; } 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(); } function rewind() { $this->iteratorkey = 0; } 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 = array( // vCard 'N', 'ADR', 'ORG', 'GENDER', 'LOCATION', // iCalendar 'REQUEST-STATUS', ); } diff --git a/plugins/libkolab/lib/kolab_attachments_handler.php b/plugins/libkolab/lib/kolab_attachments_handler.php index d38739e6..bc41e13a 100644 --- a/plugins/libkolab/lib/kolab_attachments_handler.php +++ b/plugins/libkolab/lib/kolab_attachments_handler.php @@ -1,370 +1,376 @@ * @author Aleksander Machniak * * Copyright (C) 2012-2018, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_attachments_handler { private $rc; private $attachment; public function __construct() { $this->rc = rcmail::get_instance(); } public static function ui() { $rcmail = rcmail::get_instance(); $self = new self; $rcmail->output->add_handler('plugin.attachments_form', array($self, 'files_form')); $rcmail->output->add_handler('plugin.attachments_list', array($self, 'files_list')); $rcmail->output->add_handler('plugin.filedroparea', array($self, 'files_drop_area')); } /** * Generate HTML element for attachments list */ public function files_list($attrib = array()) { - if (!$attrib['id']) { + if (empty($attrib['id'])) { $attrib['id'] = 'kolabattachmentlist'; } // define list of file types which can be displayed inline // same as in program/steps/mail/show.inc $this->rc->output->set_env('mimetypes', (array)$this->rc->config->get('client_mimetypes')); $this->rc->output->add_gui_object('attachmentlist', $attrib['id']); return html::tag('ul', $attrib, '', html::$common_attrib); } /** * Generate the form for event attachments upload */ public function files_form($attrib = array()) { // add ID if not given - if (!$attrib['id']) { + if (empty($attrib['id'])) { $attrib['id'] = 'kolabuploadform'; } return $this->rc->upload_form($attrib, 'uploadform', 'upload-file', array('multiple' => true)); } /** * Register UI object for HTML5 drag & drop file upload */ public function files_drop_area($attrib = array()) { // add ID if not given - if (!$attrib['id']) { + if (empty($attrib['id'])) { $attrib['id'] = 'kolabfiledroparea'; } $this->rc->output->add_gui_object('filedrop', $attrib['id']); $this->rc->output->set_env('filedrop', array('action' => 'upload', 'fieldname' => '_attachments')); } /** * Displays attachment preview page */ public function attachment_page($attachment) { $this->attachment = $attachment; $this->rc->plugins->include_script('libkolab/libkolab.js'); $this->rc->output->add_handler('plugin.attachmentframe', array($this, 'attachment_frame')); $this->rc->output->add_handler('plugin.attachmentcontrols', array($this, 'attachment_header')); $this->rc->output->set_env('filename', $attachment['name']); $this->rc->output->set_env('mimetype', $attachment['mimetype']); $this->rc->output->send('libkolab.attachment'); } /** * Handler for attachment uploads */ public function attachment_upload($session_key, $id_prefix = '') { // Upload progress update if (!empty($_GET['_progress'])) { $this->rc->upload_progress(); } $recid = $id_prefix . rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); $uploadid = rcube_utils::get_input_value('_uploadid', rcube_utils::INPUT_GPC); - if (!is_array($_SESSION[$session_key]) || $_SESSION[$session_key]['id'] != $recid) { + if (empty($_SESSION[$session_key]) || $_SESSION[$session_key]['id'] != $recid) { $_SESSION[$session_key] = array(); $_SESSION[$session_key]['id'] = $recid; $_SESSION[$session_key]['attachments'] = array(); } // clear all stored output properties (like scripts and env vars) $this->rc->output->reset(); if (is_array($_FILES['_attachments']['tmp_name'])) { foreach ($_FILES['_attachments']['tmp_name'] as $i => $filepath) { // Process uploaded attachment if there is no error $err = $_FILES['_attachments']['error'][$i]; if (!$err) { $filename = $_FILES['_attachments']['name'][$i]; $attachment = array( 'path' => $filepath, 'size' => $_FILES['_attachments']['size'][$i], 'name' => $filename, 'mimetype' => rcube_mime::file_content_type($filepath, $filename, $_FILES['_attachments']['type'][$i]), 'group' => $recid, ); $attachment = $this->rc->plugins->exec_hook('attachment_upload', $attachment); } if (!$err && $attachment['status'] && !$attachment['abort']) { $id = $attachment['id']; // store new attachment in session unset($attachment['status'], $attachment['abort']); $this->rc->session->append($session_key . '.attachments', $id, $attachment); - if (($icon = $_SESSION[$session_key . '_deleteicon']) && is_file($icon)) { + if (!empty($_SESSION[$session_key . '_deleteicon']) + && ($icon = $_SESSION[$session_key . '_deleteicon']) + && is_file($icon) + ) { $button = html::img(array( 'src' => $icon, 'alt' => $this->rc->gettext('delete') )); } - else if ($_SESSION[$session_key . '_textbuttons']) { + else if (!empty($_SESSION[$session_key . '_textbuttons'])) { $button = rcube::Q($this->rc->gettext('delete')); } else { $button = ''; } $link_content = sprintf('%s(%s)', rcube::Q($attachment['name']), $this->rc->show_bytes($attachment['size'])); $delete_link = html::a(array( 'href' => "#delete", 'class' => 'delete', 'onclick' => sprintf("return %s.remove_from_attachment_list('rcmfile%s')", rcmail_output::JS_OBJECT_NAME, $id), 'title' => $this->rc->gettext('delete'), 'aria-label' => $this->rc->gettext('delete') . ' ' . $attachment['name'], ), $button); $content_link = html::a(array( 'href' => "#load", 'class' => 'filename', 'onclick' => 'return false', // sprintf("return %s.command('load-attachment','rcmfile%s', this, event)", rcmail_output::JS_OBJECT_NAME, $id), ), $link_content); - $content .= $_SESSION[$session_key . '_icon_pos'] == 'left' ? $delete_link.$content_link : $content_link.$delete_link; + $left = !empty($_SESSION[$session_key . '_icon_pos']) && $_SESSION[$session_key . '_icon_pos'] == 'left'; + $content = $left ? $delete_link.$content_link : $content_link.$delete_link; $this->rc->output->command('add2attachment_list', "rcmfile$id", array( 'html' => $content, 'name' => $attachment['name'], 'mimetype' => $attachment['mimetype'], 'classname' => 'no-menu ' . rcube_utils::file2class($attachment['mimetype'], $attachment['name']), 'complete' => true ), $uploadid); } else { // upload failed 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 if ($attachment['error']) { + else if (!empty($attachment['error'])) { $msg = $attachment['error']; } else { $msg = $this->rc->gettext('fileuploaderror'); } $this->rc->output->command('display_message', $msg, 'error'); $this->rc->output->command('remove_from_attachment_list', $uploadid); } } } else if ($_SERVER['REQUEST_METHOD'] == 'POST') { // if filesize exceeds post_max_size then $_FILES array is empty, // show filesizeerror instead of fileuploaderror - if ($maxsize = ini_get('post_max_size')) + if ($maxsize = ini_get('post_max_size')) { $msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array( 'size' => $this->rc->show_bytes(parse_bytes($maxsize))))); - else + } + else { $msg = $this->rc->gettext('fileuploaderror'); + } $this->rc->output->command('display_message', $msg, 'error'); $this->rc->output->command('remove_from_attachment_list', $uploadid); } $this->rc->output->send('iframe'); } /** * Deliver an event/task attachment to the client * (similar as in Roundcube core program/steps/mail/get.inc) */ public function attachment_get($attachment) { ob_end_clean(); - if ($attachment && $attachment['body']) { + if ($attachment && !empty($attachment['body'])) { // allow post-processing of the attachment body $part = new rcube_message_part; $part->filename = $attachment['name']; $part->size = $attachment['size']; $part->mimetype = $attachment['mimetype']; $plugin = $this->rc->plugins->exec_hook('message_part_get', array( 'body' => $attachment['body'], 'mimetype' => strtolower($attachment['mimetype']), 'download' => !empty($_GET['_download']), 'part' => $part, )); if ($plugin['abort']) exit; $mimetype = $plugin['mimetype']; list($ctype_primary, $ctype_secondary) = explode('/', $mimetype); $browser = $this->rc->output->browser; // send download headers if ($plugin['download']) { header("Content-Type: application/octet-stream"); if ($browser->ie) header("Content-Type: application/force-download"); } else if ($ctype_primary == 'text') { header("Content-Type: text/$ctype_secondary"); } else { header("Content-Type: $mimetype"); header("Content-Transfer-Encoding: binary"); } // display page, @TODO: support text/plain (and maybe some other text formats) if ($mimetype == 'text/html' && empty($_GET['_download'])) { $OUTPUT = new rcmail_html_page(); // @TODO: use washtml on $body $OUTPUT->write($plugin['body']); } else { // don't kill the connection if download takes more than 30 sec. @set_time_limit(0); $filename = $attachment['name']; $filename = preg_replace('[\r\n]', '', $filename); if ($browser->ie && $browser->ver < 7) $filename = rawurlencode(abbreviate_string($filename, 55)); else if ($browser->ie) $filename = rawurlencode($filename); else $filename = addcslashes($filename, '"'); $disposition = !empty($_GET['_download']) ? 'attachment' : 'inline'; header("Content-Disposition: $disposition; filename=\"$filename\""); echo $plugin['body']; } exit; } // if we arrive here, the requested part was not found header('HTTP/1.1 404 Not Found'); exit; } /** * Show "loading..." page in attachment iframe */ public function attachment_loading_page() { $url = str_replace('&_preload=1', '', $_SERVER['REQUEST_URI']); $message = $this->rc->gettext('loadingdata'); header('Content-Type: text/html; charset=' . RCUBE_CHARSET); print "\n\n" . '' . "\n" . '' . "\n" . "\n\n$message\n\n"; exit; } /** * Template object for attachment display frame */ public function attachment_frame($attrib = array()) { $mimetype = strtolower($this->attachment['mimetype']); list($ctype_primary, $ctype_secondary) = explode('/', $mimetype); $attrib['src'] = './?' . str_replace('_frame=', ($ctype_primary == 'text' ? '_show=' : '_preload='), $_SERVER['QUERY_STRING']); $this->rc->output->add_gui_object('attachmentframe', $attrib['id']); return html::iframe($attrib); } /** * */ public function attachment_header($attrib = array()) { $rcmail = rcmail::get_instance(); $dl_link = strtolower($attrib['downloadlink']) == 'true'; $dl_url = $this->rc->url(array('_frame' => null, '_download' => 1) + $_GET); $table = new html_table(array('cols' => $dl_link ? 3 : 2)); if (!empty($this->attachment['name'])) { $table->add('title', rcube::Q($this->rc->gettext('filename'))); $table->add('header', rcube::Q($this->attachment['name'])); if ($dl_link) { $table->add('download-link', html::a($dl_url, rcube::Q($this->rc->gettext('download')))); } } if (!empty($this->attachment['mimetype'])) { $table->add('title', rcube::Q($this->rc->gettext('type'))); $table->add('header', rcube::Q($this->attachment['mimetype'])); } if (!empty($this->attachment['size'])) { $table->add('title', rcube::Q($this->rc->gettext('filesize'))); $table->add('header', rcube::Q($this->rc->show_bytes($this->attachment['size']))); } $this->rc->output->set_env('attachment_download_url', $dl_url); return $table->show($attrib); } } diff --git a/plugins/libkolab/lib/kolab_bonnie_api.php b/plugins/libkolab/lib/kolab_bonnie_api.php index 6905dcaa..c4368e2c 100644 --- a/plugins/libkolab/lib/kolab_bonnie_api.php +++ b/plugins/libkolab/lib/kolab_bonnie_api.php @@ -1,97 +1,96 @@ * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_bonnie_api { public $ready = false; private $config = array(); private $client = null; /** * Default constructor */ public function __construct($config) { $this->config = $config; $this->client = new kolab_bonnie_api_client($config['uri'], $config['timeout'] ?: 30, (bool)$config['debug']); $this->client->set_secret($config['secret']); $this->client->set_authentication($config['user'], $config['pass']); $this->client->set_request_user(rcube::get_instance()->get_user_name()); $this->ready = !empty($config['secret']) && !empty($config['user']) && !empty($config['pass']); } /** * Wrapper function for .changelog() API call */ public function changelog($type, $uid, $mailbox, $msguid=null) { return $this->client->execute($type.'.changelog', array('uid' => $uid, 'mailbox' => $mailbox, 'msguid' => $msguid)); } /** * Wrapper function for .diff() API call */ public function diff($type, $uid, $rev1, $rev2, $mailbox, $msguid=null, $instance=null) { return $this->client->execute($type.'.diff', array( 'uid' => $uid, 'rev1' => $rev1, 'rev2' => $rev2, 'mailbox' => $mailbox, 'msguid' => $msguid, 'instance' => $instance, )); } /** * Wrapper function for .get() API call */ public function get($type, $uid, $rev, $mailbox, $msguid=null) { return $this->client->execute($type.'.get', array('uid' => $uid, 'rev' => $rev, 'mailbox' => $mailbox, 'msguid' => $msguid)); } /** * Wrapper function for .rawdata() API call */ public function rawdata($type, $uid, $rev, $mailbox, $msguid=null) { return $this->client->execute($type.'.rawdata', array('uid' => $uid, 'rev' => $rev, 'mailbox' => $mailbox, 'msguid' => $msguid)); } /** * Generic wrapper for direct API calls */ public function _execute($method, $params = array()) { return $this->client->execute($method, $params); } - -} \ No newline at end of file +} diff --git a/plugins/libkolab/lib/kolab_bonnie_api_client.php b/plugins/libkolab/lib/kolab_bonnie_api_client.php index bc209f41..a69aa05e 100644 --- a/plugins/libkolab/lib/kolab_bonnie_api_client.php +++ b/plugins/libkolab/lib/kolab_bonnie_api_client.php @@ -1,239 +1,238 @@ * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_bonnie_api_client { /** * URL of the RPC endpoint * @var string */ protected $url; /** * HTTP client timeout in seconds * @var integer */ protected $timeout; /** * Debug flag * @var bool */ protected $debug; /** * Username for authentication * @var string */ protected $username; /** * Password for authentication * @var string */ protected $password; /** * Secret key for request signing * @var string */ protected $secret; /** * Default HTTP headers to send to the server * @var array */ protected $headers = array( 'Connection' => 'close', 'Content-Type' => 'application/json', 'Accept' => 'application/json', ); /** * Constructor * * @param string $url Server URL * @param integer $timeout Request timeout * @param bool $debug Enabled debug logging * @param array $headers Custom HTTP headers */ public function __construct($url, $timeout = 5, $debug = false, $headers = array()) { $this->url = $url; $this->timeout = $timeout; $this->debug = $debug; $this->headers = array_merge($this->headers, $headers); } /** * Setter for secret key for request signing */ public function set_secret($secret) { $this->secret = $secret; } /** * Setter for the X-Request-User header */ public function set_request_user($username) { $this->headers['X-Request-User'] = $username; } /** * Set authentication parameters * * @param string $username Username * @param string $password Password */ public function set_authentication($username, $password) { $this->username = $username; $this->password = $password; } /** * Automatic mapping of procedures * * @param string $method Procedure name * @param array $params Procedure arguments * @return mixed */ public function __call($method, $params) { return $this->execute($method, $params); } /** * Execute an RPC command * * @param string $method Procedure name * @param array $params Procedure arguments * @return mixed */ public function execute($method, array $params = array()) { $id = mt_rand(); $payload = array( 'jsonrpc' => '2.0', 'method' => $method, 'id' => $id, ); if (!empty($params)) { $payload['params'] = $params; } $result = $this->send_request($payload, $method != 'system.keygen'); if (isset($result['id']) && $result['id'] == $id && array_key_exists('result', $result)) { return $result['result']; } else if (isset($result['error'])) { $this->_debug('ERROR', $result); } return null; } /** * Do the HTTP request * * @param string $payload Data to send */ protected function send_request($payload, $sign = true) { try { $payload_ = json_encode($payload); // add request signature if ($sign && !empty($this->secret)) { $this->headers['X-Request-Sign'] = $this->request_signature($payload_); } else if ($this->headers['X-Request-Sign']) { unset($this->headers['X-Request-Sign']); } $this->_debug('REQUEST', $payload, $this->headers); $request = libkolab::http_request($this->url, 'POST', array('timeout' => $this->timeout)); $request->setHeader($this->headers); $request->setAuth($this->username, $this->password); $request->setBody($payload_); $response = $request->send(); if ($response->getStatus() == 200) { $result = json_decode($response->getBody(), true); $this->_debug('RESPONSE', $result); } else { throw new Exception(sprintf("HTTP %d %s", $response->getStatus(), $response->getReasonPhrase())); } } catch (Exception $e) { rcube::raise_error(array( 'code' => 500, 'type' => 'php', 'message' => "Bonnie API request failed: " . $e->getMessage(), ), true); return array('id' => $payload['id'], 'error' => $e->getMessage(), 'code' => -32000); } return is_array($result) ? $result : array(); } /** * Compute the hmac signature for the current event payload using * the secret key configured for this API client * * @param string $data The request payload data * @return string The request signature */ protected function request_signature($data) { // TODO: get the session key with a system.keygen call return hash_hmac('sha256', $this->headers['X-Request-User'] . ':' . $data, $this->secret); } /** * Write debug log */ protected function _debug(/* $message, $data1, data2, ...*/) { if (!$this->debug) return; $args = func_get_args(); $msg = array(); foreach ($args as $arg) { $msg[] = !is_string($arg) ? var_export($arg, true) : $arg; } rcube::write_log('bonnie', join(";\n", $msg)); } - -} \ No newline at end of file +} diff --git a/plugins/libkolab/lib/kolab_format_task.php b/plugins/libkolab/lib/kolab_format_task.php index cb35f98d..bc57e6b9 100644 --- a/plugins/libkolab/lib/kolab_format_task.php +++ b/plugins/libkolab/lib/kolab_format_task.php @@ -1,155 +1,154 @@ * * Copyright (C) 2012, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_format_task extends kolab_format_xcal { public $CTYPEv2 = 'application/x-vnd.kolab.task'; public static $scheduling_properties = array('start', 'due', 'summary', 'status'); protected $objclass = 'Todo'; protected $read_func = 'readTodo'; protected $write_func = 'writeTodo'; /** * Default constructor */ function __construct($data = null, $version = 3.0) { parent::__construct(is_string($data) ? $data : null, $version); // copy static property overriden by this class $this->_scheduling_properties = self::$scheduling_properties; } /** * Set properties to the kolabformat object * * @param array Object data as hash array */ public function set(&$object) { // set common xcal properties parent::set($object); $this->obj->setPercentComplete(intval($object['complete'])); $status = kolabformat::StatusUndefined; if ($object['complete'] == 100 && !array_key_exists('status', $object)) $status = kolabformat::StatusCompleted; else if ($object['status'] && array_key_exists($object['status'], $this->status_map)) $status = $this->status_map[$object['status']]; $this->obj->setStatus($status); $this->obj->setStart(self::get_datetime($object['start'], null, $object['start']->_dateonly)); $this->obj->setDue(self::get_datetime($object['due'], null, $object['due']->_dateonly)); $related = new vectors; if (!empty($object['parent_id'])) $related->push($object['parent_id']); $this->obj->setRelatedTo($related); // cache this data $this->data = $object; unset($this->data['_formatobj']); } /** * */ public function is_valid() { return !$this->formaterror && ($this->data || (is_object($this->obj) && $this->obj->isValid())); } /** * Convert the Configuration object into a hash array data structure * * @param array Additional data for merge * * @return array Config object data as hash array */ public function to_array($data = array()) { // return cached result if (!empty($this->data)) return $this->data; // read common xcal props $object = parent::to_array($data); $object['complete'] = intval($this->obj->percentComplete()); // if due date is set if ($due = $this->obj->due()) $object['due'] = self::php_datetime($due); // related-to points to parent task; we only support one relation $related = self::vector2array($this->obj->relatedTo()); if (count($related)) $object['parent_id'] = $related[0]; // TODO: map more properties $this->data = $object; return $this->data; } /** * Return the reference date for recurrence and alarms * * @return mixed DateTime instance of null if no refdate is available */ public function get_reference_date() { if ($this->data['due'] && $this->data['due'] instanceof DateTime) { return $this->data['due']; } return self::php_datetime($this->obj->due()) ?: parent::get_reference_date(); } /** * 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($obj = null) { $tags = parent::get_tags($obj); $object = $obj ?: $this->data; if ($object['status'] == 'COMPLETED' || ($object['complete'] == 100 && empty($object['status']))) $tags[] = 'x-complete'; if ($object['priority'] == 1) $tags[] = 'x-flagged'; if ($object['parent_id']) $tags[] = 'x-parent:' . $object['parent_id']; return array_unique($tags); } - } diff --git a/plugins/libkolab/lib/kolab_storage_dataset.php b/plugins/libkolab/lib/kolab_storage_dataset.php index 9ddf3f9f..9f39b12e 100644 --- a/plugins/libkolab/lib/kolab_storage_dataset.php +++ b/plugins/libkolab/lib/kolab_storage_dataset.php @@ -1,154 +1,153 @@ * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_storage_dataset implements Iterator, ArrayAccess, Countable { private $cache; // kolab_storage_cache instance to use for fetching data private $memlimit = 0; private $buffer = false; private $index = array(); private $data = array(); private $iteratorkey = 0; private $error = null; /** * Default constructor * * @param object kolab_storage_cache instance to be used for fetching objects upon access */ public function __construct($cache) { $this->cache = $cache; // enable in-memory buffering up until 1/5 of the available memory if (function_exists('memory_get_usage')) { $this->memlimit = parse_bytes(ini_get('memory_limit')) / 5; $this->buffer = true; } } /** * Return error state */ public function is_error() { return !empty($this->error); } /** * Set error state */ public function set_error($err) { $this->error = $err; } /*** Implement PHP Countable interface ***/ public function count() { return count($this->index); } /*** Implement PHP ArrayAccess interface ***/ public function offsetSet($offset, $value) { $uid = $value['_msguid']; if (is_null($offset)) { $offset = count($this->index); $this->index[] = $uid; } else { $this->index[$offset] = $uid; } // keep full payload data in memory if possible if ($this->memlimit && $this->buffer && isset($value['_mailbox'])) { $this->data[$offset] = $value; // check memory usage and stop buffering if ($offset % 10 == 0) { $this->buffer = memory_get_usage() < $this->memlimit; } } } public function offsetExists($offset) { return isset($this->index[$offset]); } public function offsetUnset($offset) { unset($this->index[$offset]); } public function offsetGet($offset) { if (isset($this->data[$offset])) { return $this->data[$offset]; } else if ($msguid = $this->index[$offset]) { return $this->cache->get($msguid); } return null; } /*** Implement PHP Iterator interface ***/ public function current() { return $this->offsetGet($this->iteratorkey); } public function key() { return $this->iteratorkey; } public function next() { $this->iteratorkey++; return $this->valid(); } public function rewind() { $this->iteratorkey = 0; } public function valid() { return !empty($this->index[$this->iteratorkey]); } - } diff --git a/plugins/libkolab/libkolab.php b/plugins/libkolab/libkolab.php index db120aa2..faadd262 100644 --- a/plugins/libkolab/libkolab.php +++ b/plugins/libkolab/libkolab.php @@ -1,378 +1,386 @@ * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class libkolab extends rcube_plugin { static $http_requests = array(); static $bonnie_api = false; /** * Required startup method of a Roundcube plugin */ public function init() { // load local config $this->load_config(); // extend include path to load bundled lib classes $include_path = $this->home . '/lib' . PATH_SEPARATOR . ini_get('include_path'); set_include_path($include_path); $this->add_hook('storage_init', array($this, 'storage_init')); $this->add_hook('storage_connect', array($this, 'storage_connect')); $this->add_hook('user_delete', array('kolab_storage', 'delete_user_folders')); // For Chwala $this->add_hook('folder_mod', array('kolab_storage', 'folder_mod')); $rcmail = rcube::get_instance(); try { kolab_format::$timezone = new DateTimeZone($rcmail->config->get('timezone', 'GMT')); } catch (Exception $e) { rcube::raise_error($e, true); kolab_format::$timezone = new DateTimeZone('GMT'); } $this->add_texts('localization/', false); if (!empty($rcmail->output->type) && $rcmail->output->type == 'html') { $rcmail->output->add_handler('libkolab.folder_search_form', array($this, 'folder_search_form')); $this->include_stylesheet($this->local_skin_path() . '/libkolab.css'); } // embed scripts and templates for email message audit trail if ($rcmail->task == 'mail' && self::get_bonnie_api()) { if ($rcmail->output->type == 'html') { $this->add_hook('render_page', array($this, 'bonnie_render_page')); $this->include_script('libkolab.js'); // add 'Show history' item to message menu $this->api->add_content(html::tag('li', array('role' => 'menuitem'), $this->api->output->button(array( 'command' => 'kolab-mail-history', 'label' => 'libkolab.showhistory', 'type' => 'link', 'classact' => 'icon history active', 'class' => 'icon history disabled', 'innerclass' => 'icon history', ))), 'messagemenu'); } $this->register_action('plugin.message-changelog', array($this, 'message_changelog')); } } /** * Hook into IMAP FETCH HEADER.FIELDS command and request Kolab-specific headers */ function storage_init($p) { - $p['fetch_headers'] = trim($p['fetch_headers'] .' X-KOLAB-TYPE X-KOLAB-MIME-VERSION MESSAGE-ID'); + $kolab_headers = 'X-KOLAB-TYPE X-KOLAB-MIME-VERSION MESSAGE-ID'; + + if (!empty($p['fetch_headers'])) { + $p['fetch_headers'] .= ' ' . $kolab_headers; + } + else { + $p['fetch_headers'] = $kolab_headers; + } + return $p; } /** * Hook into IMAP connection to replace client identity */ function storage_connect($p) { $client_name = 'Roundcube/Kolab'; if (empty($p['ident'])) { $p['ident'] = array( 'name' => $client_name, 'version' => RCUBE_VERSION, /* 'php' => PHP_VERSION, 'os' => PHP_OS, 'command' => $_SERVER['REQUEST_URI'], */ ); } else { $p['ident']['name'] = $client_name; } return $p; } /** * Getter for a singleton instance of the Bonnie API * * @return mixed kolab_bonnie_api instance if configured, false otherwise */ public static function get_bonnie_api() { // get configuration for the Bonnie API if (!self::$bonnie_api && ($bonnie_config = rcube::get_instance()->config->get('kolab_bonnie_api', false))) { self::$bonnie_api = new kolab_bonnie_api($bonnie_config); } return self::$bonnie_api; } /** * Hook to append the message history dialog template to the mail view */ function bonnie_render_page($p) { if (($p['template'] === 'mail' || $p['template'] === 'message') && !$p['kolab-audittrail']) { // append a template for the audit trail dialog $this->api->output->add_footer( html::div(array('id' => 'mailmessagehistory', 'class' => 'uidialog', 'aria-hidden' => 'true', 'style' => 'display:none'), self::object_changelog_table(array('class' => 'records-table changelog-table')) ) ); $this->api->output->set_env('kolab_audit_trail', true); $p['kolab-audittrail'] = true; } return $p; } /** * Handler for message audit trail changelog requests */ public function message_changelog() { if (!self::$bonnie_api) { return false; } $rcmail = rcube::get_instance(); $msguid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST, true); $mailbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $result = $msguid && $mailbox ? self::$bonnie_api->changelog('mail', null, $mailbox, $msguid) : null; if (is_array($result)) { if (is_array($result['changes'])) { $dtformat = $rcmail->config->get('date_format') . ' ' . $rcmail->config->get('time_format'); array_walk($result['changes'], function(&$change) use ($dtformat, $rcmail) { if ($change['date']) { $dt = rcube_utils::anytodatetime($change['date']); if ($dt instanceof DateTime) { $change['date'] = $rcmail->format_date($dt, $dtformat); } } }); } $this->api->output->command('plugin.message_render_changelog', $result['changes']); } else { $this->api->output->command('plugin.message_render_changelog', false); } $this->api->output->send(); } /** * Wrapper function to load and initalize the HTTP_Request2 Object * * @param string|Net_Url2 Request URL * @param string Request method ('OPTIONS','GET','HEAD','POST','PUT','DELETE','TRACE','CONNECT') * @param array Configuration for this Request instance, that will be merged * with default configuration * * @return HTTP_Request2 Request object */ public static function http_request($url = '', $method = 'GET', $config = array()) { $rcube = rcube::get_instance(); $http_config = (array) $rcube->config->get('kolab_http_request'); // deprecated configuration options if (empty($http_config)) { foreach (array('ssl_verify_peer', 'ssl_verify_host') as $option) { $value = $rcube->config->get('kolab_' . $option, true); if (is_bool($value)) { $http_config[$option] = $value; } } } if (!empty($config)) { $http_config = array_merge($http_config, $config); } // force CURL adapter, this allows to handle correctly // compressed responses with SplObserver registered (kolab_files) (#4507) $http_config['adapter'] = 'HTTP_Request2_Adapter_Curl'; $key = md5(serialize($http_config)); if (!($request = self::$http_requests[$key])) { // load HTTP_Request2 require_once 'HTTP/Request2.php'; try { $request = new HTTP_Request2(); $request->setConfig($http_config); } catch (Exception $e) { rcube::raise_error($e, true, true); } // proxy User-Agent string $request->setHeader('user-agent', $_SERVER['HTTP_USER_AGENT']); self::$http_requests[$key] = $request; } // cleanup try { $request->setBody(''); $request->setUrl($url); $request->setMethod($method); } catch (Exception $e) { rcube::raise_error($e, true, true); } return $request; } /** * Table oultine for object changelog display */ public static function object_changelog_table($attrib = array()) { $rcube = rcube::get_instance(); $attrib += array('domain' => 'libkolab'); $table = new html_table(array('cols' => 5, 'border' => 0, 'cellspacing' => 0)); $table->add_header('diff', ''); $table->add_header('revision', $rcube->gettext('revision', $attrib['domain'])); $table->add_header('date', $rcube->gettext('date', $attrib['domain'])); $table->add_header('user', $rcube->gettext('user', $attrib['domain'])); $table->add_header('operation', $rcube->gettext('operation', $attrib['domain'])); $table->add_header('actions', ' '); $rcube->output->add_label( 'libkolab.showrevision', 'libkolab.actionreceive', 'libkolab.actionappend', 'libkolab.actionmove', 'libkolab.actiondelete', 'libkolab.actionread', 'libkolab.actionflagset', 'libkolab.actionflagclear', 'libkolab.objectchangelog', 'libkolab.objectchangelognotavailable', 'close' ); return $table->show($attrib); } /** * Wrapper function for generating a html diff using the FineDiff class by Raymond Hill */ public static function html_diff($from, $to, $is_html = null) { // auto-detect text/html format if ($is_html === null) { $from_html = (preg_match('/<(html|body)(\s+[a-z]|>)/', $from, $m) && strpos($from, '') > 0); $to_html = (preg_match('/<(html|body)(\s+[a-z]|>)/', $to, $m) && strpos($to, '') > 0); $is_html = $from_html || $to_html; // ensure both parts are of the same format if ($is_html && !$from_html) { $converter = new rcube_text2html($from, false, array('wrap' => true)); $from = $converter->get_html(); } if ($is_html && !$to_html) { $converter = new rcube_text2html($to, false, array('wrap' => true)); $to = $converter->get_html(); } } // compute diff from HTML if ($is_html) { include_once __dir__ . '/vendor/Caxy/HtmlDiff/Match.php'; include_once __dir__ . '/vendor/Caxy/HtmlDiff/Operation.php'; include_once __dir__ . '/vendor/Caxy/HtmlDiff/HtmlDiff.php'; // replace data: urls with a transparent image to avoid memory problems $from = preg_replace('/src="data:image[^"]+/', 'src="data:image/gif;base64,R0lGODlhAQABAPAAAOjq6gAAACH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAAAsAAAAAAEAAQAAAgJEAQA7', $from); $to = preg_replace('/src="data:image[^"]+/', 'src="data:image/gif;base64,R0lGODlhAQABAPAAAOjq6gAAACH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAAAsAAAAAAEAAQAAAgJEAQA7', $to); $diff = new Caxy\HtmlDiff\HtmlDiff($from, $to); $diffhtml = $diff->build(); // remove empty inserts (from tables) return preg_replace('!\s*!Uims', '', $diffhtml); } else { include_once __dir__ . '/vendor/finediff.php'; $diff = new FineDiff($from, $to, FineDiff::$wordGranularity); return $diff->renderDiffToHTML(); } } /** * Return a date() format string to render identifiers for recurrence instances * * @param array Hash array with event properties * @return string Format string */ public static function recurrence_id_format($event) { return $event['allday'] ? 'Ymd' : 'Ymd\THis'; } /** * Returns HTML code for folder search widget * * @param array $attrib Named parameters * * @return string HTML code for the gui object */ public function folder_search_form($attrib) { $rcmail = rcube::get_instance(); $attrib += array( 'gui-object' => false, 'wrapper' => true, 'form-name' => 'foldersearchform', 'command' => 'non-extsing-command', 'reset-command' => 'non-existing-command', ); if ($attrib['label-domain'] && !strpos($attrib['buttontitle'], '.')) { $attrib['buttontitle'] = $attrib['label-domain'] . '.' . $attrib['buttontitle']; } if ($attrib['buttontitle']) { $attrib['placeholder'] = $rcmail->gettext($attrib['buttontitle']); } return $rcmail->output->search_form($attrib); } } diff --git a/plugins/tasklist/drivers/database/tasklist_database_driver.php b/plugins/tasklist/drivers/database/tasklist_database_driver.php index 4d61e1d3..628b7c52 100644 --- a/plugins/tasklist/drivers/database/tasklist_database_driver.php +++ b/plugins/tasklist/drivers/database/tasklist_database_driver.php @@ -1,850 +1,846 @@ * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class tasklist_database_driver extends tasklist_driver { const IS_COMPLETE_SQL = "(`status` = 'COMPLETED' OR (`complete` = 1 AND `status` = ''))"; public $undelete = true; // yes, we can public $sortable = false; public $alarm_types = array('DISPLAY'); private $rc; private $plugin; private $lists = array(); private $tags = array(); private $list_ids = ''; private $db_tasks = 'tasks'; private $db_lists = 'tasklists'; /** * Default constructor */ public function __construct($plugin) { $this->rc = $plugin->rc; $this->plugin = $plugin; // read database config $db = $this->rc->get_dbh(); $this->db_lists = $this->rc->config->get('db_table_lists', $db->table_name($this->db_lists)); $this->db_tasks = $this->rc->config->get('db_table_tasks', $db->table_name($this->db_tasks)); $this->_read_lists(); } /** * Read available calendars for the current user and store them internally */ private function _read_lists() { $hidden = array_filter(explode(',', $this->rc->config->get('hidden_tasklists', ''))); if (!empty($this->rc->user->ID)) { $list_ids = array(); $result = $this->rc->db->query( "SELECT *, `tasklist_id` AS id FROM " . $this->db_lists . " WHERE `user_id` = ?" . " ORDER BY CASE WHEN `name` = 'INBOX' THEN 0 ELSE 1 END, `name`", $this->rc->user->ID ); while ($result && ($arr = $this->rc->db->fetch_assoc($result))) { $arr['showalarms'] = intval($arr['showalarms']); $arr['active'] = !in_array($arr['id'], $hidden); $arr['name'] = html::quote($arr['name']); $arr['listname'] = html::quote($arr['name']); $arr['editable'] = true; $arr['rights'] = 'lrswikxtea'; $this->lists[$arr['id']] = $arr; $list_ids[] = $this->rc->db->quote($arr['id']); } $this->list_ids = join(',', $list_ids); } } /** * Get a list of available tasks lists from this source */ public function get_lists($filter = 0) { // attempt to create a default list for this user if (empty($this->lists)) { $prop = array('name' => 'Default', 'color' => '000000'); if ($this->create_list($prop)) { $this->_read_lists(); } } return $this->lists; } /** * Create a new list assigned to the current user * * @param array Hash array with list properties * @return mixed ID of the new list on success, False on error * @see tasklist_driver::create_list() */ public function create_list(&$prop) { $result = $this->rc->db->query( "INSERT INTO " . $this->db_lists . " (`user_id`, `name`, `color`, `showalarms`)" . " VALUES (?, ?, ?, ?)", $this->rc->user->ID, strval($prop['name']), - strval($prop['color']), - $prop['showalarms'] ? 1 : 0 + isset($prop['color']) ? strval($prop['color']) : '', + !empty($prop['showalarms']) ? 1 : 0 ); if ($result) { $prop['rights'] = 'lrswikxtea'; return $this->rc->db->insert_id($this->db_lists); } return false; } /** * Update properties of an existing tasklist * * @param array Hash array with list properties * @return boolean True on success, Fales on failure * @see tasklist_driver::edit_list() */ public function edit_list(&$prop) { $query = $this->rc->db->query( "UPDATE " . $this->db_lists . " SET `name` = ?, `color` = ?, `showalarms` = ?" . " WHERE `tasklist_id` = ? AND `user_id` = ?", strval($prop['name']), - strval($prop['color']), - $prop['showalarms'] ? 1 : 0, + isset($prop['color']) ? strval($prop['color']) : '', + !empty($prop['showalarms']) ? 1 : 0, $prop['id'], $this->rc->user->ID ); return $this->rc->db->affected_rows($query); } /** * Set active/subscribed state of a list * * @param array Hash array with list properties * @return boolean True on success, Fales on failure * @see tasklist_driver::subscribe_list() */ public function subscribe_list($prop) { $hidden = array_flip(explode(',', $this->rc->config->get('hidden_tasklists', ''))); - if ($prop['active']) { + if (!empty($prop['active'])) { unset($hidden[$prop['id']]); } else { $hidden[$prop['id']] = 1; } return $this->rc->user->save_prefs(array('hidden_tasklists' => join(',', array_keys($hidden)))); } /** * Delete the given list with all its contents * * @param array Hash array with list properties * @return boolean True on success, Fales on failure * @see tasklist_driver::delete_list() */ public function delete_list($prop) { $list_id = $prop['id']; if ($this->lists[$list_id]) { $query = $this->rc->db->query( "DELETE FROM " . $this->db_lists . " WHERE `tasklist_id` = ? AND `user_id` = ?", $list_id, $this->rc->user->ID ); return $this->rc->db->affected_rows($query); } 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) { return array(); } /** * Get a list of tags to assign tasks to * * @return array List of tags */ public function get_tags() { return array_values(array_unique($this->tags, SORT_STRING)); } /** * 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|today|tomorrow|overdue|nodate) * @see tasklist_driver::count_tasks() */ function count_tasks($lists = null) { if (empty($lists)) { $lists = array_keys($this->lists); } else if (!is_array($lists)) { $lists = explode(',', (string) $lists); } // only allow to select from lists of this user $list_ids = array_map(array($this->rc->db, 'quote'), array_intersect($lists, array_keys($this->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'); $result = $this->rc->db->query(sprintf( "SELECT `task_id`, `flagged`, `date` FROM " . $this->db_tasks . " WHERE `tasklist_id` IN (%s) AND `del` = 0 AND NOT " . self::IS_COMPLETE_SQL, join(',', $list_ids) )); $counts = array('all' => 0, 'today' => 0, 'tomorrow' => 0, 'overdue' => 0, 'later' => 0); while ($result && ($rec = $this->rc->db->fetch_assoc($result))) { $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 wiht filter criterias * @param array List of lists to get tasks from * * @return array List of tasks records matchin the criteria * @see tasklist_driver::list_tasks() */ function list_tasks($filter, $lists = null) { if (empty($lists)) { $lists = array_keys($this->lists); } else if (!is_array($lists)) { $lists = explode(',', (string) $lists); } // only allow to select from lists of this user $list_ids = array_map(array($this->rc->db, 'quote'), array_intersect($lists, array_keys($this->lists))); $sql_add = ''; // add filter criteria - if ($filter['from'] || ($filter['mask'] & tasklist::FILTER_MASK_TODAY)) { - $sql_add .= " AND (`date` IS NULL OR `date` >= ?)"; - $datefrom = $filter['from']; - } - if ($filter['to']) { - if ($filter['mask'] & tasklist::FILTER_MASK_OVERDUE) { - $sql_add .= " AND (`date` IS NOT NULL AND `date` <= " . $this->rc->db->quote($filter['to']) . ")"; - } - else { - $sql_add .= " AND (`date` IS NULL OR `date` <= " . $this->rc->db->quote($filter['to']) . ")"; + if ($filter) { + if (!empty($filter['from']) || ($filter['mask'] & tasklist::FILTER_MASK_TODAY)) { + $sql_add .= " AND (`date` IS NULL OR `date` >= " . $this->rc->db->quote($filter['from']) . ")"; } - } - // special case 'today': also show all events with date before today - if ($filter['mask'] & tasklist::FILTER_MASK_TODAY) { - $datefrom = date('Y-m-d', 0); - } + if (!empty($filter['to'])) { + if ($filter['mask'] & tasklist::FILTER_MASK_OVERDUE) { + $sql_add .= " AND (`date` IS NOT NULL AND `date` <= " . $this->rc->db->quote($filter['to']) . ")"; + } + else { + $sql_add .= " AND (`date` IS NULL OR `date` <= " . $this->rc->db->quote($filter['to']) . ")"; + } + } - if ($filter['mask'] & tasklist::FILTER_MASK_NODATE) { - $sql_add = " AND `date` IS NULL"; - } + if ($filter['mask'] & tasklist::FILTER_MASK_NODATE) { + $sql_add = " AND `date` IS NULL"; + } - if ($filter['mask'] & tasklist::FILTER_MASK_COMPLETE) { - $sql_add .= " AND " . self::IS_COMPLETE_SQL; - } - else if (empty($filter['since'])) { - // don't show complete tasks by default - $sql_add .= " AND NOT " . self::IS_COMPLETE_SQL; - } + if ($filter['mask'] & tasklist::FILTER_MASK_COMPLETE) { + $sql_add .= " AND " . self::IS_COMPLETE_SQL; + } + else if (empty($filter['since'])) { + // don't show complete tasks by default + $sql_add .= " AND NOT " . self::IS_COMPLETE_SQL; + } - if ($filter['mask'] & tasklist::FILTER_MASK_FLAGGED) { - $sql_add .= " AND `flagged` = 1"; - } + if ($filter['mask'] & tasklist::FILTER_MASK_FLAGGED) { + $sql_add .= " AND `flagged` = 1"; + } - // compose (slow) SQL query for searching - // FIXME: improve searching using a dedicated col and normalized values - if ($filter['search']) { - $sql_query = array(); - foreach (array('title', 'description', 'organizer', 'attendees') as $col) { - $sql_query[] = $this->rc->db->ilike($col, '%' . $filter['search'] . '%'); + // compose (slow) SQL query for searching + // FIXME: improve searching using a dedicated col and normalized values + if ($filter['search']) { + $sql_query = array(); + foreach (array('title', 'description', 'organizer', 'attendees') as $col) { + $sql_query[] = $this->rc->db->ilike($col, '%' . $filter['search'] . '%'); + } + $sql_add = " AND (" . join(" OR ", $sql_query) . ")"; } - $sql_add = " AND (" . join(" OR ", $sql_query) . ")"; - } - if ($filter['since'] && is_numeric($filter['since'])) { - $sql_add .= " AND `changed` >= " . $this->rc->db->quote(date('Y-m-d H:i:s', $filter['since'])); - } + if (!empty($filter['since']) && is_numeric($filter['since'])) { + $sql_add .= " AND `changed` >= " . $this->rc->db->quote(date('Y-m-d H:i:s', $filter['since'])); + } - if ($filter['uid']) { - $sql_add .= " AND `uid` IN (" . implode(',', array_map(array($this->rc->db, 'quote'), $filter['uid'])) . ")"; + if (!empty($filter['uid'])) { + $sql_add .= " AND `uid` IN (" . implode(',', array_map(array($this->rc->db, 'quote'), $filter['uid'])) . ")"; + } } $tasks = array(); if (!empty($list_ids)) { $result = $this->rc->db->query("SELECT * FROM " . $this->db_tasks . " WHERE `tasklist_id` IN (" . join(',', $list_ids) . ")" . " AND `del` = 0" . $sql_add - . " ORDER BY `parent_id`, `task_id` ASC", - $datefrom + . " ORDER BY `parent_id`, `task_id` ASC" ); while ($result && ($rec = $this->rc->db->fetch_assoc($result))) { $tasks[] = $this->_read_postprocess($rec); } } return $tasks; } /** * Return data of a specific task * * @param mixed Hash array with task properties or task UID * @param integer Bitmask defining filter criterias. * 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) { if (is_string($prop)) { $prop['uid'] = $prop; } - $query_col = $prop['id'] ? 'task_id' : 'uid'; + $query_col = !empty($prop['id']) ? 'task_id' : 'uid'; $result = $this->rc->db->query("SELECT * FROM " . $this->db_tasks . " WHERE `tasklist_id` IN (" . $this->list_ids . ")" . " AND `$query_col` = ? AND `del` = 0", - $prop['id'] ? $prop['id'] : $prop['uid'] + !empty($prop['id']) ? $prop['id'] : $prop['uid'] ); if ($result && ($rec = $this->rc->db->fetch_assoc($result))) { return $this->_read_postprocess($rec); } return false; } /** * Get all decendents of the given task record * * @param mixed Hash array with task properties or task UID * @param boolean True if all childrens children should be fetched * * @return array List of all child task IDs */ public function get_childs($prop, $recursive = false) { // resolve UID first if (is_string($prop)) { $result = $this->rc->db->query( "SELECT `task_id` AS id, `tasklist_id` AS list FROM " . $this->db_tasks . " WHERE `tasklist_id` IN (" . $this->list_ids . ")" . " AND `uid` = ?", $prop ); $prop = $this->rc->db->fetch_assoc($result); } $childs = array(); $task_ids = array($prop['id']); // query for childs (recursively) while (!empty($task_ids)) { $result = $this->rc->db->query( "SELECT `task_id` AS id FROM " . $this->db_tasks . " WHERE `tasklist_id` IN (" . $this->list_ids . ")" . " AND `parent_id` IN (" . join(',', array_map(array($this->rc->db, 'quote'), $task_ids)) . ")" . " AND `del` = 0" ); $task_ids = array(); while ($result && ($rec = $this->rc->db->fetch_assoc($result))) { $childs[] = $rec['id']; $task_ids[] = $rec['id']; } if (!$recursive) { break; } } return $childs; } /** * Get a list of pending alarms to be displayed to the user * * @param integer Current time (unix timestamp) * @param mixed List of list IDs to show alarms for (either as array or comma-separated string) * * @return array A list of alarms, each encoded as hash array with task properties * @see tasklist_driver::pending_alarms() */ public function pending_alarms($time, $lists = null) { if (empty($lists)) { $lists = array_keys($this->lists); } else if (!is_array($lists)) { $lists = explode(',', (string) $lists); } // only allow to select from calendars with activated alarms $list_ids = array(); foreach ($lists as $lid) { if ($this->lists[$lid] && $this->lists[$lid]['showalarms']) { $list_ids[] = $lid; } } $list_ids = array_map(array($this->rc->db, 'quote'), $list_ids); $alarms = array(); if (!empty($list_ids)) { $result = $this->rc->db->query("SELECT * FROM " . $this->db_tasks . " WHERE `tasklist_id` IN (" . join(',', $list_ids) . ")" . " AND `notify` <= " . $this->rc->db->fromunixtime($time) . " AND NOT " . self::IS_COMPLETE_SQL ); while ($result && ($rec = $this->rc->db->fetch_assoc($result))) { $alarms[] = $this->_read_postprocess($rec); } } return $alarms; } /** * Feedback after showing/sending an alarm notification * * @see tasklist_driver::dismiss_alarm() */ public function dismiss_alarm($task_id, $snooze = 0) { // set new notifyat time or unset if not snoozed $notify_at = $snooze > 0 ? date('Y-m-d H:i:s', time() + $snooze) : null; $query = $this->rc->db->query("UPDATE " . $this->db_tasks . " SET `changed` = " . $this->rc->db->now() . ", `notify` = ?" . " WHERE `task_id` = ? AND `tasklist_id` IN (" . $this->list_ids . ")", $notify_at, $task_id ); return $this->rc->db->affected_rows($query); } /** * Remove alarm dismissal or snooze state * * @param string Task identifier */ public function clear_alarms($id) { // Nothing to do here. Alarms are reset in edit_task() } /** * Map some internal database values to match the generic "API" */ private function _read_postprocess($rec) { $rec['id'] = $rec['task_id']; $rec['list'] = $rec['tasklist_id']; $rec['changed'] = new DateTime($rec['changed']); $rec['created'] = new DateTime($rec['created']); $rec['tags'] = array_filter(explode(',', $rec['tags'])); if (!$rec['parent_id']) { unset($rec['parent_id']); } // decode serialized alarms if ($rec['alarms']) { $rec['valarms'] = $this->unserialize_alarms($rec['alarms']); unset($rec['alarms']); } // decode serialze recurrence rules if ($rec['recurrence']) { $rec['recurrence'] = $this->unserialize_recurrence($rec['recurrence']); } if (!empty($rec['tags'])) { $this->tags = array_merge($this->tags, (array)$rec['tags']); } unset($rec['task_id'], $rec['tasklist_id']); return $rec; } /** * 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 * @see tasklist_driver::create_task() */ public function create_task($prop) { // check list permissions - $list_id = $prop['list'] ? $prop['list'] : reset(array_keys($this->lists)); - if (!$this->lists[$list_id] || $this->lists[$list_id]['readonly']) { + $list_id = !empty($prop['list']) ? $prop['list'] : reset(array_keys($this->lists)); + if (empty($this->lists[$list_id]) || !empty($this->lists[$list_id]['readonly'])) { return false; } - if (is_array($prop['valarms'])) { + if (!empty($prop['valarms'])) { $prop['alarms'] = $this->serialize_alarms($prop['valarms']); } - if (is_array($prop['recurrence'])) { + if (!empty($prop['recurrence'])) { $prop['recurrence'] = $this->serialize_recurrence($prop['recurrence']); } if (array_key_exists('complete', $prop)) { $prop['complete'] = number_format($prop['complete'], 2, '.', ''); } foreach (array('parent_id', 'date', 'time', 'startdate', 'starttime', 'alarms', 'recurrence', 'status') as $col) { if (empty($prop[$col])) { $prop[$col] = null; } } $notify_at = $this->_get_notification($prop); $now = $this->rc->db->now(); $result = $this->rc->db->query("INSERT INTO " . $this->db_tasks . " (`tasklist_id`, `uid`, `parent_id`, `created`, `changed`, `title`, `date`, `time`," . " `startdate`, `starttime`, `description`, `tags`, `flagged`, `complete`, `status`," . " `alarms`, `recurrence`, `notify`)" . " VALUES (?, ?, ?, $now, $now, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", $list_id, $prop['uid'], $prop['parent_id'], $prop['title'], $prop['date'], $prop['time'], $prop['startdate'], $prop['starttime'], - strval($prop['description']), - join(',', (array)$prop['tags']), - $prop['flagged'] ? 1 : 0, - $prop['complete'] ?: 0, + isset($prop['description']) ? strval($prop['description']) : '', + !empty($prop['tags']) ? join(',', (array)$prop['tags']) : '', + !empty($prop['flagged']) ? 1 : 0, + !empty($prop['complete']) ?: 0, strval($prop['status']), - $prop['alarms'], - $prop['recurrence'], + isset($prop['alarms']) ? $prop['alarms'] : '', + isset($prop['recurrence']) ? $prop['recurrence'] : '', $notify_at ); if ($result) { return $this->rc->db->insert_id($this->db_tasks); } return false; } /** * Update an task entry with the given data * * @param array Hash array with task properties * * @return boolean True on success, False on error * @see tasklist_driver::edit_task() */ public function edit_task($prop) { - if (is_array($prop['valarms'])) { + if (isset($prop['valarms'])) { $prop['alarms'] = $this->serialize_alarms($prop['valarms']); } - if (is_array($prop['recurrence'])) { + if (isset($prop['recurrence'])) { $prop['recurrence'] = $this->serialize_recurrence($prop['recurrence']); } if (array_key_exists('complete', $prop)) { $prop['complete'] = number_format($prop['complete'], 2, '.', ''); } $sql_set = array(); foreach (array('title', 'description', 'flagged', 'complete') as $col) { if (isset($prop[$col])) { $sql_set[] = $this->rc->db->quote_identifier($col) . '=' . $this->rc->db->quote($prop[$col]); } } foreach (array('parent_id', 'date', 'time', 'startdate', 'starttime', 'alarms', 'recurrence') as $col) { if (isset($prop[$col])) { $sql_set[] = $this->rc->db->quote_identifier($col) . '=' . (empty($prop[$col]) ? 'NULL' : $this->rc->db->quote($prop[$col])); } } if (isset($prop['status'])) { $sql_set[] = $this->rc->db->quote_identifier('status') . '=' . $this->rc->db->quote($prop['status']); } if (isset($prop['tags'])) { $sql_set[] = $this->rc->db->quote_identifier('tags') . '=' . $this->rc->db->quote(join(',', (array)$prop['tags'])); } if (isset($prop['date']) || isset($prop['time']) || isset($prop['alarms'])) { $notify_at = $this->_get_notification($prop); $sql_set[] = $this->rc->db->quote_identifier('notify') . '=' . (empty($notify_at) ? 'NULL' : $this->rc->db->quote($notify_at)); } // moved from another list - if ($prop['_fromlist'] && ($newlist = $prop['list'])) { + if (!empty($prop['_fromlist']) && ($newlist = $prop['list'])) { $sql_set[] = $this->rc->db->quote_identifier('tasklist_id') . '=' . $this->rc->db->quote($newlist); } $result = $this->rc->db->query("UPDATE " . $this->db_tasks . " SET `changed` = " . $this->rc->db->now() . ($sql_set ? ', ' . join(', ', $sql_set) : '') . " WHERE `task_id` = ? AND `tasklist_id` IN (" . $this->list_ids . ")", $prop['id'] ); return $this->rc->db->affected_rows($result); } /** * Move a single task to another list * * @param array Hash array with task properties * * @return boolean True on success, False on error * @see tasklist_driver::move_task() */ public function move_task($prop) { return $this->edit_task($prop); } /** * Remove a single task from the database * * @param array Hash array with task properties * @param boolean Remove record irreversible * * @return boolean True on success, False on error * @see tasklist_driver::delete_task() */ public function delete_task($prop, $force = true) { $task_id = $prop['id']; if ($task_id && $force) { $result = $this->rc->db->query("DELETE FROM " . $this->db_tasks . " WHERE `task_id` = ? AND `tasklist_id` IN (" . $this->list_ids . ")", $task_id ); } else if ($task_id) { $result = $this->rc->db->query("UPDATE " . $this->db_tasks . " SET `changed` = " . $this->rc->db->now() . ", `del` = 1" . " WHERE `task_id` = ? AND `tasklist_id` IN (" . $this->list_ids . ")", $task_id ); } return $this->rc->db->affected_rows($result); } /** * Restores a single deleted task (if supported) * * @param array Hash array with task properties * * @return boolean True on success, False on error * @see tasklist_driver::undelete_task() */ public function undelete_task($prop) { $result = $this->rc->db->query("UPDATE " . $this->db_tasks . " SET `changed` = " . $this->rc->db->now() . ", `del` = 0" . " WHERE `task_id` = ? AND `tasklist_id` IN (" . $this->list_ids . ")", $prop['id'] ); return $this->rc->db->affected_rows($result); } /** * Compute absolute time to notify the user */ private function _get_notification($task) { - if ($task['valarms'] && !$this->is_complete($task)) { + if (!empty($task['valarms']) && !$this->is_complete($task)) { $alarm = libcalendaring::get_next_alarm($task, 'task'); - if ($alarm['time'] && in_array($alarm['action'], $this->alarm_types)) { + if (!empty($alarm['time']) && in_array($alarm['action'], $this->alarm_types)) { return date('Y-m-d H:i:s', $alarm['time']); } } } /** * Helper method to serialize the list of alarms into a string */ private function serialize_alarms($valarms) { foreach ((array)$valarms as $i => $alarm) { if ($alarm['trigger'] instanceof DateTime) { $valarms[$i]['trigger'] = '@' . $alarm['trigger']->format('c'); } } return $valarms ? json_encode($valarms) : null; } /** * Helper method to decode a serialized list of alarms */ private function unserialize_alarms($alarms) { // decode json serialized alarms if ($alarms && $alarms[0] == '[') { $valarms = json_decode($alarms, true); foreach ($valarms as $i => $alarm) { if ($alarm['trigger'][0] == '@') { try { $valarms[$i]['trigger'] = new DateTime(substr($alarm['trigger'], 1)); } catch (Exception $e) { unset($valarms[$i]); } } } } // convert legacy alarms data else if (strlen($alarms)) { list($trigger, $action) = explode(':', $alarms, 2); if ($trigger = libcalendaring::parse_alarm_value($trigger)) { $valarms = array(array('action' => $action, 'trigger' => $trigger[3] ?: $trigger[0])); } } return $valarms; } /** * Helper method to serialize task recurrence properties */ private function serialize_recurrence($recurrence) { foreach ((array)$recurrence as $k => $val) { if ($val instanceof DateTime) { $recurrence[$k] = '@' . $val->format('c'); } } return $recurrence ? json_encode($recurrence) : null; } /** * Helper method to decode a serialized task recurrence struct */ private function unserialize_recurrence($ser) { if (strlen($ser)) { $recurrence = json_decode($ser, true); foreach ((array)$recurrence as $k => $val) { if ($val[0] == '@') { try { $recurrence[$k] = new DateTime(substr($val, 1)); } catch (Exception $e) { unset($recurrence[$k]); } } } } else { $recurrence = ''; } return $recurrence; } /** * Handler for user_delete plugin hook */ public function user_delete($args) { $db = $this->rc->db; $lists = $db->query("SELECT `tasklist_id` FROM " . $this->db_lists . " WHERE `user_id` = ?", $args['user']->ID); $list_ids = array(); while ($row = $db->fetch_assoc($lists)) { $list_ids[] = $row['tasklist_id']; } if (!empty($list_ids)) { foreach (array($this->db_tasks, $this->db_lists) as $table) { $db->query(sprintf("DELETE FROM $table WHERE `tasklist_id` IN (%s)", join(',', $list_ids))); } } } } diff --git a/plugins/tasklist/tasklist.php b/plugins/tasklist/tasklist.php index a5344dd9..2d991535 100644 --- a/plugins/tasklist/tasklist.php +++ b/plugins/tasklist/tasklist.php @@ -1,2515 +1,2527 @@ * * Copyright (C) 2012, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class 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' && $_GET['_rel'] != 'task') { + 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 && !$this->rc->output->env['framed']) { + 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 (is_object($this->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; // 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'])) + 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 = $rec['tempid']; + $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); $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()) { $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 && $rec['_notify'] && ($rec['attendees'] || $oldrec['attendees'])) { + 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 && $rec['_reportpartstat'] && $rec['_reportpartstat'] != 'NEEDS-ACTION') { + 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)) $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $task['organizer']['name'] ?: $task['organizer']['email']))), 'confirmation'); else $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); } } // unlock client $this->rc->output->command('plugin.unlock_saving', $success); if ($refresh) { - if ($refresh['id']) { + if (!empty($refresh['id'])) { $this->encode_task($refresh); } else if (is_array($refresh)) { 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 (!$this->itip) { - require_once realpath(__DIR__ . '/../libcalendaring/lib/libcalendaring_itip.php'); + 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 ($rec['raw']) { + 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 ($rec['valarms']) { + 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'])) { $refdate = null; if (!empty($rec['date'])) { $refdate = new DateTime($rec['date'] . ' ' . $rec['time'], $this->timezone); } else if (!empty($rec['startdate'])) { $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 recurrence, // the initial start date to count from gets lost. - if ($rec['recurrence']['COUNT']) { + 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'] = ''; } } $attachments = array(); $taskid = $rec['id']; - if (is_array($_SESSION[self::SESSION_KEY]) && $_SESSION[self::SESSION_KEY]['id'] == $taskid) { + if (!empty($_SESSION[self::SESSION_KEY]) && $_SESSION[self::SESSION_KEY]['id'] == $taskid) { if (!empty($_SESSION[self::SESSION_KEY]['attachments'])) { foreach ($_SESSION[self::SESSION_KEY]['attachments'] as $id => $attachment) { if (is_array($rec['attachments']) && in_array($id, $rec['attachments'])) { $attachments[$id] = $this->rc->plugins->exec_hook('attachment_get', $attachment); unset($attachments[$id]['abort'], $attachments[$id]['group']); } } } } $rec['attachments'] = $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'])) + if (isset($rec['attendees']) && !is_array($rec['attendees'])) { $rec['attendees'] = array(); + } - foreach ((array)$rec['attendees'] as $i => $attendee) { - if (is_string($attendee['rsvp'])) { - $rec['attendees'][$i]['rsvp'] = $attendee['rsvp'] == 'true' || $attendee['rsvp'] == '1'; + if (!empty($rec['attendees'])) { + foreach ((array) $rec['attendees'] as $i => $attendee) { + if (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); // fall back to default strtotime logic if (empty($date)) { $date = new DateTime($rec[$date_key] . ' ' . $rec[$time_key], $this->timezone); } $rec[$date_key] = $date->format('Y-m-d'); 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) { // remove temp. attachment files if (!empty($_SESSION[self::SESSION_KEY]) && ($taskid = $_SESSION[self::SESSION_KEY]['id'])) { $this->rc->plugins->exec_hook('attachments_cleanup', array('group' => $taskid)); $this->rc->session->remove(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'])) { $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); $engine->init($rrule, $date); if ($next = $engine->next()) { $updates[$date_key] = $next->format('Y-m-d'); 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()) { $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 (!$list['_reload']) { + 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 ($this->driver->search_more_results) { + 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) + if ($success) { $this->rc->output->show_message('successfullysaved', 'confirmation'); - else + } + 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 ($list['active']) + 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 ($rec['parent_id']) { + 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']); if (is_object($rec['created'])) { $rec['created_'] = $this->rc->format_date($rec['created']); $rec['created'] = $rec['created']->format('U'); } if (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']) { try { $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']) { try { $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 ($rec['valarms']) { + if (!empty($rec['valarms'])) { $rec['alarms_text'] = libcalendaring::alarms_text($rec['valarms']); $rec['valarms'] = libcalendaring::to_client_alarms($rec['valarms']); } - if ($rec['recurrence']) { + 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']); } - foreach ((array)$rec['attachments'] as $k => $attachment) { - $rec['attachments'][$k]['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); + if (!empty($rec['attachments'])) { + foreach ((array) $rec['attachments'] as $k => $attachment) { + $rec['attachments'][$k]['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); + } } // 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']; sort($rec['tags'], SORT_LOCALE_STRING); if (in_array($rec['id'], $this->collapsed_tasks)) $rec['collapsed'] = true; if (empty($rec['parent_id'])) $rec['parent_id'] = null; $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 or tags return (preg_match('/<(html|body)(\s+[a-z]|>)/', $task['description'], $m) && strpos($task['description'], '') > 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 = $this->task_tree[$rec['id']]; + $parent_id = isset($this->task_tree[$rec['id']]) ? $this->task_tree[$rec['id']] : null; while ($parent_id) { $rec['_depth']++; - array_unshift($parent_titles, $this->task_titles[$parent_id]); - $parent_id = $this->task_tree[$parent_id]; + 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 = $today_date->format('Y-m-d'); $tomorrow_date = new 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'; if ($rec['flagged']) $mask |= self::FILTER_MASK_FLAGGED; if ($this->driver->is_complete($rec)) $mask |= self::FILTER_MASK_COMPLETE; if (empty($rec['date'])) $mask |= self::FILTER_MASK_NODATE; 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)) $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); // 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()) { $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) $mask |= self::FILTER_MASK_ASSIGNED; 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) { 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, - $plugins['attachments'] ? array($this->driver, 'get_attachment_body') : null); + !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); $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); $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 ($message->attachments && $this->driver->attachments) { if (!is_array($_SESSION[self::SESSION_KEY]) || $_SESSION[self::SESSION_KEY]['id'] != $task['id']) { $_SESSION[self::SESSION_KEY] = array( 'id' => $task['id'], 'attachments' => array(), ); } foreach ((array)$message->attachments as $part) { $attachment = array( 'data' => $imap->get_message_part($uid, $part->mime_id, $part), 'size' => $part->size, 'name' => $part->filename, 'mimetype' => $part->mimetype, 'group' => $task['id'], ); $attachment = $this->rc->plugins->exec_hook('attachment_save', $attachment); if ($attachment['status'] && !$attachment['abort']) { $id = $attachment['id']; $attachment['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); // store new attachment in session unset($attachment['status'], $attachment['abort'], $attachment['data']); $_SESSION[self::SESSION_KEY]['attachments'][$id] = $attachment; $attachment['id'] = 'rcmfile' . $attachment['id']; // add prefix to consider it 'new' $task['attachments'][] = $attachment; } } } $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 (!$p['object']->headers->others['x-kolab-type']) { + 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 (!$this->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($sensitivity = null, $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 ($sensitivity && $l['subtype'] == $sensitivity) { $list = $l; break; } 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($task['sensitivity']); 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($task['sensitivity'], $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($data['sensitivity'], $lists); $response['select'] = html::span('folder-select', $this->gettext('saveintasklist') . ' ' . $select->show($is_shared ? $existing['list'] : $default_list['id'])); } $this->rc->output->command('plugin.update_itip_object_status', $response); } /** * Handler for task/itip-remove requests */ public function task_itip_remove() { $success = false; $uid = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST); // search for event if only UID is given if ($task = $this->driver->get_task($uid)) { $success = $this->driver->delete_task($task, true); } if ($success) { $this->rc->output->show_message('tasklist.successremoval', 'confirmation'); } else { $this->rc->output->show_message('tasklist.errorsaving', 'error'); } } /******* Utility functions *******/ /** * Generate a unique identifier for an event */ public function generate_uid() { return strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($this->rc->user->get_username()), 0, 16)); } /** * Map task properties for ical exprort using libcalendaring */ public function to_libcal($task) { $object = $task; $object['_type'] = 'task'; $object['categories'] = (array)$task['tags']; // convert to datetime objects if (!empty($task['date'])) { $object['due'] = rcube_utils::anytodatetime($task['date'].' '.$task['time'], $this->timezone); if (empty($task['time'])) $object['due']->_dateonly = true; unset($object['date']); } if (!empty($task['startdate'])) { $object['start'] = rcube_utils::anytodatetime($task['startdate'].' '.$task['starttime'], $this->timezone); if (empty($task['starttime'])) $object['start']->_dateonly = true; unset($object['startdate']); } $object['complete'] = $task['complete'] * 100; if ($task['complete'] == 1.0 && empty($task['complete'])) { $object['status'] = 'COMPLETED'; } if ($task['flagged']) { $object['priority'] = 1; } - else if (!$task['priority']) { + 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 f4c262ec..249cfe7c 100644 --- a/plugins/tasklist/tasklist_ui.php +++ b/plugins/tasklist/tasklist_ui.php @@ -1,586 +1,619 @@ * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class tasklist_ui { private $rc; private $plugin; private $ready = false; private $gui_objects = array(); 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['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 (!$identity) + 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()) { $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()) { $tree = true; $jsenv = array(); $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'; $html = ''; foreach ((array)$lists as $id => $prop) { - if ($attrib['activeonly'] && !$prop['active']) - continue; + if (!empty($attrib['activeonly']) && empty($prop['active'])) { + continue; + } $html .= html::tag('li', array( 'id' => 'rcmlitasklist' . rcube_utils::html_identifier($id), - 'class' => $prop['group'], + 'class' => isset($prop['group']) ? $prop['group'] : null, ), - $this->tasklist_list_item($id, $prop, $jsenv, $attrib['activeonly']) + $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
        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 (!$prop['virtual']) { + 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 = $prop['title'] ?: ($prop['name'] != $prop['listname'] || strlen($prop['name']) > 25 ? - html_entity_decode($prop['name'], ENT_COMPAT, RCUBE_CHARSET) : ''); + $title = ''; - if ($prop['virtual']) + 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 (!$prop['editable']) + } + else if (empty($prop['editable'])) { $classes[] = 'readonly'; - if ($prop['subscribed']) + } + if (!empty($prop['subscribed'])) { $classes[] = 'subscribed'; - if ($prop['class']) + } + 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 )); return html::div(join(' ', $classes), - html::a(array('class' => 'listname', 'title' => $title, 'href' => '#', 'id' => $label_id), $prop['listname'] ?: $prop['name']) . - ($prop['virtual'] ? '' : $chbox . html::span('actions', - ($prop['removable'] ? html::a(array('href' => '#', 'class' => 'remove', 'title' => $this->plugin->gettext('removelist')), ' ') : '') + 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'), ' ') : '') ) ) ); } 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; - foreach ((array) $attrib['extra'] as $id => $name) { - $select->add($name, $id); + 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 ($prop['editable'] || strpos($prop['rights'], 'i') !== false) { + 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 || $prop['default']) + 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' => $attrib['size'], 'tabindex' => $attrib['tabindex'])); + $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' => $attrib['size'], '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')); + $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 (!$attrib['id']) { + 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' => $attrib['uploadfieldsize'], + '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 (!$attrib['id']) { + if (empty($attrib['id'])) { $attrib['id'] = 'rcmTaskExportForm'; } - $html .= html::div('form-section form-group row', + $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; } }