diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index 4ed5023c..afb23854 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -1,3611 +1,3606 @@ * @author Thomas Bruederli * * Copyright (C) 2010, Lazlo Westerhof * Copyright (C) 2014-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class calendar extends rcube_plugin { const FREEBUSY_UNKNOWN = 0; const FREEBUSY_FREE = 1; const FREEBUSY_BUSY = 2; const FREEBUSY_TENTATIVE = 3; const FREEBUSY_OOF = 4; const SESSION_KEY = 'calendar_temp'; public $task = '?(?!logout).*'; public $rc; public $lib; public $resources_dir; public $home; // declare public to be used in other classes public $urlbase; public $timezone; public $timezone_offset; public $gmt_offset; public $ui; public $defaults = array( 'calendar_default_view' => "agendaWeek", 'calendar_timeslots' => 2, 'calendar_work_start' => 6, 'calendar_work_end' => 18, 'calendar_agenda_range' => 60, - 'calendar_agenda_sections' => 'smart', 'calendar_event_coloring' => 0, 'calendar_time_indicator' => true, 'calendar_allow_invite_shared' => false, 'calendar_itip_send_option' => 3, 'calendar_itip_after_action' => 0, ); // These are implemented with __get() // private $ical; // private $itip; // private $driver; /** * Plugin initialization. */ function init() { $this->rc = rcube::get_instance(); $this->register_task('calendar', 'calendar'); // load calendar configuration $this->load_config(); // catch iTIP confirmation requests that don're require a valid session if ($this->rc->action == 'attend' && !empty($_REQUEST['_t'])) { $this->add_hook('startup', array($this, 'itip_attend_response')); } else if ($this->rc->action == 'feed' && !empty($_REQUEST['_cal'])) { $this->add_hook('startup', array($this, 'ical_feed_export')); } else if ($this->rc->task != 'login') { // default startup routine $this->add_hook('startup', array($this, 'startup')); } $this->add_hook('user_delete', array($this, 'user_delete')); } /** * Setup basic plugin environment and UI */ protected function setup() { $this->require_plugin('libcalendaring'); $this->require_plugin('libkolab'); $this->lib = libcalendaring::get_instance(); $this->timezone = $this->lib->timezone; $this->gmt_offset = $this->lib->gmt_offset; $this->dst_active = $this->lib->dst_active; $this->timezone_offset = $this->gmt_offset / 3600 - $this->dst_active; // load localizations $this->add_texts('localization/', $this->rc->task == 'calendar' && (!$this->rc->action || $this->rc->action == 'print')); require($this->home . '/lib/calendar_ui.php'); $this->ui = new calendar_ui($this); } /** * Startup hook */ public function startup($args) { // the calendar module can be enabled/disabled by the kolab_auth plugin if ($this->rc->config->get('calendar_disabled', false) || !$this->rc->config->get('calendar_enabled', true)) return; $this->setup(); // load Calendar user interface if (!$this->rc->output->ajax_call && (!$this->rc->output->env['framed'] || $args['action'] == 'preview')) { $this->ui->init(); // settings are required in (almost) every GUI step if ($args['action'] != 'attend') $this->rc->output->set_env('calendar_settings', $this->load_settings()); } if ($args['task'] == 'calendar' && $args['action'] != 'save-pref') { if ($args['action'] != 'upload') { $this->load_driver(); } // register calendar actions $this->register_action('index', array($this, 'calendar_view')); $this->register_action('event', array($this, 'event_action')); $this->register_action('calendar', array($this, 'calendar_action')); $this->register_action('count', array($this, 'count_events')); $this->register_action('load_events', array($this, 'load_events')); $this->register_action('export_events', array($this, 'export_events')); $this->register_action('import_events', array($this, 'import_events')); $this->register_action('upload', array($this, 'attachment_upload')); $this->register_action('get-attachment', array($this, 'attachment_get')); $this->register_action('freebusy-status', array($this, 'freebusy_status')); $this->register_action('freebusy-times', array($this, 'freebusy_times')); $this->register_action('randomdata', array($this, 'generate_randomdata')); $this->register_action('print', array($this,'print_view')); $this->register_action('mailimportitip', array($this, 'mail_import_itip')); $this->register_action('mailimportattach', array($this, 'mail_import_attachment')); $this->register_action('dialog-ui', array($this, 'mail_message2event')); $this->register_action('check-recent', array($this, 'check_recent')); $this->register_action('itip-status', array($this, 'event_itip_status')); $this->register_action('itip-remove', array($this, 'event_itip_remove')); $this->register_action('itip-decline-reply', array($this, 'mail_itip_decline_reply')); $this->register_action('itip-delegate', array($this, 'mail_itip_delegate')); $this->register_action('resources-list', array($this, 'resources_list')); $this->register_action('resources-owner', array($this, 'resources_owner')); $this->register_action('resources-calendar', array($this, 'resources_calendar')); $this->register_action('resources-autocomplete', array($this, 'resources_autocomplete')); $this->add_hook('refresh', array($this, 'refresh')); // remove undo information... if ($undo = $_SESSION['calendar_event_undo']) { // ...after timeout $undo_time = $this->rc->config->get('undo_timeout', 0); if ($undo['ts'] < time() - $undo_time) { $this->rc->session->remove('calendar_event_undo'); // @TODO: do EXPUNGE on kolab objects? } } } else if ($args['task'] == 'settings') { // add hooks for Calendar settings $this->add_hook('preferences_sections_list', array($this, 'preferences_sections_list')); $this->add_hook('preferences_list', array($this, 'preferences_list')); $this->add_hook('preferences_save', array($this, 'preferences_save')); } else if ($args['task'] == 'mail') { // hooks to catch event invitations on incoming mails if ($args['action'] == 'show' || $args['action'] == 'preview') { $this->add_hook('template_object_messagebody', array($this, 'mail_messagebody_html')); } // add 'Create event' item to message menu if ($this->api->output->type == 'html' && $_GET['_rel'] != 'event') { $this->api->add_content(html::tag('li', null, $this->api->output->button(array( 'command' => 'calendar-create-from-mail', 'label' => 'calendar.createfrommail', 'type' => 'link', 'classact' => 'icon calendarlink active', 'class' => 'icon calendarlink', 'innerclass' => 'icon calendar', ))), 'messagemenu'); $this->api->output->add_label('calendar.createfrommail'); } $this->add_hook('messages_list', array($this, 'mail_messages_list')); $this->add_hook('message_compose', array($this, 'mail_message_compose')); } else if ($args['task'] == 'addressbook') { if ($this->rc->config->get('calendar_contact_birthdays')) { $this->add_hook('contact_update', array($this, 'contact_update')); $this->add_hook('contact_create', array($this, 'contact_update')); } } // add hooks to display alarms $this->add_hook('pending_alarms', array($this, 'pending_alarms')); $this->add_hook('dismiss_alarms', array($this, 'dismiss_alarms')); } /** * Helper method to load the backend driver according to local config */ private function load_driver() { if (is_object($this->driver)) return; $driver_name = $this->rc->config->get('calendar_driver', 'database'); $driver_class = $driver_name . '_driver'; require_once($this->home . '/drivers/calendar_driver.php'); require_once($this->home . '/drivers/' . $driver_name . '/' . $driver_class . '.php'); $this->driver = new $driver_class($this); if ($this->driver->undelete) $this->driver->undelete = $this->rc->config->get('undo_timeout', 0) > 0; } /** * Load iTIP functions */ private function load_itip() { if (!$this->itip) { require_once($this->home . '/lib/calendar_itip.php'); $this->itip = new calendar_itip($this); if ($this->rc->config->get('kolab_invitation_calendars')) $this->itip->set_rsvp_actions(array('accepted','tentative','declined','delegated','needs-action')); } return $this->itip; } /** * Load iCalendar functions */ public function get_ical() { if (!$this->ical) { $this->ical = libcalendaring::get_ical(); } return $this->ical; } /** * Get properties of the calendar this user has specified as default */ public function get_default_calendar($sensitivity = null, $calendars = null) { if ($calendars === null) { $calendars = $this->driver->list_calendars(calendar_driver::FILTER_PERSONAL | calendar_driver::FILTER_WRITEABLE); } $default_id = $this->rc->config->get('calendar_default_calendar'); $calendar = $calendars[$default_id] ?: null; if (!$calendar || $sensitivity) { foreach ($calendars as $cal) { if ($sensitivity && $cal['subtype'] == $sensitivity) { $calendar = $cal; break; } if ($cal['default'] && $cal['editable']) { $calendar = $cal; } if ($cal['editable']) { $first = $cal; } } } return $calendar ?: $first; } /** * Render the main calendar view from skin template */ function calendar_view() { $this->rc->output->set_pagetitle($this->gettext('calendar')); // Add JS files to the page header $this->ui->addJS(); $this->ui->init_templates(); $this->rc->output->add_label('lowest','low','normal','high','highest','delete','cancel','uploading','noemailwarning','close'); // initialize attendees autocompletion $this->rc->autocomplete_init(); $this->rc->output->set_env('timezone', $this->timezone->getName()); $this->rc->output->set_env('calendar_driver', $this->rc->config->get('calendar_driver'), false); $this->rc->output->set_env('calendar_resources', (bool)$this->rc->config->get('calendar_resources_driver')); $this->rc->output->set_env('identities-selector', $this->ui->identity_select(array( 'id' => 'edit-identities-list', 'aria-label' => $this->gettext('roleorganizer'), 'class' => 'form-control custom-select', ))); $view = rcube_utils::get_input_value('view', rcube_utils::INPUT_GPC); if (in_array($view, array('agendaWeek', 'agendaDay', 'month', 'list'))) $this->rc->output->set_env('view', $view); if ($date = rcube_utils::get_input_value('date', rcube_utils::INPUT_GPC)) $this->rc->output->set_env('date', $date); if ($msgref = rcube_utils::get_input_value('itip', rcube_utils::INPUT_GPC)) $this->rc->output->set_env('itip_events', $this->itip_events($msgref)); $this->rc->output->send("calendar.calendar"); } /** * Handler for preferences_sections_list hook. * Adds Calendar settings sections into preferences sections list. * * @param array Original parameters * @return array Modified parameters */ function preferences_sections_list($p) { $p['list']['calendar'] = array( 'id' => 'calendar', 'section' => $this->gettext('calendar'), ); return $p; } /** * Handler for preferences_list hook. * Adds options blocks into Calendar settings sections in Preferences. * * @param array Original parameters * @return array Modified parameters */ function preferences_list($p) { if ($p['section'] != 'calendar') { return $p; } $no_override = array_flip((array)$this->rc->config->get('dont_override')); $p['blocks']['view']['name'] = $this->gettext('mainoptions'); if (!isset($no_override['calendar_default_view'])) { if (!$p['current']) { $p['blocks']['view']['content'] = true; return $p; } $field_id = 'rcmfd_default_view'; $view = $this->rc->config->get('calendar_default_view', $this->defaults['calendar_default_view']); $select = new html_select(array('name' => '_default_view', 'id' => $field_id)); $select->add($this->gettext('day'), "agendaDay"); $select->add($this->gettext('week'), "agendaWeek"); $select->add($this->gettext('month'), "month"); $select->add($this->gettext('agenda'), "list"); $p['blocks']['view']['options']['default_view'] = array( 'title' => html::label($field_id, rcube::Q($this->gettext('default_view'))), 'content' => $select->show($view == 'table' ? 'list' : $view), ); } if (!isset($no_override['calendar_timeslots'])) { if (!$p['current']) { $p['blocks']['view']['content'] = true; return $p; } $field_id = 'rcmfd_timeslot'; $choices = array('1', '2', '3', '4', '6'); $select = new html_select(array('name' => '_timeslots', 'id' => $field_id)); $select->add($choices); $p['blocks']['view']['options']['timeslots'] = array( 'title' => html::label($field_id, rcube::Q($this->gettext('timeslots'))), 'content' => $select->show(strval($this->rc->config->get('calendar_timeslots', $this->defaults['calendar_timeslots']))), ); } if (!isset($no_override['calendar_first_day'])) { if (!$p['current']) { $p['blocks']['view']['content'] = true; return $p; } $field_id = 'rcmfd_firstday'; $select = new html_select(array('name' => '_first_day', 'id' => $field_id)); $select->add($this->gettext('sunday'), '0'); $select->add($this->gettext('monday'), '1'); $select->add($this->gettext('tuesday'), '2'); $select->add($this->gettext('wednesday'), '3'); $select->add($this->gettext('thursday'), '4'); $select->add($this->gettext('friday'), '5'); $select->add($this->gettext('saturday'), '6'); $p['blocks']['view']['options']['first_day'] = array( 'title' => html::label($field_id, rcube::Q($this->gettext('first_day'))), 'content' => $select->show(strval($this->rc->config->get('calendar_first_day', $this->defaults['calendar_first_day']))), ); } if (!isset($no_override['calendar_first_hour'])) { if (!$p['current']) { $p['blocks']['view']['content'] = true; return $p; } $time_format = $this->rc->config->get('time_format', libcalendaring::to_php_date_format($this->rc->config->get('calendar_time_format', $this->defaults['calendar_time_format']))); $select_hours = new html_select(); for ($h = 0; $h < 24; $h++) $select_hours->add(date($time_format, mktime($h, 0, 0)), $h); $field_id = 'rcmfd_firsthour'; $p['blocks']['view']['options']['first_hour'] = array( 'title' => html::label($field_id, rcube::Q($this->gettext('first_hour'))), 'content' => $select_hours->show($this->rc->config->get('calendar_first_hour', $this->defaults['calendar_first_hour']), array('name' => '_first_hour', 'id' => $field_id)), ); } if (!isset($no_override['calendar_work_start'])) { if (!$p['current']) { $p['blocks']['view']['content'] = true; return $p; } $field_id = 'rcmfd_workstart'; $work_start = $this->rc->config->get('calendar_work_start', $this->defaults['calendar_work_start']); $work_end = $this->rc->config->get('calendar_work_end', $this->defaults['calendar_work_end']); $p['blocks']['view']['options']['workinghours'] = array( 'title' => html::label($field_id, rcube::Q($this->gettext('workinghours'))), 'content' => html::div('input-group', $select_hours->show($work_start, array('name' => '_work_start', 'id' => $field_id)) . html::span('input-group-append input-group-prepend', html::span('input-group-text',' — ')) . $select_hours->show($work_end, array('name' => '_work_end', 'id' => $field_id)) ) ); } if (!isset($no_override['calendar_event_coloring'])) { if (!$p['current']) { $p['blocks']['view']['content'] = true; return $p; } $field_id = 'rcmfd_coloring'; $select_colors = new html_select(array('name' => '_event_coloring', 'id' => $field_id)); $select_colors->add($this->gettext('coloringmode0'), 0); $select_colors->add($this->gettext('coloringmode1'), 1); $select_colors->add($this->gettext('coloringmode2'), 2); $select_colors->add($this->gettext('coloringmode3'), 3); $p['blocks']['view']['options']['eventcolors'] = array( 'title' => html::label($field_id, rcube::Q($this->gettext('eventcoloring'))), 'content' => $select_colors->show($this->rc->config->get('calendar_event_coloring', $this->defaults['calendar_event_coloring'])), ); } // 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 (!$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(array('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(array('name' => '_alarm_value', 'id' => $field_id . 'value', 'size' => 3)); $select_offset = new html_select(array('name' => '_alarm_offset', 'id' => $field_id . 'offset')); foreach (array('-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'] = array( '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 (!$p['current']) { $p['blocks']['view']['content'] = true; return $p; } // default calendar selection $field_id = 'rcmfd_default_calendar'; $filter = calendar_driver::FILTER_PERSONAL | calendar_driver::FILTER_ACTIVE | calendar_driver::FILTER_INSERTABLE; $select_cal = new html_select(array('name' => '_default_calendar', 'id' => $field_id, 'is_escaped' => true)); foreach ((array)$this->driver->list_calendars($filter) as $id => $prop) { $select_cal->add($prop['name'], strval($id)); if ($prop['default']) $default_calendar = $id; } $p['blocks']['view']['options']['defaultcalendar'] = array( 'title' => html::label($field_id, rcube::Q($this->gettext('defaultcalendar'))), 'content' => $select_cal->show($this->rc->config->get('calendar_default_calendar', $default_calendar)), ); } if (!isset($no_override['calendar_show_weekno'])) { if (!$p['current']) { $p['blocks']['view']['content'] = true; return $p; } $field_id = 'rcmfd_show_weekno'; $select = new html_select(array('name' => '_show_weekno', 'id' => $field_id)); $select->add($this->gettext('weeknonone'), -1); $select->add($this->gettext('weeknodatepicker'), 0); $select->add($this->gettext('weeknoall'), 1); $p['blocks']['view']['options']['show_weekno'] = array( 'title' => html::label($field_id, rcube::Q($this->gettext('showweekno'))), 'content' => $select->show(intval($this->rc->config->get('calendar_show_weekno'))), ); } $p['blocks']['itip']['name'] = $this->gettext('itipoptions'); // Invitations handling if (!isset($no_override['calendar_itip_after_action'])) { if (!$p['current']) { $p['blocks']['itip']['content'] = true; return $p; } $field_id = 'rcmfd_after_action'; $select = new html_select(array('name' => '_after_action', 'id' => $field_id, 'onchange' => "\$('#{$field_id}_select')[this.value == 4 ? 'show' : 'hide']()")); $select->add($this->gettext('afternothing'), ''); $select->add($this->gettext('aftertrash'), 1); $select->add($this->gettext('afterdelete'), 2); $select->add($this->gettext('afterflagdeleted'), 3); $select->add($this->gettext('aftermoveto'), 4); $val = $this->rc->config->get('calendar_itip_after_action', $this->defaults['calendar_itip_after_action']); if ($val !== null && $val !== '' && !is_int($val)) { $folder = $val; $val = 4; } $folders = $this->rc->folder_selector(array( 'id' => $field_id . '_select', 'name' => '_after_action_folder', 'maxlength' => 30, 'folder_filter' => 'mail', 'folder_rights' => 'w', 'style' => $val !== 4 ? 'display:none' : '', )); $p['blocks']['itip']['options']['after_action'] = array( 'title' => html::label($field_id, rcube::Q($this->gettext('afteraction'))), 'content' => html::div('input-group input-group-combo', $select->show($val) . $folders->show($folder)), ); } // category definitions if (!$this->driver->nocategories && !isset($no_override['calendar_categories'])) { $p['blocks']['categories']['name'] = $this->gettext('categories'); if (!$p['current']) { $p['blocks']['categories']['content'] = true; return $p; } $categories = (array) $this->driver->list_categories(); $categories_list = ''; foreach ($categories as $name => $color) { $key = md5($name); $field_class = 'rcmfd_category_' . str_replace(' ', '_', $name); $category_remove = html::span('input-group-append', html::a(array( 'class' => 'button icon delete input-group-text', 'onclick' => '$(this).parent().parent().remove()', 'title' => $this->gettext('remove_category'), 'href' => '#rcmfd_new_category', ), html::span('inner', $this->gettext('delete')) )); $category_name = new html_inputfield(array('name' => "_categories[$key]", 'class' => $field_class, 'size' => 30, 'disabled' => $this->driver->categoriesimmutable)); $category_color = new html_inputfield(array('name' => "_colors[$key]", 'class' => "$field_class colors", 'size' => 6)); $hidden = $this->driver->categoriesimmutable ? html::tag('input', array('type' => 'hidden', 'name' => "_categories[$key]", 'value' => $name)) : ''; $categories_list .= $hidden . html::div('input-group', $category_name->show($name) . $category_color->show($color) . $category_remove); } $p['blocks']['categories']['options']['category_' . $name] = array( 'content' => html::div(array('id' => 'calendarcategories'), $categories_list), ); $field_id = 'rcmfd_new_category'; $new_category = new html_inputfield(array('name' => '_new_category', 'id' => $field_id, 'size' => 30)); $add_category = html::span('input-group-append', html::a(array( 'type' => 'button', 'class' => 'button create input-group-text', 'title' => $this->gettext('add_category'), 'onclick' => 'rcube_calendar_add_category()', 'href' => '#rcmfd_new_category', ), html::span('inner', $this->gettext('add_category')) )); $p['blocks']['categories']['options']['categories'] = array( 'content' => html::div('input-group', $new_category->show('') . $add_category), ); $this->rc->output->add_label('delete', 'calendar.remove_category'); $this->rc->output->add_script('function rcube_calendar_add_category() { var name = $("#rcmfd_new_category").val(); if (name.length) { var button_label = rcmail.gettext("calendar.remove_category"); var input = $("").attr({type: "text", name: "_categories[]", size: 30, "class": "form-control"}).val(name); var color = $("").attr({type: "text", name: "_colors[]", size: 6, "class": "colors form-control"}).val("000000"); var button = $("").attr({"class": "button icon delete input-group-text", title: button_label, href: "#rcmfd_new_category"}) .click(function() { $(this).parent().parent().remove(); }) .append($("").addClass("inner").text(rcmail.gettext("delete"))); $("
").addClass("input-group").append(input).append(color).append($("").append(button)) .appendTo("#calendarcategories"); color.minicolors(rcmail.env.minicolors_config || {}); $("#rcmfd_new_category").val(""); } }', 'foot'); $this->rc->output->add_script('$("#rcmfd_new_category").keypress(function(event) { if (event.which == 13) { rcube_calendar_add_category(); event.preventDefault(); } }); ', 'docready'); // load miniColors js/css files jqueryui::miniColors(); } // virtual birthdays calendar if (!isset($no_override['calendar_contact_birthdays'])) { $p['blocks']['birthdays']['name'] = $this->gettext('birthdayscalendar'); if (!$p['current']) { $p['blocks']['birthdays']['content'] = true; return $p; } $field_id = 'rcmfd_contact_birthdays'; $input = new html_checkbox(array('name' => '_contact_birthdays', 'id' => $field_id, 'value' => 1, 'onclick' => '$(".calendar_birthday_props").prop("disabled",!this.checked)')); $p['blocks']['birthdays']['options']['contact_birthdays'] = array( 'title' => html::label($field_id, $this->gettext('displaybirthdayscalendar')), 'content' => $input->show($this->rc->config->get('calendar_contact_birthdays')?1:0), ); $input_attrib = array( 'class' => 'calendar_birthday_props', 'disabled' => !$this->rc->config->get('calendar_contact_birthdays'), ); $sources = array(); $checkbox = new html_checkbox(array('name' => '_birthday_adressbooks[]') + $input_attrib); foreach ($this->rc->get_address_sources(false, true) as $source) { $active = in_array($source['id'], (array)$this->rc->config->get('calendar_birthday_adressbooks', array())) ? $source['id'] : ''; $sources[] = html::tag('li', null, html::label(null, $checkbox->show($active, array('value' => $source['id'])) . rcube::Q($source['realname'] ?: $source['name']))); } $p['blocks']['birthdays']['options']['birthday_adressbooks'] = array( 'title' => rcube::Q($this->gettext('birthdayscalendarsources')), 'content' => html::tag('ul', 'proplist', implode("\n", $sources)), ); $field_id = 'rcmfd_birthdays_alarm'; $select_type = new html_select(array('name' => '_birthdays_alarm_type', 'id' => $field_id) + $input_attrib); $select_type->add($this->gettext('none'), ''); foreach ($this->driver->alarm_types as $type) { $select_type->add($this->rc->gettext(strtolower("alarm{$type}option"), 'libcalendaring'), $type); } $input_value = new html_inputfield(array('name' => '_birthdays_alarm_value', 'id' => $field_id . 'value', 'size' => 3) + $input_attrib); $select_offset = new html_select(array('name' => '_birthdays_alarm_offset', 'id' => $field_id . 'offset') + $input_attrib); foreach (array('-M','-H','-D') as $trigger) $select_offset->add($this->rc->gettext('trigger' . $trigger, 'libcalendaring'), $trigger); $preset = libcalendaring::parse_alarm_value($this->rc->config->get('calendar_birthdays_alarm_offset', '-1D')); $preset_type = $this->rc->config->get('calendar_birthdays_alarm_type', ''); $p['blocks']['birthdays']['options']['birthdays_alarmoffset'] = array( 'title' => html::label($field_id, rcube::Q($this->gettext('showalarms'))), 'content' => html::div('input-group', $select_type->show($preset_type) . $input_value->show($preset[0]) . ' ' . $select_offset->show($preset[1])), ); } return $p; } /** * Handler for preferences_save hook. * Executed on Calendar settings form submit. * * @param array Original parameters * @return array Modified parameters */ function preferences_save($p) { if ($p['section'] == 'calendar') { $this->load_driver(); // compose default alarm preset value $alarm_offset = rcube_utils::get_input_value('_alarm_offset', rcube_utils::INPUT_POST); $alarm_value = rcube_utils::get_input_value('_alarm_value', rcube_utils::INPUT_POST); $default_alarm = $alarm_offset[0] . intval($alarm_value) . $alarm_offset[1]; $birthdays_alarm_offset = rcube_utils::get_input_value('_birthdays_alarm_offset', rcube_utils::INPUT_POST); $birthdays_alarm_value = rcube_utils::get_input_value('_birthdays_alarm_value', rcube_utils::INPUT_POST); $birthdays_alarm_value = $birthdays_alarm_offset[0] . intval($birthdays_alarm_value) . $birthdays_alarm_offset[1]; $p['prefs'] = array( 'calendar_default_view' => rcube_utils::get_input_value('_default_view', rcube_utils::INPUT_POST), 'calendar_timeslots' => intval(rcube_utils::get_input_value('_timeslots', rcube_utils::INPUT_POST)), 'calendar_first_day' => intval(rcube_utils::get_input_value('_first_day', rcube_utils::INPUT_POST)), 'calendar_first_hour' => intval(rcube_utils::get_input_value('_first_hour', rcube_utils::INPUT_POST)), 'calendar_work_start' => intval(rcube_utils::get_input_value('_work_start', rcube_utils::INPUT_POST)), 'calendar_work_end' => intval(rcube_utils::get_input_value('_work_end', rcube_utils::INPUT_POST)), 'calendar_show_weekno' => intval(rcube_utils::get_input_value('_show_weekno', rcube_utils::INPUT_POST)), 'calendar_event_coloring' => intval(rcube_utils::get_input_value('_event_coloring', rcube_utils::INPUT_POST)), 'calendar_default_alarm_type' => rcube_utils::get_input_value('_alarm_type', rcube_utils::INPUT_POST), 'calendar_default_alarm_offset' => $default_alarm, 'calendar_default_calendar' => rcube_utils::get_input_value('_default_calendar', rcube_utils::INPUT_POST), 'calendar_date_format' => null, // clear previously saved values 'calendar_time_format' => null, 'calendar_contact_birthdays' => rcube_utils::get_input_value('_contact_birthdays', rcube_utils::INPUT_POST) ? true : false, 'calendar_birthday_adressbooks' => (array) rcube_utils::get_input_value('_birthday_adressbooks', rcube_utils::INPUT_POST), 'calendar_birthdays_alarm_type' => rcube_utils::get_input_value('_birthdays_alarm_type', rcube_utils::INPUT_POST), 'calendar_birthdays_alarm_offset' => $birthdays_alarm_value ?: null, 'calendar_itip_after_action' => intval(rcube_utils::get_input_value('_after_action', rcube_utils::INPUT_POST)), ); if ($p['prefs']['calendar_itip_after_action'] == 4) { $p['prefs']['calendar_itip_after_action'] = rcube_utils::get_input_value('_after_action_folder', rcube_utils::INPUT_POST, true); } // categories if (!$this->driver->nocategories) { $old_categories = $new_categories = array(); foreach ($this->driver->list_categories() as $name => $color) { $old_categories[md5($name)] = $name; } $categories = (array) rcube_utils::get_input_value('_categories', rcube_utils::INPUT_POST); $colors = (array) rcube_utils::get_input_value('_colors', rcube_utils::INPUT_POST); foreach ($categories as $key => $name) { if (!isset($colors[$key])) { continue; } $color = preg_replace('/^#/', '', strval($colors[$key])); // rename categories in existing events -> driver's job if ($oldname = $old_categories[$key]) { $this->driver->replace_category($oldname, $name, $color); unset($old_categories[$key]); } else $this->driver->add_category($name, $color); $new_categories[$name] = $color; } // these old categories have been removed, alter events accordingly -> driver's job foreach ((array)$old_categories[$key] as $key => $name) { $this->driver->remove_category($name); } $p['prefs']['calendar_categories'] = $new_categories; } } return $p; } /** * Dispatcher for calendar actions initiated by the client */ function calendar_action() { $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $cal = rcube_utils::get_input_value('c', rcube_utils::INPUT_GPC); $success = $reload = false; if (isset($cal['showalarms'])) $cal['showalarms'] = intval($cal['showalarms']); switch ($action) { case "form-new": case "form-edit": echo $this->ui->calendar_editform($action, $cal); exit; case "new": $success = $this->driver->create_calendar($cal); $reload = true; break; case "edit": $success = $this->driver->edit_calendar($cal); $reload = true; break; case "delete": if ($success = $this->driver->delete_calendar($cal)) $this->rc->output->command('plugin.destroy_source', array('id' => $cal['id'])); break; case "subscribe": if (!$this->driver->subscribe_calendar($cal)) $this->rc->output->show_message($this->gettext('errorsaving'), 'error'); else { $calendars = $this->driver->list_calendars(); $calendar = $calendars[$cal['id']]; // find parent folder and check if it's a "user calendar" // if it's also activated we need to refresh it (#5340) while ($calendar['parent']) { if (isset($calendars[$calendar['parent']])) $calendar = $calendars[$calendar['parent']]; else break; } if ($calendar['id'] != $cal['id'] && $calendar['active'] && $calendar['group'] == "other user") $this->rc->output->command('plugin.refresh_source', $calendar['id']); } return; case "search": $results = array(); $color_mode = $this->rc->config->get('calendar_event_coloring', $this->defaults['calendar_event_coloring']); $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); foreach ((array) $this->driver->search_calendars($query, $source) as $id => $prop) { $editname = $prop['editname']; unset($prop['editname']); // force full name to be displayed $prop['active'] = false; // let the UI generate HTML and CSS representation for this calendar $html = $this->ui->calendar_list_item($id, $prop, $jsenv); $cal = $jsenv[$id]; $cal['editname'] = $editname; $cal['html'] = $html; if (!empty($prop['color'])) $cal['css'] = $this->ui->calendar_css_classes($id, $prop, $color_mode); $results[] = $cal; } // report more results available if ($this->driver->search_more_results) $this->rc->output->show_message('autocompletemore', 'notice'); $this->rc->output->command('multi_thread_http_response', $results, rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC)); return; } if ($success) $this->rc->output->show_message('successfullysaved', 'confirmation'); else { $error_msg = $this->gettext('errorsaving') . ($this->driver->last_error ? ': ' . $this->driver->last_error :''); $this->rc->output->show_message($error_msg, 'error'); } $this->rc->output->command('plugin.unlock_saving'); if ($success && $reload) $this->rc->output->command('plugin.reload_view'); } /** * Dispatcher for event actions initiated by the client */ function event_action() { $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $event = rcube_utils::get_input_value('e', rcube_utils::INPUT_POST, true); $success = $reload = $got_msg = false; // force notify if hidden + active if ((int)$this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']) === 1) $event['_notify'] = 1; // read old event data in order to find changes if (($event['_notify'] || $event['_decline']) && $action != 'new') { $old = $this->driver->get_event($event); // load main event if savemode is 'all' or if deleting 'future' events if (($event['_savemode'] == 'all' || ($event['_savemode'] == 'future' && $action == 'remove' && !$event['_decline'])) && $old['recurrence_id']) { $old['id'] = $old['recurrence_id']; $old = $this->driver->get_event($old); } } switch ($action) { case "new": // create UID for new event $event['uid'] = $this->generate_uid(); if (!$this->write_preprocess($event, $action)) { $got_msg = true; } else if ($success = $this->driver->new_event($event)) { $event['id'] = $event['uid']; $event['_savemode'] = 'all'; $this->cleanup_event($event); $this->event_save_success($event, null, $action, true); } $reload = $success && $event['recurrence'] ? 2 : 1; break; case "edit": if (!$this->write_preprocess($event, $action)) { $got_msg = true; } else if ($success = $this->driver->edit_event($event)) { $this->cleanup_event($event); $this->event_save_success($event, $old, $action, $success); } $reload = $success && ($event['recurrence'] || $event['_savemode'] || $event['_fromcalendar']) ? 2 : 1; break; case "resize": if (!$this->write_preprocess($event, $action)) { $got_msg = true; } else if ($success = $this->driver->resize_event($event)) { $this->event_save_success($event, $old, $action, $success); } $reload = $event['_savemode'] ? 2 : 1; break; case "move": if (!$this->write_preprocess($event, $action)) { $got_msg = true; } else if ($success = $this->driver->move_event($event)) { $this->event_save_success($event, $old, $action, $success); } $reload = $success && $event['_savemode'] ? 2 : 1; break; case "remove": // remove previous deletes $undo_time = $this->driver->undelete ? $this->rc->config->get('undo_timeout', 0) : 0; $this->rc->session->remove('calendar_event_undo'); // search for event if only UID is given if (!isset($event['calendar']) && $event['uid']) { if (!($event = $this->driver->get_event($event, calendar_driver::FILTER_WRITEABLE))) { break; } $undo_time = 0; } $success = $this->driver->remove_event($event, $undo_time < 1); $reload = (!$success || $event['_savemode']) ? 2 : 1; if ($undo_time > 0 && $success) { $_SESSION['calendar_event_undo'] = array('ts' => time(), 'data' => $event); // display message with Undo link. $msg = html::span(null, $this->gettext('successremoval')) . ' ' . html::a(array('onclick' => sprintf("%s.http_request('event', 'action=undo', %s.display_message('', 'loading'))", rcmail_output::JS_OBJECT_NAME, rcmail_output::JS_OBJECT_NAME)), $this->gettext('undo')); $this->rc->output->show_message($msg, 'confirmation', null, true, $undo_time); $got_msg = true; } else if ($success) { $this->rc->output->show_message('calendar.successremoval', 'confirmation'); $got_msg = true; } // send cancellation for the main event if ($event['_savemode'] == 'all') { unset($old['_instance'], $old['recurrence_date'], $old['recurrence_id']); } // send an update for the main event's recurrence rule instead of a cancellation message else if ($event['_savemode'] == 'future' && $success !== false && $success !== true) { $event['_savemode'] = 'all'; // force event_save_success() to load master event $action = 'edit'; $success = true; } // send iTIP reply that participant has declined the event if ($success && $event['_decline']) { $emails = $this->get_user_emails(); foreach ($old['attendees'] as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER') $organizer = $attendee; else if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $old['attendees'][$i]['status'] = 'DECLINED'; $reply_sender = $attendee['email']; } } if ($event['_savemode'] == 'future' && $event['id'] != $old['id']) { $old['thisandfuture'] = true; } $itip = $this->load_itip(); $itip->set_sender_email($reply_sender); if ($organizer && $itip->send_itip_message($old, 'REPLY', $organizer, 'itipsubjectdeclined', 'itipmailbodydeclined')) $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ? $organizer['name'] : $organizer['email']))), 'confirmation'); else $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); } else if ($success) { $this->event_save_success($event, $old, $action, $success); } break; case "undo": // Restore deleted event $event = $_SESSION['calendar_event_undo']['data']; if ($event) $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' && $event['to']) { $itip = $this->load_itip(); if ($itip->delegate_to($ev, $event['to'], (bool)$event['rsvp'], $attendees)) { $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation'); $noreply = false; } } $event = $ev; // compose a list of attendees affected by this change $updated_attendees = array_filter(array_map(function($j) use ($event) { return $event['attendees'][$j]; }, $attendees)); if ($success = $this->driver->edit_rsvp($event, $status, $updated_attendees)) { $noreply = rcube_utils::get_input_value('noreply', rcube_utils::INPUT_GPC); $noreply = intval($noreply) || $status == 'needs-action' || $itip_sending === 0; $reload = $event['calendar'] != $ev['calendar'] || $event['recurrence'] ? 2 : 1; $organizer = null; $emails = $this->get_user_emails(); foreach ($event['attendees'] as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER') { $organizer = $attendee; } else if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $reply_sender = $attendee['email']; } } if (!$noreply) { $itip = $this->load_itip(); $itip->set_sender_email($reply_sender); $event['thisandfuture'] = $event['_savemode'] == 'future'; if ($organizer && $itip->send_itip_message($event, 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status)) $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ? $organizer['name'] : $organizer['email']))), 'confirmation'); else $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); } // refresh all calendars if ($event['calendar'] != $ev['calendar']) { $this->rc->output->command('plugin.refresh_calendar', array('source' => null, 'refetch' => true)); $reload = 0; } } break; case "dismiss": $event['ids'] = explode(',', $event['id']); $plugin = $this->rc->plugins->exec_hook('dismiss_alarms', $event); $success = $plugin['success']; foreach ($event['ids'] as $id) { if (strpos($id, 'cal:') === 0) $success |= $this->driver->dismiss_alarm(substr($id, 4), $event['snooze']); } break; case "changelog": $data = $this->driver->get_event_changelog($event); if (is_array($data) && !empty($data)) { $lib = $this->lib; $dtformat = $this->rc->config->get('date_format') . ' ' . $this->rc->config->get('time_format'); array_walk($data, function(&$change) use ($lib, $dtformat) { if ($change['date']) { $dt = $lib->adjust_timezone($change['date']); if ($dt instanceof DateTime) $change['date'] = $this->rc->format_date($dt, $dtformat, false); } }); $this->rc->output->command('plugin.render_event_changelog', $data); } else { $this->rc->output->command('plugin.render_event_changelog', false); } $got_msg = true; $reload = false; break; case "diff": $data = $this->driver->get_event_diff($event, $event['rev1'], $event['rev2']); if (is_array($data)) { // convert some properties, similar to self::_client_event() $lib = $this->lib; array_walk($data['changes'], function(&$change, $i) use ($event, $lib) { // convert date cols foreach (array('start','end','created','changed') as $col) { if ($change['property'] == $col) { $change['old'] = $lib->adjust_timezone($change['old'], strlen($change['old']) == 10)->format('c'); $change['new'] = $lib->adjust_timezone($change['new'], strlen($change['new']) == 10)->format('c'); } } // create textual representation for alarms and recurrence if ($change['property'] == 'alarms') { if (is_array($change['old'])) $change['old_'] = libcalendaring::alarm_text($change['old']); if (is_array($change['new'])) $change['new_'] = libcalendaring::alarm_text(array_merge((array)$change['old'], $change['new'])); } if ($change['property'] == 'recurrence') { if (is_array($change['old'])) $change['old_'] = $lib->recurrence_text($change['old']); if (is_array($change['new'])) $change['new_'] = $lib->recurrence_text(array_merge((array)$change['old'], $change['new'])); } if ($change['property'] == 'attachments') { if (is_array($change['old'])) $change['old']['classname'] = rcube_utils::file2class($change['old']['mimetype'], $change['old']['name']); if (is_array($change['new'])) $change['new']['classname'] = rcube_utils::file2class($change['new']['mimetype'], $change['new']['name']); } // compute a nice diff of description texts if ($change['property'] == 'description') { $change['diff_'] = libkolab::html_diff($change['old'], $change['new']); } }); $this->rc->output->command('plugin.event_show_diff', $data); } 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; $this->rc->output->command('display_message', $this->gettext(array('name' => 'objectrestoresuccess', 'vars' => array('rev' => $event['rev']))), 'confirmation'); $this->rc->output->command('plugin.close_history_dialog'); } else { $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error'); $reload = 0; } $got_msg = true; break; } // show confirmation/error message if (!$got_msg) { if ($success) $this->rc->output->show_message('successfullysaved', 'confirmation'); else $this->rc->output->show_message('calendar.errorsaving', 'error'); } // unlock client $this->rc->output->command('plugin.unlock_saving', $success); // update event object on the client or trigger a complete refresh if too complicated if ($reload && empty($_REQUEST['_framed'])) { $args = array('source' => $event['calendar']); if ($reload > 1) $args['refetch'] = true; else if ($success && $action != 'remove') $args['update'] = $this->_client_event($this->driver->get_event($event), true); $this->rc->output->command('plugin.refresh_calendar', $args); } } /** * Helper method sending iTip notifications after successful event updates */ private function event_save_success(&$event, $old, $action, $success) { // $success is a new event ID if ($success !== true) { // send update notification on the main event if ($event['_savemode'] == 'future' && $event['_notify'] && $old['attendees'] && $old['recurrence_id']) { $master = $this->driver->get_event(array('id' => $old['recurrence_id'], 'calendar' => $old['calendar']), 0, true); unset($master['_instance'], $master['recurrence_date']); $sent = $this->notify_attendees($master, null, $action, $event['_comment'], false); if ($sent < 0) $this->rc->output->show_message('calendar.errornotifying', 'error'); $event['attendees'] = $master['attendees']; // this tricks us into the next if clause } // delete old reference if saved as new if ($event['_savemode'] == 'future' || $event['_savemode'] == 'new') { $old = null; } $event['id'] = $success; $event['_savemode'] = 'all'; } // send out notifications if ($event['_notify'] && ($event['attendees'] || $old['attendees'])) { $_savemode = $event['_savemode']; // send notification for the main event when savemode is 'all' if ($action != 'remove' && $_savemode == 'all' && ($event['recurrence_id'] || $old['recurrence_id'] || ($old && $old['id'] != $event['id']))) { $event['id'] = $event['recurrence_id'] ?: ($old['recurrence_id'] ?: $old['id']); $event = $this->driver->get_event($event, 0, true); unset($event['_instance'], $event['recurrence_date']); } else { // make sure we have the complete record $event = $action == 'remove' ? $old : $this->driver->get_event($event, 0, true); } $event['_savemode'] = $_savemode; if ($old) { $old['thisandfuture'] = $_savemode == 'future'; } // only notify if data really changed (TODO: do diff check on client already) if (!$old || $action == 'remove' || self::event_diff($event, $old)) { $sent = $this->notify_attendees($event, $old, $action, $event['_comment']); if ($sent > 0) $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation'); else if ($sent < 0) $this->rc->output->show_message('calendar.errornotifying', 'error'); } } } /** * Handler for load-requests from fullcalendar * This will return pure JSON formatted output */ function load_events() { $start = rcube_utils::get_input_value('start', rcube_utils::INPUT_GET); $end = rcube_utils::get_input_value('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); if (!is_numeric($start) || strpos($start, 'T')) { $start = new DateTime($start, $this->timezone); $start = $start->getTimestamp(); } if (!is_numeric($end) || strpos($end, 'T')) { $end = new DateTime($end, $this->timezone); $end = $end->getTimestamp(); } $events = $this->driver->load_events($start, $end, $query, $source); echo $this->encode($events, !empty($query)); exit; } /** * Handler for requests fetching event counts for calendars */ public function count_events() { // don't update session on these requests (avoiding race conditions) $this->rc->session->nowrite = true; $start = rcube_utils::get_input_value('start', rcube_utils::INPUT_GET); if (!$start) { $start = new DateTime('today 00:00:00', $this->timezone); $start = $start->format('U'); } $counts = $this->driver->count_events( rcube_utils::get_input_value('source', rcube_utils::INPUT_GET), $start, rcube_utils::get_input_value('end', rcube_utils::INPUT_GET) ); $this->rc->output->command('plugin.update_counts', array('counts' => $counts)); } /** * Load event data from an iTip message attachment */ public function itip_events($msgref) { $path = explode('/', $msgref); $msg = array_pop($path); $mbox = join('/', $path); list($uid, $mime_id) = explode('#', $msg); $events = array(); if ($event = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event')) { $partstat = 'NEEDS-ACTION'; /* $user_emails = $this->lib->get_user_emails(); foreach ($event['attendees'] as $attendee) { if (in_array($attendee['email'], $user_emails)) { $partstat = $attendee['status']; break; } } */ $event['id'] = $event['uid']; $event['temporary'] = true; $event['readonly'] = true; $event['calendar'] = '--invitation--itip'; $event['className'] = 'fc-invitation-' . strtolower($partstat); $event['_mbox'] = $mbox; $event['_uid'] = $uid; $event['_part'] = $mime_id; $events[] = $this->_client_event($event, true); // add recurring instances if (!empty($event['recurrence'])) { foreach ($this->driver->get_recurring_events($event, $event['start']) as $recurring) { $recurring['temporary'] = true; $recurring['readonly'] = true; $recurring['calendar'] = '--invitation--itip'; $events[] = $this->_client_event($recurring, true); } } } return $events; } /** * Handler for keep-alive requests * This will check for updated data in active calendars and sync them to the client */ public function refresh($attr) { // refresh the entire calendar every 10th time to also sync deleted events if (rand(0,10) == 10) { $this->rc->output->command('plugin.refresh_calendar', array('refetch' => true)); return; } $counts = array(); foreach ($this->driver->list_calendars(calendar_driver::FILTER_ACTIVE) as $cal) { $events = $this->driver->load_events( rcube_utils::get_input_value('start', rcube_utils::INPUT_GPC), rcube_utils::get_input_value('end', rcube_utils::INPUT_GPC), rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC), $cal['id'], 1, $attr['last'] ); foreach ($events as $event) { $this->rc->output->command('plugin.refresh_calendar', array('source' => $cal['id'], 'update' => $this->_client_event($event))); } // refresh count for this calendar if ($cal['counts']) { $today = new DateTime('today 00:00:00', $this->timezone); $counts += $this->driver->count_events($cal['id'], $today->format('U')); } } if (!empty($counts)) { $this->rc->output->command('plugin.update_counts', array('counts' => $counts)); } } /** * Handler for pending_alarms plugin hook triggered by the calendar module on keep-alive requests. * This will check for pending notifications and pass them to the client */ public function pending_alarms($p) { $this->load_driver(); $time = $p['time'] ?: time(); if ($alarms = $this->driver->pending_alarms($time)) { foreach ($alarms as $alarm) { $alarm['id'] = 'cal:' . $alarm['id']; // prefix ID with cal: $p['alarms'][] = $alarm; } } // get alarms for birthdays calendar if ($this->rc->config->get('calendar_contact_birthdays') && $this->rc->config->get('calendar_birthdays_alarm_type') == 'DISPLAY') { $cache = $this->rc->get_cache('calendar.birthdayalarms', 'db'); foreach ($this->driver->load_birthday_events($time, $time + 86400 * 60) as $e) { $alarm = libcalendaring::get_next_alarm($e); // overwrite alarm time with snooze value (or null if dismissed) if ($dismissed = $cache->get($e['id'])) $alarm['time'] = $dismissed['notifyat']; // add to list if alarm is set if ($alarm && $alarm['time'] && $alarm['time'] <= $time) { $e['id'] = 'cal:bday:' . $e['id']; $e['notifyat'] = $alarm['time']; $p['alarms'][] = $e; } } } return $p; } /** * Handler for alarm dismiss hook triggered by libcalendaring */ public function dismiss_alarms($p) { $this->load_driver(); foreach ((array)$p['ids'] as $id) { if (strpos($id, 'cal:bday:') === 0) { $p['success'] |= $this->driver->dismiss_birthday_alarm(substr($id, 9), $p['snooze']); } else if (strpos($id, 'cal:') === 0) { $p['success'] |= $this->driver->dismiss_alarm(substr($id, 4), $p['snooze']); } } return $p; } /** * Handler for check-recent requests which are accidentally sent to calendar */ function check_recent() { // NOP $this->rc->output->send(); } /** * Hook triggered when a contact is saved */ function contact_update($p) { // clear birthdays calendar cache if (!empty($p['record']['birthday'])) { $cache = $this->rc->get_cache('calendar.birthdays', 'db'); $cache->remove(); } } /** * */ function import_events() { // Upload progress update if (!empty($_GET['_progress'])) { $this->rc->upload_progress(); } @set_time_limit(0); // process uploaded file if there is no error $err = $_FILES['_data']['error']; if (!$err && $_FILES['_data']['tmp_name']) { $calendar = rcube_utils::get_input_value('calendar', rcube_utils::INPUT_GPC); $rangestart = $_REQUEST['_range'] ? date_create("now -" . intval($_REQUEST['_range']) . " months") : 0; // extract zip file if ($_FILES['_data']['type'] == 'application/zip') { $count = 0; if (class_exists('ZipArchive', false)) { $zip = new ZipArchive(); if ($zip->open($_FILES['_data']['tmp_name'])) { $randname = uniqid('zip-' . session_id(), true); $tmpdir = slashify($this->rc->config->get('temp_dir', sys_get_temp_dir())) . $randname; mkdir($tmpdir, 0700); // extract each ical file from the archive and import it for ($i = 0; $i < $zip->numFiles; $i++) { $filename = $zip->getNameIndex($i); if (preg_match('/\.ics$/i', $filename)) { $tmpfile = $tmpdir . '/' . basename($filename); if (copy('zip://' . $_FILES['_data']['tmp_name'] . '#'.$filename, $tmpfile)) { $count += $this->import_from_file($tmpfile, $calendar, $rangestart, $errors); unlink($tmpfile); } } } rmdir($tmpdir); $zip->close(); } else { $errors = 1; $msg = 'Failed to open zip file.'; } } else { $errors = 1; $msg = 'Zip files are not supported for import.'; } } else { // attempt to import teh uploaded file directly $count = $this->import_from_file($_FILES['_data']['tmp_name'], $calendar, $rangestart, $errors); } if ($count) { $this->rc->output->command('display_message', $this->gettext(array('name' => 'importsuccess', 'vars' => array('nr' => $count))), 'confirmation'); $this->rc->output->command('plugin.import_success', array('source' => $calendar, 'refetch' => true)); } else if (!$errors) { $this->rc->output->command('display_message', $this->gettext('importnone'), 'notice'); $this->rc->output->command('plugin.import_success', array('source' => $calendar)); } else { $this->rc->output->command('plugin.import_error', array('message' => $this->gettext('importerror') . ($msg ? ': ' . $msg : ''))); } } else { if ($err == UPLOAD_ERR_INI_SIZE || $err == UPLOAD_ERR_FORM_SIZE) { $msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array( 'size' => $this->rc->show_bytes(parse_bytes(ini_get('upload_max_filesize')))))); } else { $msg = $this->rc->gettext('fileuploaderror'); } $this->rc->output->command('plugin.import_error', array('message' => $msg)); } $this->rc->output->send('iframe'); } /** * Helper function to parse and import a single .ics file */ private function import_from_file($filepath, $calendar, $rangestart, &$errors) { $user_email = $this->rc->user->get_username(); $ical = $this->get_ical(); $errors = !$ical->fopen($filepath); $count = $i = 0; foreach ($ical as $event) { // keep the browser connection alive on long import jobs if (++$i > 100 && $i % 100 == 0) { echo ""; ob_flush(); } // TODO: correctly handle recurring events which start before $rangestart if ($event['end'] < $rangestart && (!$event['recurrence'] || ($event['recurrence']['until'] && $event['recurrence']['until'] < $rangestart))) continue; $event['_owner'] = $user_email; $event['calendar'] = $calendar; if ($this->driver->new_event($event)) { $count++; } else { $errors++; } } return $count; } /** * Construct the ics file for exporting events to iCalendar format; */ function export_events($terminate = true) { $start = rcube_utils::get_input_value('start', rcube_utils::INPUT_GET); $end = rcube_utils::get_input_value('end', rcube_utils::INPUT_GET); if (!isset($start)) $start = 'today -1 year'; if (!is_numeric($start)) $start = strtotime($start . ' 00:00:00'); if (!$end) $end = 'today +10 years'; if (!is_numeric($end)) $end = strtotime($end . ' 23:59:59'); $event_id = rcube_utils::get_input_value('id', rcube_utils::INPUT_GET); $attachments = rcube_utils::get_input_value('attachments', rcube_utils::INPUT_GET); $calid = $filename = rcube_utils::get_input_value('source', rcube_utils::INPUT_GET); $calendars = $this->driver->list_calendars(); $events = array(); if ($calendars[$calid]) { $filename = $calendars[$calid]['name'] ? $calendars[$calid]['name'] : $calid; $filename = asciiwords(html_entity_decode($filename)); // to 7bit ascii if (!empty($event_id)) { if ($event = $this->driver->get_event(array('calendar' => $calid, 'id' => $event_id), 0, true)) { if ($event['recurrence_id']) { $event = $this->driver->get_event(array('calendar' => $calid, 'id' => $event['recurrence_id']), 0, true); } $events = array($event); $filename = asciiwords($event['title']); if (empty($filename)) $filename = 'event'; } } else { $events = $this->driver->load_events($start, $end, null, $calid, 0); if (empty($filename)) $filename = $calid; } } header("Content-Type: text/calendar"); header("Content-Disposition: inline; filename=".$filename.'.ics'); $this->get_ical()->export($events, '', true, $attachments ? array($this->driver, 'get_attachment_body') : null); if ($terminate) exit; } /** * Handler for iCal feed requests */ function ical_feed_export() { $session_exists = !empty($_SESSION['user_id']); // process HTTP auth info if (!empty($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) { $_POST['_user'] = $_SERVER['PHP_AUTH_USER']; // used for rcmail::autoselect_host() $auth = $this->rc->plugins->exec_hook('authenticate', array( 'host' => $this->rc->autoselect_host(), 'user' => trim($_SERVER['PHP_AUTH_USER']), 'pass' => $_SERVER['PHP_AUTH_PW'], 'cookiecheck' => true, 'valid' => true, )); if ($auth['valid'] && !$auth['abort']) $this->rc->login($auth['user'], $auth['pass'], $auth['host']); } // require HTTP auth if (empty($_SESSION['user_id'])) { header('WWW-Authenticate: Basic realm="Roundcube Calendar"'); header('HTTP/1.0 401 Unauthorized'); exit; } // decode calendar feed hash $format = 'ics'; $calhash = rcube_utils::get_input_value('_cal', rcube_utils::INPUT_GET); if (preg_match(($suff_regex = '/\.([a-z0-9]{3,5})$/i'), $calhash, $m)) { $format = strtolower($m[1]); $calhash = preg_replace($suff_regex, '', $calhash); } if (!strpos($calhash, ':')) $calhash = base64_decode($calhash); list($user, $_GET['source']) = explode(':', $calhash, 2); // sanity check user if ($this->rc->user->get_username() == $user) { $this->setup(); $this->load_driver(); $this->export_events(false); } else { header('HTTP/1.0 404 Not Found'); } // don't save session data if (!$session_exists) session_destroy(); exit; } /** * */ function load_settings() { $this->lib->load_settings(); $this->defaults += $this->lib->defaults; $settings = array(); // configuration $settings['default_calendar'] = $this->rc->config->get('calendar_default_calendar'); $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['agenda_sections'] = $this->rc->config->get('calendar_agenda_sections', $this->defaults['calendar_agenda_sections']); $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['invitation_calendars'] = (bool)$this->rc->config->get('kolab_invitation_calendars', false); $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']); // get user identity to create default attendee if ($this->ui->screen == 'calendar') { foreach ($this->rc->user->list_emails() as $rec) { if (!$identity) $identity = $rec; $identity['emails'][] = $rec['email']; $settings['identities'][$rec['identity_id']] = $rec['email']; } $identity['emails'][] = $this->rc->user->get_username(); $settings['identity'] = array('name' => $identity['name'], 'email' => strtolower($identity['email']), 'emails' => ';' . strtolower(join(';', $identity['emails']))); } // freebusy token authentication URL if (($url = $this->rc->config->get('calendar_freebusy_session_auth_url')) && ($uniqueid = $this->rc->config->get('kolab_uniqueid')) ) { if ($url === true) $url = '/freebusy'; $url = rtrim(rcube_utils::resolve_url($url), '/ '); $url .= '/' . urlencode($this->rc->get_user_name()); $url .= '/' . urlencode($uniqueid); $settings['freebusy_url'] = $url; } return $settings; } /** * Encode events as JSON * * @param array Events as array * @param boolean Add CSS class names according to calendar and categories * @return string JSON encoded events */ function encode($events, $addcss = false) { $json = array(); foreach ($events as $event) { $json[] = $this->_client_event($event, $addcss); } return rcube_output::json_serialize($json); } /** * Convert an event object to be used on the client */ private function _client_event($event, $addcss = false) { // compose a human readable strings for alarms_text and recurrence_text if ($event['valarms']) { $event['alarms_text'] = libcalendaring::alarms_text($event['valarms']); $event['valarms'] = libcalendaring::to_client_alarms($event['valarms']); } if ($event['recurrence']) { $event['recurrence_text'] = $this->lib->recurrence_text($event['recurrence']); $event['recurrence'] = $this->lib->to_client_recurrence($event['recurrence'], $event['allday']); unset($event['recurrence_date']); } foreach ((array)$event['attachments'] as $k => $attachment) { $event['attachments'][$k]['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); unset($event['attachments'][$k]['data'], $event['attachments'][$k]['content']); if (!$attachment['id']) { $event['attachments'][$k]['id'] = $k; } } // convert link URIs references into structs if (array_key_exists('links', $event)) { foreach ((array) $event['links'] as $i => $link) { if (strpos($link, 'imap://') === 0 && ($msgref = $this->driver->get_message_reference($link))) { $event['links'][$i] = $msgref; } } } // check for organizer in attendees list $organizer = null; foreach ((array)$event['attendees'] as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER') { $organizer = $attendee; } if ($attendee['status'] == 'DELEGATED' && $attendee['rsvp'] == false) { $event['attendees'][$i]['noreply'] = true; } else { unset($event['attendees'][$i]['noreply']); } } if ($organizer === null && !empty($event['organizer'])) { $organizer = $event['organizer']; $organizer['role'] = 'ORGANIZER'; if (!is_array($event['attendees'])) $event['attendees'] = array(); array_unshift($event['attendees'], $organizer); } // Convert HTML description into plain text if ($this->is_html($event)) { $h2t = new rcube_html2text($event['description'], false, true, 0); $event['description'] = trim($h2t->get_text()); } // mapping url => vurl, allday => allDay because of the fullcalendar client script $event['vurl'] = $event['url']; $event['allDay'] = !empty($event['allday']); unset($event['url']); unset($event['allday']); $event['className'] = $event['className'] ? explode(' ', $event['className']) : array(); if ($addcss) { $event['className'][] = 'fc-event-cal-' . asciiwords($event['calendar'], true); } if ($event['allDay']) { $event['end'] = $event['end']->add(new DateInterval('P1D')); } if ($_GET['mode'] == 'print') { $event['editable'] = false; } return array( '_id' => $event['calendar'] . ':' . $event['id'], // unique identifier for fullcalendar 'start' => $this->lib->adjust_timezone($event['start'], $event['allDay'])->format('c'), 'end' => $this->lib->adjust_timezone($event['end'], $event['allDay'])->format('c'), // 'changed' might be empty for event recurrences (Bug #2185) 'changed' => $event['changed'] ? $this->lib->adjust_timezone($event['changed'])->format('c') : null, 'created' => $event['created'] ? $this->lib->adjust_timezone($event['created'])->format('c') : null, 'title' => strval($event['title']), 'description' => strval($event['description']), 'location' => strval($event['location']), ) + $event; } /** * Generate a unique identifier for an event */ public function generate_uid() { return strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($this->rc->user->get_username()), 0, 16)); } /** * TEMPORARY: generate random event data for testing * Create events by opening http:///?_task=calendar&_action=randomdata&_num=500&_date=2014-08-01&_dev=120 */ public function generate_randomdata() { @set_time_limit(0); $num = $_REQUEST['_num'] ? intval($_REQUEST['_num']) : 100; $date = $_REQUEST['_date'] ?: 'now'; $dev = $_REQUEST['_dev'] ?: 30; $cats = array_keys($this->driver->list_categories()); $cals = $this->driver->list_calendars(calendar_driver::FILTER_ACTIVE); $count = 0; while ($count++ < $num) { $spread = intval($dev) * 86400; // days $refdate = strtotime($date); $start = round(($refdate + rand(-$spread, $spread)) / 600) * 600; $duration = round(rand(30, 360) / 30) * 30 * 60; $allday = rand(0,20) > 18; $alarm = rand(-30,12) * 5; $fb = rand(0,2); if (date('G', $start) > 23) $start -= 3600; if ($allday) { $start = strtotime(date('Y-m-d 00:00:00', $start)); $duration = 86399; } $title = ''; $len = rand(2, 12); $words = explode(" ", "The Hough transform is named after Paul Hough who patented the method in 1962. It is a technique which can be used to isolate features of a particular shape within an image. Because it requires that the desired features be specified in some parametric form, the classical Hough transform is most commonly used for the de- tection of regular curves such as lines, circles, ellipses, etc. A generalized Hough transform can be employed in applications where a simple analytic description of a feature(s) is not possible. Due to the computational complexity of the generalized Hough algorithm, we restrict the main focus of this discussion to the classical Hough transform. Despite its domain restrictions, the classical Hough transform (hereafter referred to without the classical prefix ) retains many applications, as most manufac- tured parts (and many anatomical parts investigated in medical imagery) contain feature boundaries which can be described by regular curves. The main advantage of the Hough transform technique is that it is tolerant of gaps in feature boundary descriptions and is relatively unaffected by image noise."); // $chars = "!# abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890"; for ($i = 0; $i < $len; $i++) $title .= $words[rand(0,count($words)-1)] . " "; $this->driver->new_event(array( 'uid' => $this->generate_uid(), 'start' => new DateTime('@'.$start), 'end' => new DateTime('@'.($start + $duration)), 'allday' => $allday, 'title' => rtrim($title), 'free_busy' => $fb == 2 ? 'outofoffice' : ($fb ? 'busy' : 'free'), 'categories' => $cats[array_rand($cats)], 'calendar' => array_rand($cals), 'alarms' => $alarm > 0 ? "-{$alarm}M:DISPLAY" : '', 'priority' => rand(0,9), )); } $this->rc->output->redirect(''); } /** * Handler for attachments upload */ public function attachment_upload() { $handler = new kolab_attachments_handler(); $handler->attachment_upload(self::SESSION_KEY, 'cal-'); } /** * Handler for attachments download/displaying */ public function attachment_get() { $handler = new kolab_attachments_handler(); // show loading page if (!empty($_GET['_preload'])) { return $handler->attachment_loading_page(); } $event_id = rcube_utils::get_input_value('_event', rcube_utils::INPUT_GPC); $calendar = rcube_utils::get_input_value('_cal', rcube_utils::INPUT_GPC); $id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); $rev = rcube_utils::get_input_value('_rev', rcube_utils::INPUT_GPC); $event = array('id' => $event_id, 'calendar' => $calendar, 'rev' => $rev); if ($calendar == '--invitation--itip') { $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_GPC); $part = rcube_utils::get_input_value('_part', rcube_utils::INPUT_GPC); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_GPC); $event = $this->lib->mail_get_itip_object($mbox, $uid, $part, 'event'); $attachment = $event['attachments'][$id]; $attachment['body'] = &$attachment['data']; } else { $attachment = $this->driver->get_attachment($id, $event); } // show part page if (!empty($_GET['_frame'])) { $handler->attachment_page($attachment); } // deliver attachment content else if ($attachment) { if ($calendar != '--invitation--itip') { $attachment['body'] = $this->driver->get_attachment_body($id, $event); } $handler->attachment_get($attachment); } // if we arrive here, the requested part was not found header('HTTP/1.1 404 Not Found'); exit; } /** * Determine whether the given event description is HTML formatted */ private function is_html($event) { // check for opening and closing or tags return (preg_match('/<(html|body)(\s+[a-z]|>)/', $event['description'], $m) && strpos($event['description'], '') > 0); } /** * Prepares new/edited event properties before save */ private function write_preprocess(&$event, $action) { // Remove double timezone specification (T2313) $event['start'] = preg_replace('/\s*\(.*\)/', '', $event['start']); $event['end'] = preg_replace('/\s*\(.*\)/', '', $event['end']); // convert dates into DateTime objects in user's current timezone $event['start'] = new DateTime($event['start'], $this->timezone); $event['end'] = new DateTime($event['end'], $this->timezone); $event['allday'] = !empty($event['allDay']); unset($event['allDay']); // start/end is all we need for 'move' action (#1480) if ($action == 'move') { return true; } // convert the submitted recurrence settings if (is_array($event['recurrence'])) { $event['recurrence'] = $this->lib->from_client_recurrence($event['recurrence'], $event['start']); // align start date with the first occurrence if (!empty($event['recurrence']) && !empty($event['syncstart']) && (empty($event['_savemode']) || $event['_savemode'] == 'all') ) { $next = $this->find_first_occurrence($event); if (!$next) { $this->rc->output->show_message('calendar.recurrenceerror', 'error'); return false; } else if ($event['start'] != $next) { $diff = $event['start']->diff($event['end'], true); $event['start'] = $next; $event['end'] = clone $next; $event['end']->add($diff); } } } // convert the submitted alarm values if ($event['valarms']) { $event['valarms'] = libcalendaring::from_client_alarms($event['valarms']); } $attachments = array(); $eventid = 'cal-'.$event['id']; if (is_array($_SESSION[self::SESSION_KEY]) && $_SESSION[self::SESSION_KEY]['id'] == $eventid) { if (!empty($_SESSION[self::SESSION_KEY]['attachments'])) { foreach ($_SESSION[self::SESSION_KEY]['attachments'] as $id => $attachment) { if (is_array($event['attachments']) && in_array($id, $event['attachments'])) { $attachments[$id] = $this->rc->plugins->exec_hook('attachment_get', $attachment); } } } } $event['attachments'] = $attachments; // convert link references into simple URIs if (array_key_exists('links', $event)) { $event['links'] = array_map(function($link) { return is_array($link) ? $link['uri'] : strval($link); }, (array)$event['links']); } // check for organizer in attendees if ($action == 'new' || $action == 'edit') { if (!$event['attendees']) $event['attendees'] = array(); $emails = $this->get_user_emails(); $organizer = $owner = false; foreach ((array)$event['attendees'] as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER') $organizer = $i; if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) $owner = $i; if (!isset($attendee['rsvp'])) $event['attendees'][$i]['rsvp'] = true; else if (is_string($attendee['rsvp'])) $event['attendees'][$i]['rsvp'] = $attendee['rsvp'] == 'true' || $attendee['rsvp'] == '1'; } if (!empty($event['_identity'])) { $identity = $this->rc->user->get_identity($event['_identity']); } // set new organizer identity if ($organizer !== false && $identity) { $event['attendees'][$organizer]['name'] = $identity['name']; $event['attendees'][$organizer]['email'] = $identity['email']; } // set owner as organizer if yet missing else if ($organizer === false && $owner !== false) { $event['attendees'][$owner]['role'] = 'ORGANIZER'; unset($event['attendees'][$owner]['rsvp']); } // fallback to the selected identity else if ($organizer === false && $identity) { $event['attendees'][] = array( 'role' => 'ORGANIZER', 'name' => $identity['name'], 'email' => $identity['email'], ); } } // mapping url => vurl because of the fullcalendar client script if (array_key_exists('vurl', $event)) { $event['url'] = $event['vurl']; unset($event['vurl']); } 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', array('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) { 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 = array(); foreach ((array)$old['attendees'] as $attendee) { $old_attendees[] = $attendee['email']; } // send to every attendee $sent = 0; $current = array(); foreach ((array)$event['attendees'] as $attendee) { $current[] = strtolower($attendee['email']); // skip myself for obvious reasons if (!$attendee['email'] || in_array(strtolower($attendee['email']), $emails)) continue; // skip if notification is disabled for this attendee if ($attendee['noreply'] && $itip_notify & 2) continue; // skip if this attendee has delegated and set RSVP=FALSE if ($attendee['status'] == 'DELEGATED' && $attendee['rsvp'] === false) continue; // which template to use for mail text $is_new = !in_array($attendee['email'], $old_attendees); $is_rsvp = $is_new || $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 foreach ((array)$old['attendees'] as $attendee) { if ($attendee['role'] == 'ORGANIZER' || !$attendee['email'] || in_array(strtolower($attendee['email']), $current)) continue; $vevent = $old; $vevent['cancelled'] = $is_cancelled; $vevent['attendees'] = array($attendee); $vevent['comment'] = $comment; if ($itip->send_itip_message($vevent, 'CANCEL', $attendee, 'eventcancelsubject', 'eventcancelmailbody')) $sent++; else $sent = -100; } return $sent; } /** * Echo simple free/busy status text for the given user and time range */ public function freebusy_status() { $email = rcube_utils::get_input_value('email', rcube_utils::INPUT_GPC); $start = rcube_utils::get_input_value('start', rcube_utils::INPUT_GPC); $end = rcube_utils::get_input_value('end', rcube_utils::INPUT_GPC); // convert dates into unix timestamps if (!empty($start) && !is_numeric($start)) { $dts = new DateTime($start, $this->timezone); $start = $dts->format('U'); } if (!empty($end) && !is_numeric($end)) { $dte = new DateTime($end, $this->timezone); $end = $dte->format('U'); } if (!$start) $start = time(); if (!$end) $end = $start + 3600; $fbtypemap = array(calendar::FREEBUSY_UNKNOWN => 'UNKNOWN', calendar::FREEBUSY_FREE => 'FREE', calendar::FREEBUSY_BUSY => 'BUSY', calendar::FREEBUSY_TENTATIVE => 'TENTATIVE', calendar::FREEBUSY_OOF => 'OUT-OF-OFFICE'); $status = 'UNKNOWN'; // 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) && $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 = rcube_utils::get_input_value('start', rcube_utils::INPUT_GPC); $end = rcube_utils::get_input_value('end', rcube_utils::INPUT_GPC); $interval = intval(rcube_utils::get_input_value('interval', rcube_utils::INPUT_GPC)); $strformat = $interval > 60 ? 'Ymd' : 'YmdHis'; // convert dates into unix timestamps if (!empty($start) && !is_numeric($start)) { $dts = rcube_utils::anytodatetime($start, $this->timezone); $start = $dts ? $dts->format('U') : null; } if (!empty($end) && !is_numeric($end)) { $dte = rcube_utils::anytodatetime($end, $this->timezone); $end = $dte ? $dte->format('U') : null; } 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(array( '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, array('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 (isset($_REQUEST['sections'])) - $this->rc->output->set_env('listSections', rcube_utils::get_input_value('sections', rcube_utils::INPUT_GPC)); - 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', array($this->ui, 'calendar_css')); $this->register_handler('plugin.calendar_list', array($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 = array(); $ignore = array('changed' => 1, 'attachments' => 1); foreach (array_unique(array_merge(array_keys($a), array_keys($b))) as $key) { if (!$ignore[$key] && $key[0] != '_' && $a[$key] != $b[$key]) $diff[] = $key; } // only compare number of attachments if (count($a['attachments']) != count($b['attachments'])) $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 = array($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 (is_object($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 = array(); if ($directory = $this->resources_directory()) { foreach ($directory->load_resources($search, $maxnum) as $rec) { $results[] = array( 'name' => $rec['name'], 'email' => $rec['email'], 'type' => $rec['_type'], ); } } $this->rc->output->command('ksearch_query_results', $results, $search, $sid); $this->rc->output->send(); } /** * Handler for load-requests for resource data */ function resources_list() { $data = array(); if ($directory = $this->resources_directory()) { foreach ($directory->load_resources() as $rec) { $data[] = $rec; } } $this->rc->output->command('plugin.resource_data', $data); $this->rc->output->send(); } /** * Handler for requests loading resource owner information */ function resources_owner() { if ($directory = $this->resources_directory()) { $id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); $data = $directory->get_resource_owner($id); } $this->rc->output->command('plugin.resource_owner', $data); $this->rc->output->send(); } /** * Deliver event data for a resource's calendar */ function resources_calendar() { $events = array(); if ($directory = $this->resources_directory()) { $events = $directory->get_resource_calendar( rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC), rcube_utils::get_input_value('start', rcube_utils::INPUT_GET), rcube_utils::get_input_value('end', rcube_utils::INPUT_GET)); } echo $this->encode($events); exit; } /**** Event invitation plugin hooks ****/ /** * Find an event in user calendars */ protected function find_event($event, &$mode) { $this->load_driver(); // We search for writeable calendars in personal namespace by default $mode = calendar_driver::FILTER_WRITEABLE | calendar_driver::FILTER_PERSONAL; $result = $this->driver->get_event($event, $mode); // ... now check shared folders if not found if (!$result) { $result = $this->driver->get_event($event, calendar_driver::FILTER_WRITEABLE | calendar_driver::FILTER_SHARED); if ($result) { $mode |= calendar_driver::FILTER_SHARED; } } return $result; } /** * Handler for calendar/itip-status requests */ function event_itip_status() { $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); $this->load_driver(); // find local copy of the referenced event (in personal namespace) $existing = $this->find_event($data, $mode); $is_shared = $mode & calendar_driver::FILTER_SHARED; $itip = $this->load_itip(); $response = $itip->get_itip_status($data, $existing); // get a list of writeable calendars to save new events to if ((!$existing || $is_shared) && !$data['nosave'] && ($response['action'] == 'rsvp' || $response['action'] == 'import') ) { $calendars = $this->driver->list_calendars($mode); $calendar_select = new html_select(array( 'name' => 'calendar', 'id' => 'itip-saveto', 'is_escaped' => true, 'class' => 'form-control custom-select' )); $calendar_select->add('--', ''); $numcals = 0; foreach ($calendars as $calendar) { if ($calendar['editable']) { $calendar_select->add($calendar['name'], $calendar['id']); $numcals++; } } if ($numcals < 1) $calendar_select = null; } if ($calendar_select) { $default_calendar = $this->get_default_calendar($data['sensitivity'], $calendars); $response['select'] = html::span('folder-select', $this->gettext('saveincalendar') . ' ' . $calendar_select->show($is_shared ? $existing['calendar'] : $default_calendar['id'])); } else if ($data['nosave']) { $response['select'] = html::tag('input', array('type' => 'hidden', 'name' => 'calendar', 'id' => 'itip-saveto', 'value' => '')); } // render small agenda view for the respective day if ($data['method'] == 'REQUEST' && !empty($data['date']) && $response['action'] == 'rsvp') { $event_start = rcube_utils::anytodatetime($data['date']); $day_start = new Datetime(gmdate('Y-m-d 00:00', $data['date']), $this->lib->timezone); $day_end = new Datetime(gmdate('Y-m-d 23:59', $data['date']), $this->lib->timezone); // get events on that day from the user's personal calendars $calendars = $this->driver->list_calendars(calendar_driver::FILTER_PERSONAL); $events = $this->driver->load_events($day_start->format('U'), $day_end->format('U'), null, array_keys($calendars)); usort($events, function($a, $b) { return $a['start'] > $b['start'] ? 1 : -1; }); $before = $after = array(); foreach ($events as $event) { // TODO: skip events with free_busy == 'free' ? if ($event['uid'] == $data['uid'] || $event['end'] < $day_start || $event['start'] > $day_end || $event['status'] == 'CANCELLED' || (!empty($event['className']) && strpos($event['className'], 'declined') !== false) ) { continue; } if ($event['start'] < $event_start) $before[] = $this->mail_agenda_event_row($event); else $after[] = $this->mail_agenda_event_row($event); } $response['append'] = array( 'selector' => '.calendar-agenda-preview', 'replacements' => array( '%before%' => !empty($before) ? join("\n", array_slice($before, -3)) : html::div('event-row no-event', $this->gettext('noearlierevents')), '%after%' => !empty($after) ? join("\n", array_slice($after, 0, 3)) : html::div('event-row no-event', $this->gettext('nolaterevents')), ), ); } $this->rc->output->command('plugin.update_itip_object_status', $response); } /** * Handler for calendar/itip-remove requests */ function event_itip_remove() { $success = false; $uid = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST); $instance = rcube_utils::get_input_value('_instance', rcube_utils::INPUT_POST); $savemode = rcube_utils::get_input_value('_savemode', rcube_utils::INPUT_POST); $listmode = calendar_driver::FILTER_WRITEABLE | calendar_driver::FILTER_PERSONAL; // search for event if only UID is given if ($event = $this->driver->get_event(array('uid' => $uid, '_instance' => $instance), $listmode)) { $event['_savemode'] = $savemode; $success = $this->driver->remove_event($event, true); } if ($success) { $this->rc->output->show_message('calendar.successremoval', 'confirmation'); } else { $this->rc->output->show_message('calendar.errorsaving', 'error'); } } /** * Handler for URLs that allow an invitee to respond on his invitation mail */ public function itip_attend_response($p) { $this->setup(); if ($p['action'] == 'attend') { $this->ui->init(); $this->rc->output->set_env('task', 'calendar'); // override some env vars $this->rc->output->set_env('refresh_interval', 0); $this->rc->output->set_pagetitle($this->gettext('calendar')); $itip = $this->load_itip(); $token = rcube_utils::get_input_value('_t', rcube_utils::INPUT_GPC); // read event info stored under the given token if ($invitation = $itip->get_invitation($token)) { $this->token = $token; $this->event = $invitation['event']; // show message about cancellation if ($invitation['cancelled']) { $this->invitestatus = html::div('rsvp-status declined', $itip->gettext('eventcancelled')); } // save submitted RSVP status else if (!empty($_POST['rsvp'])) { $status = null; foreach (array('accepted','tentative','declined') as $method) { if ($_POST['rsvp'] == $itip->gettext('itip' . $method)) { $status = $method; break; } } // send itip reply to organizer $invitation['event']['comment'] = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST); if ($status && $itip->update_invitation($invitation, $invitation['attendee'], strtoupper($status))) { $this->invitestatus = html::div('rsvp-status ' . strtolower($status), $itip->gettext('youhave'.strtolower($status))); } else $this->rc->output->command('display_message', $this->gettext('errorsaving'), 'error', -1); // if user is logged in... // FIXME: we should really consider removing this functionality // it's confusing that it creates/updates an event only for logged-in user // what if the logged-in user is not the same as the attendee? if ($this->rc->user->ID) { $this->load_driver(); $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'])) $this->rc->output->command('display_message', $this->gettext(array('name' => 'importedsuccessfully', 'vars' => array('calendar' => $calendar['name']))), 'confirmation'); else $this->rc->output->command('display_message', $this->gettext('errorimportingevent'), 'error'); } else if ($existing && ($this->event['sequence'] >= $existing['sequence'] || $this->event['changed'] >= $existing['changed']) && ($calendar = $this->driver->get_calendar($existing['calendar'])) ) { $this->event = $invitation['event']; $this->event['id'] = $existing['id']; unset($this->event['comment']); // merge attendees status // e.g. preserve my participant status for regular updates $this->lib->merge_attendees($this->event, $existing, $status); // update attachments list $event['deleted_attachments'] = true; // show me as free when declined (#1670) if ($status == 'declined') $this->event['free_busy'] = 'free'; if ($this->driver->edit_event($this->event)) $this->rc->output->command('display_message', $this->gettext(array('name' => 'updatedsuccessfully', 'vars' => array('calendar' => $calendar->get_name()))), 'confirmation'); else $this->rc->output->command('display_message', $this->gettext('errorimportingevent'), 'error'); } } } $this->register_handler('plugin.event_inviteform', array($this, 'itip_event_inviteform')); $this->register_handler('plugin.event_invitebox', array($this->ui, 'event_invitebox')); if (!$this->invitestatus) { $this->itip->set_rsvp_actions(array('accepted','tentative','declined')); $this->register_handler('plugin.event_rsvp_buttons', array($this->ui, 'event_rsvp_buttons')); } $this->rc->output->set_pagetitle($itip->gettext('itipinvitation') . ' ' . $this->event['title']); } else $this->rc->output->command('display_message', $this->gettext('itipinvalidrequest'), 'error', -1); $this->rc->output->send('calendar.itipattend'); } } /** * */ public function itip_event_inviteform($attrib) { $hidden = new html_hiddenfield(array('name' => "_t", 'value' => $this->token)); return html::tag('form', array('action' => $this->rc->url(array('task' => 'calendar', 'action' => 'attend')), 'method' => 'post', 'noclose' => true) + $attrib) . $hidden->show(); } /** * */ private function mail_agenda_event_row($event, $class = '') { $time = $event['allday'] ? $this->gettext('all-day') : $this->rc->format_date($event['start'], $this->rc->config->get('time_format')) . ' - ' . $this->rc->format_date($event['end'], $this->rc->config->get('time_format')); return html::div(rtrim('event-row ' . ($class ?: $event['className'])), html::span('event-date', $time) . html::span('event-title', rcube::Q($event['title'])) ); } /** * */ public function mail_messages_list($p) { if (in_array('attachment', (array)$p['cols']) && !empty($p['messages'])) { foreach ($p['messages'] as $header) { $part = new StdClass; $part->mimetype = $header->ctype; if (libcalendaring::part_is_vcalendar($part)) { $header->list_flags['attachmentClass'] = 'ical'; } else if (in_array($header->ctype, array('multipart/alternative', 'multipart/mixed'))) { // TODO: fetch bodystructure and search for ical parts. Maybe too expensive? if (!empty($header->structure) && is_array($header->structure->parts)) { foreach ($header->structure->parts as $part) { if (libcalendaring::part_is_vcalendar($part) && !empty($part->ctype_parameters['method'])) { $header->list_flags['attachmentClass'] = 'ical'; break; } } } } } } } /** * Add UI element to copy event invitations or updates to the calendar */ public function mail_messagebody_html($p) { // load iCalendar functions (if necessary) if (!empty($this->lib->ical_parts)) { $this->get_ical(); $this->load_itip(); } $html = ''; $has_events = false; $ical_objects = $this->lib->get_mail_ical_objects(); // show a box for every event in the file foreach ($ical_objects as $idx => $event) { if ($event['_type'] != 'event') // skip non-event objects (#2928) continue; $has_events = true; // get prepared inline UI for this event object if ($ical_objects->method) { $append = ''; // 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', $this->rc->format_date($event['start'], $this->rc->config->get('date_format'))) ) . '%before%' . $this->mail_agenda_event_row($event, 'current') . '%after%'); } $html .= html::div('calendar-invitebox invitebox boxinformation', $this->itip->mail_itip_inline_ui( $event, $ical_objects->method, $ical_objects->mime_id . ':' . $idx, 'calendar', rcube_utils::anytodatetime($ical_objects->message_date), $this->rc->url(array('task' => 'calendar')) . '&view=agendaDay&date=' . $event['start']->format('U') ) . $append ); } // limit listing if ($idx >= 3) break; } // prepend event boxes to message body if ($html) { $this->ui->init(); $p['content'] = $html . $p['content']; $this->rc->output->add_label('calendar.savingdata','calendar.deleteventconfirm','calendar.declinedeleteconfirm'); } // add "Save to calendar" button into attachment menu if ($has_events) { $this->add_button(array( 'id' => 'attachmentsavecal', 'name' => 'attachmentsavecal', 'type' => 'link', 'wrapper' => 'li', 'command' => 'attachment-save-calendar', 'class' => 'icon calendarlink', 'classact' => 'icon calendarlink active', 'innerclass' => 'icon calendar', 'label' => 'calendar.savetocalendar', ), 'attachmentmenu'); } return $p; } /** * Handler for POST request to import an event attached to a mail message */ public function mail_import_itip() { $itip_sending = $this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']); $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $mime_id = rcube_utils::get_input_value('_part', rcube_utils::INPUT_POST); $status = rcube_utils::get_input_value('_status', rcube_utils::INPUT_POST); $delete = intval(rcube_utils::get_input_value('_del', rcube_utils::INPUT_POST)); $noreply = intval(rcube_utils::get_input_value('_noreply', rcube_utils::INPUT_POST)); $noreply = $noreply || $status == 'needs-action' || $itip_sending === 0; $instance = rcube_utils::get_input_value('_instance', rcube_utils::INPUT_POST); $savemode = rcube_utils::get_input_value('_savemode', rcube_utils::INPUT_POST); $comment = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST); $error_msg = $this->gettext('errorimportingevent'); $success = false; if ($status == 'delegated') { $delegates = rcube_mime::decode_address_list(rcube_utils::get_input_value('_to', rcube_utils::INPUT_POST, true), 1, false); $delegate = reset($delegates); if (empty($delegate) || empty($delegate['mailto'])) { $this->rc->output->command('display_message', $this->rc->gettext('libcalendaring.delegateinvalidaddress'), 'error'); return; } } // successfully parsed events? if ($event = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event')) { // forward iTip request to delegatee if ($delegate) { $rsvpme = rcube_utils::get_input_value('_rsvp', rcube_utils::INPUT_POST); $itip = $this->load_itip(); $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 = $calendars[$cal_id]; // select default calendar except user explicitly selected 'none' if (!$calendar && !$dontsave) $calendar = $this->get_default_calendar($event['sensitivity'], $calendars); $metadata = array( 'uid' => $event['uid'], '_instance' => $event['_instance'], 'changed' => is_object($event['changed']) ? $event['changed']->format('U') : 0, 'sequence' => intval($event['sequence']), 'fallback' => strtoupper($status), 'method' => $event['_method'], 'task' => 'calendar', ); // update my attendee status according to submitted method if (!empty($status)) { $organizer = null; $emails = $this->get_user_emails(); foreach ($event['attendees'] as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER') { $organizer = $attendee; } else if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $event['attendees'][$i]['status'] = strtoupper($status); if (!in_array($event['attendees'][$i]['status'], array('NEEDS-ACTION','DELEGATED'))) $event['attendees'][$i]['rsvp'] = false; // unset RSVP attribute $metadata['attendee'] = $attendee['email']; $metadata['rsvp'] = $attendee['role'] != 'NON-PARTICIPANT'; $reply_sender = $attendee['email']; $event_attendee = $attendee; } } // add attendee with this user's default identity if not listed if (!$reply_sender) { $sender_identity = $this->rc->user->list_emails(true); $event['attendees'][] = array( 'name' => $sender_identity['name'], 'email' => $sender_identity['email'], 'role' => 'OPT-PARTICIPANT', 'status' => strtoupper($status), ); $metadata['attendee'] = $sender_identity['email']; } } // save to calendar if ($calendar && $calendar['editable']) { // check for existing event with the same UID $existing = $this->find_event($event, $mode); // we'll create a new copy if user decided to change the calendar if ($existing && $cal_id && $calendar && $calendar['id'] != $existing['calendar']) { $existing = null; } if ($existing) { $calendar = $calendars[$existing['calendar']]; // forward savemode for correct updates of recurring events $existing['_savemode'] = $savemode ?: $event['_savemode']; // only update attendee status if ($event['_method'] == 'REPLY') { // try to identify the attendee using the email sender address $existing_attendee = -1; $existing_attendee_emails = array(); foreach ($existing['attendees'] as $i => $attendee) { $existing_attendee_emails[] = $attendee['email']; if ($this->itip->compare_email($attendee['email'], $event['_sender'], $event['_sender_utf'])) { $existing_attendee = $i; } } $event_attendee = null; $update_attendees = array(); foreach ($event['attendees'] as $attendee) { if ($this->itip->compare_email($attendee['email'], $event['_sender'], $event['_sender_utf'])) { $event_attendee = $attendee; $update_attendees[] = $attendee; $metadata['fallback'] = $attendee['status']; $metadata['attendee'] = $attendee['email']; $metadata['rsvp'] = $attendee['rsvp'] || $attendee['role'] != 'NON-PARTICIPANT'; if ($attendee['status'] != 'DELEGATED') { break; } } // also copy delegate attendee else if (!empty($attendee['delegated-from']) && $this->itip->compare_email($attendee['delegated-from'], $event['_sender'], $event['_sender_utf']) ) { $update_attendees[] = $attendee; if (!in_array_nocase($attendee['email'], $existing_attendee_emails)) { $existing['attendees'][] = $attendee; } } } // if delegatee has declined, set delegator's RSVP=True if ($event_attendee && $event_attendee['status'] == 'DECLINED' && $event_attendee['delegated-from']) { foreach ($existing['attendees'] as $i => $attendee) { if ($attendee['email'] == $event_attendee['delegated-from']) { $existing['attendees'][$i]['rsvp'] = true; break; } } } // Accept sender as a new participant (different email in From: and the iTip) // Use ATTENDEE entry from the iTip with replaced email address if (!$event_attendee) { // remove the organizer $itip_attendees = array_filter($event['attendees'], function($item) { return $item['role'] != 'ORGANIZER'; }); // there must be only one attendee if (is_array($itip_attendees) && count($itip_attendees) == 1) { $event_attendee = $itip_attendees[key($itip_attendees)]; $event_attendee['email'] = $event['_sender']; $update_attendees[] = $event_attendee; $metadata['fallback'] = $event_attendee['status']; $metadata['attendee'] = $event_attendee['email']; $metadata['rsvp'] = $event_attendee['rsvp'] || $event_attendee['role'] != 'NON-PARTICIPANT'; } } // found matching attendee entry in both existing and new events if ($existing_attendee >= 0 && $event_attendee) { $existing['attendees'][$existing_attendee] = $event_attendee; $success = $this->driver->update_attendees($existing, $update_attendees); } // update the entire attendees block else if (($event['sequence'] >= $existing['sequence'] || $event['changed'] >= $existing['changed']) && $event_attendee) { $existing['attendees'][] = $event_attendee; $success = $this->driver->update_attendees($existing, $update_attendees); } else if (!$event_attendee) { $error_msg = $this->gettext('errorunknownattendee'); } else { $error_msg = $this->gettext('newerversionexists'); } } // delete the event when declined (#1670) else if ($status == 'declined' && $delete) { $deleted = $this->driver->remove_event($existing, true); $success = true; } // import the (newer) event else if ($event['sequence'] >= $existing['sequence'] || $event['changed'] >= $existing['changed']) { $event['id'] = $existing['id']; $event['calendar'] = $existing['calendar']; // merge attendees status // e.g. preserve my participant status for regular updates $this->lib->merge_attendees($event, $existing, $status); // set status=CANCELLED on CANCEL messages if ($event['_method'] == 'CANCEL') $event['status'] = 'CANCELLED'; // update attachments list, allow attachments update only on REQUEST (#5342) if ($event['_method'] == 'REQUEST') $event['deleted_attachments'] = true; else unset($event['attachments']); // show me as free when declined (#1670) if ($status == 'declined' || $event['status'] == 'CANCELLED' || $event_attendee['role'] == 'NON-PARTICIPANT') $event['free_busy'] = 'free'; $success = $this->driver->edit_event($event); } else if (!empty($status)) { $existing['attendees'] = $event['attendees']; if ($status == 'declined' || $event_attendee['role'] == 'NON-PARTICIPANT') // show me as free when declined (#1670) $existing['free_busy'] = 'free'; $success = $this->driver->edit_event($existing); } else $error_msg = $this->gettext('newerversionexists'); } else if (!$existing && ($status != 'declined' || $this->rc->config->get('kolab_invitation_calendars'))) { if ($status == 'declined' || $event['status'] == 'CANCELLED' || $event_attendee['role'] == 'NON-PARTICIPANT') { $event['free_busy'] = 'free'; } // if the RSVP reply only refers to a single instance: // store unmodified master event with current instance as exception if (!empty($instance) && !empty($savemode) && $savemode != 'all') { $master = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event'); if ($master['recurrence'] && !$master['_instance']) { // compute recurring events until this instance's date if ($recurrence_date = rcube_utils::anytodatetime($instance, $master['start']->getTimezone())) { $recurrence_date->setTime(23,59,59); foreach ($this->driver->get_recurring_events($master, $master['start'], $recurrence_date) as $recurring) { if ($recurring['_instance'] == $instance) { // copy attendees block with my partstat to exception $recurring['attendees'] = $event['attendees']; $master['recurrence']['EXCEPTIONS'][] = $recurring; $event = $recurring; // set reference for iTip reply break; } } $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) { $message = $event['_method'] == 'REPLY' ? 'attendeupdateesuccess' : ($deleted ? 'successremoval' : ($existing ? 'updatedsuccessfully' : 'importedsuccessfully')); $this->rc->output->command('display_message', $this->gettext(array('name' => $message, 'vars' => array('calendar' => $calendar['name']))), 'confirmation'); } if ($success || $dontsave) { $metadata['calendar'] = $event['calendar']; $metadata['nosave'] = $dontsave; $metadata['rsvp'] = intval($metadata['rsvp']); $metadata['after_action'] = $this->rc->config->get('calendar_itip_after_action', $this->defaults['calendar_itip_after_action']); $this->rc->output->command('plugin.itip_message_processed', $metadata); $error_msg = null; } else if ($error_msg) { $this->rc->output->command('display_message', $error_msg, 'error'); } // send iTip reply if ($event['_method'] == 'REQUEST' && $organizer && !$noreply && !in_array(strtolower($organizer['email']), $emails) && !$error_msg) { $event['comment'] = $comment; $itip = $this->load_itip(); $itip->set_sender_email($reply_sender); if ($itip->send_itip_message($event, 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status)) $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ? $organizer['name'] : $organizer['email']))), 'confirmation'); else $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); } $this->rc->output->send(); } /** * Handler for calendar/itip-remove requests */ function mail_itip_decline_reply() { $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $mime_id = rcube_utils::get_input_value('_part', rcube_utils::INPUT_POST); if (($event = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event')) && $event['_method'] == 'REPLY') { $event['comment'] = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST); foreach ($event['attendees'] as $_attendee) { if ($_attendee['role'] != 'ORGANIZER') { $attendee = $_attendee; break; } } $itip = $this->load_itip(); if ($itip->send_itip_message($event, 'CANCEL', $attendee, 'itipsubjectcancel', 'itipmailbodycancel')) $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $attendee['name'] ? $attendee['name'] : $attendee['email']))), 'confirmation'); else $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); } else { $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); } } /** * Handler for calendar/itip-delegate requests */ function mail_itip_delegate() { // forward request to mail_import_itip() with the right status $_POST['_status'] = $_REQUEST['_status'] = 'delegated'; $this->mail_import_itip(); } /** * Import the full payload from a mail message attachment */ public function mail_import_attachment() { $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $mime_id = rcube_utils::get_input_value('_part', rcube_utils::INPUT_POST); $charset = RCUBE_CHARSET; // establish imap connection $imap = $this->rc->get_storage(); $imap->set_folder($mbox); if ($uid && $mime_id) { $part = $imap->get_message_part($uid, $mime_id); if ($part->ctype_parameters['charset']) $charset = $part->ctype_parameters['charset']; // $headers = $imap->get_message_headers($uid); if ($part) { $events = $this->get_ical()->import($part, $charset); } } $success = $existing = 0; if (!empty($events)) { // find writeable calendar to store event $cal_id = !empty($_REQUEST['_calendar']) ? rcube_utils::get_input_value('_calendar', rcube_utils::INPUT_POST) : null; $calendars = $this->driver->list_calendars(calendar_driver::FILTER_PERSONAL); foreach ($events as $event) { // save to calendar $calendar = $calendars[$cal_id] ?: $this->get_default_calendar($event['sensitivity']); if ($calendar && $calendar['editable'] && $event['_type'] == 'event') { $event['calendar'] = $calendar['id']; if (!$this->driver->get_event($event['uid'], calendar_driver::FILTER_WRITEABLE)) { $success += (bool)$this->driver->new_event($event); } else { $existing++; } } } } if ($success) { $this->rc->output->command('display_message', $this->gettext(array( 'name' => 'importsuccess', 'vars' => array('nr' => $success), )), 'confirmation'); } else if ($existing) { $this->rc->output->command('display_message', $this->gettext('importwarningexists'), 'warning'); } else { $this->rc->output->command('display_message', $this->gettext('errorimportingevent'), 'error'); } } /** * Read email message and return contents for a new event based on that message */ public function mail_message2event() { $this->ui->init(); $this->ui->addJS(); $this->ui->init_templates(); $this->ui->calendar_list(array(), true); // set env['calendars'] $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_GET); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_GET); $event = array(); // establish imap connection $imap = $this->rc->get_storage(); $message = new rcube_message($uid, $mbox); if ($message->headers) { $event['title'] = trim($message->subject); $event['description'] = trim($message->first_text_part()); $this->load_driver(); // add a reference to the email message if ($msgref = $this->driver->get_message_reference($message->headers, $mbox)) { $event['links'] = array($msgref); } // copy mail attachments to event else if ($message->attachments) { $eventid = 'cal-'; if (!is_array($_SESSION[self::SESSION_KEY]) || $_SESSION[self::SESSION_KEY]['id'] != $eventid) { $_SESSION[self::SESSION_KEY] = array(); $_SESSION[self::SESSION_KEY]['id'] = $eventid; $_SESSION[self::SESSION_KEY]['attachments'] = array(); } foreach ((array)$message->attachments as $part) { $attachment = array( 'data' => $imap->get_message_part($uid, $part->mime_id, $part), 'size' => $part->size, 'name' => $part->filename, 'mimetype' => $part->mimetype, 'group' => $eventid, ); $attachment = $this->rc->plugins->exec_hook('attachment_save', $attachment); if ($attachment['status'] && !$attachment['abort']) { $id = $attachment['id']; $attachment['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); // store new attachment in session unset($attachment['status'], $attachment['abort'], $attachment['data']); $_SESSION[self::SESSION_KEY]['attachments'][$id] = $attachment; $attachment['id'] = 'rcmfile' . $attachment['id']; // add prefix to consider it 'new' $event['attachments'][] = $attachment; } } } $this->rc->output->set_env('event_prop', $event); } else { $this->rc->output->command('display_message', $this->gettext('messageopenerror'), 'error'); } $this->rc->output->send('calendar.dialog'); } /** * Handler for the 'message_compose' plugin hook. This will check for * a compose parameter 'calendar_event' and create an attachment with the * referenced event in iCal format */ public function mail_message_compose($args) { // set the submitted event ID as attachment if (!empty($args['param']['calendar_event'])) { $this->load_driver(); list($cal, $id) = explode(':', $args['param']['calendar_event'], 2); if ($event = $this->driver->get_event(array('id' => $id, 'calendar' => $cal))) { $filename = asciiwords($event['title']); if (empty($filename)) $filename = 'event'; // save ics to a temp file and register as attachment $tmp_path = tempnam($this->rc->config->get('temp_dir'), 'rcmAttmntCal'); file_put_contents($tmp_path, $this->get_ical()->export(array($event), '', false, array($this->driver, 'get_attachment_body'))); $args['attachments'][] = array( 'path' => $tmp_path, 'name' => $filename . '.ics', 'mimetype' => 'text/calendar', 'size' => filesize($tmp_path), ); $args['param']['subject'] = $event['title']; } } return $args; } /** * Get a list of email addresses of the current user (from login and identities) */ public function get_user_emails() { return $this->lib->get_user_emails(); } /** * Build an absolute URL with the given parameters */ public function get_url($param = array()) { $param += array('task' => 'calendar'); return $this->rc->url($param, true, true); } public function ical_feed_hash($source) { return base64_encode($this->rc->user->get_username() . ':' . $source); } /** * Handler for user_delete plugin hook */ public function user_delete($args) { // delete itipinvitations entries related to this user $db = $this->rc->get_dbh(); $table_itipinvitations = $db->table_name('itipinvitations', true); $db->query("DELETE FROM $table_itipinvitations WHERE `user_id` = ?", $args['user']->ID); $this->setup(); $this->load_driver(); return $this->driver->user_delete($args); } /** * Find first occurrence of a recurring event excluding start date * * @param array $event Event data (with 'start' and 'recurrence') * * @return DateTime Date of the first occurrence */ public function find_first_occurrence($event) { // Make sure libkolab plugin is loaded in case of Kolab driver $this->load_driver(); // Use libkolab to compute recurring events (and libkolab plugin) // Horde-based fallback has many bugs if (class_exists('kolabformat') && class_exists('kolabcalendaring') && class_exists('kolab_date_recurrence')) { $object = kolab_format::factory('event', 3.0); $object->set($event); $recurrence = new kolab_date_recurrence($object); } else { // fallback to libcalendaring (Horde-based) recurrence implementation require_once(__DIR__ . '/lib/calendar_recurrence.php'); $recurrence = new calendar_recurrence($this, $event); } return $recurrence->first_occurrence(); } /** * 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 91575245..1253e0d6 100644 --- a/plugins/calendar/calendar_ui.js +++ b/plugins/calendar/calendar_ui.js @@ -1,4359 +1,4263 @@ /** * 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') + ' ', 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); }, -/* - listTexts: { - until: rcmail.gettext('until', 'calendar'), - past: rcmail.gettext('pastevents', 'calendar'), - today: rcmail.gettext('today', 'calendar'), - tomorrow: rcmail.gettext('tomorrow', 'calendar'), - thisWeek: rcmail.gettext('thisweek', 'calendar'), - nextWeek: rcmail.gettext('nextweek', 'calendar'), - thisMonth: rcmail.gettext('thismonth', 'calendar'), - nextMonth: rcmail.gettext('nextmonth', 'calendar'), - future: rcmail.gettext('futureevents', 'calendar'), - week: rcmail.gettext('weekofyear', 'calendar') - }, -*/ // 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()); } 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 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)); // render from-to in a nice human-readable way // -> now shown in dialog title // $('#event-date').html(Q(me.event_date_text(event))).show(); 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).css('color', calendar.textColor || calendar.color || ''); if (event.categories) $('#event-category').show().find('.event-text').html(Q(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').html(Q(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); for (var j=0; j < event.attendees.length; j++) { data = event.attendees[j]; if (data.email) { if (data.role != 'ORGANIZER' && settings.identity.emails.indexOf(';'+data.email) >= 0) { 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 && event.attendees.length >= 7) { morelink = $('').html(rcmail.gettext('andnmore', 'calendar').replace('$nr', event.attendees.length - j - 1)); } } if (html && (event.attendees.length > 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 = []; 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 && me.has_permission(calendar, 'td') && 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'); } }); // 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('.ui-button:not(.ui-dialog-titlebar-close)').first().focus(); }, 5); }, beforeClose: function(e) { rcmail.command('menu-close', 'eventoptionsmenu', null, e); }, close: function(e) { $dialog.dialog('destroy').attr('aria-hidden', 'true').hide(); $('.libcal-rsvp-replymode').hide(); }, dragStart: function(e) { rcmail.command('menu-close', 'eventoptionsmenu', null, e); $('.libcal-rsvp-replymode').hide(); }, resizeStart: function(e) { rcmail.command('menu-close', 'eventoptionsmenu', null, e); $('.libcal-rsvp-replymode').hide(); }, 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 // FIXME: .button() does nothing in Elastic skin var disabled = fb_start.getTime() < now.getTime(); $('#schedule-find-prev').button('option', 'disabled', disabled).prop('disabled', disabled); // 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'); if (me.selected_event.end.getTime() <= freebusy_ui.start.getTime() || me.selected_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) { event.start.setTime(candidatestart); 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 offset = Math.ceil((event.start.getTime() - freebusy_ui.end.getTime()) / DAY_MS); 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(); var now = new Date(), disabled = event.start.getTime() < now.getTime(); // FIXME: .button() does nothing in Elastic skin $('#schedule-find-prev').button('option', 'disabled', disabled).prop('disabled', disabled); // 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 .ui-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') ? true : false); // 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('removeEventSource', resources_events_source); } }); // 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', 'uibutton'); // 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('removeEventSource', resources_events_source); 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) { if (add_attendee($.extend({ role:'REQ-PARTICIPANT', status:'NEEDS-ACTION', cutype:'RESOURCE' }, resource))) rcmail.display_message(rcmail.get_label('resourceadded', 'calendar'), 'confirmation'); } } // when the user accepts or declines an event invitation var event_rsvp = function(response, delegate, replymode) { 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); }); 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); }); 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) { 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({}, me.selected_event, { source:null, comment:$('#reply-comment-event-rsvp').val(), _savemode: replymode || 'all' }, (delegate || {})), noreply = $('#noreply-event-rsvp:checked').length ? 1 : 0; // 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-cal-'+data.calendar, '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], _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) { 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; } } } // 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').button().filter(':not(.disabled)').click(function(e) { data._savemode = String(this.href).replace(/.+#/, ''); data._notify = settings.itip_notify; // 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('.ui-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; }; - var update_agenda_toolbar = function() - { -// $('#agenda-listrange').val(); -// $('#agenda-listsections').val(fc.fullCalendar('option', 'listSections')); - } - /*** 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 calendar day-view in a popup - this.fisheye_view = function(date) - { - $('#fish-eye-view:ui-dialog').dialog('close'); - - // create list of active event sources - var src, cals = {}, sources = []; - for (var id in this.calendars) { - src = $.extend({}, this.calendars[id]); - src.editable = false; - src.url = null; - src.events = []; - - if (src.active) { - cals[id] = src; - sources.push(src); - } - } - - // copy events already loaded - var events = fc.fullCalendar('clientEvents'); - for (var event, i=0; i< events.length; i++) { - event = events[i]; - if (event.source && (src = cals[event.source.id])) { - src.events.push(event); - } - } - - var h = $(window).height() - 50; - var dialog = $('
    ') - .attr('id', 'fish-eye-view') - .dialog({ - modal: true, - width: 680, - height: h, - title: format_date(date, 'dddd ' + settings.date_long), - buttons: [{ - text: rcmail.gettext('cancel', 'calendar'), - 'class': 'cancel', - click: function() { $(this).dialog("close"); } - }], - close: function(){ - dialog.dialog("destroy"); - me.fisheye_date = null; - } - }) - .fullCalendar($.extend({}, fullcalendar_defaults, { - defaultView: 'agendaDay', - header: { left: '', center: '', right: '' }, - height: h - 50, - defaultDate: date, - eventSources: sources - })); - - this.fisheye_date = date; - }; - // 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(); -// var sections = fc.fullCalendar('option', 'listSections'); rcmail.open_window(rcmail.url('print', { view: view, date: date2unixtime(date), range: settings.agenda_range, - // sections: sections, 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 = $('