diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index 475f1bfa..cb67f5fc 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -1,3971 +1,3996 @@ * @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 = [ '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(); 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; } /** * 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' => (bool) 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(); + $ownedResourceEmails = $this->owned_resources_emails($emails); $organizer = null; + $resourceConfirmation = false; foreach ($event['attendees'] as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER') { $organizer = $attendee; } - else if (!empty($attendee['email']) && in_array(strtolower($attendee['email']), $emails)) { + else if (!empty($attendee['email']) && in_array_nocase($attendee['email'], $emails)) { $reply_sender = $attendee['email']; } + else if (!empty($attendee['cutype']) && $attendee['cutype'] == 'RESOURCE' && !empty($attendee['email']) && in_array_nocase($attendee['email'], $ownedResourceEmails)) { + $resourceConfirmation = true; + // Note on behalf of which resource this update is going to be sent out + $event['_resource'] = $attendee['email']; + } } if (!$noreply) { $itip = $this->load_itip(); $itip->set_sender_email($reply_sender); $event['thisandfuture'] = $event['_savemode'] == 'future'; + $bodytextprefix = $resourceConfirmation ? 'itipmailbodyresource' : 'itipmailbody'; - if ($organizer && $itip->send_itip_message($event, 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status)) { + if ($organizer && $itip->send_itip_message($event, 'REPLY', $organizer, 'itipsubject' . $status, $bodytextprefix . $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(); + $identity['ownedResources'] = $this->owned_resources_emails($identity['emails']); $settings['identity'] = [ 'name' => $identity['name'], 'email' => strtolower($identity['email']), - 'emails' => ';' . strtolower(join(';', $identity['emails'])) + 'emails' => ';' . strtolower(join(';', $identity['emails'])), + 'ownedResources' => ';' . strtolower(join(';', $identity['ownedResources'])) ]; } // 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 && !empty($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 && !empty($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); // 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; } /** * 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(); $this->register_handler('plugin.calendar_css', [$this->ui, 'calendar_css']); $this->register_handler('plugin.calendar_list', [$this->ui, 'calendar_list']); $this->rc->output->set_pagetitle($title); $this->rc->output->send('calendar.print'); } /** * 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; if ($ac != $bc) { $diff[] = 'attachments'; } return $diff; } /** * 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; foreach ($event['attendees'] as $i => $candidate) { if ($candidate['email'] == $attendee['email']) { $event['attendees'][$i] = $attendee; $found = true; break; } } if (!$found) { $event['attendees'][] = $attendee; } } // filter out removed attendees if (!empty($removed)) { $event['attendees'] = array_filter($event['attendees'], function($attendee) use ($removed) { return !in_array($attendee['email'], $removed); }); } } /**** Resource management functions ****/ /** * Getter for the configured implementation of the resource directory interface */ private function resources_directory() { if (!empty($this->resources_dir)) { return $this->resources_dir; } if ($driver_name = $this->rc->config->get('calendar_resources_driver')) { $driver_class = 'resources_driver_' . $driver_name; require_once($this->home . '/drivers/resources_driver.php'); require_once($this->home . '/drivers/' . $driver_name . '/' . $driver_class . '.php'); $this->resources_dir = new $driver_class($this); } 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'], ]; } } $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 = []; 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 = []; 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; } + /** + * List email addressed of owned resources + */ + private function owned_resources_emails($identities) + { + $results = []; + if ($directory = $this->resources_directory()) { + foreach ($directory->load_owned_resources($identities) as $rec) { + $results[] = $rec['email']; + } + } + return $results; + } + + /**** 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) && 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++; } } if ($numcals < 1) { $calendar_select = null; } } 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 if (!empty($data['nosave'])) { $response['select'] = html::tag('input', ['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 = []; 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')), ], ]; } $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); } 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 (!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'); } } } } $this->register_handler('plugin.event_inviteform', [$this, 'itip_event_inviteform']); $this->register_handler('plugin.event_invitebox', [$this->ui, 'event_invitebox']); if (empty($this->invitestatus)) { $this->itip->set_rsvp_actions(['accepted', 'tentative', 'declined']); $this->register_handler('plugin.event_rsvp_buttons', [$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(['name' => "_t", 'value' => $this->token]); return html::tag('form', [ 'action' => $this->rc->url(['task' => 'calendar', 'action' => 'attend']), 'method' => 'post', 'noclose' => true ] + $attrib ) . $hidden->show(); } /** * */ 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')); 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 (!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; } } } } } } } /** * 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(['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([ '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; $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; } } // 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(); $event['comment'] = $comment; 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'); } unset($event['comment']); // the delegator is set to non-participant, thus save as non-blocking $event['free_busy'] = 'free'; } $mode = calendar_driver::FILTER_PERSONAL | calendar_driver::FILTER_SHARED | calendar_driver::FILTER_WRITEABLE; // 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; // select default calendar except user explicitly selected 'none' if (!$calendar && !$dontsave) { $calendar = $this->get_default_calendar($event['sensitivity'], $calendars); } $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; } } // 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']; } } // save to calendar if ($calendar && !empty($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 ?: (!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'); } } 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'); } if ($success || $dontsave) { $metadata['calendar'] = isset($event['calendar']) ? $event['calendar'] : null; $metadata['nosave'] = $dontsave; $metadata['rsvp'] = !empty($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' && !empty($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)) { $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'); } } $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')) { $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'); } } /** * 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); // $headers = $imap->get_message_headers($uid); if ($part) { if (!empty($part->ctype_parameters['charset'])) { $charset = $part->ctype_parameters['charset']; } $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 = !empty($calendars[$cal_id]) ? $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) { $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('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'] $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_GET); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_GET); $event = []; // 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'] = [$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' => [], ]; } 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, ]; $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; } } } $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'; } // 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']; } } 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(); // 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': $this->load_driver(); return $this->driver; } return null; } } diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js index e1f46fd5..9cee7279 100644 --- a/plugins/calendar/calendar_ui.js +++ b/plugins/calendar/calendar_ui.js @@ -1,4315 +1,4324 @@ /** * Client UI Javascript for the Calendar plugin * * @author Lazlo Westerhof * @author Thomas Bruederli * * @licstart The following is the entire license notice for the * JavaScript code in this file. * * 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 . * * @licend The above is the entire license notice * for the JavaScript code in this file. */ // Roundcube calendar UI client class function rcube_calendar_ui(settings) { // extend base class rcube_calendar.call(this, settings); /*** member vars ***/ this.is_loading = false; this.selected_event = null; this.selected_calendar = null; this.search_request = null; this.saving_lock = null; this.calendars = {}; this.quickview_sources = []; /*** private vars ***/ var DAY_MS = 86400000; var HOUR_MS = 3600000; var me = this; var day_clicked = day_clicked_ts = 0; var ignore_click = false; var event_defaults = { free_busy:'busy', alarms:'' }; var event_attendees = []; var calendars_list; var calenders_search_list; var calenders_search_container; var search_calendars = {}; var attendees_list; var resources_list; var resources_treelist; var resources_data = {}; var resources_index = []; var resource_owners = {}; var resources_events_source = { url:null, editable:false }; var freebusy_ui = { workinhoursonly:false, needsupdate:false }; var freebusy_data = {}; var current_view = null; var count_sources = []; var event_sources = []; var exec_deferred = 1; var sensitivitylabels = { 'public':rcmail.gettext('public','calendar'), 'private':rcmail.gettext('private','calendar'), 'confidential':rcmail.gettext('confidential','calendar') }; var ui_loading = rcmail.set_busy(true, 'loading'); // global fullcalendar settings var fullcalendar_defaults = { theme: false, aspectRatio: 1, timezone: false, // will treat the given date strings as in local (browser's) timezone monthNames: settings.months, monthNamesShort: settings.months_short, dayNames: settings.days, dayNamesShort: settings.days_short, weekNumbers: settings.show_weekno > 0, weekNumberTitle: rcmail.gettext('weekshort', 'calendar') + ' ', weekNumberCalculation: 'ISO', firstDay: settings.first_day, firstHour: settings.first_hour, slotDuration: {minutes: 60/settings.timeslots}, businessHours: { start: settings.work_start + ':00', end: settings.work_end + ':00' }, scrollTime: settings.work_start + ':00', views: { list: { titleFormat: settings.dates_long, listDayFormat: settings.date_long, visibleRange: function(currentDate) { return { start: currentDate.clone(), end: currentDate.clone().add(settings.agenda_range, 'days') } } }, month: { columnFormat: 'ddd', // Mon titleFormat: 'MMMM YYYY', eventLimit: 4 }, week: { columnFormat: 'ddd ' + settings.date_short, // Mon 9/7 titleFormat: settings.dates_long }, day: { columnFormat: 'dddd ' + settings.date_short, // Monday 9/7 titleFormat: 'dddd ' + settings.date_long } }, timeFormat: settings.time_format, slotLabelFormat: settings.time_format, defaultView: rcmail.env.view || settings.default_view, allDayText: rcmail.gettext('all-day', 'calendar'), buttonText: { today: settings['today'], day: rcmail.gettext('day', 'calendar'), week: rcmail.gettext('week', 'calendar'), month: rcmail.gettext('month', 'calendar'), list: rcmail.gettext('agenda', 'calendar') }, buttonIcons: { prev: 'left-single-arrow', next: 'right-single-arrow' }, nowIndicator: settings.time_indicator, eventLimitText: function(num) { return rcmail.gettext('andnmore', 'calendar').replace('$nr', num); }, // event rendering eventRender: function(event, element, view) { if (view.name != 'list') { var prefix = event.sensitivity && event.sensitivity != 'public' ? String(sensitivitylabels[event.sensitivity]).toUpperCase()+': ' : ''; element.attr('title', prefix + event.title); } if (view.name != 'month') { if (view.name == 'list') { var loc = $('').attr('class', 'fc-event-location'); if (event.location) loc.text(event.location); element.find('.fc-list-item-title').after(loc); } else if (event.location) { element.find('div.fc-title').after($('
').html('@ ' + Q(event.location))); } var time_element = element.find('div.fc-time'); if (event.sensitivity && event.sensitivity != 'public') time_element.append(''); if (event.recurrence) time_element.append(''); if (event.alarms || (event.valarms && event.valarms.length)) time_element.append(''); } if (event.status) { element.addClass('cal-event-status-' + String(event.status).toLowerCase()); } set_event_colors(element, event, view.name); element.attr('aria-label', event.title + ', ' + me.event_date_text(event, true)); }, // callback when a specific event is clicked eventClick: function(event, ev, view) { if (!event.temp && (!event.className || event.className.indexOf('fc-type-freebusy') < 0)) event_show_dialog(event, ev); } }; /*** imports ***/ var Q = this.quote_html; var text2html = this.text2html; var event_date_text = this.event_date_text; var date2unixtime = this.date2unixtime; var fromunixtime = this.fromunixtime; var parseISO8601 = this.parseISO8601; var date2servertime = this.date2ISO8601; var render_message_links = this.render_message_links; /*** private methods ***/ // same as str.split(delimiter) but it ignores delimiters within quoted strings var explode_quoted_string = function(str, delimiter) { var result = [], strlen = str.length, q, p, i, chr, last; for (q = p = i = 0; i < strlen; i++) { chr = str.charAt(i); if (chr == '"' && last != '\\') { q = !q; } else if (!q && chr == delimiter) { result.push(str.substring(p, i)); p = i + 1; } last = chr; } result.push(str.substr(p)); return result; }; // Change the first charcter to uppercase var ucfirst = function(str) { return str.charAt(0).toUpperCase() + str.substr(1); }; // clone the given date object and optionally adjust time var clone_date = function(date, adjust) { var d = 'toDate' in date ? date.toDate() : new Date(date.getTime()); // set time to 00:00 if (adjust == 1) { d.setHours(0); d.setMinutes(0); } // set time to 23:59 else if (adjust == 2) { d.setHours(23); d.setMinutes(59); } return d; }; // fix date if jumped over a DST change var fix_date = function(date) { if (date.getHours() == 23) date.setTime(date.getTime() + HOUR_MS); else if (date.getHours() > 0) date.setHours(0); }; var date2timestring = function(date, dateonly) { return date2servertime(date).replace(/[^0-9]/g, '').substr(0, (dateonly ? 8 : 14)); } var format_date = function(date, format) { return $.fullCalendar.formatDate('toDate' in date ? date : moment(date), format); }; var format_datetime = function(date, mode, voice) { return me.format_datetime(date, mode, voice); } var render_link = function(url) { var islink = false, href = url; if (url.match(/^[fhtpsmailo]+?:\/\//i)) { islink = true; } else if (url.match(/^[a-z0-9.-:]+(\/|$)/i)) { islink = true; href = 'http://' + url; } return islink ? '' + Q(url) + '' : Q(url); } // determine whether the given date is on a weekend var is_weekend = function(date) { return date.getDay() == 0 || date.getDay() == 6; }; var is_workinghour = function(date) { if (settings['work_start'] > settings['work_end']) return date.getHours() >= settings['work_start'] || date.getHours() < settings['work_end']; else return date.getHours() >= settings['work_start'] && date.getHours() < settings['work_end']; }; var set_event_colors = function(element, event, mode) { var bg_color = '', border_color = '', cat = String(event.categories), color = event.calendar && me.calendars[event.calendar] ? me.calendars[event.calendar].color : '', cat_color = rcmail.env.calendar_categories[cat] ? rcmail.env.calendar_categories[cat] : color; switch (settings.event_coloring) { case 1: bg_color = border_color = cat_color; break; case 2: border_color = color; bg_color = cat_color; break; case 3: border_color = cat_color; bg_color = color; break; default: bg_color = border_color = color; break; } var css = { 'border-color': border_color, 'background-color': bg_color, 'color': me.text_color(bg_color) }; if (String(css['border-color']).match(/^#?f+$/i)) delete css['border-color']; $.each(css, function(i, v) { if (!v) delete css[i]; else if (v.charAt(0) != '#') css[i] = '#' + v; }); if (mode == 'list') { bg_color = css['background-color']; if (bg_color && !bg_color.match(/^#?f+$/i)) $(element).find('.fc-event-dot').css('background-color', bg_color); } else $(element).css(css); }; var load_attachment = function(data) { var event = data.record, query = {_id: data.attachment.id, _event: event.recurrence_id || event.id, _cal: event.calendar}; if (event.rev) query._rev = event.rev; if (event.calendar == "--invitation--itip") $.extend(query, {_uid: event._uid, _part: event._part, _mbox: event._mbox}); libkolab.load_attachment(query, data.attachment); }; // build event attachments list var event_show_attachments = function(list, container, event, edit) { libkolab.list_attachments(list, container, edit, event, function(id) { remove_attachment(id); }, function(data) { load_attachment(data); } ); }; var remove_attachment = function(id) { rcmail.env.deleted_attachments.push(id); }; // event details dialog (show only) var event_show_dialog = function(event, ev, temp) { var $dialog = $("#eventshow"); var calendar = event.calendar && me.calendars[event.calendar] ? me.calendars[event.calendar] : { editable:false, rights:'lrs' }; if (!temp) me.selected_event = event; if ($dialog.is(':ui-dialog')) $dialog.dialog('close'); // remove status-* and sensitivity-* classes $dialog.removeClass(function(i, oldclass) { var oldies = String(oldclass).split(' '); return $.grep(oldies, function(cls) { return cls.indexOf('status-') === 0 || cls.indexOf('sensitivity-') === 0 }).join(' '); }); // convert start/end dates if not done yet by fullcalendar if (typeof event.start == 'string') event.start = parseISO8601(event.start); if (typeof event.end == 'string') event.end = parseISO8601(event.end); // allow other plugins to do actions when event form is opened rcmail.triggerEvent('calendar-event-init', {o: event}); $dialog.find('div.event-section, div.event-line, .form-group').hide(); $('#event-title').html(Q(event.title)).show(); if (event.location) $('#event-location').html('@ ' + text2html(event.location)).show(); if (event.description) $('#event-description').show().find('.event-text').html(text2html(event.description, 300, 6)); if (event.vurl) $('#event-url').show().find('.event-text').html(render_link(event.vurl)); if (event.recurrence && event.recurrence_text) $('#event-repeat').show().find('.event-text').html(Q(event.recurrence_text)); if (event.valarms && event.alarms_text) $('#event-alarm').show().find('.event-text').html(Q(event.alarms_text).replace(',', ',
')); if (calendar.name) $('#event-calendar').show().find('.event-text').html(Q(calendar.name)).addClass('cal-'+calendar.id); if (event.categories) $('#event-category').show().find('.event-text').text(event.categories).addClass('cat-'+String(event.categories).toLowerCase().replace(rcmail.identifier_expr, '')); if (event.free_busy) $('#event-free-busy').show().find('.event-text').text(rcmail.gettext(event.free_busy, 'calendar')); if (event.priority > 0) { var priolabels = [ '', rcmail.gettext('highest'), rcmail.gettext('high'), '', '', rcmail.gettext('normal'), '', '', rcmail.gettext('low'), rcmail.gettext('lowest') ]; $('#event-priority').show().find('.event-text').text(event.priority+' '+priolabels[event.priority]); } if (event.status) { var status_lc = String(event.status).toLowerCase(); $('#event-status').show().find('.event-text').text(rcmail.gettext('status-'+status_lc,'calendar')); $('#event-status-badge > span').text(rcmail.gettext('status-'+status_lc,'calendar')); $dialog.addClass('status-'+status_lc); } if (event.sensitivity && event.sensitivity != 'public') { $('#event-sensitivity').show().find('.event-text').text(sensitivitylabels[event.sensitivity]); $('#event-status-badge > span').text(sensitivitylabels[event.sensitivity]); $dialog.addClass('sensitivity-'+event.sensitivity); } if (event.created || event.changed) { var created = parseISO8601(event.created), changed = parseISO8601(event.changed); $('.event-created', $dialog).text(created ? format_datetime(created) : rcmail.gettext('unknown','calendar')); $('.event-changed', $dialog).text(changed ? format_datetime(changed) : rcmail.gettext('unknown','calendar')); $('#event-created,#event-changed,#event-created-changed').show() } // create attachments list if ($.isArray(event.attachments)) { event_show_attachments(event.attachments, $('#event-attachments').find('.event-text'), event); if (event.attachments.length > 0) { $('#event-attachments').show(); } } else if (calendar.attachments) { // fetch attachments, some drivers doesn't set 'attachments' prop of the event? } // build attachments list $('#event-links').hide(); if ($.isArray(event.links) && event.links.length) { render_message_links(event.links || [], $('#event-links').find('.event-text'), false, 'calendar'); $('#event-links').show(); } // list event attendees if (calendar.attendees && event.attendees) { // sort resources to the end event.attendees.sort(function(a,b) { var j = a.cutype == 'RESOURCE' ? 1 : 0, k = b.cutype == 'RESOURCE' ? 1 : 0; return (j - k); }); var data, mystatus = null, rsvp, line, morelink, html = '', overflow = '', organizer = me.is_organizer(event), num_attendees = event.attendees.length; for (var j=0; j < num_attendees; j++) { data = event.attendees[j]; if (data.email) { - if (data.role != 'ORGANIZER' && settings.identity.emails.indexOf(';'+data.email) >= 0) { + if (data.role != 'ORGANIZER' && is_this_me(data.email)) { mystatus = (data.status || 'UNKNOWN').toLowerCase(); if (data.status == 'NEEDS-ACTION' || data.status == 'TENTATIVE' || data.rsvp) rsvp = mystatus; } } line = rcube_libcalendaring.attendee_html(data); if (morelink) overflow += ' ' + line; else html += ' ' + line; // stop listing attendees if (j == 7 && num_attendees > 8) { morelink = $('').html(rcmail.gettext('andnmore', 'calendar').replace('$nr', num_attendees - j - 1)); } } if (html && (num_attendees > 1 || !organizer)) { $('#event-attendees').show() .find('.event-text') .html(html) .find('a.mailtolink').click(event_attendee_click); // display all attendees in a popup when clicking the "more" link if (morelink) { $('#event-attendees .event-text').append(morelink); morelink.click(function(e){ rcmail.simple_dialog( '
' + html + overflow + '
', rcmail.gettext('tabattendees','calendar'), null, {width: 450, cancel_button: 'close'}); $('#all-event-attendees a.mailtolink').click(event_attendee_click); return false; }); } } if (mystatus && !rsvp) { $('#event-partstat').show().find('.changersvp, .event-text') .removeClass('accepted tentative declined delegated needs-action unknown') .addClass(mystatus); $('#event-partstat').find('.event-text') .text(rcmail.gettext('status' + mystatus, 'libcalendaring')); } var show_rsvp = rsvp && !organizer && event.status != 'CANCELLED' && me.has_permission(calendar, 'v'); $('#event-rsvp')[(show_rsvp ? 'show' : 'hide')](); $('#event-rsvp .rsvp-buttons input').prop('disabled', false).filter('input[rel="'+(mystatus || '')+'"]').prop('disabled', true); if (show_rsvp && event.comment) $('#event-rsvp-comment').show().find('.event-text').html(Q(event.comment)); $('#event-rsvp a.reply-comment-toggle').show(); $('#event-rsvp .itip-reply-comment textarea').hide().val(''); if (event.recurrence && event.id) { var sel = event._savemode || (event.thisandfuture ? 'future' : (event.isexception ? 'current' : 'all')); $('#event-rsvp .rsvp-buttons').addClass('recurring'); } else { $('#event-rsvp .rsvp-buttons').removeClass('recurring'); } } var buttons = [], is_removable_event = function(event, calendar) { // for invitation calendars check permissions of the original folder if (event._folder_id) calendar = me.calendars[event._folder_id]; return calendar && me.has_permission(calendar, 'td'); }; if (!temp && calendar.editable && event.editable !== false) { buttons.push({ text: rcmail.gettext('edit', 'calendar'), 'class': 'edit mainaction', click: function() { event_edit_dialog('edit', event); } }); } if (!temp && is_removable_event(event, calendar) && event.editable !== false) { buttons.push({ text: rcmail.gettext('delete', 'calendar'), 'class': 'delete', click: function() { me.delete_event(event); $dialog.dialog('close'); } }); } buttons.push({ text: rcmail.gettext('close', 'calendar'), 'class': 'cancel', click: function() { $dialog.dialog('close'); } }); var close_func = function(e) { rcmail.command('menu-close', 'eventoptionsmenu', null, e); $('.libcal-rsvp-replymode').hide(); }; // open jquery UI dialog $dialog.dialog({ modal: true, resizable: true, closeOnEscape: true, title: me.event_date_text(event), open: function() { $dialog.attr('aria-hidden', 'false'); setTimeout(function(){ $dialog.parent().find('button:not(.ui-dialog-titlebar-close,.delete)').first().focus(); }, 5); }, close: function(e) { close_func(e); $dialog.dialog('close'); }, dragStart: close_func, resizeStart: close_func, buttons: buttons, minWidth: 320, width: 420 }).show(); // remember opener element (to be focused on close) $dialog.data('opener', ev && rcube_event.is_keyboard(ev) ? ev.target : null); // set voice title on dialog widget $dialog.dialog('widget').removeAttr('aria-labelledby') .attr('aria-label', me.event_date_text(event, true) + ', ', event.title); // set dialog size according to content me.dialog_resize($dialog.get(0), $dialog.height(), 420); // add link for "more options" drop-down if (!temp && !event.temporary && event.calendar != '_resource') { $('') .attr({href: '#', 'class': 'dropdown-link btn btn-link options', 'data-popup-pos': 'top'}) .text(rcmail.gettext('eventoptions','calendar')) .click(function(e) { return rcmail.command('menu-open','eventoptionsmenu', this, e); }) .appendTo($dialog.parent().find('.ui-dialog-buttonset')); } rcmail.enable_command('event-history', calendar.history); rcmail.triggerEvent('calendar-event-dialog', {dialog: $dialog}); }; // event handler for clicks on an attendee link var event_attendee_click = function(e) { var cutype = $(this).attr('data-cutype'), mailto = this.href.substr(7); if (rcmail.env.calendar_resources && cutype == 'RESOURCE') { event_resources_dialog(mailto); } else { rcmail.command('compose', mailto, e ? e.target : null, e); } return false; }; // bring up the event dialog (jquery-ui popup) var event_edit_dialog = function(action, event) { // copy opener element from show dialog var op_elem = $("#eventshow:ui-dialog").data('opener'); // close show dialog first $("#eventshow:ui-dialog").data('opener', null).dialog('close'); var $dialog = $('
'); var calendar = event.calendar && me.calendars[event.calendar] ? me.calendars[event.calendar] : { editable:true, rights: action=='new' ? 'lrwitd' : 'lrs' }; me.selected_event = $.extend($.extend({}, event_defaults), event); // clone event object (with defaults) event = me.selected_event; // change reference to clone freebusy_ui.needsupdate = false; if (rcmail.env.action == 'dialog-ui') calendar.attendees = false; // TODO: allow Attendees tab in Save-as-event dialog // reset dialog first $('#eventedit form').trigger('reset'); $('#event-panel-recurrence input, #event-panel-recurrence select, #event-panel-attachments input').prop('disabled', false); $('#event-panel-recurrence, #event-panel-attachments').removeClass('disabled'); // allow other plugins to do actions when event form is opened rcmail.triggerEvent('calendar-event-init', {o: event}); // event details var title = $('#edit-title').val(event.title || ''); var location = $('#edit-location').val(event.location || ''); var description = $('#edit-description').text(event.description || ''); var vurl = $('#edit-url').val(event.vurl || ''); var categories = $('#edit-categories').val(event.categories); var calendars = $('#edit-calendar').val(event.calendar); var eventstatus = $('#edit-event-status').val(event.status); var freebusy = $('#edit-free-busy').val(event.free_busy); var priority = $('#edit-priority').val(event.priority); var sensitivity = $('#edit-sensitivity').val(event.sensitivity); var syncstart = $('#edit-recurrence-syncstart input'); var end = 'toDate' in event.end ? event.end : moment(event.end); var start = 'toDate' in event.start ? event.start : moment(event.start); var duration = Math.round((end.format('x') - start.format('x')) / 1000); // Correct the fullCalendar end date for all-day events if (!end.hasTime()) { end.subtract(1, 'days'); } var startdate = $('#edit-startdate').val(format_date(start, settings.date_format)).data('duration', duration); var starttime = $('#edit-starttime').val(format_date(start, settings.time_format)).show(); var enddate = $('#edit-enddate').val(format_date(end, settings.date_format)); var endtime = $('#edit-endtime').val(format_date(end, settings.time_format)).show(); var allday = $('#edit-allday').get(0); var notify = $('#edit-attendees-donotify').get(0); var invite = $('#edit-attendees-invite').get(0); var comment = $('#edit-attendees-comment'); // make sure any calendar is selected if (!calendars.val()) calendars.val($('option:first', calendars).attr('value')); invite.checked = settings.itip_notify & 1 > 0; notify.checked = me.has_attendees(event) && invite.checked; if (event.allDay) { starttime.val("12:00").hide(); endtime.val("13:00").hide(); allday.checked = true; } else { allday.checked = false; } // set calendar selection according to permissions calendars.find('option').each(function(i, opt) { var cal = me.calendars[opt.value] || {}; $(opt).prop('disabled', !(cal.editable || (action == 'new' && me.has_permission(cal, 'i')))) }); // set alarm(s) me.set_alarms_edit('#edit-alarms', action != 'new' && event.valarms && calendar.alarms ? event.valarms : []); // enable/disable alarm property according to backend support $('#edit-alarms')[(calendar.alarms ? 'show' : 'hide')](); // check categories drop-down: add value if not exists if (event.categories && !categories.find("option[value='"+event.categories+"']").length) { $(' '; list_html += '
' + role_html + dispname + '
'; // clone attendees data for local modifications freebusy_ui.attendees[i] = freebusy_ui.attendees[domid] = $.extend({}, data); } // add total row list_html += '
 
