diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index 6cd4f68c..242fcc0b 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -1,3392 +1,3416 @@ * @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->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('mailtoevent', array($this, 'mail_message2event')); $this->register_action('inlineui', array($this, 'get_inline_ui')); $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') { $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 CSS stylesheets to the page header $this->ui->addCSS(); // 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')))); $view = rcube_utils::get_input_value('view', rcube_utils::INPUT_GPC); if (in_array($view, array('agendaWeek', 'agendaDay', 'month', 'table'))) $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'; $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'), "table"); $p['blocks']['view']['options']['default_view'] = array( 'title' => html::label($field_id, rcube::Q($this->gettext('default_view'))), 'content' => $select->show($this->rc->config->get('calendar_default_view', $this->defaults['calendar_default_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'; $p['blocks']['view']['options']['workinghours'] = array( 'title' => html::label($field_id, rcube::Q($this->gettext('workinghours'))), 'content' => $select_hours->show($this->rc->config->get('calendar_work_start', $this->defaults['calendar_work_start']), array('name' => '_work_start', 'id' => $field_id)) . ' — ' . $select_hours->show($this->rc->config->get('calendar_work_end', $this->defaults['calendar_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 . 'value', 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' => $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'; $select_cal = new html_select(array('name' => '_default_calendar', 'id' => $field_id, 'is_escaped' => true)); foreach ((array)$this->driver->list_calendars(calendar_driver::FILTER_PERSONAL | calendar_driver::FILTER_ACTIVE) 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 . 'value', rcube::Q($this->gettext('defaultcalendar'))), 'content' => $select_cal->show($this->rc->config->get('calendar_default_calendar', $default_calendar)), ); } $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' => $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 = new html_inputfield(array('type' => 'button', 'value' => 'X', 'class' => 'button', 'onclick' => '$(this).parent().remove()', 'title' => $this->gettext('remove_category'))); $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 .= html::div(null, $hidden . $category_name->show($name) . ' ' . $category_color->show($color) . ' ' . $category_remove->show()); } $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 = new html_inputfield(array('type' => 'button', 'class' => 'button', 'value' => $this->gettext('add_category'), 'onclick' => "rcube_calendar_add_category()")); $p['blocks']['categories']['options']['categories'] = array( 'content' => $new_category->show('') . ' ' . $add_category->show(), ); $this->rc->output->add_script('function rcube_calendar_add_category(){ var name = $("#rcmfd_new_category").val(); if (name.length) { var input = $("").attr("type", "text").attr("name", "_categories[]").attr("size", 30).val(name); var color = $("").attr("type", "text").attr("name", "_colors[]").attr("size", 6).addClass("colors").val("000000"); var button = $("").attr("type", "button").attr("value", "X").addClass("button").click(function(){ $(this).parent().remove() }); $("
").append(input).append(" ").append(color).append(" ").append(button).appendTo("#calendarcategories"); color.miniColors({ colorValues:(rcmail.env.mscolors || []) }); $("#rcmfd_new_category").val(""); } }'); $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::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' => join(html::br(), $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')); $p['blocks']['birthdays']['options']['birthdays_alarmoffset'] = array( 'title' => html::label($field_id . 'value', rcube::Q($this->gettext('showalarms'))), 'content' => $select_type->show($this->rc->config->get('calendar_birthdays_alarm_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_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) { $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', 'info'); $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(); $this->write_preprocess($event, $action); 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": $this->write_preprocess($event, $action); 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": $this->write_preprocess($event, $action); if ($success = $this->driver->resize_event($event)) { $this->event_save_success($event, $old, $action, $success); } $reload = $event['_savemode'] ? 2 : 1; break; case "move": $this->write_preprocess($event, $action); 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'); // update event object on the client or trigger a complete refretch if too complicated if ($reload) { $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() { $events = $this->driver->load_events( rcube_utils::get_input_value('start', rcube_utils::INPUT_GET), rcube_utils::get_input_value('end', rcube_utils::INPUT_GET), ($query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GET)), rcube_utils::get_input_value('source', rcube_utils::INPUT_GET) ); 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['date_agenda'] = (string)$this->rc->config->get('calendar_date_agenda', $this->defaults['calendar_date_agenda']); $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']); // 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']))); } 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 because of the fullcalendar client script $event['vurl'] = $event['url']; unset($event['url']); 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']), 'className' => ($addcss ? 'fc-event-cal-'.asciiwords($event['calendar'], true).' ' : '') . 'fc-event-cat-' . asciiwords(strtolower(join('-', (array)$event['categories'])), true) . rtrim(' ' . $event['className']), 'allDay' => ($event['allday'] == 1), ) + $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() { $this->lib->attachment_upload(self::SESSION_KEY, 'cal-'); } /** * Handler for attachments download/displaying */ public function attachment_get() { // show loading page if (!empty($_GET['_preload'])) { return $this->lib->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'])) { $this->lib->attachment = $attachment; $this->register_handler('plugin.attachmentframe', array($this->lib, 'attachment_frame')); $this->register_handler('plugin.attachmentcontrols', array($this->lib, 'attachment_header')); $this->rc->output->send('calendar.attachment'); } // deliver attachment content else if ($attachment) { if ($calendar != '--invitation--itip') { $attachment['body'] = $this->driver->get_attachment_body($id, $event); } $this->lib->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) { // 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'] = (bool)$event['allday']; // start/end is all we need for 'move' action (#1480) if ($action == 'move') { return; } // convert the submitted recurrence settings if (is_array($event['recurrence'])) { $event['recurrence'] = $this->lib->from_client_recurrence($event['recurrence'], $event['start']); } // 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']); } } /** * 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 = array(); // build a list from $start till $end with blocks representing the fb-status for ($s = 0, $t = $start; $t <= $end; $s++) { $status = self::FREEBUSY_UNKNOWN; $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; // check for possible all-day times if (gmdate('His', $from) == '000000' && gmdate('His', $to) == '235959') { // shift into the user's timezone for sane matching $from -= $this->gmt_offset; $to -= $this->gmt_offset; } if ($from < $t_end && $to > $t) { $status = isset($type) ? $type : self::FREEBUSY_BUSY; if ($status == self::FREEBUSY_BUSY) // can't get any worse :-) break; } } } $slots[$s] = $status; $times[$s] = $dt->format($strformat); $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, 'times' => $times, )); 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', 'table'))) $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 CSS stylesheets to the page header $skin_path = $this->local_skin_path(); $this->include_stylesheet($skin_path . '/fullcalendar.css'); $this->include_stylesheet($skin_path . '/print.css'); // Add JS files to the page header $this->include_script('print.js'); $this->include_script('lib/js/fullcalendar.js'); $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"); } /** * */ public function get_inline_ui() { foreach (array('save','cancel','savingdata') as $label) $texts['calendar.'.$label] = $this->gettext($label); $texts['calendar.new_event'] = $this->gettext('createfrommail'); $this->ui->init_templates(); $this->ui->calendar_list(); # set env['calendars'] echo $this->api->output->parse('calendar.eventedit', false, false); echo html::tag('script', array('type' => 'text/javascript'), "rcmail.set_env('calendars', " . rcube_output::json_serialize($this->api->output->env['calendars']) . ");\n". "rcmail.set_env('deleteicon', '" . $this->api->output->env['deleteicon'] . "');\n". "rcmail.set_env('cancelicon', '" . $this->api->output->env['cancelicon'] . "');\n". "rcmail.set_env('loadingicon', '" . $this->api->output->env['loadingicon'] . "');\n". "rcmail.gui_object('attachmentlist', '" . $this->ui->attachmentlist_id . "');\n". "rcmail.add_label(" . rcube_output::json_serialize($texts) . ");\n" ); exit; } /** * 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) + protected function find_event($event, &$mode) { $this->load_driver(); // We search for writeable calendars in personal namespace by default - return $this->driver->get_event($event, calendar_driver::FILTER_WRITEABLE | calendar_driver::FILTER_PERSONAL); + $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 - $existing = $this->find_event($data); - $itip = $this->load_itip(); - $response = $itip->get_itip_status($data, $existing); + // 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 && !$data['nosave'] && $response['action'] == 'rsvp' || $response['action'] == 'import') { - $calendars = $this->driver->list_calendars(calendar_driver::FILTER_WRITEABLE | calendar_driver::FILTER_PERSONAL); + 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)); $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($default_calendar['id'])); + $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) continue; else 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... if ($this->rc->user->ID) { $this->load_driver(); $invitation = $itip->get_invitation($token); // save the event to his/her default calendar if not yet present if (!$this->driver->get_event($this->event) && ($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'); } } } $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), 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', $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 = !empty($_REQUEST['_folder']) ? rcube_utils::get_input_value('_folder', rcube_utils::INPUT_POST) : null; - $dontsave = ($_REQUEST['_folder'] === '' && $event['_method'] == 'REQUEST'); - $calendars = $this->driver->list_calendars(calendar_driver::FILTER_PERSONAL); - $calendar = $calendars[$cal_id]; + $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']); + $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); + $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; } } } // 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 { $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']; - // preserve my participant status for regular updates - if (empty($status)) { - $this->lib->merge_attendees($event, $existing); - } + // 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() { $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $event = array(); // establish imap connection $imap = $this->rc->get_storage(); $imap->set_folder($mbox); $message = new rcube_message($uid); 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->command('plugin.mail2event_dialog', $event); } else { $this->rc->output->command('display_message', $this->gettext('messageopenerror'), 'error'); } $this->rc->output->send(); } /** * 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); } /** * 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/drivers/calendar_driver.php b/plugins/calendar/drivers/calendar_driver.php index d2703488..dcb7ac99 100644 --- a/plugins/calendar/drivers/calendar_driver.php +++ b/plugins/calendar/drivers/calendar_driver.php @@ -1,829 +1,830 @@ * @author Thomas Bruederli * * Copyright (C) 2010, Lazlo Westerhof * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /** * Struct of an internal event object how it is passed from/to the driver classes: * * $event = array( * 'id' => 'Event ID used for editing', * 'uid' => 'Unique identifier of this event', * 'calendar' => 'Calendar identifier to add event to or where the event is stored', * 'start' => DateTime, // Event start date/time as DateTime object * 'end' => DateTime, // Event end date/time as DateTime object * 'allday' => true|false, // Boolean flag if this is an all-day event * 'changed' => DateTime, // Last modification date of event * 'title' => 'Event title/summary', * 'location' => 'Location string', * 'description' => 'Event description', * 'url' => 'URL to more information', * 'recurrence' => array( // Recurrence definition according to iCalendar (RFC 2445) specification as list of key-value pairs * 'FREQ' => 'DAILY|WEEKLY|MONTHLY|YEARLY', * 'INTERVAL' => 1...n, * 'UNTIL' => DateTime, * 'COUNT' => 1..n, // number of times * // + more properties (see http://www.kanzaki.com/docs/ical/recur.html) * 'EXDATE' => array(), // list of DateTime objects of exception Dates/Times * 'EXCEPTIONS' => array(), list of event objects which denote exceptions in the recurrence chain * ), * 'recurrence_id' => 'ID of the recurrence group', // usually the ID of the starting event * '_instance' => 'ID of the recurring instance', // identifies an instance within a recurrence chain * 'categories' => 'Event category', * 'free_busy' => 'free|busy|outofoffice|tentative', // Show time as * 'status' => 'TENTATIVE|CONFIRMED|CANCELLED', // event status according to RFC 2445 * 'priority' => 0-9, // Event priority (0=undefined, 1=highest, 9=lowest) * 'sensitivity' => 'public|private|confidential', // Event sensitivity * 'alarms' => '-15M:DISPLAY', // DEPRECATED Reminder settings inspired by valarm definition (e.g. display alert 15 minutes before event) * 'valarms' => array( // List of reminders (new format), each represented as a hash array: * array( * 'trigger' => '-PT90M', // ISO 8601 period string prefixed with '+' or '-', or DateTime object * 'action' => 'DISPLAY|EMAIL|AUDIO', * 'duration' => 'PT15M', // ISO 8601 period string * 'repeat' => 0, // number of repetitions * 'description' => '', // text to display for DISPLAY actions * 'summary' => '', // message text for EMAIL actions * 'attendees' => array(), // list of email addresses to receive alarm messages * ), * ), * 'attachments' => array( // List of attachments * 'name' => 'File name', * 'mimetype' => 'Content type', * 'size' => 1..n, // in bytes * 'id' => 'Attachment identifier' * ), * 'deleted_attachments' => array(), // array of attachment identifiers to delete when event is updated * 'attendees' => array( // List of event participants * 'name' => 'Participant name', * 'email' => 'Participant e-mail address', // used as identifier * 'role' => 'ORGANIZER|REQ-PARTICIPANT|OPT-PARTICIPANT|CHAIR', * 'status' => 'NEEDS-ACTION|UNKNOWN|ACCEPTED|TENTATIVE|DECLINED' * 'rsvp' => true|false, * ), * * '_savemode' => 'all|future|current|new', // How changes on recurring event should be handled * '_notify' => true|false, // whether to notify event attendees about changes * '_fromcalendar' => 'Calendar identifier where the event was stored before', * ); */ /** * Interface definition for calendar driver classes */ abstract class calendar_driver { const FILTER_ALL = 0; const FILTER_WRITEABLE = 1; const FILTER_INSERTABLE = 2; const FILTER_ACTIVE = 4; const FILTER_PERSONAL = 8; const FILTER_PRIVATE = 16; const FILTER_CONFIDENTIAL = 32; + const FILTER_SHARED = 64; const BIRTHDAY_CALENDAR_ID = '__bdays__'; // features supported by backend public $alarms = false; public $attendees = false; public $freebusy = false; public $attachments = false; public $undelete = false; public $history = false; public $categoriesimmutable = false; public $alarm_types = array('DISPLAY'); public $alarm_absolute = true; public $last_error; protected $default_categories = array( 'Personal' => 'c0c0c0', 'Work' => 'ff0000', 'Family' => '00ff00', 'Holiday' => 'ff6600', ); /** * Get a list of available calendars from this source * * @param integer Bitmask defining filter criterias. * See FILTER_* constants for possible values. * @return array List of calendars */ abstract function list_calendars($filter = 0); /** * Create a new calendar assigned to the current user * * @param array Hash array with calendar properties * name: Calendar name * color: The color of the calendar * showalarms: True if alarms are enabled * @return mixed ID of the calendar on success, False on error */ abstract function create_calendar($prop); /** * Update properties of an existing calendar * * @param array Hash array with calendar properties * id: Calendar Identifier * name: Calendar name * color: The color of the calendar * showalarms: True if alarms are enabled (if supported) * @return boolean True on success, Fales on failure */ abstract function edit_calendar($prop); /** * Set active/subscribed state of a calendar * * @param array Hash array with calendar properties * id: Calendar Identifier * active: True if calendar is active, false if not * @return boolean True on success, Fales on failure */ abstract function subscribe_calendar($prop); /** * Delete the given calendar with all its contents * * @param array Hash array with calendar properties * id: Calendar Identifier * @return boolean True on success, Fales on failure */ abstract function delete_calendar($prop); /** * Search for shared or otherwise not listed calendars the user has access * * @param string Search string * @param string Section/source to search * @return array List of calendars */ abstract function search_calendars($query, $source); /** * Add a single event to the database * * @param array Hash array with event properties (see header of this file) * @return mixed New event ID on success, False on error */ abstract function new_event($event); /** * Update an event entry with the given data * * @param array Hash array with event properties (see header of this file) * @return boolean True on success, False on error */ abstract function edit_event($event); /** * Extended event editing with possible changes to the argument * * @param array Hash array with event properties * @param string New participant status * @param array List of hash arrays with updated attendees * @return boolean True on success, False on error */ public function edit_rsvp(&$event, $status, $attendees) { return $this->edit_event($event); } /** * Update the participant status for the given attendee * * @param array Hash array with event properties * @param array List of hash arrays each represeting an updated attendee * @return boolean True on success, False on error */ public function update_attendees(&$event, $attendees) { return $this->edit_event($event); } /** * Move a single event * * @param array Hash array with event properties: * id: Event identifier * start: Event start date/time as DateTime object * end: Event end date/time as DateTime object * allday: Boolean flag if this is an all-day event * @return boolean True on success, False on error */ abstract function move_event($event); /** * Resize a single event * * @param array Hash array with event properties: * id: Event identifier * start: Event start date/time as DateTime object with timezone * end: Event end date/time as DateTime object with timezone * @return boolean True on success, False on error */ abstract function resize_event($event); /** * Remove a single event from the database * * @param array Hash array with event properties: * id: Event identifier * @param boolean Remove event irreversible (mark as deleted otherwise, * if supported by the backend) * * @return boolean True on success, False on error */ abstract function remove_event($event, $force = true); /** * Restores a single deleted event (if supported) * * @param array Hash array with event properties: * id: Event identifier * * @return boolean True on success, False on error */ public function restore_event($event) { return false; } /** * Return data of a single event * * @param mixed UID string or hash array with event properties: * id: Event identifier * uid: Event UID * _instance: Instance identifier in combination with uid (optional) * calendar: Calendar identifier (optional) * @param integer Bitmask defining the scope to search events in. * See FILTER_* constants for possible values. * @param boolean If true, recurrence exceptions shall be added * * @return array Event object as hash array */ abstract function get_event($event, $scope = 0, $full = false); /** * Get events from source. * * @param integer Date range start (unix timestamp) * @param integer Date range end (unix timestamp) * @param string Search query (optional) * @param mixed List of calendar IDs to load events from (either as array or comma-separated string) * @param boolean Include virtual/recurring events (optional) * @param integer Only list events modified since this time (unix timestamp) * @return array A list of event objects (see header of this file for struct of an event) */ abstract function load_events($start, $end, $query = null, $calendars = null, $virtual = 1, $modifiedsince = null); /** * Get number of events in the given calendar * * @param mixed List of calendar IDs to count events (either as array or comma-separated string) * @param integer Date range start (unix timestamp) * @param integer Date range end (unix timestamp) * @return array Hash array with counts grouped by calendar ID */ abstract function count_events($calendars, $start, $end = null); /** * Get a list of pending alarms to be displayed to the user * * @param integer Current time (unix timestamp) * @param mixed List of calendar IDs to show alarms for (either as array or comma-separated string) * @return array A list of alarms, each encoded as hash array: * id: Event identifier * uid: Unique identifier of this event * start: Event start date/time as DateTime object * end: Event end date/time as DateTime object * allday: Boolean flag if this is an all-day event * title: Event title/summary * location: Location string */ abstract function pending_alarms($time, $calendars = null); /** * (User) feedback after showing an alarm notification * This should mark the alarm as 'shown' or snooze it for the given amount of time * * @param string Event identifier * @param integer Suspend the alarm for this number of seconds */ abstract function dismiss_alarm($event_id, $snooze = 0); /** * Check the given event object for validity * * @param array Event object as hash array * @return boolean True if valid, false if not */ public function validate($event) { $valid = true; if (!is_object($event['start']) || !is_a($event['start'], 'DateTime')) $valid = false; if (!is_object($event['end']) || !is_a($event['end'], 'DateTime')) $valid = false; return $valid; } /** * Get list of event's attachments. * Drivers can return list of attachments as event property. * If they will do not do this list_attachments() method will be used. * * @param array $event Hash array with event properties: * id: Event identifier * calendar: Calendar identifier * * @return array List of attachments, each as hash array: * id: Attachment identifier * name: Attachment name * mimetype: MIME content type of the attachment * size: Attachment size */ public function list_attachments($event) { } /** * Get attachment properties * * @param string $id Attachment identifier * @param array $event Hash array with event properties: * id: Event identifier * calendar: Calendar identifier * * @return array Hash array with attachment properties: * id: Attachment identifier * name: Attachment name * mimetype: MIME content type of the attachment * size: Attachment size */ public function get_attachment($id, $event) { } /** * Get attachment body * * @param string $id Attachment identifier * @param array $event Hash array with event properties: * id: Event identifier * calendar: Calendar identifier * * @return string Attachment body */ public function get_attachment_body($id, $event) { } /** * Build a struct representing the given message reference * * @param object|string $uri_or_headers rcube_message_header instance holding the message headers * or an URI from a stored link referencing a mail message. * @param string $folder IMAP folder the message resides in * * @return array An struct referencing the given IMAP message */ public function get_message_reference($uri_or_headers, $folder = null) { // to be implemented by the derived classes return false; } /** * List availabale categories * The default implementation reads them from config/user prefs */ public function list_categories() { $rcmail = rcube::get_instance(); return $rcmail->config->get('calendar_categories', $this->default_categories); } /** * Create a new category */ public function add_category($name, $color) { } /** * Remove the given category */ public function remove_category($name) { } /** * Update/replace a category */ public function replace_category($oldname, $name, $color) { } /** * Fetch free/busy information from a person within the given range * * @param string E-mail address of attendee * @param integer Requested period start date/time as unix timestamp * @param integer Requested period end date/time as unix timestamp * * @return array List of busy timeslots within the requested range */ public function get_freebusy_list($email, $start, $end) { return false; } /** * Create instances of a recurring event * * @param array Hash array with event properties * @param object DateTime Start date of the recurrence window * @param object DateTime End date of the recurrence window * @return array List of recurring event instances */ public function get_recurring_events($event, $start, $end = null) { $events = array(); if ($event['recurrence']) { // include library class require_once(dirname(__FILE__) . '/../lib/calendar_recurrence.php'); $rcmail = rcmail::get_instance(); $recurrence = new calendar_recurrence($rcmail->plugins->get_plugin('calendar'), $event); $recurrence_id_format = libcalendaring::recurrence_id_format($event); // determine a reasonable end date if none given if (!$end) { switch ($event['recurrence']['FREQ']) { case 'YEARLY': $intvl = 'P100Y'; break; case 'MONTHLY': $intvl = 'P20Y'; break; default: $intvl = 'P10Y'; break; } $end = clone $event['start']; $end->add(new DateInterval($intvl)); } $i = 0; while ($next_event = $recurrence->next_instance()) { // add to output if in range if (($next_event['start'] <= $end && $next_event['end'] >= $start)) { $next_event['_instance'] = $next_event['start']->format($recurrence_id_format); $next_event['id'] = $next_event['uid'] . '-' . $exception['_instance']; $next_event['recurrence_id'] = $event['uid']; $events[] = $next_event; } else if ($next_event['start'] > $end) { // stop loop if out of range break; } // avoid endless recursion loops if (++$i > 1000) { break; } } } return $events; } /** * Provide a list of revisions for the given event * * @param array $event Hash array with event properties: * id: Event identifier * calendar: Calendar identifier * * @return array List of changes, each as a hash array: * rev: Revision number * type: Type of the change (create, update, move, delete) * date: Change date * user: The user who executed the change * ip: Client IP * destination: Destination calendar for 'move' type */ public function get_event_changelog($event) { return false; } /** * Get a list of property changes beteen two revisions of an event * * @param array $event Hash array with event properties: * id: Event identifier * calendar: Calendar identifier * @param mixed $rev Revisions: "from:to" * * @return array List of property changes, each as a hash array: * property: Revision number * old: Old property value * new: Updated property value */ public function get_event_diff($event, $rev) { return false; } /** * Return full data of a specific revision of an event * * @param mixed UID string or hash array with event properties: * id: Event identifier * calendar: Calendar identifier * @param mixed $rev Revision number * * @return array Event object as hash array * @see self::get_event() */ public function get_event_revison($event, $rev) { return false; } /** * Command the backend to restore a certain revision of an event. * This shall replace the current event with an older version. * * @param mixed UID string or hash array with event properties: * id: Event identifier * calendar: Calendar identifier * @param mixed $rev Revision number * * @return boolean True on success, False on failure */ public function restore_event_revision($event, $rev) { return false; } /** * Callback function to produce driver-specific calendar create/edit form * * @param string Request action 'form-edit|form-new' * @param array Calendar properties (e.g. id, color) * @param array Edit form fields * * @return string HTML content of the form */ public function calendar_form($action, $calendar, $formfields) { $html = ''; foreach ($formfields as $field) { $html .= html::div('form-section', html::label($field['id'], $field['label']) . $field['value']); } return $html; } /** * Compose a list of birthday events from the contact records in the user's address books. * * This is a default implementation using Roundcube's address book API. * It can be overriden with a more optimized version by the individual drivers. * * @param integer Event's new start (unix timestamp) * @param integer Event's new end (unix timestamp) * @param string Search query (optional) * @param integer Only list events modified since this time (unix timestamp) * @return array A list of event records */ public function load_birthday_events($start, $end, $search = null, $modifiedsince = null) { // ignore update requests for simplicity reasons if (!empty($modifiedsince)) { return array(); } // convert to DateTime for comparisons $start = new DateTime('@'.$start); $end = new DateTime('@'.$end); // extract the current year $year = $start->format('Y'); $year2 = $end->format('Y'); $events = array(); $search = mb_strtolower($search); $rcmail = rcmail::get_instance(); $cache = $rcmail->get_cache('calendar.birthdays', 'db', 3600); $cache->expunge(); $alarm_type = $rcmail->config->get('calendar_birthdays_alarm_type', ''); $alarm_offset = $rcmail->config->get('calendar_birthdays_alarm_offset', '-1D'); $alarms = $alarm_type ? $alarm_offset . ':' . $alarm_type : null; // let the user select the address books to consider in prefs $selected_sources = $rcmail->config->get('calendar_birthday_adressbooks'); $sources = $selected_sources ?: array_keys($rcmail->get_address_sources(false, true)); foreach ($sources as $source) { $abook = $rcmail->get_address_book($source); // skip LDAP address books unless selected by the user if (!$abook || ($abook instanceof rcube_ldap && empty($selected_sources))) { continue; } $abook->set_pagesize(10000); // check for cached results $cache_records = array(); $cached = $cache->get($source); // iterate over (cached) contacts foreach (($cached ?: $abook->search('*', '', 2, true, true, array('birthday'))) as $contact) { $event = self::parse_contact($contact, $source); if (empty($event)) { continue; } // add stripped record to cache if (empty($cached)) { $cache_records[] = array( 'ID' => $contact['ID'], 'name' => $event['_displayname'], 'birthday' => $event['start']->format('Y-m-d'), ); } // filter by search term (only name is involved here) if (!empty($search) && strpos(mb_strtolower($event['title']), $search) === false) { continue; } $bday = clone $event['start']; $byear = $bday->format('Y'); // quick-and-dirty recurrence computation: just replace the year $bday->setDate($year, $bday->format('n'), $bday->format('j')); $bday->setTime(12, 0, 0); $this_year = $year; // date range reaches over multiple years: use end year if not in range if (($bday > $end || $bday < $start) && $year2 != $year) { $bday->setDate($year2, $bday->format('n'), $bday->format('j')); $this_year = $year2; } // birthday is within requested range if ($bday <= $end && $bday >= $start) { unset($event['_displayname']); $event['alarms'] = $alarms; // if this is not the first occurence modify event details // but not when this is "all birthdays feed" request if ($year2 - $year < 10 && ($age = ($this_year - $byear))) { $event['description'] = $rcmail->gettext(array('name' => 'birthdayage', 'vars' => array('age' => $age)), 'calendar'); $event['start'] = $bday; $event['end'] = clone $bday; unset($event['recurrence']); } // add the main instance $events[] = $event; } } // store collected contacts in cache if (empty($cached)) { $cache->write($source, $cache_records); } } return $events; } /** * Get a single birthday calendar event */ public function get_birthday_event($id) { // decode $id list(,$source,$contact_id,$year) = explode(':', rcube_ldap::dn_decode($id)); $rcmail = rcmail::get_instance(); if ($source && $contact_id && ($abook = $rcmail->get_address_book($source))) { if ($contact = $abook->get_record($contact_id, true)) { return self::parse_contact($contact, $source); } } } /** * Parse contact and create an event for its birthday * * @param array $contact Contact data * @param string $source Addressbook source ID * * @return array Birthday event data */ public static function parse_contact($contact, $source) { if (!is_array($contact)) { return; } if (is_array($contact['birthday'])) { $contact['birthday'] = reset($contact['birthday']); } if (empty($contact['birthday'])) { return; } try { $bday = $contact['birthday']; if (!$bday instanceof DateTime) { $bday = new DateTime($bday, new DateTimezone('UTC')); } $bday->_dateonly = true; } catch (Exception $e) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => 'BIRTHDAY PARSE ERROR: ' . $e->getMessage()), true, false); return; } $rcmail = rcmail::get_instance(); $birthyear = $bday->format('Y'); $display_name = rcube_addressbook::compose_display_name($contact); $label = array('name' => 'birthdayeventtitle', 'vars' => array('name' => $display_name)); $event_title = $rcmail->gettext($label, 'calendar'); $uid = rcube_ldap::dn_encode('bday:' . $source . ':' . $contact['ID'] . ':' . $birthyear); $event = array( 'id' => $uid, 'uid' => $uid, 'calendar' => self::BIRTHDAY_CALENDAR_ID, 'title' => $event_title, 'description' => '', 'allday' => true, 'start' => $bday, 'end' => clone $bday, 'recurrence' => array('FREQ' => 'YEARLY', 'INTERVAL' => 1), 'free_busy' => 'free', '_displayname' => $display_name, ); return $event; } /** * Store alarm dismissal for birtual birthay events * * @param string Event identifier * @param integer Suspend the alarm for this number of seconds */ public function dismiss_birthday_alarm($event_id, $snooze = 0) { $rcmail = rcmail::get_instance(); $cache = $rcmail->get_cache('calendar.birthdayalarms', 'db', 86400 * 30); $cache->remove($event_id); // compute new notification time or disable if not snoozed $notifyat = $snooze > 0 ? time() + $snooze : null; $cache->set($event_id, array('snooze' => $snooze, 'notifyat' => $notifyat)); return true; } /** * Handler for user_delete plugin hook * * @param array Hash array with hook arguments * @return array Return arguments for plugin hooks */ public function user_delete($args) { // TO BE OVERRIDDEN return $args; } } diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index 4c21ab8d..c2810ee9 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -1,2506 +1,2509 @@ * @author Aleksander Machniak * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_driver extends calendar_driver { const INVITATIONS_CALENDAR_PENDING = '--invitation--pending'; const INVITATIONS_CALENDAR_DECLINED = '--invitation--declined'; // features this backend supports public $alarms = true; public $attendees = true; public $freebusy = true; public $attachments = true; public $undelete = true; public $alarm_types = array('DISPLAY','AUDIO'); public $categoriesimmutable = true; private $rc; private $cal; private $calendars; private $has_writeable = false; private $freebusy_trigger = false; private $bonnie_api = false; /** * Default constructor */ public function __construct($cal) { $cal->require_plugin('libkolab'); // load helper classes *after* libkolab has been loaded (#3248) require_once(dirname(__FILE__) . '/kolab_calendar.php'); require_once(dirname(__FILE__) . '/kolab_user_calendar.php'); require_once(dirname(__FILE__) . '/kolab_invitation_calendar.php'); $this->cal = $cal; $this->rc = $cal->rc; $this->cal->register_action('push-freebusy', array($this, 'push_freebusy')); $this->cal->register_action('calendar-acl', array($this, 'calendar_acl')); $this->freebusy_trigger = $this->rc->config->get('calendar_freebusy_trigger', false); if (kolab_storage::$version == '2.0') { $this->alarm_types = array('DISPLAY'); $this->alarm_absolute = false; } // get configuration for the Bonnie API $this->bonnie_api = libkolab::get_bonnie_api(); // calendar uses fully encoded identifiers kolab_storage::$encode_ids = true; } /** * Read available calendars from server */ private function _read_calendars() { // already read sources if (isset($this->calendars)) return $this->calendars; // get all folders that have "event" type, sorted by namespace/name $folders = kolab_storage::sort_folders(kolab_storage::get_folders('event') + kolab_storage::get_user_folders('event', true)); $this->calendars = array(); foreach ($folders as $folder) { if ($folder instanceof kolab_storage_folder_user) { $calendar = new kolab_user_calendar($folder, $this->cal); $calendar->subscriptions = count($folder->children) > 0; } else { $calendar = new kolab_calendar($folder->name, $this->cal); } if ($calendar->ready) { $this->calendars[$calendar->id] = $calendar; if ($calendar->editable) $this->has_writeable = true; } } return $this->calendars; } /** * Get a list of available calendars from this source * * @param integer $filter Bitmask defining filter criterias * @param object $tree Reference to hierarchical folder tree object * * @return array List of calendars */ public function list_calendars($filter = 0, &$tree = null) { $this->_read_calendars(); // attempt to create a default calendar for this user if (!$this->has_writeable) { if ($this->create_calendar(array('name' => 'Calendar', 'color' => 'cc0000'))) { unset($this->calendars); $this->_read_calendars(); } } $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); $folders = $this->filter_calendars($filter); $calendars = array(); // include virtual folders for a full folder tree if (!is_null($tree)) $folders = kolab_storage::folder_hierarchy($folders, $tree); foreach ($folders as $id => $cal) { $fullname = $cal->get_name(); $listname = $cal->get_foldername(); $imap_path = explode($delim, $cal->name); // find parent do { array_pop($imap_path); $parent_id = kolab_storage::folder_id(join($delim, $imap_path)); } while (count($imap_path) > 1 && !$this->calendars[$parent_id]); // restore "real" parent ID if ($parent_id && !$this->calendars[$parent_id]) { $parent_id = kolab_storage::folder_id($cal->get_parent()); } // turn a kolab_storage_folder object into a kolab_calendar if ($cal instanceof kolab_storage_folder) { $cal = new kolab_calendar($cal->name, $this->cal); $this->calendars[$cal->id] = $cal; } // special handling for user or virtual folders if ($cal instanceof kolab_storage_folder_user) { $calendars[$cal->id] = array( 'id' => $cal->id, 'name' => $fullname, 'listname' => $listname, 'editname' => $cal->get_foldername(), 'color' => $cal->get_color(), 'active' => $cal->is_active(), 'title' => $cal->get_owner(), 'owner' => $cal->get_owner(), 'history' => false, 'virtual' => false, 'editable' => false, 'group' => 'other', 'class' => 'user', 'removable' => true, ); } else if ($cal->virtual) { $calendars[$cal->id] = array( 'id' => $cal->id, 'name' => $fullname, 'listname' => $listname, 'editname' => $cal->get_foldername(), 'virtual' => true, 'editable' => false, 'group' => $cal->get_namespace(), 'class' => 'folder', ); } else { $calendars[$cal->id] = array( 'id' => $cal->id, 'name' => $fullname, 'listname' => $listname, 'editname' => $cal->get_foldername(), 'title' => $cal->get_title(), 'color' => $cal->get_color(), 'editable' => $cal->editable, 'rights' => $cal->rights, 'showalarms' => $cal->alarms, 'history' => !empty($this->bonnie_api), 'group' => $cal->get_namespace(), 'default' => $cal->default, 'active' => $cal->is_active(), 'owner' => $cal->get_owner(), 'children' => true, // TODO: determine if that folder indeed has child folders 'parent' => $parent_id, 'subtype' => $cal->subtype, 'caldavurl' => $cal->get_caldav_url(), 'removable' => !$cal->default, ); } if ($cal->subscriptions) { $calendars[$cal->id]['subscribed'] = $cal->is_subscribed(); } } // list virtual calendars showing invitations if ($this->rc->config->get('kolab_invitation_calendars')) { foreach (array(self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED) as $id) { $cal = new kolab_invitation_calendar($id, $this->cal); $this->calendars[$cal->id] = $cal; if (!($filter & self::FILTER_ACTIVE) || $cal->is_active()) { $calendars[$id] = array( 'id' => $cal->id, 'name' => $cal->get_name(), 'listname' => $cal->get_name(), 'editname' => $cal->get_foldername(), 'title' => $cal->get_title(), 'color' => $cal->get_color(), 'editable' => $cal->editable, 'rights' => $cal->rights, 'showalarms' => $cal->alarms, 'history' => !empty($this->bonnie_api), 'group' => 'x-invitations', 'default' => false, 'active' => $cal->is_active(), 'owner' => $cal->get_owner(), 'children' => false, ); if ($id == self::INVITATIONS_CALENDAR_PENDING) { $calendars[$id]['counts'] = true; } if (is_object($tree)) { $tree->children[] = $cal; } } } } // append the virtual birthdays calendar if ($this->rc->config->get('calendar_contact_birthdays', false)) { $id = self::BIRTHDAY_CALENDAR_ID; $prefs = $this->rc->config->get('kolab_calendars', array()); // read local prefs if (!($filter & self::FILTER_ACTIVE) || $prefs[$id]['active']) { $calendars[$id] = array( 'id' => $id, 'name' => $this->cal->gettext('birthdays'), 'listname' => $this->cal->gettext('birthdays'), 'color' => $prefs[$id]['color'] ?: '87CEFA', 'active' => (bool)$prefs[$id]['active'], 'showalarms' => (bool)$this->rc->config->get('calendar_birthdays_alarm_type'), 'group' => 'x-birthdays', 'editable' => false, 'default' => false, 'children' => false, 'history' => false, ); } } return $calendars; } /** * Get list of calendars according to specified filters * * @param integer Bitmask defining restrictions. See FILTER_* constants for possible values. * * @return array List of calendars */ protected function filter_calendars($filter) { $this->_read_calendars(); $calendars = array(); $plugin = $this->rc->plugins->exec_hook('calendar_list_filter', array( 'list' => $this->calendars, 'calendars' => $calendars, 'filter' => $filter, - 'editable' => ($filter & self::FILTER_WRITEABLE), - 'insert' => ($filter & self::FILTER_INSERTABLE), - 'active' => ($filter & self::FILTER_ACTIVE), - 'personal' => ($filter & self::FILTER_PERSONAL) )); if ($plugin['abort']) { return $plugin['calendars']; } + $personal = $filter & self::FILTER_PERSONAL; + $shared = $filter & self::FILTER_SHARED; + foreach ($this->calendars as $cal) { if (!$cal->ready) { continue; } if (($filter & self::FILTER_WRITEABLE) && !$cal->editable) { continue; } if (($filter & self::FILTER_INSERTABLE) && !$cal->insert) { continue; } if (($filter & self::FILTER_ACTIVE) && !$cal->is_active()) { continue; } if (($filter & self::FILTER_PRIVATE) && $cal->subtype != 'private') { continue; } if (($filter & self::FILTER_CONFIDENTIAL) && $cal->subtype != 'confidential') { continue; } - if (($filter & self::FILTER_PERSONAL) && $cal->get_namespace() != 'personal') { - continue; + if ($personal || $shared) { + $ns = $cal->get_namespace(); + if (!(($personal && $ns == 'personal') || ($shared && $ns == 'shared'))) { + continue; + } } + $calendars[$cal->id] = $cal; } return $calendars; } /** * Get the kolab_calendar instance for the given calendar ID * * @param string Calendar identifier (encoded imap folder name) * @return object kolab_calendar Object nor null if calendar doesn't exist */ public function get_calendar($id) { $this->_read_calendars(); // create calendar object if necesary if (!$this->calendars[$id]) { if (in_array($id, array(self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED))) { $this->calendars[$id] = new kolab_invitation_calendar($id, $this->cal); } else if ($id !== self::BIRTHDAY_CALENDAR_ID) { $calendar = kolab_calendar::factory($id, $this->cal); if ($calendar->ready) { $this->calendars[$calendar->id] = $calendar; } } } return $this->calendars[$id]; } /** * Create a new calendar assigned to the current user * * @param array Hash array with calendar properties * name: Calendar name * color: The color of the calendar * @return mixed ID of the calendar on success, False on error */ public function create_calendar($prop) { $prop['type'] = 'event'; $prop['active'] = true; $prop['subscribed'] = true; $folder = kolab_storage::folder_update($prop); if ($folder === false) { $this->last_error = $this->cal->gettext(kolab_storage::$last_error); return false; } // create ID $id = kolab_storage::folder_id($folder); // save color in user prefs (temp. solution) $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); if (isset($prop['color'])) $prefs['kolab_calendars'][$id]['color'] = $prop['color']; if (isset($prop['showalarms'])) $prefs['kolab_calendars'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if ($prefs['kolab_calendars'][$id]) $this->rc->user->save_prefs($prefs); return $id; } /** * Update properties of an existing calendar * * @see calendar_driver::edit_calendar() */ public function edit_calendar($prop) { if ($prop['id'] && ($cal = $this->get_calendar($prop['id']))) { $id = $cal->update($prop); } else { $id = $prop['id']; } // fallback to local prefs $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); unset($prefs['kolab_calendars'][$prop['id']]['color'], $prefs['kolab_calendars'][$prop['id']]['showalarms']); if (isset($prop['color'])) $prefs['kolab_calendars'][$id]['color'] = $prop['color']; if (isset($prop['showalarms']) && $id == self::BIRTHDAY_CALENDAR_ID) $prefs['calendar_birthdays_alarm_type'] = $prop['showalarms'] ? $this->alarm_types[0] : ''; else if (isset($prop['showalarms'])) $prefs['kolab_calendars'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if (!empty($prefs['kolab_calendars'][$id])) $this->rc->user->save_prefs($prefs); return true; } /** * Set active/subscribed state of a calendar * * @see calendar_driver::subscribe_calendar() */ public function subscribe_calendar($prop) { if ($prop['id'] && ($cal = $this->get_calendar($prop['id'])) && is_object($cal->storage)) { $ret = false; if (isset($prop['permanent'])) $ret |= $cal->storage->subscribe(intval($prop['permanent'])); if (isset($prop['active'])) $ret |= $cal->storage->activate(intval($prop['active'])); // apply to child folders, too if ($prop['recursive']) { foreach ((array)kolab_storage::list_folders($cal->storage->name, '*', 'event') as $subfolder) { if (isset($prop['permanent'])) ($prop['permanent'] ? kolab_storage::folder_subscribe($subfolder) : kolab_storage::folder_unsubscribe($subfolder)); if (isset($prop['active'])) ($prop['active'] ? kolab_storage::folder_activate($subfolder) : kolab_storage::folder_deactivate($subfolder)); } } return $ret; } else { // save state in local prefs $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); $prefs['kolab_calendars'][$prop['id']]['active'] = (bool)$prop['active']; $this->rc->user->save_prefs($prefs); return true; } return false; } /** * Delete the given calendar with all its contents * * @see calendar_driver::delete_calendar() */ public function delete_calendar($prop) { if ($prop['id'] && ($cal = $this->get_calendar($prop['id']))) { $folder = $cal->get_realname(); // TODO: unsubscribe if no admin rights if (kolab_storage::folder_delete($folder)) { // remove color in user prefs (temp. solution) $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', array()); unset($prefs['kolab_calendars'][$prop['id']]); $this->rc->user->save_prefs($prefs); return true; } else $this->last_error = kolab_storage::$last_error; } return false; } /** * Search for shared or otherwise not listed calendars the user has access * * @param string Search string * @param string Section/source to search * @return array List of calendars */ public function search_calendars($query, $source) { if (!kolab_storage::setup()) return array(); $this->calendars = array(); $this->search_more_results = false; // find unsubscribed IMAP folders that have "event" type if ($source == 'folders') { foreach ((array)kolab_storage::search_folders('event', $query, array('other')) as $folder) { $calendar = new kolab_calendar($folder->name, $this->cal); $this->calendars[$calendar->id] = $calendar; } } // find other user's virtual calendars else if ($source == 'users') { $limit = $this->rc->config->get('autocomplete_max', 15) * 2; // we have slightly more space, so display twice the number foreach (kolab_storage::search_users($query, 0, array(), $limit, $count) as $user) { $calendar = new kolab_user_calendar($user, $this->cal); $this->calendars[$calendar->id] = $calendar; // search for calendar folders shared by this user foreach (kolab_storage::list_user_folders($user, 'event', false) as $foldername) { $cal = new kolab_calendar($foldername, $this->cal); $this->calendars[$cal->id] = $cal; $calendar->subscriptions = true; } } if ($count > $limit) { $this->search_more_results = true; } } // don't list the birthday calendar $this->rc->config->set('calendar_contact_birthdays', false); $this->rc->config->set('kolab_invitation_calendars', false); return $this->list_calendars(); } /** * Fetch a single event * * @see calendar_driver::get_event() * @return array Hash array with event properties, false if not found */ public function get_event($event, $scope = 0, $full = false) { if (is_array($event)) { $id = $event['id'] ?: $event['uid']; $cal = $event['calendar']; // we're looking for a recurring instance: expand the ID to our internal convention for recurring instances if (!$event['id'] && $event['_instance']) { $id .= '-' . $event['_instance']; } } else { $id = $event; } if ($cal) { if ($storage = $this->get_calendar($cal)) { $result = $storage->get_event($id); return self::to_rcube_event($result); } // get event from the address books birthday calendar else if ($cal == self::BIRTHDAY_CALENDAR_ID) { return $this->get_birthday_event($id); } } // iterate over all calendar folders and search for the event ID else { foreach ($this->filter_calendars($scope) as $calendar) { if ($result = $calendar->get_event($id)) { return self::to_rcube_event($result); } } } return false; } /** * Add a single event to the database * * @see calendar_driver::new_event() */ public function new_event($event) { if (!$this->validate($event)) return false; $event = self::from_rcube_event($event); if (!$event['calendar']) { $this->_read_calendars(); $event['calendar'] = reset(array_keys($this->calendars)); } if ($storage = $this->get_calendar($event['calendar'])) { // if this is a recurrence instance, append as exception to an already existing object for this UID if (!empty($event['recurrence_date']) && ($master = $storage->get_event($event['uid']))) { self::add_exception($master, $event); $success = $storage->update_event($master); } else { $success = $storage->insert_event($event); } if ($success && $this->freebusy_trigger) { $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); $this->freebusy_trigger = false; // disable after first execution (#2355) } return $success; } return false; } /** * Update an event entry with the given data * * @see calendar_driver::new_event() * @return boolean True on success, False on error */ public function edit_event($event) { if (!($storage = $this->get_calendar($event['calendar']))) return false; return $this->update_event(self::from_rcube_event($event, $storage->get_event($event['id']))); } /** * Extended event editing with possible changes to the argument * * @param array Hash array with event properties * @param string New participant status * @param array List of hash arrays with updated attendees * @return boolean True on success, False on error */ public function edit_rsvp(&$event, $status, $attendees) { $update_event = $event; // apply changes to master (and all exceptions) if ($event['_savemode'] == 'all' && $event['recurrence_id']) { if ($storage = $this->get_calendar($event['calendar'])) { $update_event = $storage->get_event($event['recurrence_id']); $update_event['_savemode'] = $event['_savemode']; $update_event['id'] = $update_event['uid']; unset($update_event['recurrence_id']); calendar::merge_attendee_data($update_event, $attendees); } } if ($ret = $this->update_attendees($update_event, $attendees)) { // replace with master event (for iTip reply) $event = self::to_rcube_event($update_event); // re-assign to the according (virtual) calendar if ($this->rc->config->get('kolab_invitation_calendars')) { if (strtoupper($status) == 'DECLINED') $event['calendar'] = self::INVITATIONS_CALENDAR_DECLINED; else if (strtoupper($status) == 'NEEDS-ACTION') $event['calendar'] = self::INVITATIONS_CALENDAR_PENDING; else if ($event['_folder_id']) $event['calendar'] = $event['_folder_id']; } } return $ret; } /** * Update the participant status for the given attendees * * @see calendar_driver::update_attendees() */ public function update_attendees(&$event, $attendees) { // for this-and-future updates, merge the updated attendees onto all exceptions in range if (($event['_savemode'] == 'future' && $event['recurrence_id']) || (!empty($event['recurrence']) && !$event['recurrence_id'])) { if (!($storage = $this->get_calendar($event['calendar']))) return false; // load master event $master = $event['recurrence_id'] ? $storage->get_event($event['recurrence_id']) : $event; // apply attendee update to each existing exception if ($master['recurrence'] && !empty($master['recurrence']['EXCEPTIONS'])) { $saved = false; foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { // merge the new event properties onto future exceptions if ($exception['_instance'] >= strval($event['_instance'])) { calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $attendees); } // update a specific instance if ($exception['_instance'] == $event['_instance'] && $exception['thisandfuture']) { $saved = true; } } // add the given event as new exception if (!$saved && $event['id'] != $master['id']) { $event['thisandfuture'] = true; $master['recurrence']['EXCEPTIONS'][] = $event; } // set link to top-level exceptions $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; return $this->update_event($master); } } // just update the given event (instance) return $this->update_event($event); } /** * Move a single event * * @see calendar_driver::move_event() * @return boolean True on success, False on error */ public function move_event($event) { if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { unset($ev['sequence']); self::clear_attandee_noreply($ev); return $this->update_event($event + $ev); } return false; } /** * Resize a single event * * @see calendar_driver::resize_event() * @return boolean True on success, False on error */ public function resize_event($event) { if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { unset($ev['sequence']); self::clear_attandee_noreply($ev); return $this->update_event($event + $ev); } return false; } /** * Remove a single event * * @param array Hash array with event properties: * id: Event identifier * @param boolean Remove record(s) irreversible (mark as deleted otherwise) * * @return boolean True on success, False on error */ public function remove_event($event, $force = true) { $ret = true; $success = false; $savemode = $event['_savemode']; $decline = $event['_decline']; if (($storage = $this->get_calendar($event['calendar'])) && ($event = $storage->get_event($event['id']))) { $event['_savemode'] = $savemode; $savemode = 'all'; $master = $event; $this->rc->session->remove('calendar_restore_event_data'); // read master if deleting a recurring event if ($event['recurrence'] || $event['recurrence_id'] || $event['isexception']) { $master = $storage->get_event($event['uid']); $savemode = $event['_savemode'] ?: ($event['_instance'] || $event['isexception'] ? 'current' : 'all'); // force 'current' mode for single occurrences stored as exception if (!$event['recurrence'] && !$event['recurrence_id'] && $event['isexception']) $savemode = 'current'; } // removing an exception instance if (($event['recurrence_id'] || $event['isexception']) && is_array($master['exceptions'])) { foreach ($master['exceptions'] as $i => $exception) { if ($exception['_instance'] == $event['_instance']) { unset($master['exceptions'][$i]); // set event date back to the actual occurrence if ($exception['recurrence_date']) $event['start'] = $exception['recurrence_date']; } } if (is_array($master['recurrence'])) { $master['recurrence']['EXCEPTIONS'] = &$master['exceptions']; } } switch ($savemode) { case 'current': $_SESSION['calendar_restore_event_data'] = $master; // removing the first instance => just move to next occurence if ($master['recurrence'] && $event['_instance'] == libcalendaring::recurrence_instance_identifier($master)) { $recurring = reset($storage->get_recurring_events($event, $event['start'], null, $event['id'].'-1')); // no future instances found: delete the master event (bug #1677) if (!$recurring['start']) { $success = $storage->delete_event($master, $force); break; } $master['start'] = $recurring['start']; $master['end'] = $recurring['end']; if ($master['recurrence']['COUNT']) $master['recurrence']['COUNT']--; } // remove the matching RDATE entry else if ($master['recurrence']['RDATE']) { foreach ($master['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { unset($master['recurrence']['RDATE'][$j]); break; } } } else { // add exception to master event $master['recurrence']['EXDATE'][] = $event['start']; } $success = $storage->update_event($master); break; case 'future': $master['_instance'] = libcalendaring::recurrence_instance_identifier($master); if ($master['_instance'] != $event['_instance']) { $_SESSION['calendar_restore_event_data'] = $master; // set until-date on master event $master['recurrence']['UNTIL'] = clone $event['start']; $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); unset($master['recurrence']['COUNT']); // if all future instances are deleted, remove recurrence rule entirely (bug #1677) if ($master['recurrence']['UNTIL']->format('Ymd') == $master['start']->format('Ymd')) { $master['recurrence'] = array(); } // remove matching RDATE entries else if ($master['recurrence']['RDATE']) { foreach ($master['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $event['start']->format('Ymd')) { $master['recurrence']['RDATE'] = array_slice($master['recurrence']['RDATE'], 0, $j); break; } } } $success = $storage->update_event($master); $ret = $master['uid']; break; } default: // 'all' is default // removing the master event with loose exceptions (not recurring though) if (!empty($event['recurrence_date']) && empty($master['recurrence']) && !empty($master['exceptions'])) { // make the first exception the new master $newmaster = array_shift($master['exceptions']); $newmaster['exceptions'] = $master['exceptions']; $newmaster['_attachments'] = $master['_attachments']; $newmaster['_mailbox'] = $master['_mailbox']; $newmaster['_msguid'] = $master['_msguid']; $success = $storage->update_event($newmaster); } else if ($decline && $this->rc->config->get('kolab_invitation_calendars')) { // don't delete but set PARTSTAT=DECLINED if ($this->cal->lib->set_partstat($master, 'DECLINED')) { $success = $storage->update_event($master); } } if (!$success) $success = $storage->delete_event($master, $force); break; } } if ($success && $this->freebusy_trigger) $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); return $success ? $ret : false; } /** * Restore a single deleted event * * @param array Hash array with event properties: * id: Event identifier * @return boolean True on success, False on error */ public function restore_event($event) { if ($storage = $this->get_calendar($event['calendar'])) { if (!empty($_SESSION['calendar_restore_event_data'])) $success = $storage->update_event($_SESSION['calendar_restore_event_data']); else $success = $storage->restore_event($event); if ($success && $this->freebusy_trigger) $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); return $success; } return false; } /** * Wrapper to update an event object depending on the given savemode */ private function update_event($event) { if (!($storage = $this->get_calendar($event['calendar']))) return false; // move event to another folder/calendar if ($event['_fromcalendar'] && $event['_fromcalendar'] != $event['calendar']) { if (!($fromcalendar = $this->get_calendar($event['_fromcalendar']))) return false; $old = $fromcalendar->get_event($event['id']); if ($event['_savemode'] != 'new') { if (!$fromcalendar->storage->move($old['uid'], $storage->storage)) { return false; } $fromcalendar = $storage; } } else $fromcalendar = $storage; $success = false; $savemode = 'all'; $attachments = array(); $old = $master = $storage->get_event($event['id']); if (!$old || !$old['start']) { rcube::raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed to load event object to update: id=" . $event['id']), true, false); return false; } // modify a recurring event, check submitted savemode to do the right things if ($old['recurrence'] || $old['recurrence_id'] || $old['isexception']) { $master = $storage->get_event($old['uid']); $savemode = $event['_savemode'] ?: ($old['recurrence_id'] || $old['isexception'] ? 'current' : 'all'); // this-and-future on the first instance equals to 'all' if ($savemode == 'future' && $master['start'] && $old['_instance'] == libcalendaring::recurrence_instance_identifier($master)) $savemode = 'all'; // force 'current' mode for single occurrences stored as exception else if (!$old['recurrence'] && !$old['recurrence_id'] && $old['isexception']) $savemode = 'current'; } // check if update affects scheduling and update attendee status accordingly $reschedule = $this->check_scheduling($event, $old, true); // keep saved exceptions (not submitted by the client) if ($old['recurrence']['EXDATE'] && !isset($event['recurrence']['EXDATE'])) $event['recurrence']['EXDATE'] = $old['recurrence']['EXDATE']; if (isset($event['recurrence']['EXCEPTIONS'])) $with_exceptions = true; // exceptions already provided (e.g. from iCal import) else if ($old['recurrence']['EXCEPTIONS']) $event['recurrence']['EXCEPTIONS'] = $old['recurrence']['EXCEPTIONS']; else if ($old['exceptions']) $event['exceptions'] = $old['exceptions']; // remove some internal properties which should not be saved unset($event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_owner'], $event['_notify'], $event['_method'], $event['_sender'], $event['_sender_utf'], $event['_size']); switch ($savemode) { case 'new': // save submitted data as new (non-recurring) event $event['recurrence'] = array(); $event['_copyfrom'] = $master['_msguid']; $event['_mailbox'] = $master['_mailbox']; $event['uid'] = $this->cal->generate_uid(); unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); // copy attachment metadata to new event $event = self::from_rcube_event($event, $master); self::clear_attandee_noreply($event); if ($success = $storage->insert_event($event)) $success = $event['uid']; break; case 'future': // create a new recurring event $event['_copyfrom'] = $master['_msguid']; $event['_mailbox'] = $master['_mailbox']; $event['uid'] = $this->cal->generate_uid(); unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); // copy attachment metadata to new event $event = self::from_rcube_event($event, $master); // remove recurrence exceptions on re-scheduling if ($reschedule) { unset($event['recurrence']['EXCEPTIONS'], $event['exceptions'], $master['recurrence']['EXDATE']); } else if (is_array($event['recurrence']['EXCEPTIONS'])) { // only keep relevant exceptions $event['recurrence']['EXCEPTIONS'] = array_filter($event['recurrence']['EXCEPTIONS'], function($exception) use ($event) { return $exception['start'] > $event['start']; }); if (is_array($event['recurrence']['EXDATE'])) { $event['recurrence']['EXDATE'] = array_filter($event['recurrence']['EXDATE'], function($exdate) use ($event) { return $exdate > $event['start']; }); } // set link to top-level exceptions $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; } // compute remaining occurrences if ($event['recurrence']['COUNT']) { if (!$old['_count']) $old['_count'] = $this->get_recurrence_count($master, $old['start']); $event['recurrence']['COUNT'] -= intval($old['_count']); } // remove fixed weekday when date changed if ($old['start']->format('Y-m-d') != $event['start']->format('Y-m-d')) { if (strlen($event['recurrence']['BYDAY']) == 2) unset($event['recurrence']['BYDAY']); if ($old['recurrence']['BYMONTH'] == $old['start']->format('n')) unset($event['recurrence']['BYMONTH']); } // set until-date on master event $master['recurrence']['UNTIL'] = clone $old['start']; $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); unset($master['recurrence']['COUNT']); // remove all exceptions after $event['start'] if (is_array($master['recurrence']['EXCEPTIONS'])) { $master['recurrence']['EXCEPTIONS'] = array_filter($master['recurrence']['EXCEPTIONS'], function($exception) use ($event) { return $exception['start'] < $event['start']; }); // set link to top-level exceptions $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; } if (is_array($master['recurrence']['EXDATE'])) { $master['recurrence']['EXDATE'] = array_filter($master['recurrence']['EXDATE'], function($exdate) use ($event) { return $exdate < $event['start']; }); } // save new event if ($success = $storage->insert_event($event)) { $success = $event['uid']; // update master event (no rescheduling!) self::clear_attandee_noreply($master); $storage->update_event($master); } break; case 'current': // recurring instances shall not store recurrence rules and attachments $event['recurrence'] = array(); $event['thisandfuture'] = $savemode == 'future'; unset($event['attachments'], $event['id']); // increment sequence of this instance if scheduling is affected if ($reschedule) { $event['sequence'] = max($old['sequence'], $master['sequence']) + 1; } else if (!isset($event['sequence'])) { $event['sequence'] = $old['sequence'] ?: $master['sequence']; } // save properties to a recurrence exception instance if ($old['_instance'] && is_array($master['recurrence']['EXCEPTIONS'])) { if ($this->update_recurrence_exceptions($master, $event, $old, $savemode)) { $success = $storage->update_event($master, $old['id']); break; } } $add_exception = true; // adjust matching RDATE entry if dates changed if (is_array($master['recurrence']['RDATE']) && ($old_date = $old['start']->format('Ymd')) != $event['start']->format('Ymd')) { foreach ($master['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $old_date) { $master['recurrence']['RDATE'][$j] = $event['start']; sort($master['recurrence']['RDATE']); $add_exception = false; break; } } } // save as new exception to master event if ($add_exception) { self::add_exception($master, $event, $old); } $success = $storage->update_event($master); break; default: // 'all' is default $event['id'] = $master['uid']; $event['uid'] = $master['uid']; // use start date from master but try to be smart on time or duration changes $old_start_date = $old['start']->format('Y-m-d'); $old_start_time = $old['allday'] ? '' : $old['start']->format('H:i'); $old_duration = $old['end']->format('U') - $old['start']->format('U'); $new_start_date = $event['start']->format('Y-m-d'); $new_start_time = $event['allday'] ? '' : $event['start']->format('H:i'); $new_duration = $event['end']->format('U') - $event['start']->format('U'); $diff = $old_start_date != $new_start_date || $old_start_time != $new_start_time || $old_duration != $new_duration; $date_shift = $old['start']->diff($event['start']); // shifted or resized if ($diff && ($old_start_date == $new_start_date || $old_duration == $new_duration)) { $event['start'] = $master['start']->add($date_shift); $event['end'] = clone $event['start']; $event['end']->add(new DateInterval('PT'.$new_duration.'S')); // remove fixed weekday, will be re-set to the new weekday in kolab_calendar::update_event() if ($old_start_date != $new_start_date) { if (strlen($event['recurrence']['BYDAY']) == 2) unset($event['recurrence']['BYDAY']); if ($old['recurrence']['BYMONTH'] == $old['start']->format('n')) unset($event['recurrence']['BYMONTH']); } } // dates did not change, use the ones from master else if ($new_start_date . $new_start_time == $old_start_date . $old_start_time) { $event['start'] = $master['start']; $event['end'] = $master['end']; } // when saving an instance in 'all' mode, copy recurrence exceptions over if ($old['recurrence_id']) { $event['recurrence']['EXCEPTIONS'] = $master['recurrence']['EXCEPTIONS']; } else if ($master['_instance']) { $event['_instance'] = $master['_instance']; $event['recurrence_date'] = $master['recurrence_date']; } // TODO: forward changes to exceptions (which do not yet have differing values stored) if (is_array($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) { // determine added and removed attendees $old_attendees = $current_attendees = $added_attendees = array(); foreach ((array)$old['attendees'] as $attendee) { $old_attendees[] = $attendee['email']; } foreach ((array)$event['attendees'] as $attendee) { $current_attendees[] = $attendee['email']; if (!in_array($attendee['email'], $old_attendees)) { $added_attendees[] = $attendee; } } $removed_attendees = array_diff($old_attendees, $current_attendees); foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { calendar::merge_attendee_data($event['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); } // adjust recurrence-id when start changed and therefore the entire recurrence chain changes if ($old_start_date != $new_start_date || $old_start_time != $new_start_time) { $recurrence_id_format = libcalendaring::recurrence_id_format($event); foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { $recurrence_id = is_a($exception['recurrence_date'], 'DateTime') ? $exception['recurrence_date'] : rcube_utils::anytodatetime($exception['_instance'], $old['start']->getTimezone()); if (is_a($recurrence_id, 'DateTime')) { $recurrence_id->add($date_shift); $event['recurrence']['EXCEPTIONS'][$i]['recurrence_date'] = $recurrence_id; $event['recurrence']['EXCEPTIONS'][$i]['_instance'] = $recurrence_id->format($recurrence_id_format); } } } // set link to top-level exceptions $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; } // unset _dateonly flags in (cached) date objects unset($event['start']->_dateonly, $event['end']->_dateonly); $success = $storage->update_event($event) ? $event['id'] : false; // return master UID break; } if ($success && $this->freebusy_trigger) $this->rc->output->command('plugin.ping_url', array('action' => 'calendar/push-freebusy', 'source' => $storage->id)); return $success; } /** * Determine whether the current change affects scheduling and reset attendee status accordingly */ public function check_scheduling(&$event, $old, $update = true) { // skip this check when importing iCal/iTip events if (isset($event['sequence']) || !empty($event['_method'])) { return false; } // iterate through the list of properties considered 'significant' for scheduling $kolab_event = $old['_formatobj'] ?: new kolab_format_event(); $reschedule = $kolab_event->check_rescheduling($event, $old); // reset all attendee status to needs-action (#4360) if ($update && $reschedule && is_array($event['attendees'])) { $is_organizer = false; $emails = $this->cal->get_user_emails(); $attendees = $event['attendees']; foreach ($attendees as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER' && $attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $is_organizer = true; } else if ($attendee['role'] != 'ORGANIZER' && $attendee['role'] != 'NON-PARTICIPANT' && $attendee['status'] != 'DELEGATED') { $attendees[$i]['status'] = 'NEEDS-ACTION'; $attendees[$i]['rsvp'] = true; } } // update attendees only if I'm the organizer if ($is_organizer || ($event['organizer'] && in_array(strtolower($event['organizer']['email']), $emails))) { $event['attendees'] = $attendees; } } return $reschedule; } /** * Apply the given changes to already existing exceptions */ protected function update_recurrence_exceptions(&$master, $event, $old, $savemode) { $saved = false; $existing = null; // determine added and removed attendees $added_attendees = $removed_attendees = array(); if ($savemode == 'future') { $old_attendees = $current_attendees = array(); foreach ((array)$old['attendees'] as $attendee) { $old_attendees[] = $attendee['email']; } foreach ((array)$event['attendees'] as $attendee) { $current_attendees[] = $attendee['email']; if (!in_array($attendee['email'], $old_attendees)) { $added_attendees[] = $attendee; } } $removed_attendees = array_diff($old_attendees, $current_attendees); } foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { // update a specific instance if ($exception['_instance'] == $old['_instance']) { $existing = $i; // check savemode against existing exception mode. // if matches, we can update this existing exception if ((bool)$exception['thisandfuture'] === ($savemode == 'future')) { $event['_instance'] = $old['_instance']; $event['thisandfuture'] = $old['thisandfuture']; $event['recurrence_date'] = $old['recurrence_date']; $master['recurrence']['EXCEPTIONS'][$i] = $event; $saved = true; } } // merge the new event properties onto future exceptions if ($savemode == 'future' && $exception['_instance'] >= $old['_instance']) { unset($event['thisandfuture']); self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event, array('attendees')); if (!empty($added_attendees) || !empty($removed_attendees)) { calendar::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); } } } /* // we could not update the existing exception due to savemode mismatch... if (!$saved && $existing !== null && $master['recurrence']['EXCEPTIONS'][$existing]['thisandfuture']) { // ... try to move the existing this-and-future exception to the next occurrence foreach ($this->get_recurring_events($master, $existing['start']) as $candidate) { // our old this-and-future exception is obsolete if ($candidate['thisandfuture']) { unset($master['recurrence']['EXCEPTIONS'][$existing]); $saved = true; break; } // this occurrence doesn't yet have an exception else if (!$candidate['isexception']) { $event['_instance'] = $candidate['_instance']; $event['recurrence_date'] = $candidate['recurrence_date']; $master['recurrence']['EXCEPTIONS'][$i] = $event; $saved = true; break; } } } */ // set link to top-level exceptions $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; // returning false here will add a new exception return $saved; } /** * Add or update the given event as an exception to $master */ public static function add_exception(&$master, $event, $old = null) { if ($old) { $event['_instance'] = $old['_instance']; if (!$event['recurrence_date']) $event['recurrence_date'] = $old['recurrence_date'] ?: $old['start']; } else if (!$event['recurrence_date']) { $event['recurrence_date'] = $event['start']; } if (!$event['_instance'] && is_a($event['recurrence_date'], 'DateTime')) { $event['_instance'] = libcalendaring::recurrence_instance_identifier($event); } if (!is_array($master['exceptions']) && is_array($master['recurrence']['EXCEPTIONS'])) { $master['exceptions'] = &$master['recurrence']['EXCEPTIONS']; } $existing = false; foreach ((array)$master['exceptions'] as $i => $exception) { if ($exception['_instance'] == $event['_instance']) { $master['exceptions'][$i] = $event; $existing = true; } } if (!$existing) { $master['exceptions'][] = $event; } return true; } /** * Remove the noreply flags from attendees */ public static function clear_attandee_noreply(&$event) { foreach ((array)$event['attendees'] as $i => $attendee) { unset($event['attendees'][$i]['noreply']); } } /** * Merge certain properties from the overlay event to the base event object * * @param array The event object to be altered * @param array The overlay event object to be merged over $event * @param array List of properties not allowed to be overwritten */ public static function merge_exception_data(&$event, $overlay, $blacklist = null) { $forbidden = array('id','uid','recurrence','recurrence_date','thisandfuture','organizer','_attachments'); if (is_array($blacklist)) $forbidden = array_merge($forbidden, $blacklist); // compute date offset from the exception if ($overlay['start'] instanceof DateTime && $overlay['recurrence_date'] instanceof DateTime) { $date_offset = $overlay['recurrence_date']->diff($overlay['start']); } foreach ($overlay as $prop => $value) { if ($prop == 'start' || $prop == 'end') { if (is_object($event[$prop]) && $event[$prop] instanceof DateTime) { // set date value if overlay is an exception of the current instance if (substr($overlay['_instance'], 0, 8) == substr($event['_instance'], 0, 8)) { $event[$prop]->setDate(intval($value->format('Y')), intval($value->format('n')), intval($value->format('j'))); } // apply date offset else if ($date_offset) { $event[$prop]->add($date_offset); } // adjust time of the recurring event instance $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s'))); } } else if ($prop == 'thisandfuture' && $overlay['_instance'] == $event['_instance']) { $event[$prop] = $value; } else if ($prop[0] != '_' && !in_array($prop, $forbidden)) $event[$prop] = $value; } } /** * Get events from source. * * @param integer Event's new start (unix timestamp) * @param integer Event's new end (unix timestamp) * @param string Search query (optional) * @param mixed List of calendar IDs to load events from (either as array or comma-separated string) * @param boolean Include virtual events (optional) * @param integer Only list events modified since this time (unix timestamp) * @return array A list of event records */ public function load_events($start, $end, $search = null, $calendars = null, $virtual = 1, $modifiedsince = null) { if ($calendars && is_string($calendars)) $calendars = explode(',', $calendars); else if (!$calendars) { $this->_read_calendars(); $calendars = array_keys($this->calendars); } $query = array(); if ($modifiedsince) $query[] = array('changed', '>=', $modifiedsince); $events = $categories = array(); foreach ($calendars as $cid) { if ($storage = $this->get_calendar($cid)) { $events = array_merge($events, $storage->list_events($start, $end, $search, $virtual, $query)); $categories += $storage->categories; } } // add events from the address books birthday calendar if (in_array(self::BIRTHDAY_CALENDAR_ID, $calendars)) { $events = array_merge($events, $this->load_birthday_events($start, $end, $search, $modifiedsince)); } // add new categories to user prefs $old_categories = $this->rc->config->get('calendar_categories', $this->default_categories); if ($newcats = array_udiff(array_keys($categories), array_keys($old_categories), function($a, $b){ return strcasecmp($a, $b); })) { foreach ($newcats as $category) $old_categories[$category] = ''; // no color set yet $this->rc->user->save_prefs(array('calendar_categories' => $old_categories)); } array_walk($events, 'kolab_driver::to_rcube_event'); return $events; } /** * Get number of events in the given calendar * * @param mixed List of calendar IDs to count events (either as array or comma-separated string) * @param integer Date range start (unix timestamp) * @param integer Date range end (unix timestamp) * @return array Hash array with counts grouped by calendar ID */ public function count_events($calendars, $start, $end = null) { $counts = array(); if ($calendars && is_string($calendars)) $calendars = explode(',', $calendars); else if (!$calendars) { $this->_read_calendars(); $calendars = array_keys($this->calendars); } foreach ($calendars as $cid) { if ($storage = $this->get_calendar($cid)) { $counts[$cid] = $storage->count_events($start, $end); } } return $counts; } /** * Get a list of pending alarms to be displayed to the user * * @see calendar_driver::pending_alarms() */ public function pending_alarms($time, $calendars = null) { $interval = 300; $time -= $time % 60; $slot = $time; $slot -= $slot % $interval; $last = $time - max(60, $this->rc->config->get('refresh_interval', 0)); $last -= $last % $interval; // only check for alerts once in 5 minutes if ($last == $slot) return array(); if ($calendars && is_string($calendars)) $calendars = explode(',', $calendars); $time = $slot + $interval; $candidates = array(); $query = array(array('tags', '=', 'x-has-alarms')); $this->_read_calendars(); foreach ($this->calendars as $cid => $calendar) { // skip calendars with alarms disabled if (!$calendar->alarms || ($calendars && !in_array($cid, $calendars))) continue; foreach ($calendar->list_events($time, $time + 86400 * 365, null, 1, $query) as $e) { // add to list if alarm is set $alarm = libcalendaring::get_next_alarm($e); if ($alarm && $alarm['time'] && $alarm['time'] >= $last && in_array($alarm['action'], $this->alarm_types)) { $id = $alarm['id']; // use alarm-id as primary identifier $candidates[$id] = array( 'id' => $id, 'title' => $e['title'], 'location' => $e['location'], 'start' => $e['start'], 'end' => $e['end'], 'notifyat' => $alarm['time'], 'action' => $alarm['action'], ); } } } // get alarm information stored in local database if (!empty($candidates)) { $alarm_ids = array_map(array($this->rc->db, 'quote'), array_keys($candidates)); $result = $this->rc->db->query("SELECT *" . " FROM " . $this->rc->db->table_name('kolab_alarms', true) . " WHERE `alarm_id` IN (" . join(',', $alarm_ids) . ")" . " AND `user_id` = ?", $this->rc->user->ID ); while ($result && ($e = $this->rc->db->fetch_assoc($result))) { $dbdata[$e['alarm_id']] = $e; } } $alarms = array(); foreach ($candidates as $id => $alarm) { // skip dismissed alarms if ($dbdata[$id]['dismissed']) continue; // snooze function may have shifted alarm time $notifyat = $dbdata[$id]['notifyat'] ? strtotime($dbdata[$id]['notifyat']) : $alarm['notifyat']; if ($notifyat <= $time) $alarms[] = $alarm; } return $alarms; } /** * Feedback after showing/sending an alarm notification * * @see calendar_driver::dismiss_alarm() */ public function dismiss_alarm($alarm_id, $snooze = 0) { $alarms_table = $this->rc->db->table_name('kolab_alarms', true); // delete old alarm entry $this->rc->db->query("DELETE FROM $alarms_table" . " WHERE `alarm_id` = ? AND `user_id` = ?", $alarm_id, $this->rc->user->ID ); // set new notifyat time or unset if not snoozed $notifyat = $snooze > 0 ? date('Y-m-d H:i:s', time() + $snooze) : null; $query = $this->rc->db->query("INSERT INTO $alarms_table" . " (`alarm_id`, `user_id`, `dismissed`, `notifyat`)" . " VALUES (?, ?, ?, ?)", $alarm_id, $this->rc->user->ID, $snooze > 0 ? 0 : 1, $notifyat ); return $this->rc->db->affected_rows($query); } /** * List attachments from the given event */ public function list_attachments($event) { if (!($storage = $this->get_calendar($event['calendar']))) return false; $event = $storage->get_event($event['id']); return $event['attachments']; } /** * Get attachment properties */ public function get_attachment($id, $event) { if (!($storage = $this->get_calendar($event['calendar']))) return false; // get old revision of event if ($event['rev']) { $event = $this->get_event_revison($event, $event['rev'], true); } else { $event = $storage->get_event($event['id']); } if ($event) { $attachments = isset($event['_attachments']) ? $event['_attachments'] : $event['attachments']; foreach ((array) $attachments as $att) { if ($att['id'] == $id) { return $att; } } } } /** * Get attachment body * @see calendar_driver::get_attachment_body() */ public function get_attachment_body($id, $event) { if (!($cal = $this->get_calendar($event['calendar']))) return false; // get old revision of event if ($event['rev']) { if (empty($this->bonnie_api)) { return false; } $cid = substr($id, 4); // call Bonnie API and get the raw mime message list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); if ($msg_raw = $this->bonnie_api->rawdata('event', $uid, $event['rev'], $mailbox, $msguid)) { // parse the message and find the part with the matching content-id $message = rcube_mime::parse_message($msg_raw); foreach ((array)$message->parts as $part) { if ($part->headers['content-id'] && trim($part->headers['content-id'], '<>') == $cid) { return $part->body; } } } return false; } return $cal->get_attachment_body($id, $event); } /** * Build a struct representing the given message reference * * @see calendar_driver::get_message_reference() */ public function get_message_reference($uri_or_headers, $folder = null) { if (is_object($uri_or_headers)) { $uri_or_headers = kolab_storage_config::get_message_uri($uri_or_headers, $folder); } if (is_string($uri_or_headers)) { return kolab_storage_config::get_message_reference($uri_or_headers, 'event'); } return false; } /** * List availabale categories * The default implementation reads them from config/user prefs */ public function list_categories() { // FIXME: complete list with categories saved in config objects (KEP:12) return $this->rc->config->get('calendar_categories', $this->default_categories); } /** * Create instances of a recurring event * * @param array Hash array with event properties * @param object DateTime Start date of the recurrence window * @param object DateTime End date of the recurrence window * @return array List of recurring event instances */ public function get_recurring_events($event, $start, $end = null) { // load the given event data into a libkolabxml container if (!$event['_formatobj']) { $event_xml = new kolab_format_event(); $event_xml->set($event); $event['_formatobj'] = $event_xml; } $this->_read_calendars(); $storage = reset($this->calendars); return $storage->get_recurring_events($event, $start, $end); } /** * */ private function get_recurrence_count($event, $dtstart) { // use libkolab to compute recurring events if (class_exists('kolabcalendaring') && $event['_formatobj']) { $recurrence = new kolab_date_recurrence($event['_formatobj']); } else { // fallback to local recurrence implementation require_once($this->cal->home . '/lib/calendar_recurrence.php'); $recurrence = new calendar_recurrence($this->cal, $event); } $count = 0; while (($next_event = $recurrence->next_instance()) && $next_event['start'] <= $dtstart && $count < 1000) { $count++; } return $count; } /** * Fetch free/busy information from a person within the given range */ public function get_freebusy_list($email, $start, $end) { if (empty($email)/* || $end < time()*/) return false; // map vcalendar fbtypes to internal values $fbtypemap = array( 'FREE' => calendar::FREEBUSY_FREE, 'BUSY-TENTATIVE' => calendar::FREEBUSY_TENTATIVE, 'X-OUT-OF-OFFICE' => calendar::FREEBUSY_OOF, 'OOF' => calendar::FREEBUSY_OOF); // ask kolab server first try { $request_config = array( 'store_body' => true, 'follow_redirects' => true, ); $request = libkolab::http_request(kolab_storage::get_freebusy_url($email), 'GET', $request_config); $response = $request->send(); // authentication required if ($response->getStatus() == 401) { $request->setAuth($this->rc->user->get_username(), $this->rc->decrypt($_SESSION['password'])); $response = $request->send(); } if ($response->getStatus() == 200) $fbdata = $response->getBody(); unset($request, $response); } catch (Exception $e) { PEAR::raiseError("Error fetching free/busy information: " . $e->getMessage()); } // get free-busy url from contacts if (!$fbdata) { $fburl = null; foreach ((array)$this->rc->config->get('autocomplete_addressbooks', 'sql') as $book) { $abook = $this->rc->get_address_book($book); if ($result = $abook->search(array('email'), $email, true, true, true/*, 'freebusyurl'*/)) { while ($contact = $result->iterate()) { if ($fburl = $contact['freebusyurl']) { $fbdata = @file_get_contents($fburl); break; } } } if ($fbdata) break; } } // parse free-busy information using Horde classes if ($fbdata) { $ical = $this->cal->get_ical(); $ical->import($fbdata); if ($fb = $ical->freebusy) { $result = array(); foreach ($fb['periods'] as $tuple) { list($from, $to, $type) = $tuple; $result[] = array($from->format('U'), $to->format('U'), isset($fbtypemap[$type]) ? $fbtypemap[$type] : calendar::FREEBUSY_BUSY); } // we take 'dummy' free-busy lists as "unknown" if (empty($result) && !empty($fb['comment']) && stripos($fb['comment'], 'dummy')) return false; // set period from $start till the begin of the free-busy information as 'unknown' if ($fb['start'] && ($fbstart = $fb['start']->format('U')) && $start < $fbstart) { array_unshift($result, array($start, $fbstart, calendar::FREEBUSY_UNKNOWN)); } // pad period till $end with status 'unknown' if ($fb['end'] && ($fbend = $fb['end']->format('U')) && $fbend < $end) { $result[] = array($fbend, $end, calendar::FREEBUSY_UNKNOWN); } return $result; } } return false; } /** * Handler to push folder triggers when sent from client. * Used to push free-busy changes asynchronously after updating an event */ public function push_freebusy() { // make shure triggering completes set_time_limit(0); ignore_user_abort(true); $cal = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); if (!($cal = $this->get_calendar($cal))) return false; // trigger updates on folder $trigger = $cal->storage->trigger(); if (is_object($trigger) && is_a($trigger, 'PEAR_Error')) { rcube::raise_error(array( 'code' => 900, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Failed triggering folder. Error was " . $trigger->getMessage()), true, false); } exit; } /** * Convert from driver format to external caledar app data */ public static function to_rcube_event(&$record) { if (!is_array($record)) return $record; $record['id'] = $record['uid']; if ($record['_instance']) { $record['id'] .= '-' . $record['_instance']; if (!$record['recurrence_id'] && !empty($record['recurrence'])) $record['recurrence_id'] = $record['uid']; } // all-day events go from 12:00 - 13:00 if (is_a($record['start'], 'DateTime') && $record['end'] <= $record['start'] && $record['allday']) { $record['end'] = clone $record['start']; $record['end']->add(new DateInterval('PT1H')); } // translate internal '_attachments' to external 'attachments' list if (!empty($record['_attachments'])) { foreach ($record['_attachments'] as $key => $attachment) { if ($attachment !== false) { if (!$attachment['name']) $attachment['name'] = $key; unset($attachment['path'], $attachment['content']); $attachments[] = $attachment; } } $record['attachments'] = $attachments; } if (!empty($record['attendees'])) { foreach ((array)$record['attendees'] as $i => $attendee) { if (is_array($attendee['delegated-from'])) { $record['attendees'][$i]['delegated-from'] = join(', ', $attendee['delegated-from']); } if (is_array($attendee['delegated-to'])) { $record['attendees'][$i]['delegated-to'] = join(', ', $attendee['delegated-to']); } } } // Roundcube only supports one category assignment if (is_array($record['categories'])) $record['categories'] = $record['categories'][0]; // the cancelled flag transltes into status=CANCELLED if ($record['cancelled']) $record['status'] = 'CANCELLED'; // The web client only supports DISPLAY type of alarms if (!empty($record['alarms'])) $record['alarms'] = preg_replace('/:[A-Z]+$/', ':DISPLAY', $record['alarms']); // remove empty recurrence array if (empty($record['recurrence'])) unset($record['recurrence']); // clean up exception data if (is_array($record['recurrence']['EXCEPTIONS'])) { array_walk($record['recurrence']['EXCEPTIONS'], function(&$exception) { unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj'], $exception['_attachments']); }); } unset($record['_mailbox'], $record['_msguid'], $record['_type'], $record['_size'], $record['_formatobj'], $record['_attachments'], $record['exceptions'], $record['x-custom']); return $record; } /** * */ public static function from_rcube_event($event, $old = array()) { kolab_format::merge_attachments($event, $old); return $event; } /** * Set CSS class according to the event's attendde partstat */ public static function add_partstat_class($event, $partstats, $user = null) { // set classes according to PARTSTAT if (is_array($event['attendees'])) { $user_emails = libcalendaring::get_instance()->get_user_emails($user); $partstat = 'UNKNOWN'; foreach ($event['attendees'] as $attendee) { if (in_array($attendee['email'], $user_emails)) { $partstat = $attendee['status']; break; } } if (in_array($partstat, $partstats)) { $event['className'] = trim($event['className'] . ' fc-invitation-' . strtolower($partstat)); } } return $event; } /** * Provide a list of revisions for the given event * * @param array $event Hash array with event properties * * @return array List of changes, each as a hash array * @see calendar_driver::get_event_changelog() */ public function get_event_changelog($event) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); $result = $this->bonnie_api->changelog('event', $uid, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid) { return $result['changes']; } return false; } /** * Get a list of property changes beteen two revisions of an event * * @param array $event Hash array with event properties * @param mixed $rev1 Old Revision * @param mixed $rev2 New Revision * * @return array List of property changes, each as a hash array * @see calendar_driver::get_event_diff() */ public function get_event_diff($event, $rev1, $rev2) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); // get diff for the requested recurrence instance $instance_id = $event['id'] != $uid ? substr($event['id'], strlen($uid) + 1) : null; // call Bonnie API $result = $this->bonnie_api->diff('event', $uid, $rev1, $rev2, $mailbox, $msguid, $instance_id); if (is_array($result) && $result['uid'] == $uid) { $result['rev1'] = $rev1; $result['rev2'] = $rev2; $keymap = array( 'dtstart' => 'start', 'dtend' => 'end', 'dstamp' => 'changed', 'summary' => 'title', 'alarm' => 'alarms', 'attendee' => 'attendees', 'attach' => 'attachments', 'rrule' => 'recurrence', 'transparency' => 'free_busy', 'classification' => 'sensitivity', 'lastmodified-date' => 'changed', ); $prop_keymaps = array( 'attachments' => array('fmttype' => 'mimetype', 'label' => 'name'), 'attendees' => array('partstat' => 'status'), ); $special_changes = array(); // map kolab event properties to keys the client expects array_walk($result['changes'], function(&$change, $i) use ($keymap, $prop_keymaps, $special_changes) { if (array_key_exists($change['property'], $keymap)) { $change['property'] = $keymap[$change['property']]; } // translate free_busy values if ($change['property'] == 'free_busy') { $change['old'] = $old['old'] ? 'free' : 'busy'; $change['new'] = $old['new'] ? 'free' : 'busy'; } // map alarms trigger value if ($change['property'] == 'alarms') { if (is_array($change['old']) && is_array($change['old']['trigger'])) $change['old']['trigger'] = $change['old']['trigger']['value']; if (is_array($change['new']) && is_array($change['new']['trigger'])) $change['new']['trigger'] = $change['new']['trigger']['value']; } // make all property keys uppercase if ($change['property'] == 'recurrence') { $special_changes['recurrence'] = $i; foreach (array('old','new') as $m) { if (is_array($change[$m])) { $props = array(); foreach ($change[$m] as $k => $v) $props[strtoupper($k)] = $v; $change[$m] = $props; } } } // map property keys names if (is_array($prop_keymaps[$change['property']])) { foreach ($prop_keymaps[$change['property']] as $k => $dest) { if (is_array($change['old']) && array_key_exists($k, $change['old'])) { $change['old'][$dest] = $change['old'][$k]; unset($change['old'][$k]); } if (is_array($change['new']) && array_key_exists($k, $change['new'])) { $change['new'][$dest] = $change['new'][$k]; unset($change['new'][$k]); } } } if ($change['property'] == 'exdate') { $special_changes['exdate'] = $i; } else if ($change['property'] == 'rdate') { $special_changes['rdate'] = $i; } }); // merge some recurrence changes foreach (array('exdate','rdate') as $prop) { if (array_key_exists($prop, $special_changes)) { $exdate = $result['changes'][$special_changes[$prop]]; if (array_key_exists('recurrence', $special_changes)) { $recurrence = &$result['changes'][$special_changes['recurrence']]; } else { $i = count($result['changes']); $result['changes'][$i] = array('property' => 'recurrence', 'old' => array(), 'new' => array()); $recurrence = &$result['changes'][$i]['recurrence']; } $key = strtoupper($prop); $recurrence['old'][$key] = $exdate['old']; $recurrence['new'][$key] = $exdate['new']; unset($result['changes'][$special_changes[$prop]]); } } return $result; } return false; } /** * Return full data of a specific revision of an event * * @param array Hash array with event properties * @param mixed $rev Revision number * * @return array Event object as hash array * @see calendar_driver::get_event_revison() */ public function get_event_revison($event, $rev, $internal = false) { if (empty($this->bonnie_api)) { return false; } $eventid = $event['id']; $calid = $event['calendar']; list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); // call Bonnie API $result = $this->bonnie_api->get('event', $uid, $rev, $mailbox, $msguid); if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { $format = kolab_format::factory('event'); $format->load($result['xml']); $event = $format->to_array(); $format->get_attachments($event, true); // get the right instance from a recurring event if ($eventid != $event['uid']) { $instance_id = substr($eventid, strlen($event['uid']) + 1); // check for recurrence exception first if ($instance = $format->get_instance($instance_id)) { $event = $instance; } else { // not a exception, compute recurrence... $event['_formatobj'] = $format; $recurrence_date = rcube_utils::anytodatetime($instance_id, $event['start']->getTimezone()); foreach ($this->get_recurring_events($event, $event['start'], $recurrence_date) as $instance) { if ($instance['id'] == $eventid) { $event = $instance; break; } } } } if ($format->is_valid()) { $event['calendar'] = $calid; $event['rev'] = $result['rev']; return $internal ? $event : self::to_rcube_event($event); } } return false; } /** * Command the backend to restore a certain revision of an event. * This shall replace the current event with an older version. * * @param mixed UID string or hash array with event properties: * id: Event identifier * calendar: Calendar identifier * @param mixed $rev Revision number * * @return boolean True on success, False on failure */ public function restore_event_revision($event, $rev) { if (empty($this->bonnie_api)) { return false; } list($uid, $mailbox, $msguid) = $this->_resolve_event_identity($event); $calendar = $this->get_calendar($event['calendar']); $success = false; if ($calendar && $calendar->storage && $calendar->editable) { if ($raw_msg = $this->bonnie_api->rawdata('event', $uid, $rev, $mailbox)) { $imap = $this->rc->get_storage(); // insert $raw_msg as new message if ($imap->save_message($calendar->storage->name, $raw_msg, null, false)) { $success = true; // delete old revision from imap and cache $imap->delete_message($msguid, $calendar->storage->name); $calendar->storage->cache->set($msguid, false); } } } return $success; } /** * Helper method to resolved the given event identifier into uid and folder * * @return array (uid,folder,msguid) tuple */ private function _resolve_event_identity($event) { $mailbox = $msguid = null; if (is_array($event)) { $uid = $event['uid'] ?: $event['id']; if (($cal = $this->get_calendar($event['calendar'])) && !($cal instanceof kolab_invitation_calendar)) { $mailbox = $cal->get_mailbox_id(); // get event object from storage in order to get the real object uid an msguid if ($ev = $cal->get_event($event['id'])) { $msguid = $ev['_msguid']; $uid = $ev['uid']; } } } else { $uid = $event; // get event object from storage in order to get the real object uid an msguid if ($ev = $this->get_event($event)) { $mailbox = $ev['_mailbox']; $msguid = $ev['_msguid']; $uid = $ev['uid']; } } return array($uid, $mailbox, $msguid); } /** * Callback function to produce driver-specific calendar create/edit form * * @param string Request action 'form-edit|form-new' * @param array Calendar properties (e.g. id, color) * @param array Edit form fields * * @return string HTML content of the form */ public function calendar_form($action, $calendar, $formfields) { // show default dialog for birthday calendar if (in_array($calendar['id'], array(self::BIRTHDAY_CALENDAR_ID, self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED))) { if ($calendar['id'] != self::BIRTHDAY_CALENDAR_ID) unset($formfields['showalarms']); return parent::calendar_form($action, $calendar, $formfields); } $this->_read_calendars(); if ($calendar['id'] && ($cal = $this->calendars[$calendar['id']])) { $folder = $cal->get_realname(); // UTF7 $color = $cal->get_color(); } else { $folder = ''; $color = ''; } $hidden_fields[] = array('name' => 'oldname', 'value' => $folder); $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = array(); if (strlen($folder)) { $path_imap = explode($delim, $folder); array_pop($path_imap); // pop off name part $path_imap = implode($path_imap, $delim); $options = $storage->folder_info($folder); } else { $path_imap = ''; } // General tab $form['props'] = array( 'name' => $this->rc->gettext('properties'), ); // Disable folder name input if (!empty($options) && ($options['norename'] || $options['protected'])) { $input_name = new html_hiddenfield(array('name' => 'name', 'id' => 'calendar-name')); $formfields['name']['value'] = kolab_storage::object_name($folder) . $input_name->show($folder); } // calendar name (default field) $form['props']['fieldsets']['location'] = array( 'name' => $this->rc->gettext('location'), 'content' => array( 'name' => $formfields['name'] ), ); if (!empty($options) && ($options['norename'] || $options['protected'])) { // prevent user from moving folder $hidden_fields[] = array('name' => 'parent', 'value' => $path_imap); } else { $select = kolab_storage::folder_selector('event', array('name' => 'parent', 'id' => 'calendar-parent'), $folder); $form['props']['fieldsets']['location']['content']['path'] = array( 'id' => 'calendar-parent', 'label' => $this->cal->gettext('parentcalendar'), 'value' => $select->show(strlen($folder) ? $path_imap : ''), ); } // calendar color (default field) $form['props']['fieldsets']['settings'] = array( 'name' => $this->rc->gettext('settings'), 'content' => array( 'color' => $formfields['color'], 'showalarms' => $formfields['showalarms'], ), ); if ($action != 'form-new') { $form['sharing'] = array( 'name' => rcube::Q($this->cal->gettext('tabsharing')), 'content' => html::tag('iframe', array( 'src' => $this->cal->rc->url(array('_action' => 'calendar-acl', 'id' => $calendar['id'], 'framed' => 1)), 'width' => '100%', 'height' => 350, 'border' => 0, 'style' => 'border:0'), ''), ); } $this->form_html = ''; if (is_array($hidden_fields)) { foreach ($hidden_fields as $field) { $hiddenfield = new html_hiddenfield($field); $this->form_html .= $hiddenfield->show() . "\n"; } } // Create form output foreach ($form as $tab) { if (!empty($tab['fieldsets']) && is_array($tab['fieldsets'])) { $content = ''; foreach ($tab['fieldsets'] as $fieldset) { $subcontent = $this->get_form_part($fieldset); if ($subcontent) { $content .= html::tag('fieldset', null, html::tag('legend', null, rcube::Q($fieldset['name'])) . $subcontent) ."\n"; } } } else { $content = $this->get_form_part($tab); } if ($content) { $this->form_html .= html::tag('fieldset', null, html::tag('legend', null, rcube::Q($tab['name'])) . $content) ."\n"; } } // Parse form template for skin-dependent stuff $this->rc->output->add_handler('calendarform', array($this, 'calendar_form_html')); return $this->rc->output->parse('calendar.kolabform', false, false); } /** * Handler for template object */ public function calendar_form_html() { return $this->form_html; } /** * Helper function used in calendar_form_content(). Creates a part of the form. */ private function get_form_part($form) { $content = ''; if (is_array($form['content']) && !empty($form['content'])) { $table = new html_table(array('cols' => 2)); foreach ($form['content'] as $col => $colprop) { $label = !empty($colprop['label']) ? $colprop['label'] : $this->cal->gettext($col); $table->add('title', html::label($colprop['id'], rcube::Q($label))); $table->add(null, $colprop['value']); } $content = $table->show(); } else { $content = $form['content']; } return $content; } /** * Handler to render ACL form for a calendar folder */ public function calendar_acl() { $this->rc->output->add_handler('folderacl', array($this, 'calendar_acl_form')); $this->rc->output->send('calendar.kolabacl'); } /** * Handler for ACL form template object */ public function calendar_acl_form() { $calid = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); if ($calid && ($cal = $this->get_calendar($calid))) { $folder = $cal->get_realname(); // UTF7 $color = $cal->get_color(); } else { $folder = ''; $color = ''; } $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = array(); if (strlen($folder)) { $path_imap = explode($delim, $folder); array_pop($path_imap); // pop off name part $path_imap = implode($path_imap, $delim); $options = $storage->folder_info($folder); // Allow plugins to modify the form content (e.g. with ACL form) $plugin = $this->rc->plugins->exec_hook('calendar_form_kolab', array('form' => $form, 'options' => $options, 'name' => $folder)); } if (!$plugin['form']['sharing']['content']) $plugin['form']['sharing']['content'] = html::div('hint', $this->cal->gettext('aclnorights')); return $plugin['form']['sharing']['content']; } /** * Handler for user_delete plugin hook */ public function user_delete($args) { $db = $this->rc->get_dbh(); foreach (array('kolab_alarms', 'itipinvitations') as $table) { $db->query("DELETE FROM " . $this->rc->db->table_name($table, true) . " WHERE `user_id` = ?", $args['user']->ID); } } } diff --git a/plugins/kolab_delegation/kolab_delegation_engine.php b/plugins/kolab_delegation/kolab_delegation_engine.php index 3f63df9b..ca3637fa 100644 --- a/plugins/kolab_delegation/kolab_delegation_engine.php +++ b/plugins/kolab_delegation/kolab_delegation_engine.php @@ -1,937 +1,943 @@ * @author Aleksander Machniak * * Copyright (C) 2011-2012, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_delegation_engine { public $context; private $rc; private $ldap_filter; private $ldap_delegate_field; private $ldap_login_field; private $ldap_name_field; private $ldap_email_field; private $ldap_org_field; private $ldap_dn; private $cache = array(); private $folder_types = array('mail', 'event', 'task'); const ACL_READ = 1; const ACL_WRITE = 2; /** * Class constructor */ public function __construct() { $this->rc = rcube::get_instance(); } /** * Add delegate * * @param string|array $delegate Delegate DN (encoded) or delegate data (result of delegate_get()) * @param array $acl List of folder->right map */ public function delegate_add($delegate, $acl) { if (!is_array($delegate)) { $delegate = $this->delegate_get($delegate); } $dn = $delegate['ID']; if (empty($delegate) || empty($dn)) { return false; } $list = $this->list_delegates(); // add delegate to the list $list = array_keys((array)$list); $list = array_filter($list); if (!in_array($dn, $list)) { $list[] = $dn; } $list = array_map(array('kolab_auth_ldap', 'dn_decode'), $list); // update user record $result = $this->user_update_delegates($list); // Set ACL on folders if ($result && !empty($acl)) { $this->delegate_acl_update($delegate['uid'], $acl); } return $result; } /** * Set/Update ACL on delegator's folders * * @param string $uid Delegate authentication identifier * @param array $acl List of folder->right map * @param bool $update Update (remove) old rights */ public function delegate_acl_update($uid, $acl, $update = false) { $storage = $this->rc->get_storage(); $right_types = $this->right_types(); $folders = $update ? $this->list_folders($uid) : array(); foreach ($acl as $folder_name => $rights) { $r = $right_types[$rights]; if ($r) { $storage->set_acl($folder_name, $uid, $r); } else { $storage->delete_acl($folder_name, $uid); } if (!empty($folders) && isset($folders[$folder_name])) { unset($folders[$folder_name]); } } foreach ($folders as $folder_name => $folder) { if ($folder['rights']) { $storage->delete_acl($folder_name, $uid); } } return true; } /** * Delete delgate * * @param string $dn Delegate DN (encoded) * @param bool $acl_del Enable ACL deletion on delegator folders */ public function delegate_delete($dn, $acl_del = false) { $delegate = $this->delegate_get($dn); $list = $this->list_delegates(); $user = $this->user(); if (empty($delegate) || !isset($list[$dn])) { return false; } // remove delegate from the list unset($list[$dn]); $list = array_keys($list); $list = array_map(array('kolab_auth_ldap', 'dn_decode'), $list); $user[$this->ldap_delegate_field] = $list; // update user record $result = $this->user_update_delegates($list); // remove ACL if ($result && $acl_del) { $this->delegate_acl_update($delegate['uid'], array(), true); } return $result; } /** * Return delegate data * * @param string $dn Delegate DN (encoded) * * @return array Delegate record (ID, name, uid, imap_uid) */ public function delegate_get($dn) { // use internal cache so we not query LDAP more than once per request if (!isset($this->cache[$dn])) { $ldap = $this->ldap(); if (!$ldap || empty($dn)) { return array(); } // Get delegate $user = $ldap->get_record(kolab_auth_ldap::dn_decode($dn)); if (empty($user)) { return array(); } $delegate = $this->parse_ldap_record($user); $delegate['ID'] = $dn; $this->cache[$dn] = $delegate; } return $this->cache[$dn]; } /** * Return delegate data * * @param string $login Delegate name (the 'uid' returned in get_users()) * * @return array Delegate record (ID, name, uid, imap_uid) */ public function delegate_get_by_name($login) { $ldap = $this->ldap(); if (!$ldap || empty($login)) { return array(); } $list = $ldap->dosearch($this->ldap_login_field, $login, 1); if (count($list) == 1) { $dn = key($list); $user = $list[$dn]; return $this->parse_ldap_record($user, $dn); } } /** * LDAP object getter */ private function ldap() { $ldap = kolab_auth::ldap(); if (!$ldap || !$ldap->ready) { return null; } // Default filter of LDAP queries $this->ldap_filter = $this->rc->config->get('kolab_delegation_filter', '(|(objectClass=kolabInetOrgPerson)(&(objectclass=kolabsharedfolder)(kolabFolderType=mail)))'); // Name of the LDAP field for delegates list $this->ldap_delegate_field = $this->rc->config->get('kolab_delegation_delegate_field', 'kolabDelegate'); // Encoded LDAP DN of current user, set on login by kolab_auth plugin $this->ldap_dn = $_SESSION['kolab_dn']; // Name of the LDAP field with authentication ID $this->ldap_login_field = $this->rc->config->get('kolab_auth_login'); // Name of the LDAP field with user name used for identities $this->ldap_name_field = $this->rc->config->get('kolab_auth_name'); // Name of the LDAP field with email addresses used for identities $this->ldap_email_field = $this->rc->config->get('kolab_auth_email'); // Name of the LDAP field with organization name for identities $this->ldap_org_field = $this->rc->config->get('kolab_auth_organization'); $ldap->set_filter($this->ldap_filter); $ldap->extend_fieldmap(array($this->ldap_delegate_field => $this->ldap_delegate_field)); return $ldap; } /** * List current user delegates */ public function list_delegates() { $result = array(); $ldap = $this->ldap(); $user = $this->user(); if (empty($ldap) || empty($user)) { return array(); } // Get delegates of current user $delegates = $user[$this->ldap_delegate_field]; if (!empty($delegates)) { foreach ((array)$delegates as $dn) { $delegate = $ldap->get_record($dn); $data = $this->parse_ldap_record($delegate, $dn); if (!empty($data) && !empty($data['name'])) { $result[$data['ID']] = $data['name']; } } } return $result; } /** * List current user delegators * * @return array List of delegators */ public function list_delegators() { $result = array(); $ldap = $this->ldap(); if (empty($ldap) || empty($this->ldap_dn)) { return array(); } $list = $ldap->dosearch($this->ldap_delegate_field, $this->ldap_dn, 1); foreach ($list as $dn => $delegator) { $delegator = $this->parse_ldap_record($delegator, $dn); $result[$delegator['ID']] = $delegator; } return $result; } /** * List current user delegators in format compatible with Calendar plugin * * @return array List of delegators */ public function list_delegators_js() { $list = $this->list_delegators(); $result = array(); foreach ($list as $delegator) { $name = $delegator['name']; if ($pos = strrpos($name, '(')) { $name = trim(substr($name, 0, $pos)); } $result[$delegator['imap_uid']] = array( 'emails' => ';' . implode(';', $delegator['email']), 'email' => $delegator['email'][0], 'name' => $name, ); } return $result; } /** * Prepare namespace prefixes for JS environment * * @return array List of prefixes */ public function namespace_js() { $storage = $this->rc->get_storage(); $ns = $storage->get_namespace('other'); if ($ns) { foreach ($ns as $idx => $nsval) { $ns[$idx] = kolab_storage::folder_id($nsval[0]); } } return $ns; } /** * Get all folders to which current user has admin access * * @param string $delegate IMAP user identifier * * @return array Folder type/rights */ public function list_folders($delegate = null) { $storage = $this->rc->get_storage(); $folders = $storage->list_folders(); $metadata = kolab_storage::folders_typedata(); $result = array(); if (!is_array($metadata)) { return $result; } // Definition of read and write ACL $right_types = $this->right_types(); foreach ($folders as $folder) { // get only folders in personal namespace if ($storage->folder_namespace($folder) != 'personal') { continue; } $rights = null; $type = $metadata[$folder] ?: 'mail'; list($class, $subclass) = explode('.', $type); if (!in_array($class, $this->folder_types)) { continue; } // in edit mode, get folder ACL if ($delegate) { // @TODO: cache ACL $acl = $storage->get_acl($folder); if ($acl = $acl[$delegate]) { if ($this->acl_compare($acl, $right_types[self::ACL_WRITE])) { $rights = self::ACL_WRITE; } else if ($this->acl_compare($acl, $right_types[self::ACL_READ])) { $rights = self::ACL_READ; } } } else if ($folder == 'INBOX' || $subclass == 'default' || $subclass == 'inbox') { $rights = self::ACL_WRITE; } $result[$folder] = array( 'type' => $class, 'rights' => $rights, ); } return $result; } /** * Returns list of users for autocompletion * * @param string $search Search string * * @return array Users list */ public function list_users($search) { $ldap = $this->ldap(); if (empty($ldap) || $search === '' || $search === null) { return array(); } $max = (int) $this->rc->config->get('autocomplete_max', 15); $mode = (int) $this->rc->config->get('addressbook_search_mode'); $fields = array_unique(array_filter(array_merge((array)$this->ldap_name_field, (array)$this->ldap_login_field))); $users = array(); $keys = array(); $result = $ldap->dosearch($fields, $search, $mode, (array)$this->ldap_login_field, $max); foreach ($result as $record) { // skip self if ($record['dn'] == $_SESSION['kolab_dn']) { continue; } $user = $this->parse_ldap_record($record); if ($user['uid']) { $display = rcube_addressbook::compose_search_name($record); $user = array('name' => $user['uid'], 'display' => $display); $users[] = $user; $keys[] = $display ?: $user['uid']; } } if (count($users)) { // sort users index asort($keys, SORT_LOCALE_STRING); // re-sort users according to index foreach (array_keys($keys) as $idx) { $keys[$idx] = $users[$idx]; } $users = array_values($keys); } return $users; } /** * Extract delegate identifiers and pretty name from LDAP record */ private function parse_ldap_record($data, $dn = null) { $email = array(); $uid = $data[$this->ldap_login_field]; if (is_array($uid)) { $uid = array_filter($uid); $uid = $uid[0]; } // User name for identity foreach ((array)$this->ldap_name_field as $field) { $name = is_array($data[$field]) ? $data[$field][0] : $data[$field]; if (!empty($name)) { break; } } // User email(s) for identity foreach ((array)$this->ldap_email_field as $field) { $user_email = is_array($data[$field]) ? array_filter($data[$field]) : $data[$field]; if (!empty($user_email)) { $email = array_merge((array)$email, (array)$user_email); } } // Organization for identity foreach ((array)$this->ldap_org_field as $field) { $organization = is_array($data[$field]) ? $data[$field][0] : $data[$field]; if (!empty($organization)) { break; } } $realname = $name; if ($uid && $name) { $name .= ' (' . $uid . ')'; } else { $name = $uid; } // get IMAP uid - identifier used in shared folder hierarchy $imap_uid = $uid; if ($pos = strpos($imap_uid, '@')) { $imap_uid = substr($imap_uid, 0, $pos); } return array( 'ID' => kolab_auth_ldap::dn_encode($dn), 'uid' => $uid, 'name' => $name, 'realname' => $realname, 'imap_uid' => $imap_uid, 'email' => $email, 'organization' => $organization, ); } /** * Returns LDAP record of current user * * @return array User data */ public function user($parsed = false) { if (!isset($this->cache['user'])) { $ldap = $this->ldap(); if (!$ldap) { return array(); } // Get current user record $this->cache['user'] = $ldap->get_record($this->ldap_dn); } return $parsed ? $this->parse_ldap_record($this->cache['user']) : $this->cache['user']; } /** * Update LDAP record of current user * * @param array List of delegates */ public function user_update_delegates($list) { $ldap = $this->ldap(); $pass = $this->rc->decrypt($_SESSION['password']); if (!$ldap) { return false; } // need to bind as self for sufficient privilages if (!$ldap->bind($this->ldap_dn, $pass)) { return false; } $user[$this->ldap_delegate_field] = $list; unset($this->cache['user']); // replace delegators list in user record return $ldap->replace($this->ldap_dn, $user); } /** * Manage delegation data on user login */ public function delegation_init() { // Fetch all delegators from LDAP who assigned the // current user as their delegate and create identities // a) if identity with delegator's email exists, continue // b) create identity ($delegate on behalf of $delegator // <$delegator-email>) for new delegators // c) remove all other identities which do not match the user's primary // or alias email if 'kolab_delegation_purge_identities' is set. $delegators = $this->list_delegators(); $use_subs = $this->rc->config->get('kolab_use_subscriptions'); $identities = $this->rc->user->list_emails(); $emails = array(); $uids = array(); if (!empty($delegators)) { $storage = $this->rc->get_storage(); $other_ns = $storage->get_namespace('other'); $folders = $storage->list_folders(); } // convert identities to simpler format for faster access foreach ($identities as $idx => $ident) { // get user name from default identity if (!$idx) { $default = array( 'name' => $ident['name'], ); } $emails[$ident['identity_id']] = $ident['email']; } // for every delegator... foreach ($delegators as $delegator) { $uids[$delegator['imap_uid']] = $email_arr = $delegator['email']; $diff = array_intersect($emails, $email_arr); // identity with delegator's email already exist, do nothing if (count($diff)) { $emails = array_diff($emails, $email_arr); continue; } // create identities for delegator emails foreach ($email_arr as $email) { // @TODO: "Delegatorname" or "Username on behalf of Delegatorname"? $default['name'] = $delegator['realname']; $default['email'] = $email; // Database field for organization is NOT NULL $default['organization'] = empty($delegator['organization']) ? '' : $delegator['organization']; $this->rc->user->insert_identity($default); } // IMAP folders shared by new delegators shall be subscribed on login, // as well as existing subscriptions of previously shared folders shall // be removed. I suppose the latter one is already done in Roundcube. // for every accessible folder... foreach ($folders as $folder) { // for every 'other' namespace root... foreach ($other_ns as $ns) { $prefix = $ns[0] . $delegator['imap_uid']; // subscribe delegator's folder if ($folder === $prefix || strpos($folder, $prefix . substr($ns[0], -1)) === 0) { // Event/Task folders need client-side activation $type = kolab_storage::folder_type($folder); if (preg_match('/^(event|task)/i', $type)) { kolab_storage::folder_activate($folder); } // Subscribe to mail folders and (if system is configured // to display only subscribed folders) to other if ($use_subs || preg_match('/^mail/i', $type)) { $storage->subscribe($folder); } } } } } // remove identities that "do not belong" to user nor delegators if ($this->rc->config->get('kolab_delegation_purge_identities')) { $user = $this->user(true); $emails = array_diff($emails, $user['email']); foreach (array_keys($emails) as $idx) { $this->rc->user->delete_identity($idx); } } $_SESSION['delegators'] = $uids; } /** * Sets delegator context according to email message recipient * * @param rcube_message $message Email message object */ public function delegator_context_from_message($message) { if (empty($_SESSION['delegators'])) { return; } // Match delegators' addresses with message To: address // @TODO: Is this reliable enough? // Roundcube sends invitations to every attendee separately, // but maybe there's a software which sends with CC header or many addresses in To: $emails = $message->get_header('to'); $emails = rcube_mime::decode_address_list($emails, null, false); foreach ($emails as $email) { foreach ($_SESSION['delegators'] as $uid => $addresses) { if (in_array($email['mailto'], $addresses)) { return $this->context = $uid; } } } } /** * Return (set) current delegator context * * @return string Delegator UID */ public function delegator_context() { if (!$this->context && !empty($_SESSION['delegators'])) { $context = rcube_utils::get_input_value('_context', rcube_utils::INPUT_GPC); if ($context && isset($_SESSION['delegators'][$context])) { $this->context = $context; } } return $this->context; } /** * Set user identity according to delegator delegator * * @param array $args Reference to plugin hook arguments */ public function delegator_identity_filter(&$args) { $context = $this->delegator_context(); if (!$context) { return; } $identities = $this->rc->user->list_emails(); $emails = $_SESSION['delegators'][$context]; foreach ($identities as $ident) { if (in_array($ident['email'], $emails)) { $args['identity'] = $ident; return; } } // fallback to default identity $args['identity'] = array_shift($identities); } /** * Filter user emails according to delegator context * * @param array $args Reference to plugin hook arguments */ public function delegator_emails_filter(&$args) { $context = $this->delegator_context(); // try to derive context from the given user email if (!$context && !empty($args['emails'])) { if (($user = preg_replace('/@.+$/', '', $args['emails'][0])) && isset($_SESSION['delegators'][$user])) { $context = $user; } } // return delegator's addresses if ($context) { $args['emails'] = $_SESSION['delegators'][$context]; $args['abort'] = true; } // return only user addresses (exclude all delegators addresses) else if (!empty($_SESSION['delegators'])) { $identities = $this->rc->user->list_emails(); $emails[] = $this->rc->user->get_username(); foreach ($identities as $identity) { $emails[] = $identity['email']; } foreach ($_SESSION['delegators'] as $delegator_emails) { $emails = array_diff($emails, $delegator_emails); } $args['emails'] = array_unique($emails); $args['abort'] = true; } } /** * Filters list of calendars according to delegator context * * @param array $args Reference to plugin hook arguments */ public function delegator_folder_filter(&$args) { - $context = $this->delegator_context(); + $context = $this->delegator_context(); + + if (empty($context)) { + return $args; + } + $storage = $this->rc->get_storage(); $other_ns = $storage->get_namespace('other'); $delim = $storage->get_hierarchy_delimiter(); + $editable = $filter & calendar_driver::FILTER_WRITEABLE; + $active = $filter & calendar_driver::FILTER_ACTIVE; + $personal = $filter & calendar_driver::FILTER_PERSONAL; + $shared = $filter & calendar_driver::FILTER_SHARED; $calendars = array(); // code parts derived from kolab_driver::filter_calendars() foreach ($args['list'] as $cal) { if (!$cal->ready) { continue; } - if ($args['writeable'] && $cal->readonly) { + if ($editable && !$cal->editable) { continue; } - if ($args['active'] && !$cal->storage->is_active()) { + if ($active && !$cal->storage->is_active()) { continue; } - if ($args['personal']) { + if ($personal || $shared) { $ns = $cal->get_namespace(); - if (empty($context)) { - if ($ns != 'personal') { - continue; - } + if ($personal && $ns == 'personal') { + continue; } - else { - if ($ns != 'other') { - continue; - } - + else if ($personal && $ns == 'other') { $found = false; foreach ($other_ns as $ns) { $folder = $ns[0] . $context . $delim; if (strpos($cal->name, $folder) === 0) { $found = true; } } if (!$found) { continue; } } + else if (!$shared || $ns != 'shared') { + continue; + } } $calendars[$cal->id] = $cal; } $args['calendars'] = $calendars; $args['abort'] = true; } /** * Filters/updates message headers according to delegator context * * @param array $args Reference to plugin hook arguments */ public function delegator_delivery_filter(&$args) { // no context, but message still can be send on behalf of... if (!empty($_SESSION['delegators'])) { $message = $args['message']; $headers = $message->headers(); // get email address from From: header $from = rcube_mime::decode_address_list($headers['From']); $from = array_shift($from); $from = $from['mailto']; foreach ($_SESSION['delegators'] as $uid => $addresses) { if (in_array($from, $addresses)) { $context = $uid; break; } } // add Sender: header with current user default identity if ($context) { $identity = $this->rc->user->get_identity(); $sender = format_email_recipient($identity['email'], $identity['name']); $message->headers(array('Sender' => $sender), false, true); } } } /** * Compares two ACLs (according to supported rights) * * @param array $acl1 ACL rights array (or string) * @param array $acl2 ACL rights array (or string) * * @param bool True if $acl1 contains all rights from $acl2 */ function acl_compare($acl1, $acl2) { if (!is_array($acl1)) $acl1 = str_split($acl1); if (!is_array($acl2)) $acl2 = str_split($acl2); $rights = $this->rights_supported(); $acl1 = array_intersect($acl1, $rights); $acl2 = array_intersect($acl2, $rights); $res = array_intersect($acl1, $acl2); $cnt1 = count($res); $cnt2 = count($acl2); if ($cnt1 >= $cnt2) { return true; } } /** * Get list of supported access rights (according to RIGHTS capability) * * @todo: this is stolen from acl plugin, move to rcube_storage/rcube_imap * * @return array List of supported access rights abbreviations */ public function rights_supported() { if ($this->supported !== null) { return $this->supported; } $storage = $this->rc->get_storage(); $capa = $storage->get_capability('RIGHTS'); if (is_array($capa)) { $rights = strtolower($capa[0]); } else { $rights = 'cd'; } return $this->supported = str_split('lrswi' . $rights . 'pa'); } private function right_types() { // Get supported rights and build column names $supported = $this->rights_supported(); // depending on server capability either use 'te' or 'd' for deleting msgs $deleteright = implode(array_intersect(str_split('ted'), $supported)); return array( self::ACL_READ => 'lrs', self::ACL_WRITE => 'lrswi'.$deleteright, ); } } diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php index 95138c76..929b0967 100644 --- a/plugins/libcalendaring/lib/libcalendaring_itip.php +++ b/plugins/libcalendaring/lib/libcalendaring_itip.php @@ -1,829 +1,829 @@ * * Copyright (C) 2011-2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class libcalendaring_itip { protected $rc; protected $lib; protected $plugin; protected $sender; protected $domain; protected $itip_send = false; protected $rsvp_actions = array('accepted','tentative','declined','delegated'); protected $rsvp_status = array('accepted','tentative','declined','delegated'); function __construct($plugin, $domain = 'libcalendaring') { $this->plugin = $plugin; $this->rc = rcube::get_instance(); $this->lib = libcalendaring::get_instance(); $this->domain = $domain; $hook = $this->rc->plugins->exec_hook('calendar_load_itip', array('identity' => $this->rc->user->list_emails(true))); $this->sender = $hook['identity']; $this->plugin->add_hook('message_before_send', array($this, 'before_send_hook')); $this->plugin->add_hook('smtp_connect', array($this, 'smtp_connect_hook')); } public function set_sender_email($email) { if (!empty($email)) $this->sender['email'] = $email; } public function set_rsvp_actions($actions) { $this->rsvp_actions = (array)$actions; $this->rsvp_status = array_merge($this->rsvp_actions, array('delegated')); } public function set_rsvp_status($status) { $this->rsvp_status = $status; } /** * Wrapper for rcube_plugin::gettext() * Checking for a label in different domains * * @see rcube::gettext() */ public function gettext($p) { $label = is_array($p) ? $p['name'] : $p; $domain = $this->domain; if (!$this->rc->text_exists($label, $domain)) { $domain = 'libcalendaring'; } return $this->rc->gettext($p, $domain); } /** * Send an iTip mail message * * @param array Event object to send * @param string iTip method (REQUEST|REPLY|CANCEL) * @param array Hash array with recipient data (name, email) * @param string Mail subject * @param string Mail body text label * @param object Mail_mime object with message data * @param boolean Request RSVP * @return boolean True on success, false on failure */ public function send_itip_message($event, $method, $recipient, $subject, $bodytext, $message = null, $rsvp = true) { if (!$this->sender['name']) $this->sender['name'] = $this->sender['email']; if (!$message) { libcalendaring::identify_recurrence_instance($event); $message = $this->compose_itip_message($event, $method, $rsvp); } $mailto = rcube_utils::idn_to_ascii($recipient['email']); $headers = $message->headers(); $headers['To'] = format_email_recipient($mailto, $recipient['name']); $headers['Subject'] = $this->gettext(array( 'name' => $subject, 'vars' => array( 'title' => $event['title'], 'name' => $this->sender['name'] ) )); // compose a list of all event attendees $attendees_list = array(); foreach ((array)$event['attendees'] as $attendee) { $attendees_list[] = ($attendee['name'] && $attendee['email']) ? $attendee['name'] . ' <' . $attendee['email'] . '>' : ($attendee['name'] ? $attendee['name'] : $attendee['email']); } $recurrence_info = ''; if (!empty($event['recurrence_id'])) { $recurrence_info = "\n\n** " . $this->gettext($event['thisandfuture'] ? 'itipmessagefutureoccurrence' : 'itipmessagesingleoccurrence') . ' **'; } else if (!empty($event['recurrence'])) { $recurrence_info = sprintf("\n%s: %s", $this->gettext('recurring'), $this->lib->recurrence_text($event['recurrence'])); } $mailbody = $this->gettext(array( 'name' => $bodytext, 'vars' => array( 'title' => $event['title'], 'date' => $this->lib->event_date_text($event, true) . $recurrence_info, 'attendees' => join(",\n ", $attendees_list), 'sender' => $this->sender['name'], 'organizer' => $this->sender['name'], ) )); // if (!empty($event['comment'])) { // $mailbody .= "\n\n" . $this->gettext('itipsendercomment') . $event['comment']; // } // append links for direct invitation replies if ($method == 'REQUEST' && $rsvp && ($token = $this->store_invitation($event, $recipient['email']))) { $mailbody .= "\n\n" . $this->gettext(array( 'name' => 'invitationattendlinks', 'vars' => array('url' => $this->plugin->get_url(array('action' => 'attend', 't' => $token))), )); } else if ($method == 'CANCEL' && $event['cancelled']) { $this->cancel_itip_invitation($event); } $message->headers($headers, true); $message->setTXTBody(rcube_mime::format_flowed($mailbody, 79)); if ($this->rc->config->get('libcalendaring_itip_debug', false)) { rcube::console('iTip ' . $method, $message->txtHeaders() . "\r\n" . $message->get()); } // finally send the message $this->itip_send = true; $sent = $this->rc->deliver_message($message, $headers['X-Sender'], $mailto, $smtp_error); $this->itip_send = false; return $sent; } /** * Plugin hook triggered by rcube::deliver_message() before delivering a message. * Here we can set the 'smtp_server' config option to '' in order to use * PHP's mail() function for unauthenticated email sending. */ public function before_send_hook($p) { if ($this->itip_send && !$this->rc->user->ID && $this->rc->config->get('calendar_itip_smtp_server', null) === '') { $this->rc->config->set('smtp_server', ''); } return $p; } /** * Plugin hook to alter SMTP authentication. * This is used if iTip messages are to be sent from an unauthenticated session */ public function smtp_connect_hook($p) { // replace smtp auth settings if we're not in an authenticated session if ($this->itip_send && !$this->rc->user->ID) { foreach (array('smtp_server', 'smtp_user', 'smtp_pass') as $prop) { $p[$prop] = $this->rc->config->get("calendar_itip_$prop", $p[$prop]); } } return $p; } /** * Helper function to build a Mail_mime object to send an iTip message * * @param array Event object to send * @param string iTip method (REQUEST|REPLY|CANCEL) * @param boolean Request RSVP * @return object Mail_mime object with message data */ public function compose_itip_message($event, $method, $rsvp = true) { $from = rcube_utils::idn_to_ascii($this->sender['email']); $from_utf = rcube_utils::idn_to_utf8($from); $sender = format_email_recipient($from, $this->sender['name']); // truncate list attendees down to the recipient of the iTip Reply. // constraints for a METHOD:REPLY according to RFC 5546 if ($method == 'REPLY') { $replying_attendee = null; $reply_attendees = array(); foreach ($event['attendees'] as $attendee) { if ($attendee['role'] == 'ORGANIZER') { $reply_attendees[] = $attendee; } else if (strcasecmp($attendee['email'], $from) == 0 || strcasecmp($attendee['email'], $from_utf) == 0) { $replying_attendee = $attendee; if ($attendee['status'] != 'DELEGATED') { unset($replying_attendee['rsvp']); // unset the RSVP attribute } } // include attendees relevant for delegation (RFC 5546, Section 4.2.5) else if ((!empty($attendee['delegated-to']) && (strcasecmp($attendee['delegated-to'], $from) == 0 || strcasecmp($attendee['delegated-to'], $from_utf) == 0)) || (!empty($attendee['delegated-from']) && (strcasecmp($attendee['delegated-from'], $from) == 0 || strcasecmp($attendee['delegated-from'], $from_utf) == 0))) { $reply_attendees[] = $attendee; } } if ($replying_attendee) { array_unshift($reply_attendees, $replying_attendee); $event['attendees'] = $reply_attendees; } if ($event['recurrence']) { unset($event['recurrence']['EXCEPTIONS']); } } // set RSVP for every attendee else if ($method == 'REQUEST') { foreach ($event['attendees'] as $i => $attendee) { if (($rsvp || !isset($attendee['rsvp'])) && ($attendee['status'] != 'DELEGATED' && $attendee['role'] != 'NON-PARTICIPANT')) { $event['attendees'][$i]['rsvp']= (bool)$rsvp; } } } else if ($method == 'CANCEL') { if ($event['recurrence']) { unset($event['recurrence']['EXCEPTIONS']); } } // compose multipart message using PEAR:Mail_Mime $message = new Mail_mime("\r\n"); $message->setParam('text_encoding', 'quoted-printable'); $message->setParam('head_encoding', 'quoted-printable'); $message->setParam('head_charset', RCUBE_CHARSET); $message->setParam('text_charset', RCUBE_CHARSET . ";\r\n format=flowed"); $message->setContentType('multipart/alternative'); // compose common headers array $headers = array( 'From' => $sender, 'Date' => $this->rc->user_date(), 'Message-ID' => $this->rc->gen_message_id(), 'X-Sender' => $from, ); if ($agent = $this->rc->config->get('useragent')) { $headers['User-Agent'] = $agent; } $message->headers($headers); // attach ics file for this event $ical = libcalendaring::get_ical(); $ics = $ical->export(array($event), $method, false, $method == 'REQUEST' && $this->plugin->driver ? array($this->plugin->driver, 'get_attachment_body') : false); $filename = $event['_type'] == 'task' ? 'todo.ics' : 'event.ics'; $message->addAttachment($ics, 'text/calendar', $filename, false, '8bit', '', RCUBE_CHARSET . "; method=" . $method); return $message; } /** * Forward the given iTip event as delegation to another person * * @param array Event object to delegate * @param mixed Delegatee as string or hash array with keys 'name' and 'mailto' * @param boolean The delegator's RSVP flag * @param array List with indexes of new/updated attendees * @return boolean True on success, False on failure */ public function delegate_to(&$event, $delegate, $rsvp = false, &$attendees = array()) { if (is_string($delegate)) { $delegates = rcube_mime::decode_address_list($delegate, 1, false); if (count($delegates) > 0) { $delegate = reset($delegates); } } $emails = $this->lib->get_user_emails(); $me = $this->rc->user->list_emails(true); // find/create the delegate attendee $delegate_attendee = array( 'email' => $delegate['mailto'], 'name' => $delegate['name'], 'role' => 'REQ-PARTICIPANT', ); $delegate_index = count($event['attendees']); foreach ($event['attendees'] as $i => $attendee) { // set myself the DELEGATED-TO parameter if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $event['attendees'][$i]['delegated-to'] = $delegate['mailto']; $event['attendees'][$i]['status'] = 'DELEGATED'; $event['attendees'][$i]['role'] = 'NON-PARTICIPANT'; $event['attendees'][$i]['rsvp'] = $rsvp; $me['email'] = $attendee['email']; $delegate_attendee['role'] = $attendee['role']; } // the disired delegatee is already listed as an attendee else if (stripos($delegate['mailto'], $attendee['email']) !== false && $attendee['role'] != 'ORGANIZER') { $delegate_attendee = $attendee; $delegate_index = $i; break; } // TODO: remove previous delegatee (i.e. attendee that has DELEGATED-FROM == $me) } // set/add delegate attendee with RSVP=TRUE and DELEGATED-FROM parameter $delegate_attendee['rsvp'] = true; $delegate_attendee['status'] = 'NEEDS-ACTION'; $delegate_attendee['delegated-from'] = $me['email']; $event['attendees'][$delegate_index] = $delegate_attendee; $attendees[] = $delegate_index; $this->set_sender_email($me['email']); return $this->send_itip_message($event, 'REQUEST', $delegate_attendee, 'itipsubjectdelegatedto', 'itipmailbodydelegatedto'); } /** * Handler for calendar/itip-status requests */ public function get_itip_status($event, $existing = null) { $action = $event['rsvp'] ? 'rsvp' : ''; $status = $event['fallback']; $latest = false; $html = ''; if (is_numeric($event['changed'])) $event['changed'] = new DateTime('@'.$event['changed']); // check if the given itip object matches the last state if ($existing) { $latest = (isset($event['sequence']) && intval($existing['sequence']) == intval($event['sequence'])) || (!isset($event['sequence']) && $existing['changed'] && $existing['changed'] >= $event['changed']); } // determine action for REQUEST if ($event['method'] == 'REQUEST') { $html = html::div('rsvp-status', $this->gettext('acceptinvitation')); if ($existing) { $rsvp = $event['rsvp']; $emails = $this->lib->get_user_emails(); foreach ($existing['attendees'] as $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $status = strtoupper($attendee['status']); break; } } } else { $rsvp = $event['rsvp'] && $this->rc->config->get('calendar_allow_itip_uninvited', true); } $status_lc = strtolower($status); if ($status_lc == 'unknown' && !$this->rc->config->get('calendar_allow_itip_uninvited', true)) { $html = html::div('rsvp-status', $this->gettext('notanattendee')); $action = 'import'; } else if (in_array($status_lc, $this->rsvp_status)) { $status_text = $this->gettext(($latest ? 'youhave' : 'youhavepreviously') . $status_lc); if ($existing && ($existing['sequence'] > $event['sequence'] || (!isset($event['sequence']) && $existing['changed'] && $existing['changed'] > $event['changed']))) { $action = ''; // nothing to do here, outdated invitation if ($status_lc == 'needs-action') $status_text = $this->gettext('outdatedinvitation'); } else if (!$existing && !$rsvp) { $action = 'import'; } - else if ($latest && $status_lc != 'needs-action') { - $action = 'update'; + else if ($status_lc != 'needs-action') { + $action = !$latest ? 'update' : ''; } $html = html::div('rsvp-status ' . $status_lc, $status_text); } } // determine action for REPLY else if ($event['method'] == 'REPLY') { // check whether the sender already is an attendee if ($existing) { $action = $this->rc->config->get('calendar_allow_itip_uninvited', true) ? 'accept' : ''; $listed = false; foreach ($existing['attendees'] as $attendee) { if ($attendee['role'] != 'ORGANIZER' && strcasecmp($attendee['email'], $event['attendee']) == 0) { $status_lc = strtolower($status); if (in_array($status_lc, $this->rsvp_status)) { $html = html::div('rsvp-status ' . $status_lc, $this->gettext(array( 'name' => 'attendee' . $status_lc, 'vars' => array( 'delegatedto' => rcube::Q($event['delegated-to'] ?: ($attendee['delegated-to'] ?: '?')), ) ))); } $action = $attendee['status'] == $status || !$latest ? '' : 'update'; $listed = true; break; } } if (!$listed) { $html = html::div('rsvp-status', $this->gettext('itipnewattendee')); } } else { $html = html::div('rsvp-status hint', $this->gettext('itipobjectnotfound')); $action = ''; } } else if ($event['method'] == 'CANCEL') { if (!$existing) { $html = html::div('rsvp-status hint', $this->gettext('itipobjectnotfound')); $action = ''; } } return array( 'uid' => $event['uid'], 'id' => asciiwords($event['uid'], true), 'existing' => $existing ? true : false, 'saved' => $existing ? true : false, 'latest' => $latest, 'status' => $status, 'action' => $action, 'html' => $html, ); } /** * Build inline UI elements for iTip messages */ public function mail_itip_inline_ui($event, $method, $mime_id, $task, $message_date = null, $preview_url = null) { $buttons = array(); $dom_id = asciiwords($event['uid'], true); $rsvp_status = 'unknown'; // pass some metadata about the event and trigger the asynchronous status check $changed = is_object($event['changed']) ? $event['changed'] : $message_date; $metadata = array( 'uid' => $event['uid'], '_instance' => $event['_instance'], 'changed' => $changed ? $changed->format('U') : 0, 'sequence' => intval($event['sequence']), 'method' => $method, 'task' => $task, ); // create buttons to be activated from async request checking existence of this event in local calendars $buttons[] = html::div(array('id' => 'loading-'.$dom_id, 'class' => 'rsvp-status loading'), $this->gettext('loading')); // on iTip REPLY we have two options: if ($method == 'REPLY') { $title = $this->gettext('itipreply'); foreach ($event['attendees'] as $attendee) { if (!empty($attendee['email']) && $attendee['role'] != 'ORGANIZER') { if (empty($event['_sender']) || self::compare_email($attendee['email'], $event['_sender'], $event['_sender_utf'])) { $metadata['attendee'] = $attendee['email']; $rsvp_status = strtoupper($attendee['status']); if ($attendee['delegated-to']) { $metadata['delegated-to'] = $attendee['delegated-to']; } break; } } } // 1. update the attendee status on our copy $update_button = html::tag('input', array( 'type' => 'button', 'class' => 'button', 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('updateattendeestatus'), )); // 2. accept or decline a new or delegate attendee $accept_buttons = html::tag('input', array( 'type' => 'button', 'class' => "button accept", 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('acceptattendee'), )); $accept_buttons .= html::tag('input', array( 'type' => 'button', 'class' => "button decline", 'onclick' => "rcube_libcalendaring.decline_attendee_reply('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('declineattendee'), )); $buttons[] = html::div(array('id' => 'update-'.$dom_id, 'style' => 'display:none'), $update_button); $buttons[] = html::div(array('id' => 'accept-'.$dom_id, 'style' => 'display:none'), $accept_buttons); } // when receiving iTip REQUEST messages: else if ($method == 'REQUEST') { $emails = $this->lib->get_user_emails(); $title = $event['sequence'] > 0 ? $this->gettext('itipupdate') : $this->gettext('itipinvitation'); $metadata['rsvp'] = true; $metadata['sensitivity'] = $event['sensitivity']; if (is_object($event['start'])) { $metadata['date'] = $event['start']->format('U'); } // check for X-KOLAB-INVITATIONTYPE property and only show accept/decline buttons if (self::get_custom_property($event, 'X-KOLAB-INVITATIONTYPE') == 'CONFIRMATION') { $this->rsvp_actions = array('accepted','declined'); $metadata['nosave'] = true; } // 1. display RSVP buttons (if the user was invited) foreach ($this->rsvp_actions as $method) { $rsvp_buttons .= html::tag('input', array( 'type' => 'button', 'class' => "button $method", 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task', '$method', '$dom_id')", 'value' => $this->gettext('itip' . $method), )); } // add button to open calendar/preview if (!empty($preview_url)) { $msgref = $this->lib->ical_message->folder . '/' . $this->lib->ical_message->uid . '#' . $mime_id; $rsvp_buttons .= html::tag('input', array( 'type' => 'button', 'class' => "button preview", 'onclick' => "rcube_libcalendaring.open_itip_preview('" . rcube::JQ($preview_url) . "', '" . rcube::JQ($msgref) . "')", 'value' => $this->gettext('openpreview'), )); } // 2. update the local copy with minor changes $update_button = html::tag('input', array( 'type' => 'button', 'class' => 'button', 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('updatemycopy'), )); // 3. Simply import the event without replying $import_button = html::tag('input', array( 'type' => 'button', 'class' => 'button', 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('importtocalendar'), )); // check my status foreach ($event['attendees'] as $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $metadata['attendee'] = $attendee['email']; $metadata['rsvp'] = $attendee['rsvp'] || $attendee['role'] != 'NON-PARTICIPANT'; $rsvp_status = !empty($attendee['status']) ? strtoupper($attendee['status']) : 'NEEDS-ACTION'; break; } } // add itip reply message controls $rsvp_buttons .= html::div('itip-reply-controls', $this->itip_rsvp_options_ui($dom_id, $metadata['nosave'])); $buttons[] = html::div(array('id' => 'rsvp-'.$dom_id, 'class' => 'rsvp-buttons', 'style' => 'display:none'), $rsvp_buttons); $buttons[] = html::div(array('id' => 'update-'.$dom_id, 'style' => 'display:none'), $update_button); // prepare autocompletion for delegation dialog if (in_array('delegated', $this->rsvp_actions)) { $this->rc->autocomplete_init(); } } // for CANCEL messages, we can: else if ($method == 'CANCEL') { $title = $this->gettext('itipcancellation'); $event_prop = array_filter(array( 'uid' => $event['uid'], '_instance' => $event['_instance'], '_savemode' => $event['_savemode'], )); // 1. remove the event from our calendar $button_remove = html::tag('input', array( 'type' => 'button', 'class' => 'button', 'onclick' => "rcube_libcalendaring.remove_from_itip(" . rcube_output::json_serialize($event_prop) . ", '$task', '" . rcube::JQ($event['title']) . "')", 'value' => $this->gettext('removefromcalendar'), )); // 2. update our copy with status=cancelled $button_update = html::tag('input', array( 'type' => 'button', 'class' => 'button', 'onclick' => "rcube_libcalendaring.add_from_itip_mail('" . rcube::JQ($mime_id) . "', '$task')", 'value' => $this->gettext('updatemycopy'), )); $buttons[] = html::div(array('id' => 'rsvp-'.$dom_id, 'style' => 'display:none'), $button_remove . $button_update); $rsvp_status = 'CANCELLED'; $metadata['rsvp'] = true; } // append generic import button if ($import_button) { $buttons[] = html::div(array('id' => 'import-'.$dom_id, 'style' => 'display:none'), $import_button); } // pass some metadata about the event and trigger the asynchronous status check $metadata['fallback'] = $rsvp_status; $metadata['rsvp'] = intval($metadata['rsvp']); $this->rc->output->add_script("rcube_libcalendaring.fetch_itip_object_status(" . rcube_output::json_serialize($metadata) . ")", 'docready'); // get localized texts from the right domain foreach (array('savingdata','deleteobjectconfirm','declinedeleteconfirm','declineattendee', 'cancel','itipdelegated','declineattendeeconfirm','itipcomment','delegateinvitation', 'delegateto','delegatersvpme','delegateinvalidaddress') as $label) { $this->rc->output->command('add_label', "itip.$label", $this->gettext($label)); } // show event details with buttons return $this->itip_object_details_table($event, $title) . html::div(array('class' => 'itip-buttons', 'id' => 'itip-buttons-' . asciiwords($metadata['uid'], true)), join('', $buttons)); } /** * Render an RSVP UI widget with buttons to respond on iTip invitations */ function itip_rsvp_buttons($attrib = array(), $actions = null) { $attrib += array('type' => 'button'); if (!$actions) $actions = $this->rsvp_actions; foreach ($actions as $method) { $buttons .= html::tag('input', array( 'type' => $attrib['type'], 'name' => $attrib['iname'], 'class' => 'button', 'rel' => $method, 'value' => $this->gettext('itip' . $method), )); } // add localized texts for the delegation dialog if (in_array('delegated', $actions)) { foreach (array('itipdelegated','itipcomment','delegateinvitation', 'delegateto','delegatersvpme','delegateinvalidaddress','cancel') as $label) { $this->rc->output->command('add_label', "itip.$label", $this->gettext($label)); } } foreach (array('all','current','future') as $mode) { $this->rc->output->command('add_label', "rsvpmode$mode", $this->gettext("rsvpmode$mode")); } $savemode_radio = new html_radiobutton(array('name' => '_rsvpmode', 'class' => 'rsvp-replymode')); return html::div($attrib, html::div('label', $this->gettext('acceptinvitation')) . html::div('rsvp-buttons', $buttons . html::div('itip-reply-controls', $this->itip_rsvp_options_ui($attrib['id'])) ) ); } /** * Render UI elements to control iTip reply message sending */ public function itip_rsvp_options_ui($dom_id, $disable = false) { $itip_sending = $this->rc->config->get('calendar_itip_send_option', 3); // itip sending is entirely disabled if ($itip_sending === 0) { return ''; } // add checkbox to suppress itip reply message else if ($itip_sending >= 2) { $rsvp_additions = html::label(array('class' => 'noreply-toggle'), html::tag('input', array('type' => 'checkbox', 'id' => 'noreply-'.$dom_id, 'value' => 1, 'disabled' => $disable, 'checked' => ($itip_sending & 1) == 0)) . ' ' . $this->gettext('itipsuppressreply') ); } // add input field for reply comment $rsvp_additions .= html::a(array('href' => '#toggle', 'class' => 'reply-comment-toggle'), $this->gettext('itipeditresponse')); $rsvp_additions .= html::div('itip-reply-comment', html::tag('textarea', array('id' => 'reply-comment-'.$dom_id, 'name' => '_comment', 'cols' => 40, 'rows' => 6, 'style' => 'display:none', 'placeholder' => $this->gettext('itipcomment')), '') ); return $rsvp_additions; } /** * Render event/task details in a table */ function itip_object_details_table($event, $title) { $table = new html_table(array('cols' => 2, 'border' => 0, 'class' => 'calendar-eventdetails')); $table->add('ititle', $title); $table->add('title', rcube::Q($event['title'])); if ($event['start'] && $event['end']) { $table->add('label', $this->gettext('date')); $table->add('date', rcube::Q($this->lib->event_date_text($event))); } else if ($event['due'] && $event['_type'] == 'task') { $table->add('label', $this->gettext('date')); $table->add('date', rcube::Q($this->lib->event_date_text($event))); } if (!empty($event['recurrence_date'])) { $table->add('label', ''); $table->add('recurrence-id', $this->gettext($event['thisandfuture'] ? 'itipfutureoccurrence' : 'itipsingleoccurrence')); } else if (!empty($event['recurrence'])) { $table->add('label', $this->gettext('recurring')); $table->add('recurrence', $this->lib->recurrence_text($event['recurrence'])); } if ($event['location']) { $table->add('label', $this->gettext('location')); $table->add('location', rcube::Q($event['location'])); } if ($event['sensitivity'] && $event['sensitivity'] != 'public') { $table->add('label', $this->gettext('sensitivity')); $table->add('sensitivity', ucfirst($this->gettext($event['sensitivity'])) . '!'); } if ($event['status'] == 'COMPLETED' || $event['status'] == 'CANCELLED') { $table->add('label', $this->gettext('status')); $table->add('status', $this->gettext('status-' . strtolower($event['status']))); } if ($event['comment']) { $table->add('label', $this->gettext('comment')); $table->add('location', rcube::Q($event['comment'])); } return $table->show(); } /** * Create iTIP invitation token for later replies via URL * * @param array Hash array with event properties * @param string Attendee email address * @return string Invitation token */ public function store_invitation($event, $attendee) { // empty stub return false; } /** * Mark invitations for the given event as cancelled * * @param array Hash array with event properties */ public function cancel_itip_invitation($event) { // empty stub return false; } /** * Utility function to get the value of a custom property */ public static function get_custom_property($event, $name) { $ret = false; if (is_array($event['x-custom'])) { array_walk($event['x-custom'], function($prop, $i) use ($name, &$ret) { if (strcasecmp($prop[0], $name) === 0) { $ret = $prop[1]; } }); } return $ret; } /** * Compare email address */ public static function compare_email($value, $email, $email_utf = null) { $v1 = !empty($email) && strcasecmp($value, $email) === 0; $v2 = !empty($email_utf) && strcasecmp($value, $email_utf) === 0; return $v1 || $v2; } } diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php index 1ddf7ac7..1efead86 100644 --- a/plugins/libcalendaring/libcalendaring.php +++ b/plugins/libcalendaring/libcalendaring.php @@ -1,1724 +1,1742 @@ * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class libcalendaring extends rcube_plugin { public $rc; public $timezone; public $gmt_offset; public $dst_active; public $timezone_offset; public $ical_parts = array(); public $ical_message; public $defaults = array( 'calendar_date_format' => "yyyy-MM-dd", 'calendar_date_short' => "M-d", 'calendar_date_long' => "MMM d yyyy", 'calendar_date_agenda' => "ddd MM-dd", 'calendar_time_format' => "HH:mm", 'calendar_first_day' => 1, 'calendar_first_hour' => 6, 'calendar_date_format_sets' => array( 'yyyy-MM-dd' => array('MMM d yyyy', 'M-d', 'ddd MM-dd'), 'dd-MM-yyyy' => array('d MMM yyyy', 'd-M', 'ddd dd-MM'), 'yyyy/MM/dd' => array('MMM d yyyy', 'M/d', 'ddd MM/dd'), 'MM/dd/yyyy' => array('MMM d yyyy', 'M/d', 'ddd MM/dd'), 'dd/MM/yyyy' => array('d MMM yyyy', 'd/M', 'ddd dd/MM'), 'dd.MM.yyyy' => array('dd. MMM yyyy', 'd.M', 'ddd dd.MM.'), 'd.M.yyyy' => array('d. MMM yyyy', 'd.M', 'ddd d.MM.'), ), ); private static $instance; private $mail_ical_parser; /** * Singleton getter to allow direct access from other plugins */ public static function get_instance() { if (!self::$instance) { self::$instance = new libcalendaring(rcube::get_instance()->plugins); self::$instance->init_instance(); } return self::$instance; } /** * Initializes class properties */ public function init_instance() { $this->rc = rcube::get_instance(); // set user's timezone try { $this->timezone = new DateTimeZone($this->rc->config->get('timezone', 'GMT')); } catch (Exception $e) { $this->timezone = new DateTimeZone('GMT'); } $now = new DateTime('now', $this->timezone); $this->gmt_offset = $now->getOffset(); $this->dst_active = $now->format('I'); $this->timezone_offset = $this->gmt_offset / 3600 - $this->dst_active; $this->add_texts('localization/', false); } /** * Required plugin startup method */ public function init() { self::$instance = $this; $this->rc = rcube::get_instance(); $this->init_instance(); // include client scripts and styles if ($this->rc->output) { // add hook to display alarms $this->add_hook('refresh', array($this, 'refresh')); $this->register_action('plugin.alarms', array($this, 'alarms_action')); $this->register_action('plugin.expand_attendee_group', array($this, 'expand_attendee_group')); } // proceed initialization in startup hook $this->add_hook('startup', array($this, 'startup')); } /** * Startup hook */ public function startup($args) { if ($this->rc->output && $this->rc->output->type == 'html') { $this->rc->output->set_env('libcal_settings', $this->load_settings()); $this->include_script('libcalendaring.js'); $this->include_stylesheet($this->local_skin_path() . '/libcal.css'); $this->rc->output->add_label( 'libcalendaring.itipaccepted', 'libcalendaring.itiptentative', 'libcalendaring.itipdeclined', 'libcalendaring.itipdelegated', 'libcalendaring.expandattendeegroup', 'libcalendaring.expandattendeegroupnodata', 'libcalendaring.statusorganizer', 'libcalendaring.statusaccepted', 'libcalendaring.statusdeclined', 'libcalendaring.statusdelegated', 'libcalendaring.statusunknown', 'libcalendaring.statusneeds-action', 'libcalendaring.statustentative', 'libcalendaring.statuscompleted', 'libcalendaring.statusin-process' ); } if ($args['task'] == 'mail') { if ($args['action'] == 'show' || $args['action'] == 'preview') { $this->add_hook('message_load', array($this, 'mail_message_load')); } } } /** * Load iCalendar functions */ public static function get_ical() { $self = self::get_instance(); require_once __DIR__ . '/libvcalendar.php'; return new libvcalendar(); } /** * Load iTip functions */ public static function get_itip($domain = 'libcalendaring') { $self = self::get_instance(); require_once __DIR__ . '/lib/libcalendaring_itip.php'; return new libcalendaring_itip($self, $domain); } /** * Load recurrence computation engine */ public static function get_recurrence() { $self = self::get_instance(); require_once __DIR__ . '/lib/libcalendaring_recurrence.php'; return new libcalendaring_recurrence($self); } /** * Shift dates into user's current timezone * * @param mixed Any kind of a date representation (DateTime object, string or unix timestamp) * @return object DateTime object in user's timezone */ public function adjust_timezone($dt, $dateonly = false) { if (is_numeric($dt)) $dt = new DateTime('@'.$dt); else if (is_string($dt)) $dt = rcube_utils::anytodatetime($dt); if ($dt instanceof DateTime && !($dt->_dateonly || $dateonly)) { $dt->setTimezone($this->timezone); } return $dt; } /** * */ public function load_settings() { $this->date_format_defaults(); $settings = array(); $keys = array('date_format', 'time_format', 'date_short', 'date_long'); foreach ($keys as $key) { $settings[$key] = (string)$this->rc->config->get('calendar_' . $key, $this->defaults['calendar_' . $key]); $settings[$key] = str_replace('Y', 'y', $settings[$key]); } $settings['dates_long'] = str_replace(' yyyy', '[ yyyy]', $settings['date_long']) . "{ '—' " . $settings['date_long'] . '}'; $settings['first_day'] = (int)$this->rc->config->get('calendar_first_day', $this->defaults['calendar_first_day']); $settings['timezone'] = $this->timezone_offset; $settings['dst'] = $this->dst_active; // localization $settings['days'] = array( $this->rc->gettext('sunday'), $this->rc->gettext('monday'), $this->rc->gettext('tuesday'), $this->rc->gettext('wednesday'), $this->rc->gettext('thursday'), $this->rc->gettext('friday'), $this->rc->gettext('saturday') ); $settings['days_short'] = array( $this->rc->gettext('sun'), $this->rc->gettext('mon'), $this->rc->gettext('tue'), $this->rc->gettext('wed'), $this->rc->gettext('thu'), $this->rc->gettext('fri'), $this->rc->gettext('sat') ); $settings['months'] = array( $this->rc->gettext('longjan'), $this->rc->gettext('longfeb'), $this->rc->gettext('longmar'), $this->rc->gettext('longapr'), $this->rc->gettext('longmay'), $this->rc->gettext('longjun'), $this->rc->gettext('longjul'), $this->rc->gettext('longaug'), $this->rc->gettext('longsep'), $this->rc->gettext('longoct'), $this->rc->gettext('longnov'), $this->rc->gettext('longdec') ); $settings['months_short'] = array( $this->rc->gettext('jan'), $this->rc->gettext('feb'), $this->rc->gettext('mar'), $this->rc->gettext('apr'), $this->rc->gettext('may'), $this->rc->gettext('jun'), $this->rc->gettext('jul'), $this->rc->gettext('aug'), $this->rc->gettext('sep'), $this->rc->gettext('oct'), $this->rc->gettext('nov'), $this->rc->gettext('dec') ); $settings['today'] = $this->rc->gettext('today'); // define list of file types which can be displayed inline // same as in program/steps/mail/show.inc $settings['mimetypes'] = (array)$this->rc->config->get('client_mimetypes'); return $settings; } /** * Helper function to set date/time format according to config and user preferences */ private function date_format_defaults() { static $defaults = array(); // nothing to be done if (isset($defaults['date_format'])) return; $defaults['date_format'] = $this->rc->config->get('calendar_date_format', self::from_php_date_format($this->rc->config->get('date_format'))); $defaults['time_format'] = $this->rc->config->get('calendar_time_format', self::from_php_date_format($this->rc->config->get('time_format'))); // override defaults if ($defaults['date_format']) $this->defaults['calendar_date_format'] = $defaults['date_format']; if ($defaults['time_format']) $this->defaults['calendar_time_format'] = $defaults['time_format']; // derive format variants from basic date format $format_sets = $this->rc->config->get('calendar_date_format_sets', $this->defaults['calendar_date_format_sets']); if ($format_set = $format_sets[$this->defaults['calendar_date_format']]) { $this->defaults['calendar_date_long'] = $format_set[0]; $this->defaults['calendar_date_short'] = $format_set[1]; $this->defaults['calendar_date_agenda'] = $format_set[2]; } } /** * Compose a date string for the given event */ public function event_date_text($event, $tzinfo = false) { $fromto = '--'; // handle task objects if ($event['_type'] == 'task' && is_object($event['due'])) { $date_format = $event['due']->_dateonly ? self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format'])) : null; $fromto = $this->rc->format_date($event['due'], $date_format, false); // add timezone information if ($fromto && $tzinfo && ($tzname = $this->timezone->getName())) { $fromto .= ' (' . strtr($tzname, '_', ' ') . ')'; } return $fromto; } // abort if no valid event dates are given if (!is_object($event['start']) || !is_a($event['start'], 'DateTime') || !is_object($event['end']) || !is_a($event['end'], 'DateTime')) { return $fromto; } $duration = $event['start']->diff($event['end'])->format('s'); $this->date_format_defaults(); $date_format = self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format'])); $time_format = self::to_php_date_format($this->rc->config->get('calendar_time_format', $this->defaults['calendar_time_format'])); if ($event['allday']) { $fromto = $this->rc->format_date($event['start'], $date_format); if (($todate = $this->rc->format_date($event['end'], $date_format)) != $fromto) $fromto .= ' - ' . $todate; } else if ($duration < 86400 && $event['start']->format('d') == $event['end']->format('d')) { $fromto = $this->rc->format_date($event['start'], $date_format) . ' ' . $this->rc->format_date($event['start'], $time_format) . ' - ' . $this->rc->format_date($event['end'], $time_format); } else { $fromto = $this->rc->format_date($event['start'], $date_format) . ' ' . $this->rc->format_date($event['start'], $time_format) . ' - ' . $this->rc->format_date($event['end'], $date_format) . ' ' . $this->rc->format_date($event['end'], $time_format); } // add timezone information if ($tzinfo && ($tzname = $this->timezone->getName())) { $fromto .= ' (' . strtr($tzname, '_', ' ') . ')'; } return $fromto; } /** * Render HTML form for alarm configuration */ public function alarm_select($attrib, $alarm_types, $absolute_time = true) { unset($attrib['name']); $input_value = new html_inputfield(array('name' => 'alarmvalue[]', 'class' => 'edit-alarm-value', 'size' => 3)); $input_date = new html_inputfield(array('name' => 'alarmdate[]', 'class' => 'edit-alarm-date', 'size' => 10)); $input_time = new html_inputfield(array('name' => 'alarmtime[]', 'class' => 'edit-alarm-time', 'size' => 6)); $select_type = new html_select(array('name' => 'alarmtype[]', 'class' => 'edit-alarm-type', 'id' => $attrib['id'])); $select_offset = new html_select(array('name' => 'alarmoffset[]', 'class' => 'edit-alarm-offset')); $select_related = new html_select(array('name' => 'alarmrelated[]', 'class' => 'edit-alarm-related')); $object_type = $attrib['_type'] ?: 'event'; $select_type->add($this->gettext('none'), ''); foreach ($alarm_types as $type) $select_type->add($this->gettext(strtolower("alarm{$type}option")), $type); foreach (array('-M','-H','-D','+M','+H','+D') as $trigger) $select_offset->add($this->gettext('trigger' . $trigger), $trigger); $select_offset->add($this->gettext('trigger0'), '0'); if ($absolute_time) $select_offset->add($this->gettext('trigger@'), '@'); $select_related->add($this->gettext('relatedstart'), 'start'); $select_related->add($this->gettext('relatedend' . $object_type), 'end'); // pre-set with default values from user settings $preset = self::parse_alarm_value($this->rc->config->get('calendar_default_alarm_offset', '-15M')); $hidden = array('style' => 'display:none'); $html = html::span('edit-alarm-set', $select_type->show($this->rc->config->get('calendar_default_alarm_type', '')) . ' ' . html::span(array('class' => 'edit-alarm-values', 'style' => 'display:none'), $input_value->show($preset[0]) . ' ' . $select_offset->show($preset[1]) . ' ' . $select_related->show() . ' ' . $input_date->show('', $hidden) . ' ' . $input_time->show('', $hidden) ) ); // TODO: support adding more alarms #$html .= html::a(array('href' => '#', 'id' => 'edit-alam-add', 'title' => $this->gettext('addalarm')), # $attrib['addicon'] ? html::img(array('src' => $attrib['addicon'], 'alt' => 'add')) : '(+)'); return $html; } /** * Get a list of email addresses of the given user (from login and identities) * * @param string User Email (default to current user) * @return array Email addresses related to the user */ public function get_user_emails($user = null) { static $_emails = array(); if (empty($user)) { $user = $this->rc->user->get_username(); } // return cached result if (is_array($_emails[$user])) { return $_emails[$user]; } $emails = array($user); $plugin = $this->rc->plugins->exec_hook('calendar_user_emails', array('emails' => $emails)); $emails = array_map('strtolower', $plugin['emails']); // add all emails from the current user's identities if (!$plugin['abort'] && ($user == $this->rc->user->get_username())) { foreach ($this->rc->user->list_emails() as $identity) { $emails[] = strtolower($identity['email']); } } $_emails[$user] = array_unique($emails); return $_emails[$user]; } /** * Set the given participant status to the attendee matching the current user's identities * * @param array Hash array with event struct * @param string The PARTSTAT value to set * @return mixed Email address of the updated attendee or False if none matching found */ public function set_partstat(&$event, $status, $recursive = true) { $success = false; $emails = $this->get_user_emails(); foreach ((array)$event['attendees'] as $i => $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $event['attendees'][$i]['status'] = strtoupper($status); $success = $attendee['email']; } } // apply partstat update to each existing exception if ($event['recurrence'] && is_array($event['recurrence']['EXCEPTIONS'])) { foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { $this->set_partstat($event['recurrence']['EXCEPTIONS'][$i], $status, false); } // set link to top-level exceptions $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; } return $success; } /********* Alarms handling *********/ /** * Helper function to convert alarm trigger strings * into two-field values (e.g. "-45M" => 45, "-M") */ public static function parse_alarm_value($val) { if ($val[0] == '@') { return array(new DateTime($val)); } else if (preg_match('/([+-]?)P?(T?\d+[HMSDW])+/', $val, $m) && preg_match_all('/T?(\d+)([HMSDW])/', $val, $m2, PREG_SET_ORDER)) { if ($m[1] == '') $m[1] = '+'; foreach ($m2 as $seg) { $prefix = $seg[2] == 'D' || $seg[2] == 'W' ? 'P' : 'PT'; if ($seg[1] > 0) { // ignore zero values // convert seconds to minutes if ($seg[2] == 'S') { $seg[2] = 'M'; $seg[1] = max(1, round($seg[1]/60)); } return array($seg[1], $m[1].$seg[2], $m[1].$seg[1].$seg[2], $m[1].$prefix.$seg[1].$seg[2]); } } // return zero value nevertheless return array($seg[1], $m[1].$seg[2], $m[1].$seg[1].$seg[2], $m[1].$prefix.$seg[1].$seg[2]); } return false; } /** * Convert the alarms list items to be processed on the client */ public static function to_client_alarms($valarms) { return array_map(function($alarm){ if ($alarm['trigger'] instanceof DateTime) { $alarm['trigger'] = '@' . $alarm['trigger']->format('U'); } else if ($trigger = libcalendaring::parse_alarm_value($alarm['trigger'])) { $alarm['trigger'] = $trigger[2]; } return $alarm; }, (array)$valarms); } /** * Process the alarms values submitted by the client */ public static function from_client_alarms($valarms) { return array_map(function($alarm){ if ($alarm['trigger'][0] == '@') { try { $alarm['trigger'] = new DateTime($alarm['trigger']); $alarm['trigger']->setTimezone(new DateTimeZone('UTC')); } catch (Exception $e) { /* handle this ? */ } } else if ($trigger = libcalendaring::parse_alarm_value($alarm['trigger'])) { $alarm['trigger'] = $trigger[3]; } return $alarm; }, (array)$valarms); } /** * Render localized text for alarm settings */ public static function alarms_text($alarms) { if (is_array($alarms) && is_array($alarms[0])) { $texts = array(); foreach ($alarms as $alarm) { if ($text = self::alarm_text($alarm)) $texts[] = $text; } return join(', ', $texts); } else { return self::alarm_text($alarms); } } /** * Render localized text for a single alarm property */ public static function alarm_text($alarm) { if (is_string($alarm)) { list($trigger, $action) = explode(':', $alarm); } else { $trigger = $alarm['trigger']; $action = $alarm['action']; $related = $alarm['related']; } $text = ''; $rcube = rcube::get_instance(); switch ($action) { case 'EMAIL': $text = $rcube->gettext('libcalendaring.alarmemail'); break; case 'DISPLAY': $text = $rcube->gettext('libcalendaring.alarmdisplay'); break; case 'AUDIO': $text = $rcube->gettext('libcalendaring.alarmaudio'); break; } if ($trigger instanceof DateTime) { $text .= ' ' . $rcube->gettext(array( 'name' => 'libcalendaring.alarmat', 'vars' => array('datetime' => $rcube->format_date($trigger)) )); } else if (preg_match('/@(\d+)/', $trigger, $m)) { $text .= ' ' . $rcube->gettext(array( 'name' => 'libcalendaring.alarmat', 'vars' => array('datetime' => $rcube->format_date($m[1])) )); } else if ($val = self::parse_alarm_value($trigger)) { $r = strtoupper($related ?: 'start') == 'END' ? 'end' : ''; // TODO: for all-day events say 'on date of event at XX' ? if ($val[0] == 0) { $text .= ' ' . $rcube->gettext('libcalendaring.triggerattime' . $r); } else { $label = 'libcalendaring.trigger' . $r . $val[1]; $text .= ' ' . intval($val[0]) . ' ' . $rcube->gettext($label); } } else { return false; } return $text; } /** * Get the next alarm (time & action) for the given event * * @param array Record data * @return array Hash array with alarm time/type or null if no alarms are configured */ public static function get_next_alarm($rec, $type = 'event') { if (!($rec['valarms'] || $rec['alarms']) || $rec['cancelled'] || $rec['status'] == 'CANCELLED') return null; if ($type == 'task') { $timezone = self::get_instance()->timezone; if ($rec['startdate']) $rec['start'] = new DateTime($rec['startdate'] . ' ' . ($rec['starttime'] ?: '12:00'), $timezone); if ($rec['date']) $rec[($rec['start'] ? 'end' : 'start')] = new DateTime($rec['date'] . ' ' . ($rec['time'] ?: '12:00'), $timezone); } if (!$rec['end']) $rec['end'] = $rec['start']; // support legacy format if (!$rec['valarms']) { list($trigger, $action) = explode(':', $rec['alarms'], 2); if ($alarm = self::parse_alarm_value($trigger)) { $rec['valarms'] = array(array('action' => $action, 'trigger' => $alarm[3] ?: $alarm[0])); } } $expires = new DateTime('now - 12 hours'); $alarm_id = $rec['id']; // alarm ID eq. record ID by default to keep backwards compatibility // handle multiple alarms $notify_at = null; foreach ($rec['valarms'] as $alarm) { $notify_time = null; if ($alarm['trigger'] instanceof DateTime) { $notify_time = $alarm['trigger']; } else if (is_string($alarm['trigger'])) { $refdate = $alarm['related'] == 'END' ? $rec['end'] : $rec['start']; // abort if no reference date is available to compute notification time if (!is_a($refdate, 'DateTime')) continue; // TODO: for all-day events, take start @ 00:00 as reference date ? try { $interval = new DateInterval(trim($alarm['trigger'], '+-')); $interval->invert = $alarm['trigger'][0] == '-'; $notify_time = clone $refdate; $notify_time->add($interval); } catch (Exception $e) { rcube::raise_error($e, true); continue; } } if ($notify_time && (!$notify_at || ($notify_time > $notify_at && $notify_time > $expires))) { $notify_at = $notify_time; $action = $alarm['action']; $alarm_prop = $alarm; // generate a unique alarm ID if multiple alarms are set if (count($rec['valarms']) > 1) { $alarm_id = substr(md5($rec['id']), 0, 16) . '-' . $notify_at->format('Ymd\THis'); } } } return !$notify_at ? null : array( 'time' => $notify_at->format('U'), 'action' => $action ? strtoupper($action) : 'DISPLAY', 'id' => $alarm_id, 'prop' => $alarm_prop, ); } /** * Handler for keep-alive requests * This will check for pending notifications and pass them to the client */ public function refresh($attr) { // collect pending alarms from all providers (e.g. calendar, tasks) $plugin = $this->rc->plugins->exec_hook('pending_alarms', array( 'time' => time(), 'alarms' => array(), )); if (!$plugin['abort'] && !empty($plugin['alarms'])) { // make sure texts and env vars are available on client $this->add_texts('localization/', true); $this->rc->output->add_label('close'); $this->rc->output->set_env('snooze_select', $this->snooze_select()); $this->rc->output->command('plugin.display_alarms', $this->_alarms_output($plugin['alarms'])); } } /** * Handler for alarm dismiss/snooze requests */ public function alarms_action() { // $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); $data['ids'] = explode(',', $data['id']); $plugin = $this->rc->plugins->exec_hook('dismiss_alarms', $data); if ($plugin['success']) $this->rc->output->show_message('successfullysaved', 'confirmation'); else $this->rc->output->show_message('calendar.errorsaving', 'error'); } /** * Generate reduced and streamlined output for pending alarms */ private function _alarms_output($alarms) { $out = array(); foreach ($alarms as $alarm) { $out[] = array( 'id' => $alarm['id'], 'start' => $alarm['start'] ? $this->adjust_timezone($alarm['start'])->format('c') : '', 'end' => $alarm['end'] ? $this->adjust_timezone($alarm['end'])->format('c') : '', 'allDay' => $alarm['allday'] == 1, 'action' => $alarm['action'], 'title' => $alarm['title'], 'location' => $alarm['location'], 'calendar' => $alarm['calendar'], ); } return $out; } /** * Render a dropdown menu to choose snooze time */ private function snooze_select($attrib = array()) { $steps = array( 5 => 'repeatinmin', 10 => 'repeatinmin', 15 => 'repeatinmin', 20 => 'repeatinmin', 30 => 'repeatinmin', 60 => 'repeatinhr', 120 => 'repeatinhrs', 1440 => 'repeattomorrow', 10080 => 'repeatinweek', ); $items = array(); foreach ($steps as $n => $label) { $items[] = html::tag('li', null, html::a(array('href' => "#" . ($n * 60), 'class' => 'active'), $this->gettext(array('name' => $label, 'vars' => array('min' => $n % 60, 'hrs' => intval($n / 60)))))); } return html::tag('ul', $attrib + array('class' => 'toolbarmenu'), join("\n", $items), html::$common_attrib); } /********* Recurrence rules handling ********/ /** * Render localized text describing the recurrence rule of an event */ public function recurrence_text($rrule) { $limit = 10; $exdates = array(); $format = $this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format']); $format = self::to_php_date_format($format); $format_fn = function($dt) use ($format) { return rcmail::get_instance()->format_date($dt, $format); }; if (is_array($rrule['EXDATE']) && !empty($rrule['EXDATE'])) { $exdates = array_map($format_fn, $rrule['EXDATE']); } if (empty($rrule['FREQ']) && !empty($rrule['RDATE'])) { $rdates = array_map($format_fn, $rrule['RDATE']); if (!empty($exdates)) { $rdates = array_diff($rdates, $exdates); } if (count($rdates) > $limit) { $rdates = array_slice($rdates, 0, $limit); $more = true; } return $this->gettext('ondate') . ' ' . join(', ', $rdates) . ($more ? '...' : ''); } $output = sprintf('%s %d ', $this->gettext('every'), $rrule['INTERVAL'] ?: 1); switch ($rrule['FREQ']) { case 'DAILY': $output .= $this->gettext('days'); break; case 'WEEKLY': $output .= $this->gettext('weeks'); break; case 'MONTHLY': $output .= $this->gettext('months'); break; case 'YEARLY': $output .= $this->gettext('years'); break; } if ($rrule['COUNT']) { $until = $this->gettext(array('name' => 'forntimes', 'vars' => array('nr' => $rrule['COUNT']))); } else if ($rrule['UNTIL']) { $until = $this->gettext('recurrencend') . ' ' . $this->rc->format_date($rrule['UNTIL'], $format); } else { $until = $this->gettext('forever'); } $output .= ', ' . $until; if (!empty($exdates)) { if (count($exdates) > $limit) { $exdates = array_slice($exdates, 0, $limit); $more = true; } $output = '; ' . $this->gettext('except') . ' ' . join(', ', $exdates) . ($more ? '...' : ''); } return $output; } /** * Generate the form for recurrence settings */ public function recurrence_form($attrib = array()) { switch ($attrib['part']) { // frequency selector case 'frequency': $select = new html_select(array('name' => 'frequency', 'id' => 'edit-recurrence-frequency')); $select->add($this->gettext('never'), ''); $select->add($this->gettext('daily'), 'DAILY'); $select->add($this->gettext('weekly'), 'WEEKLY'); $select->add($this->gettext('monthly'), 'MONTHLY'); $select->add($this->gettext('yearly'), 'YEARLY'); $select->add($this->gettext('rdate'), 'RDATE'); $html = html::label('edit-recurrence-frequency', $this->gettext('frequency')) . $select->show(''); break; // daily recurrence case 'daily': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval', 'id' => 'edit-recurrence-interval-daily')); $html = html::div($attrib, html::label('edit-recurrence-interval-daily', $this->gettext('every')) . $select->show(1) . html::span('label-after', $this->gettext('days'))); break; // weekly recurrence form case 'weekly': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval', 'id' => 'edit-recurrence-interval-weekly')); $html = html::div($attrib, html::label('edit-recurrence-interval-weekly', $this->gettext('every')) . $select->show(1) . html::span('label-after', $this->gettext('weeks'))); // weekday selection $daymap = array('sun','mon','tue','wed','thu','fri','sat'); $checkbox = new html_checkbox(array('name' => 'byday', 'class' => 'edit-recurrence-weekly-byday')); $first = $this->rc->config->get('calendar_first_day', 1); for ($weekdays = '', $j = $first; $j <= $first+6; $j++) { $d = $j % 7; $weekdays .= html::label(array('class' => 'weekday'), $checkbox->show('', array('value' => strtoupper(substr($daymap[$d], 0, 2)))) . $this->gettext($daymap[$d]) ) . ' '; } $html .= html::div($attrib, html::label(null, $this->gettext('bydays')) . $weekdays); break; // monthly recurrence form case 'monthly': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval', 'id' => 'edit-recurrence-interval-monthly')); $html = html::div($attrib, html::label('edit-recurrence-interval-monthly', $this->gettext('every')) . $select->show(1) . html::span('label-after', $this->gettext('months'))); $checkbox = new html_checkbox(array('name' => 'bymonthday', 'class' => 'edit-recurrence-monthly-bymonthday')); for ($monthdays = '', $d = 1; $d <= 31; $d++) { $monthdays .= html::label(array('class' => 'monthday'), $checkbox->show('', array('value' => $d)) . $d); $monthdays .= $d % 7 ? ' ' : html::br(); } // rule selectors $radio = new html_radiobutton(array('name' => 'repeatmode', 'class' => 'edit-recurrence-monthly-mode')); $table = new html_table(array('cols' => 2, 'border' => 0, 'cellpadding' => 0, 'class' => 'formtable')); $table->add('label', html::label(null, $radio->show('BYMONTHDAY', array('value' => 'BYMONTHDAY')) . ' ' . $this->gettext('each'))); $table->add(null, $monthdays); $table->add('label', html::label(null, $radio->show('', array('value' => 'BYDAY')) . ' ' . $this->gettext('onevery'))); $table->add(null, $this->rrule_selectors($attrib['part'])); $html .= html::div($attrib, $table->show()); break; // annually recurrence form case 'yearly': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval', 'id' => 'edit-recurrence-interval-yearly')); $html = html::div($attrib, html::label('edit-recurrence-interval-yearly', $this->gettext('every')) . $select->show(1) . html::span('label-after', $this->gettext('years'))); // month selector $monthmap = array('','jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'); $checkbox = new html_checkbox(array('name' => 'bymonth', 'class' => 'edit-recurrence-yearly-bymonth')); for ($months = '', $m = 1; $m <= 12; $m++) { $months .= html::label(array('class' => 'month'), $checkbox->show(null, array('value' => $m)) . $this->gettext($monthmap[$m])); $months .= $m % 4 ? ' ' : html::br(); } $html .= html::div($attrib + array('id' => 'edit-recurrence-yearly-bymonthblock'), $months); // day rule selection $html .= html::div($attrib, html::label(null, $this->gettext('onevery')) . $this->rrule_selectors($attrib['part'], '---')); break; // end of recurrence form case 'until': $radio = new html_radiobutton(array('name' => 'repeat', 'class' => 'edit-recurrence-until')); $select = $this->interval_selector(array('name' => 'times', 'id' => 'edit-recurrence-repeat-times')); $input = new html_inputfield(array('name' => 'untildate', 'id' => 'edit-recurrence-enddate', 'size' => "10")); $html = html::div('line first', html::label(null, $radio->show('', array('value' => '', 'id' => 'edit-recurrence-repeat-forever')) . ' ' . $this->gettext('forever')) ); $forntimes = $this->gettext(array( 'name' => 'forntimes', 'vars' => array('nr' => '%s')) ); $html .= html::div('line', $radio->show('', array('value' => 'count', 'id' => 'edit-recurrence-repeat-count', 'aria-label' => sprintf($forntimes, 'N'))) . ' ' . sprintf($forntimes, $select->show(1)) ); $html .= html::div('line', $radio->show('', array('value' => 'until', 'id' => 'edit-recurrence-repeat-until', 'aria-label' => $this->gettext('untilenddate'))) . ' ' . $this->gettext('untildate') . ' ' . $input->show('', array('aria-label' => $this->gettext('untilenddate'))) ); $html = html::div($attrib, html::label(null, ucfirst($this->gettext('recurrencend'))) . $html); break; case 'rdate': $ul = html::tag('ul', array('id' => 'edit-recurrence-rdates'), ''); $input = new html_inputfield(array('name' => 'rdate', 'id' => 'edit-recurrence-rdate-input', 'size' => "10")); $button = new html_inputfield(array('type' => 'button', 'class' => 'button add', 'value' => $this->gettext('addrdate'))); $html .= html::div($attrib, $ul . html::div('inputform', $input->show() . $button->show())); break; } return $html; } /** * Input field for interval selection */ private function interval_selector($attrib) { $select = new html_select($attrib); $select->add(range(1,30), range(1,30)); return $select; } /** * Drop-down menus for recurrence rules like "each last sunday of" */ private function rrule_selectors($part, $noselect = null) { // rule selectors $select_prefix = new html_select(array('name' => 'bydayprefix', 'id' => "edit-recurrence-$part-prefix")); if ($noselect) $select_prefix->add($noselect, ''); $select_prefix->add(array( $this->gettext('first'), $this->gettext('second'), $this->gettext('third'), $this->gettext('fourth'), $this->gettext('last') ), array(1, 2, 3, 4, -1)); $select_wday = new html_select(array('name' => 'byday', 'id' => "edit-recurrence-$part-byday")); if ($noselect) $select_wday->add($noselect, ''); $daymap = array('sunday','monday','tuesday','wednesday','thursday','friday','saturday'); $first = $this->rc->config->get('calendar_first_day', 1); for ($j = $first; $j <= $first+6; $j++) { $d = $j % 7; $select_wday->add($this->gettext($daymap[$d]), strtoupper(substr($daymap[$d], 0, 2))); } return $select_prefix->show() . ' ' . $select_wday->show(); } /** * Convert the recurrence settings to be processed on the client */ public function to_client_recurrence($recurrence, $allday = false) { if ($recurrence['UNTIL']) $recurrence['UNTIL'] = $this->adjust_timezone($recurrence['UNTIL'], $allday)->format('c'); // format RDATE values if (is_array($recurrence['RDATE'])) { $libcal = $this; $recurrence['RDATE'] = array_map(function($rdate) use ($libcal) { return $libcal->adjust_timezone($rdate, true)->format('c'); }, $recurrence['RDATE']); } unset($recurrence['EXCEPTIONS']); return $recurrence; } /** * Process the alarms values submitted by the client */ public function from_client_recurrence($recurrence, $start = null) { if (is_array($recurrence) && !empty($recurrence['UNTIL'])) { $recurrence['UNTIL'] = new DateTime($recurrence['UNTIL'], $this->timezone); } if (is_array($recurrence) && is_array($recurrence['RDATE'])) { $tz = $this->timezone; $recurrence['RDATE'] = array_map(function($rdate) use ($tz, $start) { try { $dt = new DateTime($rdate, $tz); if (is_a($start, 'DateTime')) $dt->setTime($start->format('G'), $start->format('i')); return $dt; } catch (Exception $e) { return null; } }, $recurrence['RDATE']); } return $recurrence; } /********* Attachments handling *********/ /** * Handler for attachment uploads */ public function attachment_upload($session_key, $id_prefix = '') { // Upload progress update if (!empty($_GET['_progress'])) { $this->rc->upload_progress(); } $recid = $id_prefix . rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); $uploadid = rcube_utils::get_input_value('_uploadid', rcube_utils::INPUT_GPC); if (!is_array($_SESSION[$session_key]) || $_SESSION[$session_key]['id'] != $recid) { $_SESSION[$session_key] = array(); $_SESSION[$session_key]['id'] = $recid; $_SESSION[$session_key]['attachments'] = array(); } // clear all stored output properties (like scripts and env vars) $this->rc->output->reset(); if (is_array($_FILES['_attachments']['tmp_name'])) { foreach ($_FILES['_attachments']['tmp_name'] as $i => $filepath) { // Process uploaded attachment if there is no error $err = $_FILES['_attachments']['error'][$i]; if (!$err) { $attachment = array( 'path' => $filepath, 'size' => $_FILES['_attachments']['size'][$i], 'name' => $_FILES['_attachments']['name'][$i], 'mimetype' => rcube_mime::file_content_type($filepath, $_FILES['_attachments']['name'][$i], $_FILES['_attachments']['type'][$i]), 'group' => $recid, ); $attachment = $this->rc->plugins->exec_hook('attachment_upload', $attachment); } if (!$err && $attachment['status'] && !$attachment['abort']) { $id = $attachment['id']; // store new attachment in session unset($attachment['status'], $attachment['abort']); $_SESSION[$session_key]['attachments'][$id] = $attachment; if (($icon = $_SESSION[$session_key . '_deleteicon']) && is_file($icon)) { $button = html::img(array( 'src' => $icon, 'alt' => $this->rc->gettext('delete') )); } else { $button = rcube::Q($this->rc->gettext('delete')); } $content = html::a(array( 'href' => "#delete", 'class' => 'delete', 'onclick' => sprintf("return %s.remove_from_attachment_list('rcmfile%s')", rcmail_output::JS_OBJECT_NAME, $id), 'title' => $this->rc->gettext('delete'), 'aria-label' => $this->rc->gettext('delete') . ' ' . $attachment['name'], ), $button); $content .= rcube::Q($attachment['name']); $this->rc->output->command('add2attachment_list', "rcmfile$id", array( 'html' => $content, 'name' => $attachment['name'], 'mimetype' => $attachment['mimetype'], 'classname' => rcube_utils::file2class($attachment['mimetype'], $attachment['name']), 'complete' => true), $uploadid); } else { // upload failed if ($err == UPLOAD_ERR_INI_SIZE || $err == UPLOAD_ERR_FORM_SIZE) { $msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array( 'size' => $this->rc->show_bytes(parse_bytes(ini_get('upload_max_filesize')))))); } else if ($attachment['error']) { $msg = $attachment['error']; } else { $msg = $this->rc->gettext('fileuploaderror'); } $this->rc->output->command('display_message', $msg, 'error'); $this->rc->output->command('remove_from_attachment_list', $uploadid); } } } else if ($_SERVER['REQUEST_METHOD'] == 'POST') { // if filesize exceeds post_max_size then $_FILES array is empty, // show filesizeerror instead of fileuploaderror if ($maxsize = ini_get('post_max_size')) $msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array( 'size' => $this->rc->show_bytes(parse_bytes($maxsize))))); else $msg = $this->rc->gettext('fileuploaderror'); $this->rc->output->command('display_message', $msg, 'error'); $this->rc->output->command('remove_from_attachment_list', $uploadid); } $this->rc->output->send('iframe'); } /** * Deliver an event/task attachment to the client * (similar as in Roundcube core program/steps/mail/get.inc) */ public function attachment_get($attachment) { ob_end_clean(); if ($attachment && $attachment['body']) { // allow post-processing of the attachment body $part = new rcube_message_part; $part->filename = $attachment['name']; $part->size = $attachment['size']; $part->mimetype = $attachment['mimetype']; $plugin = $this->rc->plugins->exec_hook('message_part_get', array( 'body' => $attachment['body'], 'mimetype' => strtolower($attachment['mimetype']), 'download' => !empty($_GET['_download']), 'part' => $part, )); if ($plugin['abort']) exit; $mimetype = $plugin['mimetype']; list($ctype_primary, $ctype_secondary) = explode('/', $mimetype); $browser = $this->rc->output->browser; // send download headers if ($plugin['download']) { header("Content-Type: application/octet-stream"); if ($browser->ie) header("Content-Type: application/force-download"); } else if ($ctype_primary == 'text') { header("Content-Type: text/$ctype_secondary"); } else { header("Content-Type: $mimetype"); header("Content-Transfer-Encoding: binary"); } // display page, @TODO: support text/plain (and maybe some other text formats) if ($mimetype == 'text/html' && empty($_GET['_download'])) { $OUTPUT = new rcmail_html_page(); // @TODO: use washtml on $body $OUTPUT->write($plugin['body']); } else { // don't kill the connection if download takes more than 30 sec. @set_time_limit(0); $filename = $attachment['name']; $filename = preg_replace('[\r\n]', '', $filename); if ($browser->ie && $browser->ver < 7) $filename = rawurlencode(abbreviate_string($filename, 55)); else if ($browser->ie) $filename = rawurlencode($filename); else $filename = addcslashes($filename, '"'); $disposition = !empty($_GET['_download']) ? 'attachment' : 'inline'; header("Content-Disposition: $disposition; filename=\"$filename\""); echo $plugin['body']; } exit; } // if we arrive here, the requested part was not found header('HTTP/1.1 404 Not Found'); exit; } /** * Show "loading..." page in attachment iframe */ public function attachment_loading_page() { $url = str_replace('&_preload=1', '', $_SERVER['REQUEST_URI']); $message = $this->rc->gettext('loadingdata'); header('Content-Type: text/html; charset=' . RCUBE_CHARSET); print "\n\n" . '' . "\n" . '' . "\n" . "\n\n$message\n\n"; exit; } /** * Template object for attachment display frame */ public function attachment_frame($attrib = array()) { $mimetype = strtolower($this->attachment['mimetype']); list($ctype_primary, $ctype_secondary) = explode('/', $mimetype); $attrib['src'] = './?' . str_replace('_frame=', ($ctype_primary == 'text' ? '_show=' : '_preload='), $_SERVER['QUERY_STRING']); $this->rc->output->add_gui_object('attachmentframe', $attrib['id']); return html::iframe($attrib); } /** * */ public function attachment_header($attrib = array()) { $rcmail = rcmail::get_instance(); $dl_link = strtolower($attrib['downloadlink']) == 'true'; $dl_url = $this->rc->url(array('_frame' => null, '_download' => 1) + $_GET); $table = new html_table(array('cols' => $dl_link ? 3 : 2)); if (!empty($this->attachment['name'])) { $table->add('title', rcube::Q($this->rc->gettext('filename'))); $table->add('header', rcube::Q($this->attachment['name'])); if ($dl_link) { $table->add('download-link', html::a($dl_url, rcube::Q($this->rc->gettext('download')))); } } if (!empty($this->attachment['mimetype'])) { $table->add('title', rcube::Q($this->rc->gettext('type'))); $table->add('header', rcube::Q($this->attachment['mimetype'])); } if (!empty($this->attachment['size'])) { $table->add('title', rcube::Q($this->rc->gettext('filesize'))); $table->add('header', rcube::Q($this->rc->show_bytes($this->attachment['size']))); } $this->rc->output->set_env('attachment_download_url', $dl_url); return $table->show($attrib); } /********* iTip message detection *********/ /** * Check mail message structure of there are .ics files attached */ public function mail_message_load($p) { $this->ical_message = $p['object']; $itip_part = null; // check all message parts for .ics files foreach ((array)$this->ical_message->mime_parts as $part) { if (self::part_is_vcalendar($part)) { if ($part->ctype_parameters['method']) $itip_part = $part->mime_id; else $this->ical_parts[] = $part->mime_id; } } // priorize part with method parameter if ($itip_part) { $this->ical_parts = array($itip_part); } } /** * Getter for the parsed iCal objects attached to the current email message * * @return object libvcalendar parser instance with the parsed objects */ public function get_mail_ical_objects() { // create parser and load ical objects if (!$this->mail_ical_parser) { $this->mail_ical_parser = $this->get_ical(); foreach ($this->ical_parts as $mime_id) { $part = $this->ical_message->mime_parts[$mime_id]; $charset = $part->ctype_parameters['charset'] ?: RCUBE_CHARSET; $this->mail_ical_parser->import($this->ical_message->get_part_body($mime_id, true), $charset); // check if the parsed object is an instance of a recurring event/task array_walk($this->mail_ical_parser->objects, 'libcalendaring::identify_recurrence_instance'); // stop on the part that has an iTip method specified if (count($this->mail_ical_parser->objects) && $this->mail_ical_parser->method) { $this->mail_ical_parser->message_date = $this->ical_message->headers->date; $this->mail_ical_parser->mime_id = $mime_id; // store the message's sender address for comparisons $from = rcube_mime::decode_address_list($this->ical_message->headers->from, 1, true, null, true); $this->mail_ical_parser->sender = !empty($from) ? $from[1] : ''; if (!empty($this->mail_ical_parser->sender)) { foreach ($this->mail_ical_parser->objects as $i => $object) { $this->mail_ical_parser->objects[$i]['_sender'] = $this->mail_ical_parser->sender; $this->mail_ical_parser->objects[$i]['_sender_utf'] = rcube_utils::idn_to_utf8($this->mail_ical_parser->sender); } } break; } } } return $this->mail_ical_parser; } /** * Read the given mime message from IMAP and parse ical data * * @param string Mailbox name * @param string Message UID * @param string Message part ID and object index (e.g. '1.2:0') * @param string Object type filter (optional) * * @return array Hash array with the parsed iCal */ public function mail_get_itip_object($mbox, $uid, $mime_id, $type = null) { $charset = RCUBE_CHARSET; // establish imap connection $imap = $this->rc->get_storage(); $imap->set_folder($mbox); if ($uid && $mime_id) { list($mime_id, $index) = explode(':', $mime_id); $part = $imap->get_message_part($uid, $mime_id); $headers = $imap->get_message_headers($uid); $parser = $this->get_ical(); if ($part->ctype_parameters['charset']) { $charset = $part->ctype_parameters['charset']; } if ($part) { $objects = $parser->import($part, $charset); } } // successfully parsed events/tasks? if (!empty($objects) && ($object = $objects[$index]) && (!$type || $object['_type'] == $type)) { if ($parser->method) $object['_method'] = $parser->method; // store the message's sender address for comparisons $from = rcube_mime::decode_address_list($headers->from, 1, true, null, true); $object['_sender'] = !empty($from) ? $from[1] : ''; $object['_sender_utf'] = rcube_utils::idn_to_utf8($object['_sender']); // check if this is an instance of a recurring event/task self::identify_recurrence_instance($object); return $object; } return null; } /** * Checks if specified message part is a vcalendar data * * @param rcube_message_part Part object * @return boolean True if part is of type vcard */ public static function part_is_vcalendar($part) { return ( in_array($part->mimetype, array('text/calendar', 'text/x-vcalendar', 'application/ics')) || // Apple sends files as application/x-any (!?) ($part->mimetype == 'application/x-any' && $part->filename && preg_match('/\.ics$/i', $part->filename)) ); } /** * Single occourrences of recurring events are identified by their RECURRENCE-ID property * in iCal which is represented as 'recurrence_date' in our internal data structure. * * Check if such a property exists and derive the '_instance' identifier and '_savemode' * attributes which are used in the storage backend to identify the nested exception item. */ public static function identify_recurrence_instance(&$object) { // for savemode=all, remove recurrence instance identifiers if (!empty($object['_savemode']) && $object['_savemode'] == 'all' && $object['recurrence']) { unset($object['_instance'], $object['recurrence_date']); } // set instance and 'savemode' according to recurrence-id else if (!empty($object['recurrence_date']) && is_a($object['recurrence_date'], 'DateTime')) { $object['_instance'] = self::recurrence_instance_identifier($object); $object['_savemode'] = $object['thisandfuture'] ? 'future' : 'current'; } else if (!empty($object['recurrence_id']) && !empty($object['_instance'])) { if (strlen($object['_instance']) > 4) { $object['recurrence_date'] = rcube_utils::anytodatetime($object['_instance'], $object['start']->getTimezone()); } else { $object['recurrence_date'] = clone $object['start']; } } } /** * Return a date() format string to render identifiers for recurrence instances * * @param array Hash array with event properties * @return string Format string */ public static function recurrence_id_format($event) { return $event['allday'] ? 'Ymd' : 'Ymd\THis'; } /** * Return the identifer for the given instance of a recurring event * * @param array Hash array with event properties * @return mixed Format string or null if identifier cannot be generated */ public static function recurrence_instance_identifier($event) { $instance_date = $event['recurrence_date'] ?: $event['start']; if ($instance_date && is_a($instance_date, 'DateTime')) { $recurrence_id_format = $event['allday'] ? 'Ymd' : 'Ymd\THis'; return $instance_date->format($recurrence_id_format); } return null; } /********* Attendee handling functions *********/ /** * Handler for attendee group expansion requests */ public function expand_attendee_group() { $id = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST); $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); $result = array('id' => $id, 'members' => array()); $maxnum = 500; // iterate over all autocomplete address books (we don't know the source of the group) foreach ((array)$this->rc->config->get('autocomplete_addressbooks', 'sql') as $abook_id) { if (($abook = $this->rc->get_address_book($abook_id)) && $abook->groups) { foreach ($abook->list_groups($data['name'], 1) as $group) { // this is the matching group to expand if (in_array($data['email'], (array)$group['email'])) { $abook->set_pagesize($maxnum); $abook->set_group($group['ID']); // get all members $res = $abook->list_records($this->rc->config->get('contactlist_fields')); // handle errors (e.g. sizelimit, timelimit) if ($abook->get_error()) { $result['error'] = $this->rc->gettext('expandattendeegrouperror', 'libcalendaring'); $res = false; } // check for maximum number of members (we don't wanna bloat the UI too much) else if ($res->count > $maxnum) { $result['error'] = $this->rc->gettext('expandattendeegroupsizelimit', 'libcalendaring'); $res = false; } while ($res && ($member = $res->iterate())) { $emails = (array)$abook->get_col_values('email', $member, true); if (!empty($emails) && ($email = array_shift($emails))) { $result['members'][] = array( 'email' => $email, 'name' => rcube_addressbook::compose_list_name($member), ); } } break 2; } } } } $this->rc->output->command('plugin.expand_attendee_callback', $result); } /** * Merge attendees of the old and new event version * with keeping current user and his delegatees status * - * @param array &$new New object data - * @param array $old Old object data + * @param array &$new New object data + * @param array $old Old object data + * @param bool $status New status of the current user */ - public function merge_attendees(&$new, $old) + public function merge_attendees(&$new, $old, $status = null) { - $emails = $this->get_user_emails(); - $delegates = array(); - $attendees = array(); + if (empty($status)) { + $emails = $this->get_user_emails(); + $delegates = array(); + $attendees = array(); + + // keep attendee status of the current user + foreach ((array) $new['attendees'] as $i => $attendee) { + if (empty($attendee['email'])) { + continue; + } - // keep attendee status of the current user - foreach ((array) $new['attendees'] as $i => $attendee) { - if (empty($attendee['email'])) { - continue; - } + $attendees[] = $email = strtolower($attendee['email']); - $attendees[] = $email = strtolower($attendee['email']); + if (in_array($email, $emails)) { + foreach ($old['attendees'] as $_attendee) { + if ($attendee['email'] == $_attendee['email']) { + $new['attendees'][$i] = $_attendee; + if ($_attendee['status'] == 'DELEGATED' && ($email = $_attendee['delegated-to'])) { + $delegates[] = strtolower($email); + } - if (in_array($email, $emails)) { - foreach ($old['attendees'] as $_attendee) { - if ($attendee['email'] == $_attendee['email']) { - $new['attendees'][$i] = $_attendee; - if ($_attendee['status'] == 'DELEGATED' && ($email = $_attendee['delegated-to'])) { - $delegates[] = strtolower($email); + break; } + } + } + } - break; + // make sure delegated attendee is not lost + foreach ($delegates as $delegatee) { + if (!in_array($delegatee, $attendees)) { + foreach ((array) $old['attendees'] as $attendee) { + if ($attendee['email'] && ($email = strtolower($attendee['email'])) && $email == $delegatee) { + $new['attendees'][] = $attendee; + break; + } } } } } - // make sure delegated attendee is not lost - foreach ($delegates as $delegatee) { - if (!in_array($delegatee, $attendees)) { - foreach ((array) $old['attendees'] as $attendee) { - if ($attendee['email'] && ($email = strtolower($attendee['email'])) && $email == $delegatee) { - $new['attendees'][] = $attendee; + // We also make sure that status of any attendee + // is not overriden by NEEDS-ACTION if it was already set + // which could happen if you work with shared events + foreach ((array) $new['attendees'] as $i => $attendee) { + if ($attendee['email'] && $attendee['status'] == 'NEEDS-ACTION') { + foreach ($old['attendees'] as $_attendee) { + if ($attendee['email'] == $_attendee['email']) { + $new['attendees'][$i]['status'] = $_attendee['status']; + unset($new['attendees'][$i]['rsvp']); break; } } } } } /********* Static utility functions *********/ /** * Convert the internal structured data into a vcalendar rrule 2.0 string */ public static function to_rrule($recurrence, $allday = false) { if (is_string($recurrence)) return $recurrence; $rrule = ''; foreach ((array)$recurrence as $k => $val) { $k = strtoupper($k); switch ($k) { case 'UNTIL': // convert to UTC according to RFC 5545 if (is_a($val, 'DateTime')) { if (!$allday && !$val->_dateonly) { $until = clone $val; $until->setTimezone(new DateTimeZone('UTC')); $val = $until->format('Ymd\THis\Z'); } else { $val = $val->format('Ymd'); } } break; case 'RDATE': case 'EXDATE': foreach ((array)$val as $i => $ex) { if (is_a($ex, 'DateTime')) $val[$i] = $ex->format('Ymd\THis'); } $val = join(',', (array)$val); break; case 'EXCEPTIONS': continue 2; } if (strlen($val)) $rrule .= $k . '=' . $val . ';'; } return rtrim($rrule, ';'); } /** * Convert from fullcalendar date format to PHP date() format string */ public static function to_php_date_format($from) { // "dd.MM.yyyy HH:mm:ss" => "d.m.Y H:i:s" return strtr(strtr($from, array( 'YYYY' => 'Y', 'YY' => 'y', 'yyyy' => 'Y', 'yy' => 'y', 'MMMM' => 'F', 'MMM' => 'M', 'MM' => 'm', 'M' => 'n', 'dddd' => 'l', 'ddd' => 'D', 'dd' => 'd', 'd' => 'j', 'HH' => '**', 'hh' => '%%', 'H' => 'G', 'h' => 'g', 'mm' => 'i', 'ss' => 's', 'TT' => 'A', 'tt' => 'a', 'T' => 'A', 't' => 'a', 'u' => 'c', )), array( '**' => 'H', '%%' => 'h', )); } /** * Convert from PHP date() format to fullcalendar format string */ public static function from_php_date_format($from) { // "d.m.Y H:i:s" => "dd.MM.yyyy HH:mm:ss" return strtr($from, array( 'y' => 'yy', 'Y' => 'yyyy', 'M' => 'MMM', 'F' => 'MMMM', 'm' => 'MM', 'n' => 'M', 'j' => 'd', 'd' => 'dd', 'D' => 'ddd', 'l' => 'dddd', 'H' => 'HH', 'h' => 'hh', 'G' => 'H', 'g' => 'h', 'i' => 'mm', 's' => 'ss', 'A' => 'TT', 'a' => 'tt', 'c' => 'u', )); } }