'; list_html += '
' + rcmail.gettext('reqallattendees','calendar') + '
'; $('#schedule-attendees-list').html(list_html) .unbind('click.roleicons') .bind('click.roleicons', function(e){ // toggle attendee status upon click on icon if (e.target.id && e.target.id.match(/rcmlia(.+)/)) { var attendee, domid = RegExp.$1, roles = [ 'REQ-PARTICIPANT', 'OPT-PARTICIPANT', 'NON-PARTICIPANT', 'CHAIR' ]; if ((attendee = freebusy_ui.attendees[domid]) && attendee.role != 'ORGANIZER') { var req = attendee.role != 'OPT-PARTICIPANT' && attendee.role != 'NON-PARTICIPANT'; var j = $.inArray(attendee.role, roles); j = (j+1) % roles.length; attendee.role = roles[j]; $(e.target).parent().attr('class', 'attendee '+String(attendee.role).toLowerCase()); // update total display if required-status changed if (req != (roles[j] != 'OPT-PARTICIPANT' && roles[j] != 'NON-PARTICIPANT')) { compute_freebusy_totals(); update_freebusy_display(attendee.email); } } } return false; }); // enable/disable buttons $('#schedule-find-prev').prop('disabled', fb_start.getTime() < now.getTime()); // dialog buttons var buttons = [ { text: rcmail.gettext('select', 'calendar'), 'class': 'save mainaction', click: function() { $('#edit-startdate').val(freebusy_ui.startdate.val()); $('#edit-starttime').val(freebusy_ui.starttime.val()); $('#edit-enddate').val(freebusy_ui.enddate.val()); $('#edit-endtime').val(freebusy_ui.endtime.val()); // write role changes back to main dialog $('select.edit-attendee-role').each(function(i, elem){ if (event_attendees[i] && freebusy_ui.attendees[i]) { event_attendees[i].role = freebusy_ui.attendees[i].role; $(elem).val(event_attendees[i].role); } }); if (freebusy_ui.needsupdate) update_freebusy_status(me.selected_event); freebusy_ui.needsupdate = false; $dialog.dialog("close"); } }, { text: rcmail.gettext('cancel', 'calendar'), 'class': 'cancel', click: function() { $dialog.dialog("close"); } } ]; $dialog.dialog({ modal: true, resizable: true, closeOnEscape: true, title: rcmail.gettext('scheduletime', 'calendar'), open: function() { rcmail.ksearch_blur(); $dialog.attr('aria-hidden', 'false').find('#schedule-find-next, #schedule-find-prev').not(':disabled').first().focus(); }, close: function() { $dialog.dialog("destroy").attr('aria-hidden', 'true').hide(); // TODO: focus opener button }, resizeStop: function() { render_freebusy_overlay(); }, buttons: buttons, minWidth: 640, width: 850 }).show(); // adjust dialog size to fit grid without scrolling var gridw = $('#schedule-freebusy-times').width(); var overflow = gridw - $('#attendees-freebusy-table td.times').width(); me.dialog_resize($dialog.get(0), $dialog.height() + (bw.ie ? 20 : 0), 800 + Math.max(0, overflow)); // fetch data from server freebusy_ui.loading = 0; load_freebusy_data(freebusy_ui.start, freebusy_ui.interval); }; // render an HTML table showing free-busy status for all the event attendees var render_freebusy_grid = function(delta) { if (delta) { freebusy_ui.start.setTime(freebusy_ui.start.getTime() + DAY_MS * delta); fix_date(freebusy_ui.start); // skip weekends if in workinhoursonly-mode if (Math.abs(delta) == 1 && freebusy_ui.workinhoursonly) { while (is_weekend(freebusy_ui.start)) freebusy_ui.start.setTime(freebusy_ui.start.getTime() + DAY_MS * delta); fix_date(freebusy_ui.start); } freebusy_ui.end = new Date(freebusy_ui.start.getTime() + DAY_MS * freebusy_ui.numdays); } var dayslots = Math.floor(1440 / freebusy_ui.interval); var date_format = 'ddd '+ (dayslots <= 2 ? settings.date_short : settings.date_format); var lastdate, datestr, css, curdate = new Date(), allday = (freebusy_ui.interval == 1440), interval = allday ? 1440 : (freebusy_ui.interval * (settings.timeslots || 1)); times_css = (allday ? 'allday ' : ''), dates_row = '', times_row = '', slots_row = ''; for (var s = 0, t = freebusy_ui.start.getTime(); t < freebusy_ui.end.getTime(); s++) { curdate.setTime(t); datestr = format_date(curdate, date_format); if (datestr != lastdate) { if (lastdate && !allday) break; dates_row += '' + Q(datestr) + ''; lastdate = datestr; } // set css class according to working hours css = is_weekend(curdate) || (freebusy_ui.interval <= 60 && !is_workinghour(curdate)) ? 'offhours' : 'workinghours'; times_row += '' + Q(allday ? rcmail.gettext('all-day','calendar') : format_date(curdate, settings.time_format)) + ''; slots_row += ' '; t += interval * 60000; } dates_row += ''; times_row += ''; // render list of attendees var domid, data, list_html = '', times_html = ''; for (var i=0; i < event_attendees.length; i++) { data = event_attendees[i]; domid = String(data.email).replace(rcmail.identifier_expr, ''); times_html += '' + slots_row + ''; } // add line for all/required attendees times_html += ''; times_html += '' + slots_row + ''; var table = $('#schedule-freebusy-times'); table.children('thead').html(dates_row + times_row); table.children('tbody').html(times_html); // initialize event handlers on grid if (!freebusy_ui.grid_events) { freebusy_ui.grid_events = true; table.children('thead').click(function(e){ // move event to the clicked date/time if (e.target.id && e.target.id.match(/t-(\d+)/)) { var newstart = new Date(RegExp.$1 * 1000); // set time to 00:00 if (me.selected_event.allDay) { newstart.setMinutes(0); newstart.setHours(0); } update_freebusy_dates(newstart, new Date(newstart.getTime() + freebusy_ui.startdate.data('duration') * 1000)); render_freebusy_overlay(); } }); } // if we have loaded free-busy data, show it if (!freebusy_ui.loading) { if (freebusy_ui.start < freebusy_data.start || freebusy_ui.end > freebusy_data.end || freebusy_ui.interval != freebusy_data.interval) { load_freebusy_data(freebusy_ui.start, freebusy_ui.interval); } else { for (var email, i=0; i < event_attendees.length; i++) { if ((email = event_attendees[i].email)) update_freebusy_display(email); } } } // render current event date/time selection over grid table // use timeout to let the dom attributes (width/height/offset) be set first window.setTimeout(function(){ render_freebusy_overlay(); }, 10); }; // render overlay element over the grid to visiualize the current event date/time var render_freebusy_overlay = function() { var overlay = $('#schedule-event-time'), event_start = 'toDate' in me.selected_event.start ? me.selected_event.start.toDate() : me.selected_event.start; event_end = 'toDate' in me.selected_event.end ? me.selected_event.end.toDate() : me.selected_event.end; if (event_end.getTime() <= freebusy_ui.start.getTime() || event_start.getTime() >= freebusy_ui.end.getTime()) { overlay.hide(); if (overlay.data('isdraggable')) overlay.draggable('disable'); } else { var i, n, table = $('#schedule-freebusy-times'), width = 0, pos = { top:table.children('thead').height(), left:0 }, eventstart = date2unixtime(clone_date(me.selected_event.start, me.selected_event.allDay?1:0)), eventend = date2unixtime(clone_date(me.selected_event.end, me.selected_event.allDay?2:0)) - 60, slotstart = date2unixtime(freebusy_ui.start), slotsize = freebusy_ui.interval * 60, slotnum = freebusy_ui.interval > 60 ? 1 : (60 / freebusy_ui.interval), cells = table.children('thead').find('td'), cell_width = cells.first().get(0).offsetWidth, h_margin = table.parents('table').data('h-margin'), v_margin = table.parents('table').data('v-margin'), slotend; if (h_margin === undefined) h_margin = 4; if (v_margin === undefined) v_margin = 4; // iterate through slots to determine position and size of the overlay for (i=0; i < cells.length; i++) { for (n=0; n < slotnum; n++) { slotend = slotstart + slotsize - 1; // event starts in this slot: compute left if (eventstart >= slotstart && eventstart <= slotend) { pos.left = Math.round(i * cell_width + (cell_width / slotnum) * n); } // event ends in this slot: compute width if (eventend >= slotstart && eventend <= slotend) { width = Math.round(i * cell_width + (cell_width / slotnum) * (n + 1)) - pos.left; } slotstart += slotsize; } } if (!width) width = table.width() - pos.left; // overlay is visible if (width > 0) { overlay.css({ width: (width - h_margin) + 'px', height: (table.children('tbody').height() - v_margin) + 'px', left: pos.left + 'px', top: pos.top + 'px' }).show(); // configure draggable if (!overlay.data('isdraggable')) { overlay.draggable({ axis: 'x', scroll: true, stop: function(e, ui){ // convert pixels to time var px = ui.position.left; var range_p = $('#schedule-freebusy-times').width(); var range_t = freebusy_ui.end.getTime() - freebusy_ui.start.getTime(); var newstart = new Date(freebusy_ui.start.getTime() + px * (range_t / range_p)); newstart.setSeconds(0); newstart.setMilliseconds(0); // snap to day boundaries if (me.selected_event.allDay) { if (newstart.getHours() >= 12) // snap to next day newstart.setTime(newstart.getTime() + DAY_MS); newstart.setMinutes(0); newstart.setHours(0); } else { // round to 5 minutes // @TODO: round to timeslots? var round = newstart.getMinutes() % 5; if (round > 2.5) newstart.setTime(newstart.getTime() + (5 - round) * 60000); else if (round > 0) newstart.setTime(newstart.getTime() - round * 60000); } // update event times and display update_freebusy_dates(newstart, new Date(newstart.getTime() + freebusy_ui.startdate.data('duration') * 1000)); if (me.selected_event.allDay) render_freebusy_overlay(); } }).data('isdraggable', true); } else overlay.draggable('enable'); } else overlay.draggable('disable').hide(); } }; // fetch free-busy information for each attendee from server var load_freebusy_data = function(from, interval) { var start = new Date(from.getTime() - DAY_MS * 2); // start 2 days before event fix_date(start); var end = new Date(start.getTime() + DAY_MS * Math.max(14, freebusy_ui.numdays + 7)); // load min. 14 days freebusy_ui.numrequired = 0; freebusy_data.all = []; freebusy_data.required = []; // load free-busy information for every attendee var domid, email; for (var i=0; i < event_attendees.length; i++) { if ((email = event_attendees[i].email)) { domid = String(email).replace(rcmail.identifier_expr, ''); $('#rcmli' + domid).addClass('loading'); freebusy_ui.loading++; $.ajax({ type: 'GET', dataType: 'json', url: rcmail.url('freebusy-times'), data: { email:email, start:date2servertime(clone_date(start, 1)), end:date2servertime(clone_date(end, 2)), interval:interval, _remote:1 }, success: function(data) { freebusy_ui.loading--; // find attendee var i, attendee = null; for (i=0; i < event_attendees.length; i++) { if (freebusy_ui.attendees[i].email == data.email) { attendee = freebusy_ui.attendees[i]; break; } } // copy data to member var var ts, status, req = attendee.role != 'OPT-PARTICIPANT', start = parseISO8601(data.start); freebusy_data.start = new Date(start); freebusy_data.end = parseISO8601(data.end); freebusy_data.interval = data.interval; freebusy_data[data.email] = {}; for (i=0; i < data.slots.length; i++) { ts = date2timestring(start, data.interval > 60); status = data.slots.charAt(i); freebusy_data[data.email][ts] = status start = new Date(start.getTime() + data.interval * 60000); // set totals if (!freebusy_data.required[ts]) freebusy_data.required[ts] = [0,0,0,0]; if (req) freebusy_data.required[ts][status]++; if (!freebusy_data.all[ts]) freebusy_data.all[ts] = [0,0,0,0]; freebusy_data.all[ts][status]++; } // hide loading indicator var domid = String(data.email).replace(rcmail.identifier_expr, ''); $('#rcmli' + domid).removeClass('loading'); // update display update_freebusy_display(data.email); } }); // count required attendees if (freebusy_ui.attendees[i].role != 'OPT-PARTICIPANT') freebusy_ui.numrequired++; } } }; // re-calculate total status after role change var compute_freebusy_totals = function() { freebusy_ui.numrequired = 0; freebusy_data.all = []; freebusy_data.required = []; var email, req, status; for (var i=0; i < event_attendees.length; i++) { if (!(email = event_attendees[i].email)) continue; req = freebusy_ui.attendees[i].role != 'OPT-PARTICIPANT'; if (req) freebusy_ui.numrequired++; for (var ts in freebusy_data[email]) { if (!freebusy_data.required[ts]) freebusy_data.required[ts] = [0,0,0,0]; if (!freebusy_data.all[ts]) freebusy_data.all[ts] = [0,0,0,0]; status = freebusy_data[email][ts]; freebusy_data.all[ts][status]++; if (req) freebusy_data.required[ts][status]++; } } }; // update free-busy grid with status loaded from server var update_freebusy_display = function(email) { var status_classes = ['unknown','free','busy','tentative','out-of-office']; var domid = String(email).replace(rcmail.identifier_expr, ''); var row = $('#fbrow' + domid); var rowall = $('#fbrowall').children(); var dateonly = freebusy_ui.interval > 60, t, ts = date2timestring(freebusy_ui.start, dateonly), curdate = new Date(), fbdata = freebusy_data[email]; if (fbdata && fbdata[ts] !== undefined && row.length) { t = freebusy_ui.start.getTime(); row.children().each(function(i, cell) { var j, n, attr, last, all_slots = [], slots = [], all_cell = rowall.get(i), cnt = dateonly ? 1 : (60 / freebusy_ui.interval), percent = (100 / cnt); for (n=0; n < cnt; n++) { curdate.setTime(t); ts = date2timestring(curdate, dateonly); attr = { 'style': 'float:left; width:' + percent.toFixed(2) + '%', 'class': fbdata[ts] ? status_classes[fbdata[ts]] : 'unknown' }; slots.push($('
').attr(attr)); // also update total row if all data was loaded if (!freebusy_ui.loading && freebusy_data.all[ts] && all_cell) { var w, all_status = freebusy_data.all[ts][2] ? 'busy' : 'unknown', req_status = freebusy_data.required[ts][2] ? 'busy' : 'free'; for (j=1; j < status_classes.length; j++) { if (freebusy_ui.numrequired && freebusy_data.required[ts][j] >= freebusy_ui.numrequired) req_status = status_classes[j]; if (freebusy_data.all[ts][j] == event_attendees.length) all_status = status_classes[j]; } attr['class'] = req_status + ' all-' + all_status; // these elements use some specific styling, so we want to minimize their number if (last && last.attr('class').startsWith(attr['class'])) { w = percent + parseFloat(last.css('width').replace('%', '')); last.css('width', w.toFixed(2) + '%').attr('class', attr['class'] + ' w' + w.toFixed(0)); } else { last = $('
').attr(attr).addClass('w' + percent.toFixed(0)).append(''); all_slots.push(last); } } t += freebusy_ui.interval * 60000; } $(cell).html('').append(slots); if (all_slots.length) $(all_cell).html('').append(all_slots); }); } }; // write changed event date/times back to form fields var update_freebusy_dates = function(start, end) { // fix all-day evebt times if (me.selected_event.allDay) { var numdays = Math.floor((me.selected_event.end.getTime() - me.selected_event.start.getTime()) / DAY_MS); start.setHours(12); start.setMinutes(0); end.setTime(start.getTime() + numdays * DAY_MS); end.setHours(13); end.setMinutes(0); } me.selected_event.start = start; me.selected_event.end = end; freebusy_ui.startdate.val(format_date(start, settings.date_format)); freebusy_ui.starttime.val(format_date(start, settings.time_format)); freebusy_ui.enddate.val(format_date(end, settings.date_format)); freebusy_ui.endtime.val(format_date(end, settings.time_format)); freebusy_ui.needsupdate = true; }; // attempt to find a time slot where all attemdees are available var freebusy_find_slot = function(dir) { // exit if free-busy data isn't available yet if (!freebusy_data || !freebusy_data.start) return false; var event = me.selected_event, eventstart = clone_date(event.start, event.allDay ? 1 : 0).getTime(), // calculate with integers eventend = clone_date(event.end, event.allDay ? 2 : 0).getTime(), duration = eventend - eventstart - (event.allDay ? HOUR_MS : 0), /* make sure we don't cross day borders on DST change */ sinterval = freebusy_data.interval * 60000, intvlslots = 1, numslots = Math.ceil(duration / sinterval), fb_start = freebusy_data.start.getTime(), fb_end = freebusy_data.end.getTime(), checkdate, slotend, email, ts, slot, slotdate = new Date(), candidatecount = 0, candidatestart = false, success = false; // shift event times to next possible slot eventstart += sinterval * intvlslots * dir; eventend += sinterval * intvlslots * dir; // iterate through free-busy slots and find candidates for (slot = dir > 0 ? fb_start : fb_end - sinterval; (dir > 0 && slot < fb_end) || (dir < 0 && slot >= fb_start); slot += sinterval * dir ) { slotdate.setTime(slot); // fix slot if just crossed a DST change if (event.allDay) { fix_date(slotdate); slot = slotdate.getTime(); } slotend = slot + sinterval; if ((dir > 0 && slotend <= eventstart) || (dir < 0 && slot >= eventend)) // skip continue; // respect workinghours setting if (freebusy_ui.workinhoursonly) { if (is_weekend(slotdate) || (freebusy_data.interval <= 60 && !is_workinghour(slotdate))) { // skip off-hours candidatestart = false; candidatecount = 0; continue; } } if (!candidatestart) candidatestart = slot; ts = date2timestring(slotdate, freebusy_data.interval > 60); // check freebusy data for all attendees for (var i=0; i < event_attendees.length; i++) { if (freebusy_ui.attendees[i].role != 'OPT-PARTICIPANT' && (email = freebusy_ui.attendees[i].email) && freebusy_data[email] && freebusy_data[email][ts] > 1) { candidatestart = false; break; } } // occupied slot if (!candidatestart) { slot += Math.max(0, intvlslots - candidatecount - 1) * sinterval * dir; candidatecount = 0; continue; } else if (dir < 0) candidatestart = slot; candidatecount++; // if candidate is big enough, this is it! if (candidatecount == numslots) { 'toDate' in event.start ? (event.start = new Date(candidatestart)) : event.start.setTime(candidatestart); 'toDate' in event.end ? (event.end = new Date(candidatestart + duration)) : event.end.setTime(candidatestart + duration); success = true; break; } } // update event date/time display if (success) { update_freebusy_dates(event.start, event.end); // move freebusy grid if necessary var event_start = 'toDate' in event.start ? event.start.toDate() : event.start, event_end = 'toDate' in event.end ? event.end.toDate() : event.end, offset = Math.ceil((event_start.getTime() - freebusy_ui.end.getTime()) / DAY_MS), now = new Date(); if (event_start.getTime() >= freebusy_ui.end.getTime()) render_freebusy_grid(Math.max(1, offset)); else if (event_end.getTime() <= freebusy_ui.start.getTime()) render_freebusy_grid(Math.min(-1, offset)); else render_freebusy_overlay(); $('#schedule-find-prev').prop('disabled', event_start.getTime() < now.getTime()); // speak new selection rcmail.display_message(rcmail.gettext('suggestedslot', 'calendar') + ': ' + me.event_date_text(event, true), 'voice'); } else { rcmail.alert_dialog(rcmail.gettext('noslotfound','calendar')); } }; // update event properties and attendees availability if event times have changed var event_times_changed = function() { if (me.selected_event) { var allday = $('#edit-allday').get(0); me.selected_event.allDay = allday.checked; me.selected_event.start = me.parse_datetime(allday.checked ? '12:00' : $('#edit-starttime').val(), $('#edit-startdate').val()); me.selected_event.end = me.parse_datetime(allday.checked ? '13:00' : $('#edit-endtime').val(), $('#edit-enddate').val()); if (event_attendees) freebusy_ui.needsupdate = true; $('#edit-startdate').data('duration', Math.round((me.selected_event.end.getTime() - me.selected_event.start.getTime()) / 1000)); } }; // add the given list of participants var add_attendees = function(names, params) { names = explode_quoted_string(names.replace(/,\s*$/, ''), ','); // parse name/email pairs var item, email, name, success = false; for (var i=0; i < names.length; i++) { email = name = ''; item = $.trim(names[i]); if (!item.length) { continue; } // address in brackets without name (do nothing) else if (item.match(/^<[^@]+@[^>]+>$/)) { email = item.replace(/[<>]/g, ''); } // address without brackets and without name (add brackets) else if (rcube_check_email(item)) { email = item; } // address with name else if (item.match(/([^\s<@]+@[^>]+)>*$/)) { email = RegExp.$1; name = item.replace(email, '').replace(/^["\s<>]+/, '').replace(/["\s<>]+$/, ''); } if (email) { add_attendee($.extend({ email:email, name:name }, params)); success = true; } else { rcmail.alert_dialog(rcmail.gettext('noemailwarning')); } } return success; }; // add the given attendee to the list var add_attendee = function(data, readonly, before) { if (!me.selected_event) return false; // check for dupes... var exists = false; $.each(event_attendees, function(i, v){ exists |= (v.email == data.email); }); if (exists) return false; var calendar = me.selected_event && me.calendars[me.selected_event.calendar] ? me.calendars[me.selected_event.calendar] : me.calendars[me.selected_calendar]; var dispname = Q(data.name || data.email); dispname = '' + dispname + ''; // role selection var organizer = data.role == 'ORGANIZER'; var opts = {}; if (organizer) opts.ORGANIZER = rcmail.gettext('calendar.roleorganizer'); opts['REQ-PARTICIPANT'] = rcmail.gettext('calendar.rolerequired'); opts['OPT-PARTICIPANT'] = rcmail.gettext('calendar.roleoptional'); opts['NON-PARTICIPANT'] = rcmail.gettext('calendar.rolenonparticipant'); if (data.cutype != 'RESOURCE') opts['CHAIR'] = rcmail.gettext('calendar.rolechair'); if (organizer && !readonly) dispname = rcmail.env['identities-selector']; var select = ''; // delete icon var icon = rcmail.env.deleteicon ? '' : '' + Q(rcmail.gettext('delete')) + ''; var dellink = '' + icon + ''; var tooltip = '', status = (data.status || '').toLowerCase(), status_label = rcmail.gettext('status' + status, 'libcalendaring'); // send invitation checkbox var invbox = ''; if (data['delegated-to']) tooltip = rcmail.gettext('libcalendaring.delegatedto') + ' ' + data['delegated-to']; else if (data['delegated-from']) tooltip = rcmail.gettext('libcalendaring.delegatedfrom') + ' ' + data['delegated-from']; else if (!status && organizer) tooltip = rcmail.gettext('statusorganizer', 'libcalendaring'); else if (status) tooltip = status_label; // add expand button for groups if (data.cutype == 'GROUP') { dispname += ' ' + rcmail.gettext('expandattendeegroup','libcalendaring') + ''; } var avail = data.email ? 'loading' : 'unknown'; var table = rcmail.env.calendar_resources && data.cutype == 'RESOURCE' ? resources_list : attendees_list; var img_src = rcmail.assets_path('program/resources/blank.gif'); var elastic = $(table).parents('.no-img').length > 0; var avail_tag = elastic ? ('' + '' + dispname + '' + '' + avail_tag + ' data-email="' + data.email + '" />' + '' + Q(status && !elastic ? status_label : '') + '' + (data.cutype != 'RESOURCE' ? '' + (organizer || readonly || !invbox ? '' : invbox) + '' : '') + '' + (organizer || readonly ? '' : dellink) + ''; var tr = $('') .addClass(String(data.role).toLowerCase()) .html(html); if (before) tr.insertBefore(before) else tr.appendTo(table); tr.find('a.deletelink').click({ id:(data.email || data.name) }, function(e) { remove_attendee(this, e.data.id); return false; }); tr.find('a.expandlink').click(data, function(e) { me.expand_attendee_group(e, add_attendee, remove_attendee); return false; }); tr.find('input.edit-attendee-reply').click(function() { var enabled = $('#edit-attendees-invite:checked').length || $('input.edit-attendee-reply:checked').length; $('#eventedit .attendees-commentbox')[enabled ? 'show' : 'hide'](); }); // select organizer identity if (data.identity_id) $('#edit-identities-list').val(data.identity_id); // check free-busy status if (avail == 'loading') { check_freebusy_status(tr.find('.availability > *:first'), data.email, me.selected_event); } // Make Elastic checkboxes pretty if (window.UI && UI.pretty_checkbox) { $(tr).find('input[type=checkbox]').each(function() { UI.pretty_checkbox(this); }); $(tr).find('select').each(function() { UI.pretty_select(this); }); } event_attendees.push(data); return true; }; // iterate over all attendees and update their free-busy status display var update_freebusy_status = function(event) { attendees_list.find('.availability > *').each(function(i,v) { var email, icon = $(this); if (email = icon.attr('data-email')) check_freebusy_status(icon, email, event); }); freebusy_ui.needsupdate = false; }; // load free-busy status from server and update icon accordingly var check_freebusy_status = function(icon, email, event) { var calendar = event.calendar && me.calendars[event.calendar] ? me.calendars[event.calendar] : { freebusy:false }; if (!calendar.freebusy) { $(icon).attr('class', 'availabilityicon unknown'); return; } icon = $(icon).attr('class', 'availabilityicon loading'); $.ajax({ type: 'GET', dataType: 'html', url: rcmail.url('freebusy-status'), data: { email:email, start:date2servertime(clone_date(event.start, event.allDay?1:0)), end:date2servertime(clone_date(event.end, event.allDay?2:0)), _remote: 1 }, success: function(status){ var avail = String(status).toLowerCase(); icon.removeClass('loading').addClass(avail).attr('title', rcmail.gettext('avail' + avail, 'calendar')); }, error: function(){ icon.removeClass('loading').addClass('unknown').attr('title', rcmail.gettext('availunknown', 'calendar')); } }); }; // remove an attendee from the list var remove_attendee = function(elem, id) { $(elem).closest('tr').remove(); event_attendees = $.grep(event_attendees, function(data){ return (data.name != id && data.email != id) }); }; // open a dialog to display detailed free-busy information and to find free slots var event_resources_dialog = function(search) { var $dialog = $('#eventresourcesdialog'); if ($dialog.is(':ui-dialog')) $dialog.dialog('close'); // dialog buttons var buttons = [ { text: rcmail.gettext('addresource', 'calendar'), 'class': 'mainaction save', click: function() { rcmail.command('add-resource'); $dialog.dialog("close"); } }, { text: rcmail.gettext('close'), 'class': 'cancel', click: function() { $dialog.dialog("close"); } } ]; var resize = function() { var container = $(rcmail.gui_objects.resourceinfocalendar); container.fullCalendar('option', 'height', container.height() + 1); }; // open jquery UI dialog $dialog.dialog({ modal: true, resizable: false, // prevents from Availability tab reflow bugs on resize closeOnEscape: true, title: rcmail.gettext('findresources', 'calendar'), classes: {'ui-dialog': 'selection-dialog resources-dialog'}, open: function() { rcmail.ksearch_blur(); $dialog.attr('aria-hidden', 'false'); // for Elastic if ($('html.layout-small,html.layout-phone').length) { $('#eventresourcesdialog .resource-selection').css('display', 'flex'); $('#eventresourcesdialog .resource-content').css('display', 'none'); } setTimeout(resize, 50); }, close: function() { $dialog.dialog('destroy').attr('aria-hidden', 'true').hide(); }, resize: resize, buttons: buttons, width: 900, height: 500 }).show(); $('.ui-dialog-buttonset button', $dialog.parent()).first().attr('id', 'rcmbtncalresadd'); me.dialog_resize($dialog.get(0), 540, Math.min(1000, $(window).width() - 50)); // set search query $('#resourcesearchbox').val(search || ''); // initialize the treelist widget if (!resources_treelist) { resources_treelist = new rcube_treelist_widget(rcmail.gui_objects.resourceslist, { id_prefix: 'rcres', id_encode: rcmail.html_identifier_encode, id_decode: rcmail.html_identifier_decode, selectable: true, save_state: true }); resources_treelist.addEventListener('select', function(node) { if (resources_data[node.id]) { resource_showinfo(resources_data[node.id]); rcmail.enable_command('add-resource', me.selected_event && $("#eventedit").is(':visible')); // on elastic mobile display resource info box if ($('html.layout-small,html.layout-phone').length) { $('#eventresourcesdialog .resource-selection').css('display', 'none'); $('#eventresourcesdialog .resource-content').css('display', 'flex'); $(window).resize(); resize(); } } else { rcmail.enable_command('add-resource', false); $(rcmail.gui_objects.resourceinfo).hide(); $(rcmail.gui_objects.resourceownerinfo).hide(); $(rcmail.gui_objects.resourceinfocalendar).fullCalendar('removeEventSources'); } }); // fetch (all) resource data from server me.loading_lock = rcmail.set_busy(true, 'loading', me.loading_lock); rcmail.http_request('resources-list', {}, me.loading_lock); // register button rcmail.register_button('add-resource', 'rcmbtncalresadd', 'button'); // initialize resource calendar display var resource_cal = $(rcmail.gui_objects.resourceinfocalendar); resource_cal.fullCalendar($.extend({}, fullcalendar_defaults, { header: { left: '', center: '', right: '' }, height: resource_cal.height() + 4, defaultView: 'agendaWeek', eventSources: [], slotMinutes: 60, allDaySlot: false, eventRender: function(event, element, view) { var title = rcmail.get_label(event.status, 'calendar'); element.addClass('status-' + event.status); element.find('.fc-title').text(title); element.attr('aria-label', me.event_date_text(event, true) + ': ' + title); } })); $('#resource-calendar-prev').click(function(){ resource_cal.fullCalendar('prev'); return false; }); $('#resource-calendar-next').click(function(){ resource_cal.fullCalendar('next'); return false; }); } else if (search) { resource_search(); } else { resource_render_list(resources_index); } if (me.selected_event && me.selected_event.start) { $(rcmail.gui_objects.resourceinfocalendar).fullCalendar('gotoDate', me.selected_event.start); } }; // render the resource details UI box var resource_showinfo = function(resource) { // inline function to render a resource attribute function render_attrib(value) { if (typeof value == 'boolean') { return value ? rcmail.get_label('yes') : rcmail.get_label('no'); } return value; } if (rcmail.gui_objects.resourceinfo) { var tr, table = $(rcmail.gui_objects.resourceinfo).show().find('tbody').html(''), attribs = $.extend({ name:resource.name }, resource.attributes||{}) attribs.description = resource.description; for (var k in attribs) { if (typeof attribs[k] == 'undefined') continue; table.append($('').addClass(k + ' form-group row') .append('') .append('' + text2html(render_attrib(attribs[k])) + '') ); } $(rcmail.gui_objects.resourceownerinfo).hide(); $(rcmail.gui_objects.resourceinfocalendar).fullCalendar('removeEventSources'); if (resource.owner) { // display cached data if (resource_owners[resource.owner]) { resource_owner_load(resource_owners[resource.owner]); } else { // fetch owner data from server me.loading_lock = rcmail.set_busy(true, 'loading', me.loading_lock); rcmail.http_request('resources-owner', { _id: resource.owner }, me.loading_lock); } } // load resource calendar resources_events_source.url = "./?_task=calendar&_action=resources-calendar&_id="+urlencode(resource.ID); $(rcmail.gui_objects.resourceinfocalendar).fullCalendar('addEventSource', resources_events_source); } }; // callback from server for resource listing var resource_data_load = function(data) { var resources_tree = {}; // store data by ID $.each(data, function(i, rec) { resources_data[rec.ID] = rec; // assign parent-relations if (rec.members) { $.each(rec.members, function(j, m){ resources_tree[m] = rec.ID; }); } }); // walk the parent-child tree to determine the depth of each node $.each(data, function(i, rec) { rec._depth = 0; if (resources_tree[rec.ID]) rec.parent_id = resources_tree[rec.ID]; var parent_id = resources_tree[rec.ID]; while (parent_id) { rec._depth++; parent_id = resources_tree[parent_id]; } }); // sort by depth, collection and name data.sort(function(a,b) { var j = a._type == 'collection' ? 1 : 0, k = b._type == 'collection' ? 1 : 0, d = a._depth - b._depth; if (!d) d = (k - j); if (!d) d = b.name < a.name ? 1 : -1; return d; }); $.each(data, function(i, rec) { resources_index.push(rec.ID); }); // apply search filter... if ($('#resourcesearchbox').val() != '') resource_search(); else // ...or render full list resource_render_list(resources_index); rcmail.set_busy(false, null, me.loading_lock); }; // renders the given list of resource records into the treelist var resource_render_list = function(index) { var rec, link; resources_treelist.reset(); $.each(index, function(i, dn) { if (rec = resources_data[dn]) { link = $('').attr('href', '#') .attr('rel', rec.ID) .html(Q(rec.name)); resources_treelist.insert({ id:rec.ID, html:link, classes:[rec._type], collapsed:true }, rec.parent_id, false); } }); }; // callback from server for owner information display var resource_owner_load = function(data) { if (data) { // cache this! resource_owners[data.ID] = data; var table = $(rcmail.gui_objects.resourceownerinfo).find('tbody').html(''); for (var k in data) { if (k == 'event' || k == 'ID') continue; table.append($('').addClass(k) .append('' + Q(ucfirst(rcmail.get_label(k, 'calendar'))) + '') .append('' + text2html(data[k]) + '') ); } table.parent().show(); } } // quick-filter the loaded resource data var resource_search = function() { var dn, rec, dataset = [], q = $('#resourcesearchbox').val().toLowerCase(); if (q.length && resources_data) { // search by iterating over all resource records for (dn in resources_data) { rec = resources_data[dn]; if ((rec.name && String(rec.name).toLowerCase().indexOf(q) >= 0) || (rec.email && String(rec.email).toLowerCase().indexOf(q) >= 0) || (rec.description && String(rec.description).toLowerCase().indexOf(q) >= 0) ) { dataset.push(rec.ID); } } resource_render_list(dataset); // select single match if (dataset.length == 1) { resources_treelist.select(dataset[0]); } } else { $('#resourcesearchbox').val(''); } }; // var reset_resource_search = function() { $('#resourcesearchbox').val('').focus(); resource_render_list(resources_index); }; // var add_resource2event = function() { var resource = resources_data[resources_treelist.get_selection()]; if (resource) add_attendee($.extend({ role:'REQ-PARTICIPANT', status:'NEEDS-ACTION', cutype:'RESOURCE' }, resource)); } + var is_this_me = function(email) + { + if (settings.identity.emails.indexOf(';'+email) >= 0 || settings.identity.ownedResources.indexOf(';'+email) >= 0) { + return true; + } + return false; + }; + // when the user accepts or declines an event invitation var event_rsvp = function(response, delegate, replymode, event) { var btn; if (typeof response == 'object') { btn = $(response); response = btn.attr('rel') } else { btn = $('#event-rsvp input.button[rel='+response+']'); } // show menu to select rsvp reply mode (current or all) if (me.selected_event && me.selected_event.recurrence && !replymode) { rcube_libcalendaring.itip_rsvp_recurring(btn, function(resp, mode) { event_rsvp(resp, null, mode, event); }, event); return; } if (me.selected_event && me.selected_event.attendees && response) { // bring up delegation dialog if (response == 'delegated' && !delegate) { rcube_libcalendaring.itip_delegate_dialog(function(data) { data.rsvp = data.rsvp ? 1 : ''; event_rsvp('delegated', data, replymode, event); }); return; } // update attendee status attendees = []; for (var data, i=0; i < me.selected_event.attendees.length; i++) { data = me.selected_event.attendees[i]; - if (settings.identity.emails.indexOf(';'+String(data.email).toLowerCase()) >= 0) { + //FIXME this can only work if there is a single resource per invitation + if (is_this_me(String(data.email).toLowerCase())) { data.status = response.toUpperCase(); data.rsvp = 0; // unset RSVP flag if (data.status == 'DELEGATED') { data['delegated-to'] = delegate.to; data.rsvp = delegate.rsvp } else { if (data['delegated-to']) { delete data['delegated-to']; if (data.role == 'NON-PARTICIPANT' && data.status != 'DECLINED') data.role = 'REQ-PARTICIPANT'; } } attendees.push(i) } else if (response != 'DELEGATED' && data['delegated-from'] && settings.identity.emails.indexOf(';'+String(data['delegated-from']).toLowerCase()) >= 0) { delete data['delegated-from']; } // set free_busy status to transparent if declined (#4425) if (data.status == 'DECLINED' || data.role == 'NON-PARTICIPANT') { me.selected_event.free_busy = 'free'; } else { me.selected_event.free_busy = 'busy'; } } // submit status change to server var submit_data = $.extend({}, { source:null, comment:$('#reply-comment-event-rsvp').val(), _savemode: replymode || 'all' }, (delegate || {})), submit_items = 'id,uid,_instance,calendar,_mbox,_uid,_part,attendees,free_busy,allDay', noreply = $('#noreply-event-rsvp:checked').length ? 1 : 0; // Submit only that data we really need $.each(submit_items.split(','), function() { if (this in me.selected_event) submit_data[this] = me.selected_event[this]; }); // import event from mail (temporary iTip event) if (submit_data._mbox && submit_data._uid) { me.saving_lock = rcmail.set_busy(true, 'calendar.savingdata'); rcmail.http_post('mailimportitip', { _mbox: submit_data._mbox, _uid: submit_data._uid, _part: submit_data._part, _status: response, _to: (delegate ? delegate.to : null), _rsvp: (delegate && delegate.rsvp) ? 1 : 0, _noreply: noreply, _comment: submit_data.comment, _instance: submit_data._instance, _savemode: submit_data._savemode }); } else if (settings.invitation_calendars) { update_event('rsvp', submit_data, { status:response, noreply:noreply, attendees:attendees }); } else { me.saving_lock = rcmail.set_busy(true, 'calendar.savingdata'); rcmail.http_post('calendar/event', { action:'rsvp', e:submit_data, status:response, attendees:attendees, noreply:noreply }); } event_show_dialog(me.selected_event); } }; // add the given date to the RDATE list var add_rdate = function(date) { var li = $('
  • ') .attr('data-value', date2servertime(date)) .append($('').text(format_date(date, settings.date_format))) .appendTo('#edit-recurrence-rdates'); $('').attr('href', '#del') .addClass('iconbutton delete') .html(rcmail.get_label('delete', 'calendar')) .attr('title', rcmail.get_label('delete', 'calendar')) .appendTo(li); }; // re-sort the list items by their 'data-value' attribute var sort_rdates = function() { var mylist = $('#edit-recurrence-rdates'), listitems = mylist.children('li').get(); listitems.sort(function(a, b) { var compA = $(a).attr('data-value'); var compB = $(b).attr('data-value'); return (compA < compB) ? -1 : (compA > compB) ? 1 : 0; }) $.each(listitems, function(idx, item) { mylist.append(item); }); } // remove the link reference matching the given uri function remove_link(elem) { var $elem = $(elem), uri = $elem.attr('data-uri'); me.selected_event.links = $.grep(me.selected_event.links, function(link) { return link.uri != uri; }); // remove UI list item $elem.hide().closest('li').addClass('deleted'); } // post the given event data to server var update_event = function(action, data, add) { me.saving_lock = rcmail.set_busy(true, 'calendar.savingdata'); rcmail.http_post('calendar/event', $.extend({ action:action, e:data }, (add || {}))); // render event temporarily into the calendar if ((data.start && data.end) || data.id) { var tmp, event = data.id ? $.extend(fc.fullCalendar('clientEvents', data.id)[0], data) : data; if (data.start) event.start = data.start; if (data.end) event.end = data.end; if (data.allDay !== undefined) event.allDay = !!data.allDay; // must be boolean for fullcalendar // For fullCalendar all-day event's end date must be exclusive if (event.allDay && data.end && (tmp = moment(data.end)) && tmp.format('Hms') !== '000') { event.end = moment().year(tmp.year()).month(tmp.month()).date(tmp.date()).hour(0).minute(0).second(0).add(1, 'days'); } event.editable = false; event.temp = true; event.className = ['fc-event-temp']; fc.fullCalendar(data.id ? 'updateEvent' : 'renderEvent', event); // mark all recurring instances as temp if (event.recurrence || event.recurrence_id) { var base_id = event.recurrence_id ? event.recurrence_id : String(event.id).replace(/-\d+(T\d{6})?$/, ''); $.each(fc.fullCalendar('clientEvents', function(e){ return e.id == base_id || e.recurrence_id == base_id; }), function(i,ev) { ev.temp = true; ev.editable = false; event.className.push('fc-event-temp'); fc.fullCalendar('updateEvent', ev); }); } } }; // mouse-click handler to check if the show dialog is still open and prevent default action var dialog_check = function(e) { var showd = $("#eventshow"); if (showd.is(':visible') && !$(e.target).closest('.ui-dialog').length && !$(e.target).closest('.popupmenu').length) { showd.dialog('close'); e.stopImmediatePropagation(); ignore_click = true; return false; } else if (ignore_click) { window.setTimeout(function(){ ignore_click = false; }, 20); return false; } return true; }; // display confirm dialog when modifying/deleting an event var update_event_confirm = function(action, event, data) { // Allow other plugins to do actions here // E.g. when you move/resize the event init wasn't called // but we need it as some plugins may modify user identities // we depend on here (kolab_delegation) rcmail.triggerEvent('calendar-event-init', {o: event}); if (!data) data = event; var decline = false, notify = false, html = '', cal = me.calendars[event.calendar], _is_invitation = String(event.calendar).match(/^--invitation--(declined|pending)/) && RegExp.$1, _has_attendees = me.has_attendees(event), _is_attendee = _has_attendees && me.is_attendee(event), _is_organizer = me.is_organizer(event); // event has attendees, ask whether to notify them if (_has_attendees) { var checked = (settings.itip_notify & 1 ? ' checked="checked"' : ''); if (action == 'remove' && cal.group != 'shared' && !_is_organizer && _is_attendee && _is_invitation != 'declined') { decline = true; checked = event.status != 'CANCELLED' ? checked : ''; html += '
    ' + '
    '; } else if (_is_organizer) { notify = true; if (settings.itip_notify & 2) { html += '
    ' + '
    '; } else { data._notify = settings.itip_notify; } } else if (cal.group != 'shared' && !_is_invitation) { html += '
    ' + $('#edit-localchanges-warning').html() + '
    '; data._notify = 0; } } // recurring event: user needs to select the savemode if (event.recurrence) { var future_disabled = '', message_label = (action == 'remove' ? 'removerecurringeventwarning' : 'changerecurringeventwarning'); // disable the 'future' savemode if I'm an attendee // reason: no calendaring system supports the thisandfuture range parameter in iTip REPLY if (action == 'remove' && !_is_organizer && _is_attendee) { future_disabled = ' disabled'; } html += '
    ' + rcmail.gettext(message_label, 'calendar') + '
    ' + '
    '; } // show dialog if (html) { var $dialog = $('
    ').html(html); $dialog.find('a.button').filter(':not(.disabled)').click(function(e) { data._savemode = String(this.href).replace(/.+#/, ''); // open event edit dialog when saving as new if (data._savemode == 'new') { event._savemode = 'new'; event_edit_dialog('edit', event); fc.fullCalendar('refetchEvents'); } else { if ($dialog.find('input.confirm-attendees-donotify').length) data._notify = $dialog.find('input.confirm-attendees-donotify').get(0).checked ? 1 : 0; if (decline) { data._decline = $dialog.find('input.confirm-attendees-decline:checked').length; data._notify = 0; } update_event(action, data); } $dialog.dialog("close"); return false; }); var buttons = []; if (!event.recurrence) { buttons.push({ text: rcmail.gettext((action == 'remove' ? 'delete' : 'save'), 'calendar'), 'class': action == 'remove' ? 'delete mainaction' : 'save mainaction', click: function() { data._notify = notify && $dialog.find('input.confirm-attendees-donotify:checked').length ? 1 : 0; data._decline = decline && $dialog.find('input.confirm-attendees-decline:checked').length ? 1 : 0; update_event(action, data); $(this).dialog("close"); } }); } buttons.push({ text: rcmail.gettext('cancel', 'calendar'), 'class': 'cancel', click: function() { $(this).dialog("close"); } }); $dialog.dialog({ modal: true, width: 460, dialogClass: 'warning', title: rcmail.gettext((action == 'remove' ? 'removeeventconfirm' : 'changeeventconfirm'), 'calendar'), buttons: buttons, open: function() { setTimeout(function(){ $dialog.parent().find('button:not(.ui-dialog-titlebar-close)').first().focus(); }, 5); }, close: function(){ $dialog.dialog("destroy").remove(); if (!rcmail.busy) fc.fullCalendar('refetchEvents'); } }).addClass('event-update-confirm').show(); return false; } // show regular confirm box when deleting else if (action == 'remove' && !cal.undelete) { if (!confirm(rcmail.gettext('deleteventconfirm', 'calendar'))) return false; } // do update update_event(action, data); return true; }; /*** public methods ***/ /** * Remove saving lock and free the UI for new input */ this.unlock_saving = function() { if (me.saving_lock) rcmail.set_busy(false, null, me.saving_lock); }; // opens the given calendar in a popup dialog this.quickview = function(id, shift) { var src, in_quickview = false; $.each(this.quickview_sources, function(i,cal) { if (cal.id == id) { in_quickview = true; src = cal; } }); // remove source from quickview if (in_quickview && shift) { this.quickview_sources = $.grep(this.quickview_sources, function(src) { return src.id != id; }); } else { if (!shift) { // remove all current quickview event sources if (this.quickview_active) { fc.fullCalendar('removeEventSources'); } this.quickview_sources = []; // uncheck all active quickview icons calendars_list.container.find('div.focusview') .add('#calendars .searchresults div.focusview') .removeClass('focusview') .find('a.quickview').attr('aria-checked', 'false'); } if (!in_quickview) { // clone and modify calendar properties src = $.extend({}, this.calendars[id]); src.url += '&_quickview=1'; this.quickview_sources.push(src); } } // disable quickview if (this.quickview_active && !this.quickview_sources.length) { // register regular calendar event sources $.each(this.calendars, function(k, cal) { if (cal.active) fc.fullCalendar('addEventSource', cal); }); this.quickview_active = false; $('body').removeClass('quickview-active'); // uncheck all active quickview icons calendars_list.container.find('div.focusview') .add('#calendars .searchresults div.focusview') .removeClass('focusview') .find('a.quickview').attr('aria-checked', 'false'); } // activate quickview else if (!this.quickview_active) { // remove regular calendar event sources fc.fullCalendar('removeEventSources'); // register quickview event sources $.each(this.quickview_sources, function(i, src) { fc.fullCalendar('addEventSource', src); }); this.quickview_active = true; $('body').addClass('quickview-active'); } // update quickview sources else if (in_quickview) { fc.fullCalendar('removeEventSource', src); } else if (src) { fc.fullCalendar('addEventSource', src); } // activate quickview icon if (this.quickview_active) { $(calendars_list.get_item(id)).find('.calendar').first() .add('#calendars .searchresults .cal-' + id) [in_quickview ? 'removeClass' : 'addClass']('focusview') .find('a.quickview').attr('aria-checked', in_quickview ? 'false' : 'true'); } }; // disable quickview mode function reset_quickview() { // remove all current quickview event sources if (me.quickview_active) { fc.fullCalendar('removeEventSources'); me.quickview_sources = []; } // register regular calendar event sources $.each(me.calendars, function(k, cal) { if (cal.active) fc.fullCalendar('addEventSource', cal); }); // uncheck all active quickview icons calendars_list.container.find('div.focusview') .add('#calendars .searchresults div.focusview') .removeClass('focusview') .find('a.quickview').attr('aria-checked', 'false'); me.quickview_active = false; $('body').removeClass('quickview-active'); }; //public method to show the print dialog. this.print_calendars = function(view) { if (!view) view = fc.fullCalendar('getView').name; var date = fc.fullCalendar('getDate').toDate(); rcmail.open_window(rcmail.url('print', { view: view, date: date2unixtime(date), range: settings.agenda_range, search: this.search_query }), true, true); }; // public method to bring up the new event dialog this.add_event = function(templ) { if (this.selected_calendar) { var now = new Date(); var date = fc.fullCalendar('getDate').toDate(); date.setHours(now.getHours()+1); date.setMinutes(0); var end = new Date(date.getTime()); end.setHours(date.getHours()+1); event_edit_dialog('new', $.extend({ start:date, end:end, allDay:false, calendar:this.selected_calendar }, templ || {})); } }; // delete the given event after showing a confirmation dialog this.delete_event = function(event) { // show confirm dialog for recurring events, use jquery UI dialog return update_event_confirm('remove', event, { id:event.id, calendar:event.calendar, attendees:event.attendees }); }; // opens a jquery UI dialog with event properties (or empty for creating a new calendar) this.calendar_edit_dialog = function(calendar) { if (!calendar) calendar = { name:'', color:'cc0000', editable:true, showalarms:true }; var title = rcmail.gettext((calendar.id ? 'editcalendar' : 'createcalendar'), 'calendar'), params = {action: calendar.id ? 'form-edit' : 'form-new', c: {id: calendar.id}, _framed: 1}, $dialog = $('