From 13b36ba29b9a5b5dcd90c156754e6dcf89ad6c3d Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Thu, 12 Mar 2015 22:37:40 +0100 Subject: [PATCH] Synchronized with git master from git.kolab.org (v3.2.7) --- README | 54 + UPGRADING | 4 +- calendar.php | 2124 +++++-- calendar_base.js | 122 +- calendar_ui.js | 2489 ++++++-- composer.json | 5 +- config.inc.php.dist | 95 +- drivers/calendar_driver.php | 442 +- drivers/database/SQL/mysql.initial.sql | 10 +- drivers/database/SQL/mysql/2014040900.sql | 3 + drivers/database/SQL/mysql/2015022700.sql | 15 + drivers/database/SQL/postgres.initial.sql | 20 +- drivers/database/SQL/postgres/2014040900.sql | 3 + drivers/database/SQL/postgres/2015022700.sql | 9 + drivers/database/SQL/sqlite.initial.sql | 10 +- drivers/database/SQL/sqlite/2014040900.sql | 67 + drivers/database/SQL/sqlite/2015022700.sql | 79 + drivers/database/database_driver.php | 725 ++- drivers/kolab/SQL/mysql.initial.sql | 6 +- drivers/kolab/SQL/mysql/2014041700.sql | 1 + drivers/kolab/SQL/oracle.initial.sql | 31 + drivers/kolab/SQL/postgres.initial.sql | 10 +- drivers/kolab/kolab_calendar.php | 652 +- drivers/kolab/kolab_driver.php | 1607 ++++- drivers/kolab/kolab_invitation_calendar.php | 377 ++ drivers/kolab/kolab_user_calendar.php | 432 ++ drivers/ldap/resources_driver_ldap.php | 150 + drivers/resources_driver.php | 114 + lib/calendar_itip.php | 208 +- lib/calendar_recurrence.php | 75 +- lib/calendar_ui.php | 573 +- lib/js/fullcalendar.js | 5740 ++++++++++-------- localization/bg_BG.inc | 210 +- localization/ca_ES.inc | 267 + localization/cs_CZ.inc | 245 +- localization/da_DK.inc | 204 + localization/de_CH.inc | 121 +- localization/de_DE.inc | 198 +- localization/en_US.inc | 171 +- localization/es_AR.inc | 270 + localization/es_ES.inc | 249 +- localization/et_EE.inc | 405 +- localization/fi_FI.inc | 271 + localization/fr_FR.inc | 200 +- localization/he.inc | 272 + localization/hr.inc | 272 + localization/hu_HU.inc | 227 +- localization/it_IT.inc | 278 +- localization/ja_JP.inc | 435 +- localization/ku_IQ.inc | 9 + localization/nl_NL.inc | 231 +- localization/pl.inc | 103 + localization/pl_PL.inc | 329 +- localization/pt_BR.inc | 226 +- localization/pt_PT.inc | 275 + localization/ro.inc | 9 + localization/ru_RU.inc | 293 +- localization/sk.inc | 82 + localization/sl.inc | 274 + localization/sv.inc | 9 + localization/sv_SE.inc | 274 + localization/th.inc | 258 + localization/tr_TR.inc | 9 + localization/uk.inc | 229 + localization/vi.inc | 272 + localization/vi_VN.inc | 272 + localization/zh_CN.inc | 76 + localization/zh_TW.inc | 272 + print.js | 18 +- skins/classic/calendar.css | 564 +- skins/classic/fullcalendar.css | 725 +-- skins/classic/images/attendee-status.gif | Bin 2041 -> 3487 bytes skins/classic/images/calendars.gif | Bin 1928 -> 2734 bytes skins/classic/images/calendars.png | Bin 2103 -> 1872 bytes skins/classic/print.css | 2 +- skins/classic/templates/attachment.html | 2 +- skins/classic/templates/calendar.html | 72 +- skins/classic/templates/eventedit.html | 39 +- skins/classic/templates/freebusylegend.html | 10 +- skins/classic/templates/itipattend.html | 2 +- skins/classic/templates/print.html | 2 +- skins/larry/calendar.css | 1297 +++- skins/larry/fullcalendar.css | 343 +- skins/larry/images/attendee-status.png | Bin 0 -> 2202 bytes skins/larry/images/autocomplete.png | Bin 0 -> 558 bytes skins/larry/images/badge_cancelled.png | Bin 0 -> 924 bytes skins/larry/images/badge_confidential.png | Bin 3441 -> 1522 bytes skins/larry/images/badge_private.png | Bin 3359 -> 1346 bytes skins/larry/images/calendar.png | Bin 888 -> 613 bytes skins/larry/images/calendars.png | Bin 2239 -> 2582 bytes skins/larry/images/eventicons.png | Bin 1233 -> 217 bytes skins/larry/images/focusview.png | Bin 0 -> 4224 bytes skins/larry/images/freebusy-colors.png | Bin 490 -> 302 bytes skins/larry/images/ical-attachment.png | Bin 0 -> 492 bytes skins/larry/images/invitation.png | Bin 1909 -> 1485 bytes skins/larry/images/sendinvitation.png | Bin 0 -> 337 bytes skins/larry/images/toolbar.png | Bin 8805 -> 3662 bytes skins/larry/print.css | 28 +- skins/larry/templates/attachment.html | 57 +- skins/larry/templates/calendar.html | 397 +- skins/larry/templates/eventedit.html | 56 +- skins/larry/templates/freebusylegend.html | 10 +- skins/larry/templates/itipattend.html | 2 +- skins/larry/templates/print.html | 3 +- 104 files changed, 20278 insertions(+), 7400 deletions(-) create mode 100644 drivers/database/SQL/mysql/2014040900.sql create mode 100644 drivers/database/SQL/mysql/2015022700.sql create mode 100644 drivers/database/SQL/postgres/2014040900.sql create mode 100644 drivers/database/SQL/postgres/2015022700.sql create mode 100644 drivers/database/SQL/sqlite/2014040900.sql create mode 100644 drivers/database/SQL/sqlite/2015022700.sql create mode 100644 drivers/kolab/SQL/mysql/2014041700.sql create mode 100644 drivers/kolab/SQL/oracle.initial.sql create mode 100644 drivers/kolab/kolab_invitation_calendar.php create mode 100644 drivers/kolab/kolab_user_calendar.php create mode 100644 drivers/ldap/resources_driver_ldap.php create mode 100644 drivers/resources_driver.php create mode 100644 localization/ca_ES.inc create mode 100644 localization/da_DK.inc create mode 100644 localization/es_AR.inc create mode 100644 localization/fi_FI.inc create mode 100644 localization/he.inc create mode 100644 localization/hr.inc create mode 100644 localization/ku_IQ.inc create mode 100644 localization/pl.inc create mode 100644 localization/pt_PT.inc create mode 100644 localization/ro.inc create mode 100644 localization/sk.inc create mode 100644 localization/sl.inc create mode 100644 localization/sv.inc create mode 100644 localization/sv_SE.inc create mode 100644 localization/th.inc create mode 100644 localization/tr_TR.inc create mode 100644 localization/uk.inc create mode 100644 localization/vi.inc create mode 100644 localization/vi_VN.inc create mode 100644 localization/zh_CN.inc create mode 100644 localization/zh_TW.inc mode change 100644 => 120000 skins/classic/fullcalendar.css create mode 100644 skins/larry/images/attendee-status.png create mode 100644 skins/larry/images/autocomplete.png create mode 100644 skins/larry/images/badge_cancelled.png create mode 100644 skins/larry/images/focusview.png create mode 100644 skins/larry/images/ical-attachment.png create mode 100644 skins/larry/images/sendinvitation.png diff --git a/README b/README index e73efe9..2426fcf 100644 --- a/README +++ b/README @@ -20,5 +20,59 @@ are used. They are packaged in a slightly modified version with this plugin. IMPORTANT --------- + The calendar module makes heavy use of PHP's DateTime as well as DateInterval classes. The latter one requires at least PHP 5.3.0 to run. + + +REQUIREMENTS +------------ + +Some functions are shared with other plugins and therefore being moved to +library plugins. Thus in order to run the calendar plugin, you also need the +following plugins installed: + +* libcalendaring [1] +* libkolab [1] (when using the 'kolab' driver) + + +INSTALLATION +------------ + +For a manual installation of the calendar plugin (and its dependencies), +execute the following steps. This will set it up with the database backend +driver. + +1. Get the source from git + + $ cd /tmp + $ git clone git://git.kolab.org/git/roundcubemail-plugins-kolab + $ cd //plugins + $ cp -r /tmp/roundcubemail-plugins-kolab/plugins/calendar . + $ cp -r /tmp/roundcubemail-plugins-kolab/plugins/libcalendaring . + +2. Create calendar plugin configuration + + $ cd calendar/ + $ cp config.inc.php.dist config.inc.php + $ edit config.inc.php + +3. Initialize the calendar database tables + + $ mysql roundcubemail < drivers/database/SQL/mysql.initial.sql + +4. Enable the calendar plugin + + $ cd ../../ + $ edit config/config.inc.php + +Add 'calendar' to the list of active plugins: + + $config['plugins'] = array( + (...) + 'calendar', + ); + + + +[1] http://git.kolab.org/roundcubemail-plugins-kolab/ diff --git a/UPGRADING b/UPGRADING index 524c2ac..0e36e85 100644 --- a/UPGRADING +++ b/UPGRADING @@ -8,10 +8,10 @@ updatedb.sh --package=calendar- --version= \ --dir=../plugins/calendar/drivers//SQL [*] Replace with "database" or "kolab" (without quotes) -[*] Replace with Roundcube version e.g. 0.7.3 +[*] Replace with Roundcube version e.g. 0.9.0 [*] Roundcube should be upgraded before plugin upgrades Example: -updatedb.sh --package=calendar-kolab --version=0.8.0 \ +updatedb.sh --package=calendar-kolab --version=0.9.0 \ --dir=../plugins/calendar/drivers/kolab/SQL diff --git a/calendar.php b/calendar.php index 95240ab..e3d152a 100644 --- a/calendar.php +++ b/calendar.php @@ -3,12 +3,11 @@ /** * Calendar plugin for Roundcube webmail * - * @version @package_version@ * @author Lazlo Westerhof * @author Thomas Bruederli * * Copyright (C) 2010, Lazlo Westerhof - * Copyright (C) 2012, Kolab Systems AG + * 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 @@ -37,14 +36,12 @@ class calendar extends rcube_plugin public $task = '?(?!logout).*'; public $rc; public $lib; - public $driver; + public $resources_dir; public $home; // declare public to be used in other classes public $urlbase; public $timezone; public $timezone_offset; public $gmt_offset; - - public $ical; public $ui; public $defaults = array( @@ -57,9 +54,13 @@ class calendar extends rcube_plugin 'calendar_event_coloring' => 0, 'calendar_time_indicator' => true, 'calendar_allow_invite_shared' => false, + 'calendar_itip_send_option' => 3, + 'calendar_itip_after_action' => 0, ); - private $ics_parts = array(); + private $ical; + private $itip; + private $driver; /** @@ -99,6 +100,8 @@ class calendar extends rcube_plugin // default startup routine $this->add_hook('startup', array($this, 'startup')); } + + $this->add_hook('user_delete', array($this, 'user_delete')); } /** @@ -111,7 +114,7 @@ class calendar extends rcube_plugin return; // load Calendar user interface - if (!$this->rc->output->ajax_call && !$this->rc->output->env['framed']) { + 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 @@ -128,6 +131,7 @@ class calendar extends rcube_plugin $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')); @@ -137,10 +141,19 @@ class calendar extends rcube_plugin $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('mailimportevent', array($this, 'mail_import_event')); + $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... @@ -162,7 +175,6 @@ class calendar extends rcube_plugin else if ($args['task'] == 'mail') { // hooks to catch event invitations on incoming mails if ($args['action'] == 'show' || $args['action'] == 'preview') { - $this->add_hook('message_load', array($this, 'mail_message_load')); $this->add_hook('template_object_messagebody', array($this, 'mail_messagebody_html')); } @@ -181,6 +193,15 @@ class calendar extends rcube_plugin $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 @@ -202,16 +223,10 @@ class calendar extends rcube_plugin require_once($this->home . '/drivers/calendar_driver.php'); require_once($this->home . '/drivers/' . $driver_name . '/' . $driver_class . '.php'); - switch ($driver_name) { - case "kolab": - $this->require_plugin('libkolab'); - default: - $this->driver = new $driver_class($this); - break; - } + $this->driver = new $driver_class($this); - if ($this->driver->undelete) - $this->driver->undelete = $this->rc->config->get('undo_timeout', 0) > 0; + if ($this->driver->undelete) + $this->driver->undelete = $this->rc->config->get('undo_timeout', 0) > 0; } /** @@ -221,11 +236,10 @@ class calendar extends rcube_plugin { if (!$this->itip) { require_once($this->home . '/lib/calendar_itip.php'); - - $plugin = $this->rc->plugins->exec_hook('calendar_load_itip', - array('identity' => null)); - - $this->itip = new calendar_itip($this, $plugin['identity']); + $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; @@ -246,18 +260,23 @@ class calendar extends rcube_plugin /** * Get properties of the calendar this user has specified as default */ - public function get_default_calendar($writeable = false) + public function get_default_calendar($writeable = false, $confidential = false) { $default_id = $this->rc->config->get('calendar_default_calendar'); - $calendars = $this->driver->list_calendars(false, true); + $calendars = $this->driver->list_calendars(calendar_driver::FILTER_PERSONAL); $calendar = $calendars[$default_id] ?: null; - if (!$calendar || ($writeable && $calendar['readonly'])) { + if (!$calendar || $confidential || ($writeable && !$calendar['editable'])) { foreach ($calendars as $cal) { - if ($cal['default']) { + if ($confidential && $cal['subtype'] == 'confidential') { $calendar = $cal; break; } - if (!$writeable || !$cal['readonly']) { + if ($cal['default']) { + $calendar = $cal; + if (!$confidential) + break; + } + if (!$writeable || $cal['editable']) { $first = $cal; } } @@ -281,23 +300,28 @@ class calendar extends rcube_plugin $this->ui->addJS(); $this->ui->init_templates(); - $this->rc->output->add_label('lowest','low','normal','high','highest','delete','cancel','uploading','noemailwarning'); + $this->rc->output->add_label('lowest','low','normal','high','highest','delete','cancel','uploading','noemailwarning','close'); + $this->rc->output->add_label('libcalendaring.itipaccepted','libcalendaring.itiptentative','libcalendaring.itipdeclined','libcalendaring.itipdelegated','libcalendaring.expandattendeegroup','libcalendaring.expandattendeegroupnodata'); // initialize attendees autocompletion rcube_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('mscolors', $this->driver->get_color_values()); - $this->rc->output->set_env('identities-selector', $this->ui->identity_select(array('id' => 'edit-identities-list'))); + $this->rc->output->set_env('calendar_resources', (bool)$this->rc->config->get('calendar_resources_driver')); + $this->rc->output->set_env('mscolors', jqueryui::get_color_values()); + $this->rc->output->set_env('identities-selector', $this->ui->identity_select(array('id' => 'edit-identities-list', 'aria-label' => $this->gettext('roleorganizer')))); - $view = get_input_value('view', RCUBE_INPUT_GPC); + $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 = get_input_value('date', RCUBE_INPUT_GPC)) + + 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"); } @@ -473,7 +497,7 @@ class calendar extends rcube_plugin foreach (array('-M','-H','-D','+M','+H','+D') as $trigger) $select_offset->add(rcube_label('trigger' . $trigger, 'libcalendaring'), $trigger); - $preset = libcalendaring::parse_alaram_value($this->rc->config->get('calendar_default_alarm_offset', '-15M')); + $preset = libcalendaring::parse_alarm_value($this->rc->config->get('calendar_default_alarm_offset', '-15M')); $p['blocks']['view']['options']['alarmoffset'] = array( 'title' => html::label($field_id . 'value', Q($this->gettext('defaultalarmoffset'))), 'content' => $input_value->show($preset[0]) . ' ' . $select_offset->show($preset[1]), @@ -488,7 +512,7 @@ class calendar extends rcube_plugin // 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(false, true) as $id => $prop) { + foreach ((array)$this->driver->list_calendars(calendar_driver::FILTER_PERSONAL) as $id => $prop) { $select_cal->add($prop['name'], strval($id)); if ($prop['default']) $default_calendar = $id; @@ -499,6 +523,46 @@ class calendar extends rcube_plugin ); } + $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, 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'); @@ -551,11 +615,61 @@ class calendar extends rcube_plugin }); ', 'docready'); - // include color picker - $this->include_script('lib/js/jquery.miniColors.min.js'); - $this->include_stylesheet($this->local_skin_path() . '/jquery.miniColors.css'); - $this->rc->output->set_env('mscolors', $this->driver->get_color_values()); - $this->rc->output->add_script('$("input.colors").miniColors({ colorValues:rcmail.env.mscolors })', '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(rcube_label(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(rcube_label('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; @@ -574,24 +688,38 @@ class calendar extends rcube_plugin $this->load_driver(); // compose default alarm preset value - $alarm_offset = get_input_value('_alarm_offset', RCUBE_INPUT_POST); - $default_alarm = $alarm_offset[0] . intval(get_input_value('_alarm_value', RCUBE_INPUT_POST)) . $alarm_offset[1]; + $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' => get_input_value('_default_view', RCUBE_INPUT_POST), - 'calendar_timeslots' => intval(get_input_value('_timeslots', RCUBE_INPUT_POST)), - 'calendar_first_day' => intval(get_input_value('_first_day', RCUBE_INPUT_POST)), - 'calendar_first_hour' => intval(get_input_value('_first_hour', RCUBE_INPUT_POST)), - 'calendar_work_start' => intval(get_input_value('_work_start', RCUBE_INPUT_POST)), - 'calendar_work_end' => intval(get_input_value('_work_end', RCUBE_INPUT_POST)), - 'calendar_event_coloring' => intval(get_input_value('_event_coloring', RCUBE_INPUT_POST)), - 'calendar_default_alarm_type' => get_input_value('_alarm_type', RCUBE_INPUT_POST), + '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' => get_input_value('_default_calendar', RCUBE_INPUT_POST), + '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(); @@ -599,8 +727,8 @@ class calendar extends rcube_plugin $old_categories[md5($name)] = $name; } - $categories = (array) get_input_value('_categories', RCUBE_INPUT_POST); - $colors = (array) get_input_value('_colors', RCUBE_INPUT_POST); + $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])); @@ -633,8 +761,8 @@ class calendar extends rcube_plugin */ function calendar_action() { - $action = get_input_value('action', RCUBE_INPUT_GPC); - $cal = get_input_value('c', RCUBE_INPUT_GPC); + $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'])) @@ -653,14 +781,41 @@ class calendar extends rcube_plugin $success = $this->driver->edit_calendar($cal); $reload = true; break; - case "remove": - if ($success = $this->driver->remove_calendar($cal)) + 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'); 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) @@ -672,9 +827,8 @@ class calendar extends rcube_plugin $this->rc->output->command('plugin.unlock_saving'); - // TODO: keep view and date selection if ($success && $reload) - $this->rc->output->redirect(''); + $this->rc->output->command('plugin.reload_view'); } @@ -683,46 +837,61 @@ class calendar extends rcube_plugin */ function event_action() { - $action = get_input_value('action', RCUBE_INPUT_GPC); - $event = get_input_value('e', RCUBE_INPUT_POST, true); + $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; - // don't notify if modifying a recurring instance (really?) - if ($event['_savemode'] && $event['_savemode'] != 'all' && $event['_notify']) - unset($event['_notify']); - + // 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') + 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->prepare_event($event, $action); + $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->prepare_event($event, $action); - if ($success = $this->driver->edit_event($event)) - $this->cleanup_event($event); - $reload = $success && ($event['recurrence'] || $event['_savemode'] || $event['_fromcalendar']) ? 2 : 1; + $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->prepare_event($event, $action); - $success = $this->driver->resize_event($event); + $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->prepare_event($event, $action); - $success = $this->driver->move_event($event); + $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; @@ -733,7 +902,7 @@ class calendar extends rcube_plugin // search for event if only UID is given if (!isset($event['calendar']) && $event['uid']) { - if (!($event = $this->driver->get_event($event, true))) { + if (!($event = $this->driver->get_event($event, calendar_driver::FILTER_WRITEABLE))) { break; } $undo_time = 0; @@ -756,8 +925,19 @@ class calendar extends rcube_plugin $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']) { + if ($success && $event['_decline']) { $emails = $this->get_user_emails(); foreach ($old['attendees'] as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER') @@ -767,7 +947,11 @@ class calendar extends rcube_plugin $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')) @@ -775,6 +959,9 @@ class calendar extends rcube_plugin 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": @@ -793,82 +980,66 @@ class calendar extends rcube_plugin break; - case "rsvp-status": - $action = 'rsvp'; - $status = $event['fallback']; - $latest = false; - $html = html::div('rsvp-status', $status != 'CANCELLED' ? $this->gettext('acceptinvitation') : ''); - if (is_numeric($event['changed'])) - $event['changed'] = new DateTime('@'.$event['changed']); - $this->load_driver(); - if ($existing = $this->driver->get_event($event, true, false, true)) { - $latest = ($event['sequence'] && $existing['sequence'] == $event['sequence']) || (!$event['sequence'] && $existing['changed'] && $existing['changed'] >= $event['changed']); - $emails = $this->get_user_emails(); - foreach ($existing['attendees'] as $i => $attendee) { - if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { - $status = $attendee['status']; - break; - } - } - } - else { - // get a list of writeable calendars - $calendars = $this->driver->list_calendars(false, true); - $calendar_select = new html_select(array('name' => 'calendar', 'id' => 'calendar-saveto', 'is_escaped' => true)); - $numcals = 0; - foreach ($calendars as $calendar) { - if (!$calendar['readonly']) { - $calendar_select->add($calendar['name'], $calendar['id']); - $numcals++; - } - } - if ($numcals <= 1) - $calendar_select = null; - } - - if ($status == 'unknown') { - $html = html::div('rsvp-status', $this->gettext('notanattendee')); - $action = 'import'; - } - else if (in_array($status, array('ACCEPTED','TENTATIVE','DECLINED'))) { - $html = html::div('rsvp-status ' . strtolower($status), $this->gettext('youhave'.strtolower($status))); - if ($existing['sequence'] > $event['sequence'] || (!$event['sequence'] && $existing['changed'] && $existing['changed'] > $event['changed'])) { - $action = ''; // nothing to do here, outdated invitation - } - } - - $default_calendar = $calendar_select ? $this->get_default_calendar(true) : null; - $this->rc->output->command('plugin.update_event_rsvp_status', array( - 'uid' => $event['uid'], - 'id' => asciiwords($event['uid'], true), - 'saved' => $existing ? true : false, - 'latest' => $latest, - 'status' => $status, - 'action' => $action, - 'html' => $html, - 'select' => $calendar_select ? html::span('calendar-select', $this->gettext('saveincalendar') . ' ' . $calendar_select->show($this->rc->config->get('calendar_default_calendar', $default_calendar['id']))) : '', - )); - return; - 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']; + + // 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; - if ($success = $this->driver->edit_event($event)) { - $status = get_input_value('status', RCUBE_INPUT_GPC); + // 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; - break; + } + else if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { + $reply_sender = $attendee['email']; } } - $itip = $this->load_itip(); - 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'); + + if (!$noreply) { + $itip = $this->load_itip(); + $itip->set_sender_email($reply_sender); + $event['comment'] = $reply_comment; + $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; @@ -881,6 +1052,95 @@ class calendar extends rcube_plugin $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; + array_walk($data, function(&$change) use ($lib) { + if ($change['date']) { + $dt = $lib->adjust_timezone($change['date']); + if ($dt instanceof DateTime) + $change['date'] = $dt->format('c'); + } + }); + $this->rc->output->command('plugin.render_event_changelog', $data); + } + else { + $this->rc->output->command('plugin.render_event_changelog', false); + $this->rc->output->command('display_message', $this->gettext('eventchangelognotavailable'), 'error'); + } + $got_msg = true; + $reload = false; + break; + + case "diff": + $data = $this->driver->get_event_diff($event, $event['rev']); + 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('eventdiffnotavailable'), '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('eventnotfound'), 'error'); + } + $got_msg = true; + $reload = false; + break; + + case "restore": + if ($success = $this->driver->restore_event_revision($event, $event['rev'])) { + + } + else { + $this->rc->output->command('display_message', 'Not implemented yet', 'error'); + $got_msg = true; + } + $reload = false; + break; } // show confirmation/error message @@ -891,21 +1151,6 @@ class calendar extends rcube_plugin $this->rc->output->show_message('calendar.errorsaving', 'error'); } - // send out notifications - if ($success && $event['_notify'] && ($event['attendees'] || $old['attendees'])) { - // make sure we have the complete record - $event = $action == 'remove' ? $old : $this->driver->get_event($event); - - // 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); - if ($sent > 0) - $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation'); - else if ($sent < 0) - $this->rc->output->show_message('calendar.errornotifying', 'error'); - } - } - // unlock client $this->rc->output->command('plugin.unlock_saving'); @@ -915,11 +1160,71 @@ class calendar extends rcube_plugin if ($reload > 1) $args['refetch'] = true; else if ($success && $action != 'remove') - $args['update'] = $this->_client_event($this->driver->get_event($event)); + $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']); + 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 @@ -927,15 +1232,85 @@ class calendar extends rcube_plugin function load_events() { $events = $this->driver->load_events( - get_input_value('start', RCUBE_INPUT_GET), - get_input_value('end', RCUBE_INPUT_GET), - ($query = get_input_value('q', RCUBE_INPUT_GET)), - get_input_value('source', RCUBE_INPUT_GET) + 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 @@ -948,11 +1323,13 @@ class calendar extends rcube_plugin return; } - foreach ($this->driver->list_calendars(true) as $cal) { + $counts = array(); + + foreach ($this->driver->list_calendars(calendar_driver::FILTER_ACTIVE) as $cal) { $events = $this->driver->load_events( - get_input_value('start', RCUBE_INPUT_GPC), - get_input_value('end', RCUBE_INPUT_GPC), - get_input_value('q', RCUBE_INPUT_GPC), + 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'] @@ -962,6 +1339,16 @@ class calendar extends rcube_plugin $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)); } } @@ -972,13 +1359,34 @@ class calendar extends rcube_plugin public function pending_alarms($p) { $this->load_driver(); - if ($alarms = $this->driver->pending_alarms($p['time'] ?: time())) { + $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; } @@ -989,8 +1397,12 @@ class calendar extends rcube_plugin { $this->load_driver(); foreach ((array)$p['ids'] as $id) { - if (strpos($id, 'cal:') === 0) + 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; @@ -1005,6 +1417,18 @@ class calendar extends rcube_plugin $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(); + } + } + /** * */ @@ -1021,31 +1445,47 @@ class calendar extends rcube_plugin $err = $_FILES['_data']['error']; if (!$err && $_FILES['_data']['tmp_name']) { - $calendar = get_input_value('calendar', RCUBE_INPUT_GPC); + $calendar = rcube_utils::get_input_value('calendar', rcube_utils::INPUT_GPC); $rangestart = $_REQUEST['_range'] ? date_create("now -" . intval($_REQUEST['_range']) . " months") : 0; - $user_email = $this->rc->user->get_username(); - $ical = $this->get_ical(); - $errors = !$ical->fopen($_FILES['_data']['tmp_name']); - $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(); + // 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.'; + } } - - // 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 = 1; + $msg = 'Zip files are not supported for import.'; } - else - $errors++; + } + else { + // attempt to import teh uploaded file directly + $count = $this->import_from_file($_FILES['_data']['tmp_name'], $calendar, $rangestart, $errors); } if ($count) { @@ -1070,19 +1510,54 @@ class calendar extends rcube_plugin } $this->rc->output->command('plugin.import_error', array('message' => $msg)); - $this->rc->output->command('plugin.unlock_saving', false); } $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 = get_input_value('start', RCUBE_INPUT_GET); - $end = get_input_value('end', RCUBE_INPUT_GET); + $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)) @@ -1092,22 +1567,36 @@ class calendar extends rcube_plugin if (!is_numeric($end)) $end = strtotime($end . ' 23:59:59'); - $attachments = get_input_value('attachments', RCUBE_INPUT_GET); - $calid = $calname = get_input_value('source', RCUBE_INPUT_GET); + $event_id = rcube_utils::get_input_value('id', rcube_utils::INPUT_GET); + $attachments = rcube_utils::get_input_value('attachments', rcube_utils::INPUT_GET); + $calid = $filename = rcube_utils::get_input_value('source', rcube_utils::INPUT_GET); $calendars = $this->driver->list_calendars(); + $events = array(); if ($calendars[$calid]) { - $calname = $calendars[$calid]['name'] ? $calendars[$calid]['name'] : $calid; - $calname = preg_replace('/[^a-z0-9_.-]/i', '', html_entity_decode($calname)); // to 7bit ascii - if (empty($calname)) $calname = $calid; - $events = $this->driver->load_events($start, $end, null, $calid, 0); + $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; + } } - else - $events = array(); header("Content-Type: text/calendar"); - header("Content-Disposition: inline; filename=".$calname.'.ics'); + header("Content-Disposition: inline; filename=".$filename.'.ics'); $this->get_ical()->export($events, '', true, $attachments ? array($this->driver, 'get_attachment_body') : null); @@ -1121,6 +1610,8 @@ class calendar extends rcube_plugin */ 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() @@ -1144,7 +1635,7 @@ class calendar extends rcube_plugin // decode calendar feed hash $format = 'ics'; - $calhash = get_input_value('_cal', RCUBE_INPUT_GET); + $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); @@ -1165,7 +1656,8 @@ class calendar extends rcube_plugin } // don't save session data - session_destroy(); + if (!$session_exists) + session_destroy(); exit; } @@ -1195,10 +1687,12 @@ class calendar extends rcube_plugin $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_identities() as $rec) { + foreach ($this->rc->user->list_emails() as $rec) { if (!$identity) $identity = $rec; $identity['emails'][] = $rec['email']; @@ -1233,33 +1727,40 @@ class calendar extends rcube_plugin private function _client_event($event, $addcss = false) { // compose a human readable strings for alarms_text and recurrence_text - if ($event['alarms']) - $event['alarms_text'] = libcalendaring::alarms_text($event['alarms']); + 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->_recurrence_text($event['recurrence']); - if ($event['recurrence']['UNTIL']) - $event['recurrence']['UNTIL'] = $this->lib->adjust_timezone($event['recurrence']['UNTIL'], $event['allday'])->format('c'); - unset($event['recurrence']['EXCEPTIONS']); - - // format RDATE values - if (is_array($event['recurrence']['RDATE'])) { - $libcal = $this->lib; - $event['recurrence']['RDATE'] = array_map(function($rdate) use ($libcal) { - return $libcal->adjust_timezone($rdate, true)->format('c'); - }, $event['recurrence']['RDATE']); - } + $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']); } + // 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; - break; + } + if ($attendee['status'] == 'DELEGATED' && $attendee['rsvp'] == false) { + $event['attendees'][$i]['noreply'] = true; + } + else { + unset($event['attendees'][$i]['noreply']); } } @@ -1285,76 +1786,14 @@ class calendar extends rcube_plugin '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), + '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; } - /** - * Render localized text describing the recurrence rule of an event - */ - private function _recurrence_text($rrule) - { - // derive missing FREQ and INTERVAL from RDATE list - if (empty($rrule['FREQ']) && !empty($rrule['RDATE'])) { - $first = $rrule['RDATE'][0]; - $second = $rrule['RDATE'][1]; - $third = $rrule['RDATE'][2]; - if (is_a($first, 'DateTime') && is_a($second, 'DateTime')) { - $diff = $first->diff($second); - foreach (array('y' => 'YEARLY', 'm' => 'MONTHLY', 'd' => 'DAILY') as $k => $freq) { - if ($diff->$k != 0) { - $rrule['FREQ'] = $freq; - $rrule['INTERVAL'] = $diff->$k; - - // verify interval with next item - if (is_a($third, 'DateTime')) { - $diff2 = $second->diff($third); - if ($diff2->$k != $diff->$k) { - unset($rrule['INTERVAL']); - } - } - break; - } - } - } - if (!$rrule['INTERVAL']) - $rrule['FREQ'] = 'RDATE'; - $rrule['UNTIL'] = end($rrule['RDATE']); - } - - // TODO: finish this - $freq = sprintf('%s %d ', $this->gettext('every'), $rrule['INTERVAL']); - $details = ''; - switch ($rrule['FREQ']) { - case 'DAILY': - $freq .= $this->gettext('days'); - break; - case 'WEEKLY': - $freq .= $this->gettext('weeks'); - break; - case 'MONTHLY': - $freq .= $this->gettext('months'); - break; - case 'YEARLY': - $freq .= $this->gettext('years'); - break; - } - - if ($rrule['INTERVAL'] <= 1) - $freq = $this->gettext(strtolower($rrule['FREQ'])); - - if ($rrule['COUNT']) - $until = $this->gettext(array('name' => 'forntimes', 'vars' => array('nr' => $rrule['COUNT']))); - else if ($rrule['UNTIL']) - $until = $this->gettext('recurrencend') . ' ' . format_date($rrule['UNTIL'], libcalendaring::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format']))); - else - $until = $this->gettext('forever'); - - return rtrim($freq . $details . ', ' . $until); - } - /** * Generate a unique identifier for an event */ @@ -1366,17 +1805,23 @@ class calendar extends rcube_plugin /** * TEMPORARY: generate random event data for testing - * Create events by opening http:///?_task=calendar&_action=randomdata&_num=500 + * 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(true); + $cals = $this->driver->list_calendars(calendar_driver::FILTER_ACTIVE); $count = 0; while ($count++ < $num) { - $start = round((time() + rand(-2600, 2600) * 1000) / 300) * 300; + $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; @@ -1419,7 +1864,7 @@ class calendar extends rcube_plugin */ public function attachment_upload() { - $this->lib->attachment_upload(self::SESSION_KEY, 'cal:'); + $this->lib->attachment_upload(self::SESSION_KEY, 'cal-'); } /** @@ -1432,9 +1877,9 @@ class calendar extends rcube_plugin return $this->lib->attachment_loading_page(); } - $event_id = get_input_value('_event', RCUBE_INPUT_GPC); - $calendar = get_input_value('_cal', RCUBE_INPUT_GPC); - $id = get_input_value('_id', RCUBE_INPUT_GPC); + $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); $event = array('id' => $event_id, 'calendar' => $calendar); $attachment = $this->driver->get_attachment($id, $event); @@ -1461,37 +1906,31 @@ class calendar extends rcube_plugin /** * Prepares new/edited event properties before save */ - private function prepare_event(&$event, $action) + 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; } - if (is_array($event['recurrence']) && !empty($event['recurrence']['UNTIL'])) - $event['recurrence']['UNTIL'] = new DateTime($event['recurrence']['UNTIL'], $this->timezone); + // convert the submitted recurrence settings + if (is_array($event['recurrence'])) { + $event['recurrence'] = $this->lib->from_client_recurrence($event['recurrence'], $event['start']); + } - if (is_array($event['recurrence']) && is_array($event['recurrence']['RDATE'])) { - $tz = $this->timezone; - $start = $event['start']; - $event['recurrence']['RDATE'] = array_map(function($rdate) use ($tz, $start) { - try { - $dt = new DateTime($rdate, $tz); - $dt->setTime($start->format('G'), $start->format('i')); - return $dt; - } - catch (Exception $e) { - return null; - } - }, $event['recurrence']['RDATE']); + // convert the submitted alarm values + if ($event['valarms']) { + $event['valarms'] = libcalendaring::from_client_alarms($event['valarms']); } $attachments = array(); - $eventid = 'cal:'.$event['id']; + $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) { @@ -1504,6 +1943,13 @@ class calendar extends rcube_plugin $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']) @@ -1516,8 +1962,10 @@ class calendar extends rcube_plugin $organizer = $i; if ($attendee['email'] == in_array(strtolower($attendee['email']), $emails)) $owner = $i; - else if (!isset($attendee['rsvp'])) + 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'; } // set new organizer identity @@ -1531,9 +1979,6 @@ class calendar extends rcube_plugin $event['attendees'][$owner]['role'] = 'ORGANIZER'; unset($event['attendees'][$owner]['rsvp']); } - else if ($organizer === false && $action == 'new' && ($identity = $this->rc->user->get_identity($event['_identity'])) && $identity['email']) { - array_unshift($event['attendees'], array('role' => 'ORGANIZER', 'name' => $identity['name'], 'email' => $identity['email'], 'status' => 'ACCEPTED')); - } } // mapping url => vurl because of the fullcalendar client script @@ -1558,19 +2003,26 @@ class calendar extends rcube_plugin /** * Send out an invitation/notification to all event attendees */ - private function notify_attendees($event, $old, $action = 'edit') + private function notify_attendees($event, $old, $action = 'edit', $comment = null) { - if ($action == 'remove') { + if ($action == 'remove' || ($event['status'] == 'CANCELLED' && $old['status'] != $event['status'])) { $event['cancelled'] = true; $is_cancelled = true; } - + $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); + $message = $itip->compose_itip_message($event, $method, !$old || $event['sequence'] > $old['sequence']); // list existing attendees from $old event $old_attendees = array(); @@ -1579,24 +2031,54 @@ class calendar extends rcube_plugin } // send to every attendee - $sent = 0; + $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)) + 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; } @@ -1605,10 +2087,10 @@ class calendar extends rcube_plugin */ public function freebusy_status() { - $email = get_input_value('email', RCUBE_INPUT_GPC); - $start = get_input_value('start', RCUBE_INPUT_GPC); - $end = get_input_value('end', RCUBE_INPUT_GPC); - + $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); @@ -1652,20 +2134,20 @@ class calendar extends rcube_plugin */ public function freebusy_times() { - $email = get_input_value('email', RCUBE_INPUT_GPC); - $start = get_input_value('start', RCUBE_INPUT_GPC); - $end = get_input_value('end', RCUBE_INPUT_GPC); - $interval = intval(get_input_value('interval', RCUBE_INPUT_GPC)); + $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 = new DateTime($start, $this->timezone); - $start = $dts->format('U'); + $dts = rcube_utils::anytodatetime($start, $this->timezone); + $start = $dts ? $dts->format('U') : null; } if (!empty($end) && !is_numeric($end)) { - $dte = new DateTime($end, $this->timezone); - $end = $dte->format('U'); + $dte = rcube_utils::anytodatetime($end, $this->timezone); + $end = $dte ? $dte->format('U') : null; } if (!$start) $start = time(); @@ -1692,6 +2174,14 @@ class calendar extends rcube_plugin $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 :-) @@ -1721,34 +2211,34 @@ class calendar extends rcube_plugin )); exit; } - + /** * Handler for printing calendars */ public function print_view() { $title = $this->gettext('print'); - - $view = get_input_value('view', RCUBE_INPUT_GPC); + + $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 = get_input_value('date', RCUBE_INPUT_GPC)) + + if ($date = rcube_utils::get_input_value('date', rcube_utils::INPUT_GPC)) $this->rc->output->set_env('date', $date); - if ($range = get_input_value('range', RCUBE_INPUT_GPC)) + 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', get_input_value('sections', RCUBE_INPUT_GPC)); - - if ($search = get_input_value('search', RCUBE_INPUT_GPC)) { + $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'); @@ -1801,7 +2291,7 @@ class calendar extends rcube_plugin $diff = array(); $ignore = array('changed' => 1, 'attachments' => 1); foreach (array_unique(array_merge(array_keys($a), array_keys($b))) as $key) { - if (!$ignore[$key] && $a[$key] != $b[$key]) + if (!$ignore[$key] && $key[0] != '_' && $a[$key] != $b[$key]) $diff[] = $key; } @@ -1812,9 +2302,239 @@ class calendar extends rcube_plugin 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 ****/ - + + /** + * Handler for calendar/itip-status requests + */ + function event_itip_status() + { + $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); + + // find local copy of the referenced event + $this->load_driver(); + $existing = $this->driver->get_event($data, calendar_driver::FILTER_WRITEABLE | calendar_driver::FILTER_PERSONAL); + + $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_PERSONAL); + $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(true, $data['sensitivity'] == 'confidential'); + $response['select'] = html::span('folder-select', $this->gettext('saveincalendar') . ' ' . + $calendar_select->show($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); + + // search for event if only UID is given + if ($event = $this->driver->get_event(array('uid' => $uid, '_instance' => $instance), calendar_driver::FILTER_WRITEABLE)) { + $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 */ @@ -1827,9 +2547,9 @@ class calendar extends rcube_plugin $this->rc->output->set_env('refresh_interval', 0); $this->rc->output->set_pagetitle($this->gettext('calendar')); - $itip = $this->load_itip(); - $token = get_input_value('_t', RCUBE_INPUT_GPC); - + $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; @@ -1837,21 +2557,22 @@ class calendar extends rcube_plugin // show message about cancellation if ($invitation['cancelled']) { - $this->invitestatus = html::div('rsvp-status declined', $this->gettext('eventcancelled')); + $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'] == $this->gettext('itip' . $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), $this->gettext('youhave'.strtolower($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); @@ -1862,7 +2583,7 @@ class calendar extends rcube_plugin $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(true))) { + if (!$this->driver->get_event($this->event) && ($calendar = $this->get_default_calendar(true, $invitation['event']['sensitivity'] == 'confidential'))) { $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'); @@ -1873,10 +2594,12 @@ class calendar extends rcube_plugin $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) + 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($this->gettext('itipinvitation') . ' ' . $this->event['title']); + $this->rc->output->set_pagetitle($itip->gettext('itipinvitation') . ' ' . $this->event['title']); } else $this->rc->output->command('display_message', $this->gettext('itipinvalidrequest'), 'error', -1); @@ -1893,28 +2616,48 @@ class calendar extends rcube_plugin $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', Q($event['title'])) + ); + } /** - * Check mail message structure of there are .ics files attached + * */ - public function mail_message_load($p) + public function mail_messages_list($p) { - $this->message = $p['object']; - $itip_part = null; + 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? - // check all message parts for .ics files - foreach ((array)$this->message->mime_parts as $part) { - if ($this->is_vcalendar($part)) { - if ($part->ctype_parameters['method']) - $itip_part = $part->mime_id; - else - $this->ics_parts[] = $part->mime_id; + 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; + } + } + } + } } } - - // priorize part with method parameter - if ($itip_part) - $this->ics_parts = array($itip_part); } /** @@ -1923,134 +2666,49 @@ class calendar extends rcube_plugin public function mail_messagebody_html($p) { // load iCalendar functions (if necessary) - if (!empty($this->ics_parts)) { + if (!empty($this->lib->ical_parts)) { $this->get_ical(); + $this->load_itip(); } $html = ''; - $seen = array(); - foreach ($this->ics_parts as $mime_id) { - $part = $this->message->mime_parts[$mime_id]; - $charset = $part->ctype_parameters['charset'] ? $part->ctype_parameters['charset'] : RCMAIL_CHARSET; - $events = $this->ical->import($this->message->get_part_content($mime_id), $charset); - $title = $this->gettext('title'); - $date = rcube_utils::anytodatetime($this->message->headers->date); + $has_events = false; + $ical_objects = $this->lib->get_mail_ical_objects(); - // successfully parsed events? - if (empty($events)) - continue; + // 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; - // show a box for every event in the file - foreach ($events as $idx => $event) { - if ($event['_type'] != 'event') // skip non-event objects (#2928) - continue; + $has_events = true; - // avoid duplicates with the same UID (e.g. from Google invitations, #3585) - if ($seen[$event['uid']]++) - continue; + // get prepared inline UI for this event object + if ($ical_objects->method) { + $append = ''; - // define buttons according to method - if ($this->ical->method == 'REPLY') { - $title = $this->gettext('itipreply'); - $buttons = html::tag('input', array( - 'type' => 'button', - 'class' => 'button', - 'onclick' => "rcube_calendar.add_event_from_mail('" . JQ($mime_id.':'.$idx) . "')", - 'value' => $this->gettext('updateattendeestatus'), - )); + // 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%'); } - else if ($this->ical->method == 'REQUEST') { - $emails = $this->get_user_emails(); - $title = $event['sequence'] > 0 ? $this->gettext('itipupdate') : $this->gettext('itipinvitation'); - - // add (hidden) buttons and activate them from asyncronous request - foreach (array('accepted','tentative','declined') as $method) { - $rsvp_buttons .= html::tag('input', array( - 'type' => 'button', - 'class' => "button $method", - 'onclick' => "rcube_calendar.add_event_from_mail('" . JQ($mime_id.':'.$idx) . "', '$method')", - 'value' => $this->gettext('itip' . $method), - )); - } - $import_button = html::tag('input', array( - 'type' => 'button', - 'class' => 'button', - 'onclick' => "rcube_calendar.add_event_from_mail('" . JQ($mime_id.':'.$idx) . "')", - 'value' => $this->gettext('importtocalendar'), - )); - - // check my status - $status = 'unknown'; - foreach ($event['attendees'] as $attendee) { - if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { - $status = !empty($attendee['status']) ? strtoupper($attendee['status']) : 'NEEDS-ACTION'; - break; - } - } - $dom_id = asciiwords($event['uid'], true); - $buttons = html::div(array('id' => 'rsvp-'.$dom_id, 'style' => 'display:none'), $rsvp_buttons); - $buttons .= html::div(array('id' => 'import-'.$dom_id, 'style' => 'display:none'), $import_button); - $buttons_pre = html::div(array('id' => 'loading-'.$dom_id, 'class' => 'rsvp-status loading'), $this->gettext('loading')); - $changed = is_object($event['changed']) ? $event['changed'] : $date; - - $script = json_serialize(array( - 'uid' => $event['uid'], - 'changed' => $changed ? $changed->format('U') : 0, - 'sequence' => intval($event['sequence']), - 'fallback' => $status, - )); - - $this->rc->output->add_script("rcube_calendar.fetch_event_rsvp_status($script)", 'docready'); - } - else if ($this->ical->method == 'CANCEL') { - $title = $this->gettext('itipcancellation'); - - // create buttons to be activated from async request checking existence of this event in local calendars - $button_import = html::tag('input', array( - 'type' => 'button', - 'class' => 'button', - 'onclick' => "rcube_calendar.add_event_from_mail('" . JQ($mime_id.':'.$idx) . "')", - 'value' => $this->gettext('importtocalendar'), - )); - $button_remove = html::tag('input', array( - 'type' => 'button', - 'class' => 'button', - 'onclick' => "rcube_calendar.remove_event_from_mail('" . JQ($event['uid']) . "', '" . JQ($event['title']) . "')", - 'value' => $this->gettext('removefromcalendar'), - )); - - $dom_id = asciiwords($event['uid'], true); - $buttons = html::div(array('id' => 'rsvp-'.$dom_id, 'style' => 'display:none'), $button_remove); - $buttons .= html::div(array('id' => 'import-'.$dom_id, 'style' => 'display:none'), $button_import); - $buttons_pre = html::div(array('id' => 'loading-'.$dom_id, 'class' => 'rsvp-status loading'), $this->gettext('loading')); - $changed = is_object($event['changed']) ? $event['changed'] : $date; - - $script = json_serialize(array( - 'uid' => $event['uid'], - 'changed' => $changed ? $changed->format('U') : 0, - 'sequence' => intval($event['sequence']), - 'fallback' => 'CANCELLED', - )); - - $this->rc->output->add_script("rcube_calendar.fetch_event_rsvp_status($script)", 'docready'); - } - else { - $buttons = html::tag('input', array( - 'type' => 'button', - 'class' => 'button', - 'onclick' => "rcube_calendar.add_event_from_mail('" . JQ($mime_id.':'.$idx) . "')", - 'value' => $this->gettext('importtocalendar'), - )); - } - - // show event details with buttons - $html .= html::div('calendar-invitebox', $this->ui->event_details_table($event, $title) . $buttons_pre . html::div('rsvp-buttons', $buttons)); - - // limit listing - if ($idx >= 3) - break; + $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 @@ -2060,6 +2718,21 @@ class calendar extends rcube_plugin $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; } @@ -2067,38 +2740,71 @@ class calendar extends rcube_plugin /** * Handler for POST request to import an event attached to a mail message */ - public function mail_import_event() + public function mail_import_itip() { - $uid = get_input_value('_uid', RCUBE_INPUT_POST); - $mbox = get_input_value('_mbox', RCUBE_INPUT_POST); - $mime_id = get_input_value('_part', RCUBE_INPUT_POST); - $status = get_input_value('_status', RCUBE_INPUT_POST); - $delete = intval(get_input_value('_del', RCUBE_INPUT_POST)); - $charset = RCMAIL_CHARSET; - - // establish imap connection - $imap = $this->rc->get_storage(); - $imap->set_mailbox($mbox); + $itip_sending = $this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']); - if ($uid && $mime_id) { - list($mime_id, $index) = explode(':', $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); - } - - $events = $this->get_ical()->import($part, $charset); + $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); $error_msg = $this->gettext('errorimportingevent'); $success = false; + $delegate = null; + + if ($status == 'delegated') { + $delegates = rcube_mime::decode_address_list(rcube_utils::get_input_value('_to', rcube_utils::INPUT_POST, true), 1, false); + $delegate = reset($delegates); + + if (empty($delegate) || empty($delegate['mailto'])) { + $this->rc->output->command('display_message', $this->gettext('libcalendaring.delegateinvalidaddress'), 'error'); + return; + } + } // successfully parsed events? - if (!empty($events) && ($event = $events[$index])) { + if ($event = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event')) { + // forward iTip request to delegatee + if ($delegate) { + $rsvpme = intval(rcube_utils::get_input_value('_rsvp', rcube_utils::INPUT_POST)); + + $itip = $this->load_itip(); + if ($itip->delegate_to($event, $delegate, $rsvpme ? true : false)) { + $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation'); + } + else { + $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); + } + + // the delegator is set to non-participant, thus save as non-blocking + $event['free_busy'] = 'free'; + } + // find writeable calendar to store event - $cal_id = !empty($_REQUEST['_calendar']) ? get_input_value('_calendar', RCUBE_INPUT_POST) : null; - $calendars = $this->driver->list_calendars(false, true); - $calendar = $calendars[$cal_id] ?: $this->get_default_calendar(true); + $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]; + + // select default calendar except user explicitly selected 'none' + if (!$calendar && !$dontsave) + $calendar = $this->get_default_calendar(true, $event['sensitivity'] == 'confidential'); + + $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)) { @@ -2110,49 +2816,92 @@ class calendar extends rcube_plugin } 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['readonly']) { - $event['calendar'] = $calendar['id']; - + if ($calendar && $calendar['editable']) { // check for existing event with the same UID - $existing = $this->driver->get_event($event['uid'], true, false, true); - + $existing = $this->driver->get_event($event, calendar_driver::FILTER_WRITEABLE | calendar_driver::FILTER_PERSONAL); + if ($existing) { + // forward savemode for correct updates of recurring events + $existing['_savemode'] = $savemode ?: $event['_savemode']; + // only update attendee status - if ($this->ical->method == 'REPLY') { + if ($event['_method'] == 'REPLY') { // try to identify the attendee using the email sender address - $sender = preg_match('/([a-z0-9][a-z0-9\-\.\+\_]*@[^&@"\'.][^@&"\']*\\.([^\\x00-\\x40\\x5b-\\x60\\x7b-\\x7f]{2,}|xn--[a-z0-9]{2,}))/', $headers->from, $m) ? $m[1] : ''; - $sender_utf = rcube_idn_to_utf8($sender); - $existing_attendee = -1; + $existing_attendee_emails = array(); foreach ($existing['attendees'] as $i => $attendee) { - if ($sender && ($attendee['email'] == $sender || $attendee['email'] == $sender_utf)) { + $existing_attendee_emails[] = $attendee['email']; + if ($event['_sender'] && ($attendee['email'] == $event['_sender'] || $attendee['email'] == $event['_sender_utf'])) { $existing_attendee = $i; - break; } } $event_attendee = null; + $update_attendees = array(); foreach ($event['attendees'] as $attendee) { - if ($sender && ($attendee['email'] == $sender || $attendee['email'] == $sender_utf)) { + if ($event['_sender'] && ($attendee['email'] == $event['_sender'] || $attendee['email'] == $event['_sender_utf'])) { $event_attendee = $attendee; - break; + $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']) && + (stripos($attendee['delegated-from'], $event['_sender']) !== false || + stripos($attendee['delegated-from'], $event['_sender_utf']) !== false)) { + $update_attendees[] = $attendee; + if (!in_array($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->edit_event($existing); + $success = $this->driver->update_attendees($existing, $update_attendees); } // update the entire attendees block - else if ($event['changed'] >= $existing['changed'] && $event['attendees']) { - $existing['attendees'] = $event['attendees']; - $success = $this->driver->edit_event($existing); + 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'); @@ -2167,48 +2916,111 @@ class calendar extends rcube_plugin else if ($event['sequence'] >= $existing['sequence'] || $event['changed'] >= $existing['changed']) { $event['id'] = $existing['id']; $event['calendar'] = $existing['calendar']; - if ($status == 'declined') // show me as free when declined (#1670) + + // preserve my participant status for regular updates + if (empty($status)) { + $emails = $this->get_user_emails(); + foreach ($event['attendees'] as $i => $attendee) { + if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { + foreach ($existing['attendees'] as $j => $_attendee) { + if ($attendee['email'] == $_attendee['email']) { + $event['attendees'][$i] = $existing['attendees'][$j]; + break; + } + } + } + } + } + + // set status=CANCELLED on CANCEL messages + if ($event['_method'] == 'CANCEL') + $event['status'] = 'CANCELLED'; + // 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') // show me as free when declined (#1670) + 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') { - $success = $this->driver->new_event($event); + 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') + else if ($status == 'declined' || $dontsave) $error_msg = null; else $error_msg = $this->gettext('nowritecalendarfound'); } if ($success) { - $message = $this->ical->method == 'REPLY' ? 'attendeupdateesuccess' : ($deleted ? 'successremoval' : 'importedsuccessfully'); + $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'); - $this->rc->output->command('plugin.fetch_event_rsvp_status', array( - 'uid' => $event['uid'], - 'changed' => is_object($event['changed']) ? $event['changed']->format('U') : 0, - 'sequence' => intval($event['sequence']), - 'fallback' => strtoupper($status), - )); + } + + 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) + else if ($error_msg) { $this->rc->output->command('display_message', $error_msg, 'error'); - + } // send iTip reply - if ($this->ical->method == 'REQUEST' && $organizer && !in_array(strtolower($organizer['email']), $emails) && !$error_msg) { + if ($event['_method'] == 'REQUEST' && $organizer && !$noreply && !in_array(strtolower($organizer['email']), $emails) && !$error_msg) { + $event['comment'] = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST); $itip = $this->load_itip(); $itip->set_sender_email($reply_sender); if ($itip->send_itip_message($event, 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status)) @@ -2221,15 +3033,116 @@ class calendar extends rcube_plugin } + /** + * 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 = RCMAIL_CHARSET; + + // establish imap connection + $imap = $this->rc->get_storage(); + $imap->set_mailbox($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(true, $event['sensitivity'] == 'confidential'); + 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 = get_input_value('_uid', RCUBE_INPUT_POST); - $mbox = get_input_value('_mbox', RCUBE_INPUT_POST); + $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_mailbox($mbox); @@ -2238,10 +3151,16 @@ class calendar extends rcube_plugin 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 - if ($message->attachments) { - $eventid = 'cal:'; + 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; @@ -2282,41 +3201,42 @@ class calendar extends rcube_plugin $this->rc->output->send(); } - /** - * Checks if specified message part is a vcalendar data - * - * @param rcube_message_part Part object - * @return boolean True if part is of type vcard + * 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 */ - private function is_vcalendar($part) + public function mail_message_compose($args) { - 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)) - ); + // 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'); + $args['param']['subject'] = $event['title']; + } + } + + return $args; } /** * Get a list of email addresses of the current user (from login and identities) */ - private function get_user_emails() + public function get_user_emails() { - $emails = array(); - $plugin = $this->rc->plugins->exec_hook('calendar_user_emails', array('emails' => $emails)); - $emails = array_map('strtolower', $plugin['emails']); - - if ($plugin['abort']) { - return $emails; - } - - $emails[] = $this->rc->user->get_username(); - foreach ($this->rc->user->list_identities() as $identity) - $emails[] = strtolower($identity['email']); - - return array_unique($emails); + return $this->lib->get_user_emails(); } @@ -2326,21 +3246,7 @@ class calendar extends rcube_plugin public function get_url($param = array()) { $param += array('task' => 'calendar'); - - $schema = 'http'; - $default_port = 80; - if (rcube_https_check()) { - $schema = 'https'; - $default_port = 443; - } - $url = $schema . '://' . preg_replace('/:\d+$/', '', $_SERVER['HTTP_HOST']); - if ($_SERVER['SERVER_PORT'] != $default_port) - $url .= ':' . $_SERVER['SERVER_PORT']; - if (dirname($_SERVER['SCRIPT_NAME']) != '/') - $url .= dirname($_SERVER['SCRIPT_NAME']); - $url .= preg_replace('!^\./!', '/', $this->rc->url($param)); - - return $url; + return $this->rc->url($param, true, true); } @@ -2349,4 +3255,38 @@ class calendar extends rcube_plugin 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->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/calendar_base.js b/calendar_base.js index 33fe9e4..41ae8e5 100644 --- a/calendar_base.js +++ b/calendar_base.js @@ -1,12 +1,14 @@ /** * Base Javascript class for the Calendar plugin * - * @version @package_version@ * @author Lazlo Westerhof * @author Thomas Bruederli * + * @licstart The following is the entire license notice for the + * JavaScript code in this page. + * * Copyright (C) 2010, Lazlo Westerhof - * Copyright (C) 2013, Kolab Systems AG + * Copyright (C) 2013-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -20,6 +22,9 @@ * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . + * + * @licend The above is the entire license notice + * for the JavaScript code in this page. */ // Basic setup for Roundcube calendar client class @@ -31,6 +36,7 @@ function rcube_calendar(settings) // member vars this.ui; this.ui_loaded = false; + this.selected_attachment = null; // private vars var me = this; @@ -42,13 +48,13 @@ function rcube_calendar(settings) // load calendar UI (scripts and edit dialog template) if (!this.ui_loaded) { $.when( - $.getScript('./plugins/calendar/calendar_ui.js'), - $.getScript('./plugins/calendar/lib/js/fullcalendar.js'), + $.getScript(rcmail.assets_path('plugins/calendar/calendar_ui.js')), + $.getScript(rcmail.assets_path('plugins/calendar/lib/js/fullcalendar.js')), $.get(rcmail.url('calendar/inlineui'), function(html){ $(document.body).append(html); }, 'html') ).then(function() { // disable attendees feature (autocompletion and stuff is not initialized) for (var c in rcmail.env.calendars) - rcmail.env.calendars[c].attendees = false; + rcmail.env.calendars[c].attendees = rcmail.env.calendars[c].resources = false; me.ui_loaded = true; me.ui = new rcube_calendar_ui(me.settings); @@ -72,90 +78,36 @@ function rcube_calendar(settings) { if (event.title) { this.ui.add_event(event); - rcmail.message_list.blur(); + if (rcmail.message_list) + rcmail.message_list.blur(); } }; + + // handler for attachment-save-calendar commands + this.save_to_calendar = function(p) + { + // TODO: show dialog to select the calendar for importing + if (this.selected_attachment && window.rcube_libcalendaring) { + rcmail.http_post('calendar/mailimportattach', { + _uid: rcmail.env.uid, + _mbox: rcmail.env.mailbox, + _part: this.selected_attachment, + // _calendar: $('#calendar-attachment-saveto').val(), + }, rcmail.set_busy(true, 'itip.savingdata')); + } + } } -// static methods -rcube_calendar.add_event_from_mail = function(mime_id, status) -{ - // ask user to delete the declined event from the local calendar (#1670) - var del = false; - if (rcmail.env.rsvp_saved && status == 'declined') { - del = confirm(rcmail.gettext('calendar.declinedeleteconfirm')); - } - - var lock = rcmail.set_busy(true, 'calendar.savingdata'); - rcmail.http_post('calendar/mailimportevent', { - '_uid': rcmail.env.uid, - '_mbox': rcmail.env.mailbox, - '_part': mime_id, - '_calendar': $('#calendar-saveto').val(), - '_status': status, - '_del': del?1:0 - }, lock); - - return false; -}; - -rcube_calendar.remove_event_from_mail = function(uid, title) -{ - if (confirm(rcmail.gettext('calendar.deleteventconfirm'))) { - var lock = rcmail.set_busy(true, 'calendar.savingdata'); - rcmail.http_post('calendar/event', { - e:{ uid:uid }, - action: 'remove' - }, lock); - } -}; - -rcube_calendar.fetch_event_rsvp_status = function(event) -{ -/* - var id = event.uid.replace(rcmail.identifier_expr, ''); - $('#import-'+id+', #rsvp-'+id+', div.rsvp-status').hide(); - $('#loading-'+id).show(); -*/ - rcmail.http_post('calendar/event', { - e:event, - action:'rsvp-status' - }); -}; - /* calendar plugin initialization (for non-calendar tasks) */ window.rcmail && rcmail.addEventListener('init', function(evt) { if (rcmail.task != 'calendar') { var cal = new rcube_calendar($.extend(rcmail.env.calendar_settings, rcmail.env.libcal_settings)); - rcmail.addEventListener('plugin.update_event_rsvp_status', function(p){ - rcmail.env.rsvp_saved = p.saved; - - if (p.html) { - // append/replace rsvp status display - $('#loading-'+p.id).next('.rsvp-status').remove(); - $('#loading-'+p.id).hide().after(p.html); - } - else { - $('#loading-'+p.id).hide(); - } - - // enable/disable rsvp buttons - $('.rsvp-buttons input.button').prop('disabled', false) - .filter('.'+String(p.status).toLowerCase()).prop('disabled', p.latest); - - // show rsvp/import buttons with or without calendar selector - if (!p.select) - $('#rsvp-'+p.id+' .calendar-select').remove(); - $('#'+p.action+'-'+p.id).show().append(p.select); - }); - - rcmail.addEventListener('plugin.fetch_event_rsvp_status', rcube_calendar.fetch_event_rsvp_status); - // register create-from-mail command to message_commands array if (rcmail.env.task == 'mail') { rcmail.register_command('calendar-create-from-mail', function() { cal.create_from_mail() }); + rcmail.register_command('attachment-save-calendar', function() { cal.save_to_calendar() }); rcmail.addEventListener('plugin.mail2event_dialog', function(p){ cal.mail2event_dialog(p) }); rcmail.addEventListener('plugin.unlock_saving', function(p){ cal.ui && cal.ui.unlock_saving(); }); @@ -163,17 +115,17 @@ window.rcmail && rcmail.addEventListener('init', function(evt) { rcmail.env.message_commands.push('calendar-create-from-mail'); rcmail.add_element($('')); } - else + else { rcmail.enable_command('calendar-create-from-mail', true); - - // add contextmenu item - if (window.rcm_contextmenu_register_command) { - rcm_contextmenu_register_command( - 'calendar-create-from-mail', - function(cmd,el){ cal.create_from_mail() }, - 'calendar.createfrommail', - 'moveto'); } + + rcmail.addEventListener('beforemenu-open', function(p) { + if (p.menu == 'attachmentmenu') { + cal.selected_attachment = p.id; + var mimetype = rcmail.env.attachments[p.id]; + rcmail.enable_command('attachment-save-calendar', mimetype == 'text/calendar' || mimetype == 'text/x-vcalendar' || mimetype == 'application/ics'); + } + }); } } diff --git a/calendar_ui.js b/calendar_ui.js index c36184a..aa3973e 100644 --- a/calendar_ui.js +++ b/calendar_ui.js @@ -1,12 +1,14 @@ /** * Client UI Javascript for the Calendar plugin * - * @version @package_version@ * @author Lazlo Westerhof * @author Thomas Bruederli * + * @licstart The following is the entire license notice for the + * JavaScript code in this file. + * * Copyright (C) 2010, Lazlo Westerhof - * Copyright (C) 2012, Kolab Systems AG + * 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 @@ -20,6 +22,9 @@ * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . + * + * @licend The above is the entire license notice + * for the JavaScript code in this file. */ // Roundcube calendar UI client class @@ -34,6 +39,8 @@ function rcube_calendar_ui(settings) this.selected_calendar = null; this.search_request = null; this.saving_lock; + this.calendars = {}; + this.quickview_sources = []; /*** private vars ***/ @@ -46,10 +53,21 @@ function rcube_calendar_ui(settings) var ignore_click = false; var event_defaults = { free_busy:'busy', alarms:'' }; var event_attendees = []; + var calendars_list; + var calenders_search_list; + var calenders_search_container; + var search_calendars = {}; var attendees_list; + var resources_list; + var resources_treelist; + var resources_data = {}; + var resources_index = []; + var resource_owners = {}; + var resources_events_source = { url:null, editable:false }; var freebusy_ui = { workinhoursonly:false, needsupdate:false }; var freebusy_data = {}; var current_view = null; + var count_sources = []; var exec_deferred = bw.ie6 ? 5 : 1; var sensitivitylabels = { 'public':rcmail.gettext('public','calendar'), 'private':rcmail.gettext('private','calendar'), 'confidential':rcmail.gettext('confidential','calendar') }; var ui_loading = rcmail.set_busy(true, 'loading'); @@ -67,6 +85,99 @@ function rcube_calendar_ui(settings) selectOtherMonths: true }; + // global fullcalendar settings + var fullcalendar_defaults = { + aspectRatio: 1, + ignoreTimezone: true, // will treat the given date strings as in local (browser's) timezone + monthNames : settings.months, + monthNamesShort : settings.months_short, + dayNames : settings.days, + dayNamesShort : settings.days_short, + firstDay : settings.first_day, + firstHour : settings.first_hour, + slotMinutes : 60/settings.timeslots, + timeFormat: { + '': settings.time_format, + agenda: settings.time_format + '{ - ' + settings.time_format + '}', + list: settings.time_format + '{ - ' + settings.time_format + '}', + table: settings.time_format + '{ - ' + settings.time_format + '}' + }, + axisFormat : settings.time_format, + columnFormat: { + month: 'ddd', // Mon + week: 'ddd ' + settings.date_short, // Mon 9/7 + day: 'dddd ' + settings.date_short, // Monday 9/7 + table: settings.date_agenda + }, + titleFormat: { + month: 'MMMM yyyy', + week: settings.dates_long, + day: 'dddd ' + settings['date_long'], + table: settings.dates_long + }, + listPage: 7, // advance one week in agenda view + listRange: settings.agenda_range, + listSections: settings.agenda_sections, + tableCols: ['handle', 'date', 'time', 'title', 'location'], + defaultView: rcmail.env.view || settings.default_view, + allDayText: rcmail.gettext('all-day', 'calendar'), + buttonText: { + prev: ' ◄ ', + next: ' ► ', + today: settings['today'], + day: rcmail.gettext('day', 'calendar'), + week: rcmail.gettext('week', 'calendar'), + month: rcmail.gettext('month', 'calendar'), + table: rcmail.gettext('agenda', 'calendar') + }, + listTexts: { + until: rcmail.gettext('until', 'calendar'), + past: rcmail.gettext('pastevents', 'calendar'), + today: rcmail.gettext('today', 'calendar'), + tomorrow: rcmail.gettext('tomorrow', 'calendar'), + thisWeek: rcmail.gettext('thisweek', 'calendar'), + nextWeek: rcmail.gettext('nextweek', 'calendar'), + thisMonth: rcmail.gettext('thismonth', 'calendar'), + nextMonth: rcmail.gettext('nextmonth', 'calendar'), + future: rcmail.gettext('futureevents', 'calendar'), + week: rcmail.gettext('weekofyear', 'calendar') + }, + currentTimeIndicator: settings.time_indicator, + // event rendering + eventRender: function(event, element, view) { + if (view.name != 'list' && view.name != 'table') { + var prefix = event.sensitivity && event.sensitivity != 'public' ? String(sensitivitylabels[event.sensitivity]).toUpperCase()+': ' : ''; + element.attr('title', prefix + event.title); + } + if (view.name != 'month') { + if (event.location) { + element.find('div.fc-event-title').after('
@ ' + Q(event.location) + '
'); + } + if (event.sensitivity && event.sensitivity != 'public') + element.find('div.fc-event-time').append(''); + if (event.recurrence) + element.find('div.fc-event-time').append(''); + if (event.alarms || (event.valarms && event.valarms.length)) + element.find('div.fc-event-time').append(''); + } + if (event.status) { + element.addClass('cal-event-status-' + String(event.status).toLowerCase()); + } + + element.attr('aria-label', event.title + ', ' + me.event_date_text(event, true)); + }, + // render element indicating more (invisible) events + overflowRender: function(data, element) { + element.html(rcmail.gettext('andnmore', 'calendar').replace('$nr', data.count)) + .click(function(e){ me.fisheye_view(data.date); }); + }, + // callback when a specific event is clicked + eventClick: function(event, ev, view) { + if (!event.temp && String(event.className).indexOf('fc-type-freebusy') < 0) + event_show_dialog(event, ev); + } + }; + /*** imports ***/ var Q = this.quote_html; var text2html = this.text2html; @@ -75,7 +186,8 @@ function rcube_calendar_ui(settings) var date2unixtime = this.date2unixtime; var fromunixtime = this.fromunixtime; var parseISO8601 = this.parseISO8601; - var init_alarms_edit = this.init_alarms_edit; + var date2servertime = this.date2ISO8601; + var render_message_links = this.render_message_links; /*** private methods ***/ @@ -103,6 +215,11 @@ function rcube_calendar_ui(settings) return result; }; + // Change the first charcter to uppercase + var ucfirst = function(str) + { + return str.charAt(0).toUpperCase() + str.substr(1); + }; // clone the given date object and optionally adjust time var clone_date = function(date, adjust) @@ -132,21 +249,14 @@ function rcube_calendar_ui(settings) date.setHours(0); }; - // turn the given date into an ISO 8601 date string understandable by PHPs strtotime() - var date2servertime = function(date) - { - return date.getFullYear()+'-'+zeropad(date.getMonth()+1)+'-'+zeropad(date.getDate()) - + 'T'+zeropad(date.getHours())+':'+zeropad(date.getMinutes())+':'+zeropad(date.getSeconds()); - } - var date2timestring = function(date, dateonly) { return date2servertime(date).replace(/[^0-9]/g, '').substr(0, (dateonly ? 8 : 14)); } - var zeropad = function(num) + var format_datetime = function(date, mode, voice) { - return (num < 10 ? '0' : '') + num; + return me.format_datetime(date, mode, voice); } var render_link = function(url) @@ -199,18 +309,42 @@ function rcube_calendar_ui(settings) return is_attendee(event, 'ORGANIZER', email) || !event.id; }; + /** + * Check permissions on the given calendar object + */ + var has_permission = function(cal, perm) + { + // multiple chars means "either of" + if (String(perm).length > 1) { + for (var i=0; i < perm.length; i++) { + if (has_permission(cal, perm[i])) + return true; + } + } + + if (cal.rights && String(cal.rights).indexOf(perm) >= 0) { + return true; + } + + return (perm == 'i' && cal.editable) || (perm == 'v' && cal.editable); + } + var load_attachment = function(event, att) { - var qstring = '_id='+urlencode(att.id)+'&_event='+urlencode(event.recurrence_id||event.id)+'&_cal='+urlencode(event.calendar); + var query = { _id: att.id, _event: event.recurrence_id || event.id, _cal:event.calendar, _frame: 1 }; + if (event.rev) + query._rev = event.rev; // open attachment in frame if it's of a supported mimetype if (id && att.mimetype && $.inArray(att.mimetype, settings.mimetypes)>=0) { - if (rcmail.open_window(rcmail.env.comm_path+'&_action=get-attachment&'+qstring+'&_frame=1', true, true)) { + if (rcmail.open_window(rcmail.url('get-attachment', query), true, true)) { return; } } - rcmail.goto_url('get-attachment', qstring+'&_download=1', false); + query._frame = null; + query._download = 1; + rcmail.goto_url('get-attachment', query, false); }; // build event attachments list @@ -228,32 +362,33 @@ function rcube_calendar_ui(settings) if (edit) { rcmail.env.attachments[elem.id] = elem; // delete icon - content = document.createElement('A'); - content.href = '#delete'; - content.title = rcmail.gettext('delete'); - content.className = 'delete'; - $(content).click({id: elem.id}, function(e) { remove_attachment(this, e.data.id); return false; }); + content = $('
') + .attr('title', rcmail.gettext('delete')) + .attr('aria-label', rcmail.gettext('delete') + ' ' + Q(elem.name)) + .addClass('delete') + .click({id: elem.id}, function(e) { remove_attachment(this, e.data.id); return false; }); if (!rcmail.env.deleteicon) - content.innerHTML = rcmail.gettext('delete'); + content.html(rcmail.gettext('delete')); else { img = document.createElement('IMG'); img.src = rcmail.env.deleteicon; img.alt = rcmail.gettext('delete'); - content.appendChild(img); + content.append(img); } - li.appendChild(content); + content.appendTo(li); } // name/link - content = document.createElement('A'); - content.innerHTML = elem.name; - content.className = 'file'; - content.href = '#load'; - $(content).click({event: event, att: elem}, function(e) { - load_attachment(e.data.event, e.data.att); return false; }); - li.appendChild(content); + content = $('') + .html(Q(elem.name)) + .addClass('file') + .click({event: event, att: elem}, function(e) { + load_attachment(e.data.event, e.data.att); + return false; + }) + .appendTo(li); ul.appendChild(li); } @@ -274,11 +409,28 @@ function rcube_calendar_ui(settings) }; // event details dialog (show only) - var event_show_dialog = function(event) + var event_show_dialog = function(event, ev, temp) { - var $dialog = $("#eventshow").removeClass().addClass('uidialog'); - var calendar = event.calendar && me.calendars[event.calendar] ? me.calendars[event.calendar] : { editable:false }; - me.selected_event = event; + var $dialog = $("#eventshow"); + var calendar = event.calendar && me.calendars[event.calendar] ? me.calendars[event.calendar] : { editable:false, rights:'lrs' }; + + if (!temp) + me.selected_event = event; + + if ($dialog.is(':ui-dialog')) + $dialog.dialog('close'); + + // remove status-* classes + $dialog.removeClass(function(i, oldclass) { + var oldies = String(oldclass).split(' '); + return $.grep(oldies, function(cls) { return cls.indexOf('status-') === 0 || cls.indexOf('sensitivity-') === 0 }).join(' '); + }); + + // convert start/end dates if not done yet by fullcalendar + if (typeof event.start == 'string') + event.start = parseISO8601(event.start); + if (typeof event.end == 'string') + event.end = parseISO8601(event.end); // allow other plugins to do actions when event form is opened rcmail.triggerEvent('calendar-event-init', {o: event}); @@ -300,13 +452,13 @@ function rcube_calendar_ui(settings) if (event.recurrence && event.recurrence_text) $('#event-repeat').show().children('.event-text').html(Q(event.recurrence_text)); - if (event.alarms && event.alarms_text) + if (event.valarms && event.alarms_text) $('#event-alarm').show().children('.event-text').html(Q(event.alarms_text)); if (calendar.name) - $('#event-calendar').show().children('.event-text').html(Q(calendar.name)).removeClass().addClass('event-text').addClass('cal-'+calendar.id); + $('#event-calendar').show().children('.event-text').html(Q(calendar.name)).attr('class', 'event-text cal-'+calendar.id).css('color', calendar.textColor || calendar.color || ''); if (event.categories) - $('#event-category').show().children('.event-text').html(Q(event.categories)).removeClass().addClass('event-text cat-'+String(event.categories).replace(rcmail.identifier_expr, '')); + $('#event-category').show().children('.event-text').html(Q(event.categories)).attr('class', 'event-text cat-'+String(event.categories).toLowerCase().replace(rcmail.identifier_expr, '')); if (event.free_busy) $('#event-free-busy').show().children('.event-text').html(Q(rcmail.gettext(event.free_busy, 'calendar'))); if (event.priority > 0) { @@ -314,10 +466,22 @@ function rcube_calendar_ui(settings) $('#event-priority').show().children('.event-text').html(Q(event.priority+' '+priolabels[event.priority])); } + if (event.status) { + var status_lc = String(event.status).toLowerCase(); + $('#event-status').show().children('.event-text').html(Q(rcmail.gettext('status-'+status_lc,'calendar'))); + $dialog.addClass('status-'+status_lc); + } if (event.sensitivity && event.sensitivity != 'public') { $('#event-sensitivity').show().children('.event-text').html(Q(sensitivitylabels[event.sensitivity])); $dialog.addClass('sensitivity-'+event.sensitivity); } + if (event.created || event.changed) { + var created = parseISO8601(event.created), + changed = parseISO8601(event.changed) + $('#event-created-changed .event-created').html(Q(created ? format_datetime(created) : rcmail.gettext('unknown','calendar'))) + $('#event-created-changed .event-changed').html(Q(changed ? format_datetime(changed) : rcmail.gettext('unknown','calendar'))) + $('#event-created-changed').show() + } // create attachments list if ($.isArray(event.attachments)) { @@ -330,26 +494,42 @@ function rcube_calendar_ui(settings) // fetch attachments, some drivers doesn't set 'attachments' prop of the event? } + // build attachments list + $('#event-links').hide(); + if ($.isArray(event.links) && event.links.length) { + render_message_links(event.links || [], $('#event-links').children('.event-text'), false, 'calendar'); + $('#event-links').show(); + } + // list event attendees if (calendar.attendees && event.attendees) { - var data, dispname, organizer = false, rsvp = false, line, morelink, html = '',overflow = ''; + // sort resources to the end + event.attendees.sort(function(a,b) { + var j = a.cutype == 'RESOURCE' ? 1 : 0, + k = b.cutype == 'RESOURCE' ? 1 : 0; + return (j - k); + }); + + var data, mystatus = null, rsvp, line, morelink, html = '', overflow = ''; for (var j=0; j < event.attendees.length; j++) { data = event.attendees[j]; - dispname = Q(data.name || data.email); if (data.email) { - dispname = '' + dispname + ''; if (data.role == 'ORGANIZER') organizer = true; - else if ((data.status == 'NEEDS-ACTION' || data.status == 'TENTATIVE') && settings.identity.emails.indexOf(';'+data.email) >= 0) - rsvp = data.status.toLowerCase(); + else if (settings.identity.emails.indexOf(';'+data.email) >= 0) { + mystatus = data.status.toLowerCase(); + if (data.status == 'NEEDS-ACTION' || data.status == 'TENTATIVE' || data.rsvp) + rsvp = mystatus; + } } - - line = '' + dispname + ' '; + + line = event_attendee_html(data); + if (morelink) overflow += line; else html += line; - + // stop listing attendees if (j == 7 && event.attendees.length >= 7) { morelink = $('').html(rcmail.gettext('andnmore', 'calendar').replace('$nr', event.attendees.length - j - 1)); @@ -360,7 +540,7 @@ function rcube_calendar_ui(settings) $('#event-attendees').show() .children('.event-text') .html(html) - .find('a.mailtolink').click(function(e) { rcmail.redirect(rcmail.url('mail/compose', { _to:this.href.substr(7) })); return false; }); + .find('a.mailtolink').click(event_attendee_click); // display all attendees in a popup when clicking the "more" link if (morelink) { @@ -371,76 +551,174 @@ function rcube_calendar_ui(settings) rcmail.gettext('tabattendees','calendar'), null, { width:450, modal:false }); - $('#all-event-attendees a.mailtolink').click(function(e) { rcmail.redirect(rcmail.url('mail/compose', { _to:this.href.substr(7) })); return false; }); + $('#all-event-attendees a.mailtolink').click(event_attendee_click); return false; }) } } - - $('#event-rsvp')[(rsvp&&!organizer?'show':'hide')](); - $('#event-rsvp .rsvp-buttons input').prop('disabled', false).filter('input[rel='+rsvp+']').prop('disabled', true); + + if (mystatus && !rsvp) { + $('#event-partstat').show().children('.changersvp') + .removeClass('accepted tentative declined delegated needs-action') + .addClass(mystatus) + .children('.event-text') + .html(Q(rcmail.gettext('itip' + mystatus, 'libcalendaring'))); + } + + var show_rsvp = rsvp && !is_organizer(event) && event.status != 'CANCELLED' && has_permission(calendar, 'v'); + $('#event-rsvp')[(show_rsvp ? 'show' : 'hide')](); + $('#event-rsvp .rsvp-buttons input').prop('disabled', false).filter('input[rel='+mystatus+']').prop('disabled', true); + + if (show_rsvp && event.comment) + $('#event-rsvp-comment').show().children('.event-text').html(Q(event.comment)); + + $('#event-rsvp a.reply-comment-toggle').show(); + $('#event-rsvp .itip-reply-comment textarea').hide().val(''); + + if (event.recurrence && event.id) { + var sel = event._savemode || (event.thisandfuture ? 'future' : (event.isexception ? 'current' : 'all')); + $('#event-rsvp .rsvp-buttons').addClass('recurring'); + } + else { + $('#event-rsvp .rsvp-buttons').removeClass('recurring'); + } } - var buttons = {}; - if (calendar.editable && event.editable !== false) { - buttons[rcmail.gettext('edit', 'calendar')] = function() { - event_edit_dialog('edit', event); - }; - buttons[rcmail.gettext('remove', 'calendar')] = function() { - me.delete_event(event); - $dialog.dialog('close'); - }; + var buttons = []; + if (!temp && calendar.editable && event.editable !== false) { + buttons.push({ + text: rcmail.gettext('edit', 'calendar'), + click: function() { + event_edit_dialog('edit', event); + } + }); } - else { - buttons[rcmail.gettext('close', 'calendar')] = function(){ - $dialog.dialog('close'); - }; + if (!temp && has_permission(calendar, 'td') && event.editable !== false) { + buttons.push({ + text: rcmail.gettext('delete', 'calendar'), + 'class': 'delete', + click: function() { + me.delete_event(event); + $dialog.dialog('close'); + } + }); } - + + if (!buttons.length) { + buttons.push({ + text: rcmail.gettext('close', 'calendar'), + click: function(){ + $dialog.dialog('close'); + } + }); + } + // open jquery UI dialog $dialog.dialog({ modal: false, resizable: !bw.ie6, closeOnEscape: (!bw.ie6 && !bw.ie7), // disable for performance reasons - title: Q(me.event_date_text(event)), + title: me.event_date_text(event), open: function() { - $dialog.parent().find('.ui-button').first().focus(); + $dialog.attr('aria-hidden', 'false'); + setTimeout(function(){ + $dialog.parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().focus(); + }, 5); }, close: function() { - $dialog.dialog('destroy').hide(); + $dialog.dialog('destroy').attr('aria-hidden', 'true').hide(); + rcmail.command('menu-close','eventoptionsmenu'); + $('.libcal-rsvp-replymode').hide(); + }, + dragStart: function() { + rcmail.command('menu-close','eventoptionsmenu'); + $('.libcal-rsvp-replymode').hide(); + }, + resizeStart: function() { + rcmail.command('menu-close','eventoptionsmenu'); + $('.libcal-rsvp-replymode').hide(); }, buttons: buttons, minWidth: 320, width: 420 }).show(); - + + // remember opener element (to be focused on close) + $dialog.data('opener', ev && rcube_event.is_keyboard(ev) ? ev.target : null); + + // set voice title on dialog widget + $dialog.dialog('widget').removeAttr('aria-labelledby') + .attr('aria-label', me.event_date_text(event, true) + ', ', event.title); + // set dialog size according to content me.dialog_resize($dialog.get(0), $dialog.height(), 420); -/* + // add link for "more options" drop-down - $('') - .attr('href', '#') - .html('More Options') - .addClass('dropdown-link') - .click(function(){ return false; }) - .insertBefore($dialog.parent().find('.ui-dialog-buttonset').children().first()); -*/ + if (!temp && !event.temporary && event.calendar != '_resource') { + $('') + .attr('href', '#') + .html(rcmail.gettext('eventoptions','calendar')) + .addClass('dropdown-link') + .click(function(e) { + return rcmail.command('menu-open','eventoptionsmenu', this, e) + }) + .appendTo($dialog.parent().find('.ui-dialog-buttonset')); + } + + rcmail.enable_command('event-history', calendar.history) + }; + + // render HTML code for displaying an attendee record + var event_attendee_html = function(data) + { + var dispname = Q(data.name || data.email), tooltip = ''; + + if (data.email) { + tooltip = data.email + '; ' + data.status; + dispname = '' + dispname + ''; + } + + if (data['delegated-to']) + tooltip = rcmail.gettext('delegatedto', 'calendar') + data['delegated-to']; + else if (data['delegated-from']) + tooltip = rcmail.gettext('delegatedfrom', 'calendar') + data['delegated-from']; + + return '' + dispname + ' '; + }; + + // event handler for clicks on an attendee link + var event_attendee_click = function(e) + { + var cutype = $(this).attr('data-cutype'), + mailto = this.href.substr(7); + if (rcmail.env.calendar_resources && cutype == 'RESOURCE') { + event_resources_dialog(mailto); + } + else { + rcmail.command('compose', mailto, e ? e.target : null, e); + } + return false; }; // bring up the event dialog (jquery-ui popup) var event_edit_dialog = function(action, event) { + // copy opener element from show dialog + var op_elem = $("#eventshow:ui-dialog").data('opener'); + // close show dialog first - $("#eventshow:ui-dialog").dialog('close'); + $("#eventshow:ui-dialog").data('opener', null).dialog('close'); var $dialog = $('
'); - var calendar = event.calendar && me.calendars[event.calendar] ? me.calendars[event.calendar] : { editable:action=='new' }; + var calendar = event.calendar && me.calendars[event.calendar] ? me.calendars[event.calendar] : { editable:true, rights: action=='new' ? 'lrwitd' : 'lrs' }; me.selected_event = $.extend($.extend({}, event_defaults), event); // clone event object (with defaults) event = me.selected_event; // change reference to clone freebusy_ui.needsupdate = false; // reset dialog first $('#eventtabs').get(0).reset(); + $('#event-panel-recurrence input, #event-panel-recurrence select, #event-panel-attachments input').prop('disabled', false); + $('#event-panel-recurrence, #event-panel-attachments').removeClass('disabled'); // allow other plugins to do actions when event form is opened rcmail.triggerEvent('calendar-event-init', {o: event}); @@ -448,10 +726,11 @@ function rcube_calendar_ui(settings) // event details var title = $('#edit-title').val(event.title || ''); var location = $('#edit-location').val(event.location || ''); - var description = $('#edit-description').html(event.description || ''); + var description = $('#edit-description').text(event.description || ''); var vurl = $('#edit-url').val(event.vurl || ''); var categories = $('#edit-categories').val(event.categories); var calendars = $('#edit-calendar').val(event.calendar); + var eventstatus = $('#edit-event-status').val(event.status); var freebusy = $('#edit-free-busy').val(event.free_busy); var priority = $('#edit-priority').val(event.priority); var sensitivity = $('#edit-sensitivity').val(event.sensitivity); @@ -464,8 +743,11 @@ function rcube_calendar_ui(settings) var allday = $('#edit-allday').get(0); var notify = $('#edit-attendees-donotify').get(0); var invite = $('#edit-attendees-invite').get(0); - notify.checked = has_attendees(event), invite.checked = true; - + var comment = $('#edit-attendees-comment'); + + invite.checked = settings.itip_notify & 1 > 0; + notify.checked = has_attendees(event) && invite.checked; + if (event.allDay) { starttime.val("12:00").hide(); endtime.val("13:00").hide(); @@ -474,34 +756,16 @@ function rcube_calendar_ui(settings) else { allday.checked = false; } - + + // set calendar selection according to permissions + calendars.find('option').each(function(i, opt) { + var cal = me.calendars[opt.value] || {}; + $(opt).prop('disabled', !(cal.editable || (action == 'new' && has_permission(cal, 'i')))) + }); + // set alarm(s) - // TODO: support multiple alarm entries - if (event.alarms || action != 'new') { - if (typeof event.alarms == 'string') - event.alarms = event.alarms.split(';'); - - var valarms = event.alarms || ['']; - for (var alarm, i=0; i < valarms.length; i++) { - alarm = String(valarms[i]).split(':'); - if (!alarm[1] && alarm[0]) alarm[1] = 'DISPLAY'; - $('#eventedit select.edit-alarm-type').val(alarm[1]); - - if (alarm[0].match(/@(\d+)/)) { - var ondate = fromunixtime(parseInt(RegExp.$1)); - $('#eventedit select.edit-alarm-offset').val('@'); - $('#eventedit input.edit-alarm-date').val($.fullCalendar.formatDate(ondate, settings['date_format'])); - $('#eventedit input.edit-alarm-time').val($.fullCalendar.formatDate(ondate, settings['time_format'])); - } - else if (alarm[0].match(/([-+])(\d+)([MHD])/)) { - $('#eventedit input.edit-alarm-value').val(RegExp.$2); - $('#eventedit select.edit-alarm-offset').val(''+RegExp.$1+RegExp.$3); - } - } - } - // set correct visibility by triggering onchange handlers - $('#eventedit select.edit-alarm-type, #eventedit select.edit-alarm-offset').change(); - + me.set_alarms_edit('#edit-alarms', action != 'new' && event.valarms && calendar.alarms ? event.valarms : []); + // enable/disable alarm property according to backend support $('#edit-alarms')[(calendar.alarms ? 'show' : 'hide')](); @@ -510,60 +774,19 @@ function rcube_calendar_ui(settings) $(''; select += ''; @@ -1494,20 +2005,48 @@ function rcube_calendar_ui(settings) // delete icon var icon = rcmail.env.deleteicon ? '' : rcmail.gettext('delete'); var dellink = '' + icon + ''; - + var tooltip = data.status || ''; + + // send invitation checkbox + var invbox = ''; + + if (data['delegated-to']) + tooltip = rcmail.gettext('delegatedto', 'calendar') + data['delegated-to']; + else if (data['delegated-from']) + tooltip = rcmail.gettext('delegatedfrom', 'calendar') + data['delegated-from']; + + // add expand button for groups + if (data.cutype == 'GROUP') { + dispname += ' ' + + rcmail.gettext('expandattendeegroup','libcalendaring') + ''; + } + + var img_src = rcmail.assets_path('program/resources/blank.gif'); var html = '' + select + '' + - '' + dispname + '' + - '' + - '' + Q(data.status) + '' + + '' + dispname + '' + + '' + + '' + Q(data.status || '') + '' + + (data.cutype != 'RESOURCE' ? '' + (organizer || readonly || !invbox ? '' : invbox) + '' : '') + '' + (organizer || readonly ? '' : dellink) + ''; - + + var table = rcmail.env.calendar_resources && data.cutype == 'RESOURCE' ? resources_list : attendees_list; var tr = $('') .addClass(String(data.role).toLowerCase()) - .html(html) - .appendTo(attendees_list); - + .html(html); + + if (before) + tr.insertBefore(before) + else + tr.appendTo(table); + tr.find('a.deletelink').click({ id:(data.email || data.name) }, function(e) { remove_attendee(this, e.data.id); return false; }); - tr.find('a.mailtolink').click(function(e) { rcmail.redirect(rcmail.url('mail/compose', { _to:this.href.substr(7) })); return false; }); + tr.find('a.mailtolink').click(event_attendee_click); + tr.find('a.expandlink').click(data, function(e) { me.expand_attendee_group(e, add_attendee, remove_attendee); return false; }); + tr.find('input.edit-attendee-reply').click(function() { + var enabled = $('#edit-attendees-invite:checked').length || $('input.edit-attendee-reply:checked').length; + $('#eventedit .attendees-commentbox')[enabled ? 'show' : 'hide'](); + }); // select organizer identity if (data.identity_id) @@ -1519,16 +2058,17 @@ function rcube_calendar_ui(settings) } event_attendees.push(data); + return true; }; // iterate over all attendees and update their free-busy status display var update_freebusy_status = function(event) { - var icons = attendees_list.find('img.availabilityicon'); - for (var i=0; i < event_attendees.length; i++) { - if (icons.get(i) && event_attendees[i].email) - check_freebusy_status(icons.get(i), event_attendees[i].email, event); - } + attendees_list.find('img.availabilityicon').each(function(i,v) { + var email, icon = $(this); + if (email = icon.attr('data-email')) + check_freebusy_status(icon, email, event); + }); freebusy_ui.needsupdate = false; }; @@ -1538,11 +2078,11 @@ function rcube_calendar_ui(settings) { var calendar = event.calendar && me.calendars[event.calendar] ? me.calendars[event.calendar] : { freebusy:false }; if (!calendar.freebusy) { - $(icon).removeClass().addClass('availabilityicon unknown'); + $(icon).attr('class', 'availabilityicon unknown'); return; } - icon = $(icon).removeClass().addClass('availabilityicon loading'); + icon = $(icon).attr('class', 'availabilityicon loading'); $.ajax({ type: 'GET', @@ -1550,10 +2090,11 @@ function rcube_calendar_ui(settings) url: rcmail.url('freebusy-status'), data: { email:email, start:date2servertime(clone_date(event.start, event.allDay?1:0)), end:date2servertime(clone_date(event.end, event.allDay?2:0)), _remote: 1 }, success: function(status){ - icon.removeClass('loading').addClass(String(status).toLowerCase()); + var avail = String(status).toLowerCase(); + icon.removeClass('loading').addClass(avail).attr('alt', rcmail.gettext('avail' + avail, 'calendar')); }, error: function(){ - icon.removeClass('loading').addClass('unknown'); + icon.removeClass('loading').addClass('unknown').attr('alt', rcmail.gettext('availunknown', 'calendar')); } }); }; @@ -1564,22 +2105,405 @@ function rcube_calendar_ui(settings) $(elem).closest('tr').remove(); event_attendees = $.grep(event_attendees, function(data){ return (data.name != id && data.email != id) }); }; - - // when the user accepts or declines an event invitation - var event_rsvp = function(response) + + // open a dialog to display detailed free-busy information and to find free slots + var event_resources_dialog = function(search) { + var $dialog = $('#eventresourcesdialog'); + + if ($dialog.is(':ui-dialog')) + $dialog.dialog('close'); + + // dialog buttons + var buttons = {}; + + buttons[rcmail.gettext('addresource', 'calendar')] = function() { + rcmail.command('add-resource'); + }; + + buttons[rcmail.gettext('close')] = function() { + $dialog.dialog("close"); + }; + + // open jquery UI dialog + $dialog.dialog({ + modal: true, + resizable: true, + closeOnEscape: true, + title: rcmail.gettext('findresources', 'calendar'), + open: function() { + $dialog.attr('aria-hidden', 'false'); + }, + close: function() { + $dialog.dialog('destroy').attr('aria-hidden', 'true').hide(); + }, + resize: function(e) { + var container = $(rcmail.gui_objects.resourceinfocalendar); + container.fullCalendar('option', 'height', container.height() + 4); + }, + buttons: buttons, + width: 900, + height: 500 + }).show(); + + // define add-button as main action + $('.ui-dialog-buttonset .ui-button', $dialog.parent()).first().addClass('mainaction').attr('id', 'rcmbtncalresadd'); + + me.dialog_resize($dialog.get(0), 540, Math.min(1000, $(window).width() - 50)); + + // set search query + $('#resourcesearchbox').val(search || ''); + + // initialize the treelist widget + if (!resources_treelist) { + resources_treelist = new rcube_treelist_widget(rcmail.gui_objects.resourceslist, { + id_prefix: 'rcres', + id_encode: rcmail.html_identifier_encode, + id_decode: rcmail.html_identifier_decode, + selectable: true, + save_state: true + }); + resources_treelist.addEventListener('select', function(node) { + if (resources_data[node.id]) { + resource_showinfo(resources_data[node.id]); + rcmail.enable_command('add-resource', me.selected_event && $("#eventedit").is(':visible') ? true : false); + } + else { + rcmail.enable_command('add-resource', false); + $(rcmail.gui_objects.resourceinfo).hide(); + $(rcmail.gui_objects.resourceownerinfo).hide(); + $(rcmail.gui_objects.resourceinfocalendar).fullCalendar('removeEventSource', resources_events_source); + } + }); + + // fetch (all) resource data from server + me.loading_lock = rcmail.set_busy(true, 'loading', me.loading_lock); + rcmail.http_request('resources-list', {}, me.loading_lock); + + // register button + rcmail.register_button('add-resource', 'rcmbtncalresadd', 'uibutton'); + + // initialize resource calendar display + var resource_cal = $(rcmail.gui_objects.resourceinfocalendar); + resource_cal.fullCalendar($.extend({}, fullcalendar_defaults, { + header: { left: '', center: '', right: '' }, + height: resource_cal.height() + 4, + defaultView: 'agendaWeek', + eventSources: [], + slotMinutes: 60, + allDaySlot: false, + eventRender: function(event, element, view) { + var title = rcmail.get_label(event.status, 'calendar'); + element.addClass('status-' + event.status); + element.find('.fc-event-head').hide(); + element.find('.fc-event-title').text(title); + element.attr('aria-label', me.event_date_text(event, true) + ': ' + title); + } + })); + + $('#resource-calendar-prev').click(function(){ + resource_cal.fullCalendar('prev'); + return false; + }); + $('#resource-calendar-next').click(function(){ + resource_cal.fullCalendar('next'); + return false; + }); + } + else if (search) { + resource_search(); + } + else { + resource_render_list(resources_index); + } + + if (me.selected_event && me.selected_event.start) { + $(rcmail.gui_objects.resourceinfocalendar).fullCalendar('gotoDate', me.selected_event.start); + } + }; + + // render the resource details UI box + var resource_showinfo = function(resource) + { + // inline function to render a resource attribute + function render_attrib(value) { + if (typeof value == 'boolean') { + return value ? rcmail.get_label('yes') : rcmail.get_label('no'); + } + + return value; + } + + if (rcmail.gui_objects.resourceinfo) { + var tr, table = $(rcmail.gui_objects.resourceinfo).show().find('tbody').html(''), + attribs = $.extend({ name:resource.name }, resource.attributes||{}) + attribs.description = resource.description; + + for (var k in attribs) { + if (typeof attribs[k] == 'undefined') + continue; + table.append($('').addClass(k) + .append('' + Q(ucfirst(rcmail.get_label(k, 'calendar'))) + '') + .append('' + text2html(render_attrib(attribs[k])) + '') + ); + } + + $(rcmail.gui_objects.resourceownerinfo).hide(); + $(rcmail.gui_objects.resourceinfocalendar).fullCalendar('removeEventSource', resources_events_source); + + if (resource.owner) { + // display cached data + if (resource_owners[resource.owner]) { + resource_owner_load(resource_owners[resource.owner]); + } + else { + // fetch owner data from server + me.loading_lock = rcmail.set_busy(true, 'loading', me.loading_lock); + rcmail.http_request('resources-owner', { _id: resource.owner }, me.loading_lock); + } + } + + // load resource calendar + resources_events_source.url = "./?_task=calendar&_action=resources-calendar&_id="+urlencode(resource.ID); + $(rcmail.gui_objects.resourceinfocalendar).fullCalendar('addEventSource', resources_events_source); + } + }; + + // callback from server for resource listing + var resource_data_load = function(data) + { + var resources_tree = {}; + + // store data by ID + $.each(data, function(i, rec) { + resources_data[rec.ID] = rec; + + // assign parent-relations + if (rec.members) { + $.each(rec.members, function(j, m){ + resources_tree[m] = rec.ID; + }); + } + }); + + // walk the parent-child tree to determine the depth of each node + $.each(data, function(i, rec) { + rec._depth = 0; + if (resources_tree[rec.ID]) + rec.parent_id = resources_tree[rec.ID]; + + var parent_id = resources_tree[rec.ID]; + while (parent_id) { + rec._depth++; + parent_id = resources_tree[parent_id]; + } + }); + + // sort by depth, collection and name + data.sort(function(a,b) { + var j = a._type == 'collection' ? 1 : 0, + k = b._type == 'collection' ? 1 : 0, + d = a._depth - b._depth; + if (!d) d = (k - j); + if (!d) d = b.name < a.name ? 1 : -1; + return d; + }); + + $.each(data, function(i, rec) { + resources_index.push(rec.ID); + }); + + // apply search filter... + if ($('#resourcesearchbox').val() != '') + resource_search(); + else // ...or render full list + resource_render_list(resources_index); + + rcmail.set_busy(false, null, me.loading_lock); + }; + + // renders the given list of resource records into the treelist + var resource_render_list = function(index) { + var rec, link; + + resources_treelist.reset(); + + $.each(index, function(i, dn) { + if (rec = resources_data[dn]) { + link = $('').attr('href', '#') + .attr('rel', rec.ID) + .html(Q(rec.name)); + + resources_treelist.insert({ id:rec.ID, html:link, classes:[rec._type], collapsed:true }, rec.parent_id, false); + } + }); + }; + + // callback from server for owner information display + var resource_owner_load = function(data) + { + if (data) { + // cache this! + resource_owners[data.ID] = data; + + var table = $(rcmail.gui_objects.resourceownerinfo).find('tbody').html(''); + + for (var k in data) { + if (k == 'event' || k == 'ID') + continue; + + table.append($('').addClass(k) + .append('' + Q(ucfirst(rcmail.get_label(k, 'calendar'))) + '') + .append('' + text2html(data[k]) + '') + ); + } + + table.parent().show(); + } + } + + // quick-filter the loaded resource data + var resource_search = function() + { + var dn, rec, dataset = [], + q = $('#resourcesearchbox').val().toLowerCase(); + + if (q.length && resources_data) { + // search by iterating over all resource records + for (dn in resources_data) { + rec = resources_data[dn]; + if ((rec.name && String(rec.name).toLowerCase().indexOf(q) >= 0) + || (rec.email && String(rec.email).toLowerCase().indexOf(q) >= 0) + || (rec.description && String(rec.description).toLowerCase().indexOf(q) >= 0) + ) { + dataset.push(rec.ID); + } + } + + resource_render_list(dataset); + + // select single match + if (dataset.length == 1) { + resources_treelist.select(dataset[0]); + } + } + else { + $('#resourcesearchbox').val(''); + } + }; + + // + var reset_resource_search = function() + { + $('#resourcesearchbox').val('').focus(); + resource_render_list(resources_index); + }; + + // + var add_resource2event = function() + { + var resource = resources_data[resources_treelist.get_selection()]; + if (resource) { + if (add_attendee($.extend({ role:'REQ-PARTICIPANT', status:'NEEDS-ACTION', cutype:'RESOURCE' }, resource))) + rcmail.display_message(rcmail.get_label('resourceadded', 'calendar'), 'confirmation'); + } + } + + // when the user accepts or declines an event invitation + var event_rsvp = function(response, delegate, replymode) + { + var btn; + if (typeof response == 'object') { + btn = $(response); + response = btn.attr('rel') + } + else { + btn = $('#event-rsvp input.button[rel='+response+']'); + } + + // show menu to select rsvp reply mode (current or all) + if (me.selected_event && me.selected_event.recurrence && !replymode) { + rcube_libcalendaring.itip_rsvp_recurring(btn, function(resp, mode) { + event_rsvp(resp, null, mode); + }); + return; + } + if (me.selected_event && me.selected_event.attendees && response) { + // bring up delegation dialog + if (response == 'delegated' && !delegate) { + rcube_libcalendaring.itip_delegate_dialog(function(data) { + data.rsvp = data.rsvp ? 1 : ''; + event_rsvp('delegated', data, replymode); + }); + return; + } + // update attendee status + attendees = []; for (var data, i=0; i < me.selected_event.attendees.length; i++) { data = me.selected_event.attendees[i]; - if (settings.identity.emails.indexOf(';'+String(data.email).toLowerCase()) >= 0) + if (settings.identity.emails.indexOf(';'+String(data.email).toLowerCase()) >= 0) { data.status = response.toUpperCase(); + data.rsvp = 0; // unset RSVP flag + + if (data.status == 'DELEGATED') { + data['delegated-to'] = delegate.to; + data.rsvp = delegate.rsvp + } + else { + if (data['delegated-to']) { + delete data['delegated-to']; + if (data.role == 'NON-PARTICIPANT' && data.status != 'DECLINED') + data.role = 'REQ-PARTICIPANT'; + } + } + + attendees.push(i) + } + else if (response != 'DELEGATED' && data['delegated-from'] && + settings.identity.emails.indexOf(';'+String(data['delegated-from']).toLowerCase()) >= 0) { + delete data['delegated-from']; + } + + // set free_busy status to transparent if declined (#4425) + if (data.status == 'DECLINED' || data.role == 'NON-PARTICIPANT') { + me.selected_event.free_busy = 'free'; + } + else { + me.selected_event.free_busy = 'busy'; + } } - event_show_dialog(me.selected_event); - + // submit status change to server - me.saving_lock = rcmail.set_busy(true, 'calendar.savingdata'); - rcmail.http_post('event', { action:'rsvp', e:me.selected_event, status:response }); + var submit_data = $.extend({}, me.selected_event, { source:null, comment:$('#reply-comment-event-rsvp').val(), _savemode: replymode || 'all' }, (delegate || {})), + noreply = $('#noreply-event-rsvp:checked').length ? 1 : 0; + + // import event from mail (temporary iTip event) + if (submit_data._mbox && submit_data._uid) { + me.saving_lock = rcmail.set_busy(true, 'calendar.savingdata'); + rcmail.http_post('mailimportitip', { + _mbox: submit_data._mbox, + _uid: submit_data._uid, + _part: submit_data._part, + _status: response, + _to: (delegate ? delegate.to : null), + _rsvp: (delegate && delegate.rsvp) ? 1 : 0, + _noreply: noreply, + _comment: submit_data.comment, + _instance: submit_data._instance, + _savemode: submit_data._savemode + }); + } + else if (settings.invitation_calendars) { + update_event('rsvp', submit_data, { status:response, noreply:noreply, attendees:attendees }); + } + else { + me.saving_lock = rcmail.set_busy(true, 'calendar.savingdata'); + rcmail.http_post('event', { action:'rsvp', e:submit_data, status:response, attendees:attendees, noreply:noreply }); + } + + event_show_dialog(me.selected_event); } }; @@ -1610,12 +2534,23 @@ function rcube_calendar_ui(settings) }) $.each(listitems, function(idx, item) { mylist.append(item); }); } - + + // remove the link reference matching the given uri + function remove_link(elem) + { + var $elem = $(elem), uri = $elem.attr('data-uri'); + + me.selected_event.links = $.grep(me.selected_event.links, function(link) { return link.uri != uri; }); + + // remove UI list item + $elem.hide().closest('li').addClass('deleted'); + } + // post the given event data to server - var update_event = function(action, data) + var update_event = function(action, data, add) { me.saving_lock = rcmail.set_busy(true, 'calendar.savingdata'); - rcmail.http_post('calendar/event', { action:action, e:data }); + rcmail.http_post('calendar/event', $.extend({ action:action, e:data }, (add || {}))); // render event temporarily into the calendar if ((data.start && data.end) || data.id) { @@ -1633,7 +2568,7 @@ function rcube_calendar_ui(settings) // mark all recurring instances as temp if (event.recurrence || event.recurrence_id) { - var base_id = event.recurrence_id ? event.recurrence_id.replace(/-\d+$/, '') : event.id; + var base_id = event.recurrence_id ? event.recurrence_id : String(event.id).replace(/-\d+(T\d{6})?$/, ''); $.each(fc.fullCalendar('clientEvents', function(e){ return e.id == base_id || e.recurrence_id == base_id; }), function(i,ev) { ev.temp = true; ev.editable = false; @@ -1648,7 +2583,7 @@ function rcube_calendar_ui(settings) var dialog_check = function(e) { var showd = $("#eventshow"); - if (showd.is(':visible') && !$(e.target).closest('.ui-dialog').length) { + if (showd.is(':visible') && !$(e.target).closest('.ui-dialog').length && !$(e.target).closest('.popupmenu').length) { showd.dialog('close'); e.stopImmediatePropagation(); ignore_click = true; @@ -1665,21 +2600,29 @@ function rcube_calendar_ui(settings) var update_event_confirm = function(action, event, data) { if (!data) data = event; - var decline = false, notify = false, html = '', cal = me.calendars[event.calendar]; + var decline = false, notify = false, html = '', cal = me.calendars[event.calendar], + _has_attendees = has_attendees(event), _is_organizer = is_organizer(event); // event has attendees, ask whether to notify them - if (has_attendees(event)) { - if (is_organizer(event)) { + if (_has_attendees) { + var checked = (settings.itip_notify & 1 ? ' checked="checked"' : ''); + if (_is_organizer) { notify = true; - html += '
' + - '
'; + if (settings.itip_notify & 2) { + html += '
' + + '
'; + } + else { + data._notify = settings.itip_notify; + } } else if (action == 'remove' && is_attendee(event)) { decline = true; + checked = event.status != 'CANCELLED' ? checked : ''; html += '
' + - '
'; } @@ -1690,11 +2633,19 @@ function rcube_calendar_ui(settings) // recurring event: user needs to select the savemode if (event.recurrence) { + var future_disabled = '', message_label = (action == 'remove' ? 'removerecurringeventwarning' : 'changerecurringeventwarning'); + + // disable the 'future' savemode if I'm an attendee + // reason: no calendaring system supports the thisandfuture range parameter in iTip REPLY + if (action == 'remove' && _has_attendees && !_is_organizer && is_attendee(event)) { + future_disabled = ' disabled'; + } + html += '
' + - rcmail.gettext((action == 'remove' ? 'removerecurringeventwarning' : 'changerecurringeventwarning'), 'calendar') + '
' + + rcmail.gettext(message_label, 'calendar') + '
' + ''; @@ -1704,44 +2655,64 @@ function rcube_calendar_ui(settings) if (html) { var $dialog = $('
').html(html); - $dialog.find('a.button').button().click(function(e){ + $dialog.find('a.button').button().filter(':not(.disabled)').click(function(e) { data._savemode = String(this.href).replace(/.+#/, ''); - if ($dialog.find('input.confirm-attendees-donotify').get(0)) - data._notify = notify && $dialog.find('input.confirm-attendees-donotify').get(0).checked ? 1 : 0; - if (decline && $dialog.find('input.confirm-attendees-decline:checked')) - data.decline = 1; - update_event(action, data); - $dialog.dialog("destroy").hide(); + data._notify = settings.itip_notify; + + // open event edit dialog when saving as new + if (data._savemode == 'new') { + event._savemode = 'new'; + event_edit_dialog('edit', event); + fc.fullCalendar('refetchEvents'); + } + else { + if ($dialog.find('input.confirm-attendees-donotify').length) + data._notify = $dialog.find('input.confirm-attendees-donotify').get(0).checked ? 1 : 0; + if (decline) { + data._decline = $dialog.find('input.confirm-attendees-decline:checked').length; + data._notify = 0; + } + update_event(action, data); + } + + $dialog.dialog("close"); return false; }); - var buttons = [{ - text: rcmail.gettext('cancel', 'calendar'), - click: function() { - $(this).dialog("close"); - } - }]; - + var buttons = []; + if (!event.recurrence) { buttons.push({ - text: rcmail.gettext((action == 'remove' ? 'remove' : 'save'), 'calendar'), + text: rcmail.gettext((action == 'remove' ? 'delete' : 'save'), 'calendar'), click: function() { - data._notify = notify && $dialog.find('input.confirm-attendees-donotify').get(0).checked ? 1 : 0; - data.decline = decline && $dialog.find('input.confirm-attendees-decline:checked').length ? 1 : 0; + data._notify = notify && $dialog.find('input.confirm-attendees-donotify:checked').length ? 1 : 0; + data._decline = decline && $dialog.find('input.confirm-attendees-decline:checked').length ? 1 : 0; update_event(action, data); $(this).dialog("close"); } }); } - + + buttons.push({ + text: rcmail.gettext('cancel', 'calendar'), + click: function() { + $(this).dialog("close"); + } + }); + $dialog.dialog({ modal: true, width: 460, dialogClass: 'warning', title: rcmail.gettext((action == 'remove' ? 'removeeventconfirm' : 'changeeventconfirm'), 'calendar'), buttons: buttons, + open: function() { + setTimeout(function(){ + $dialog.parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().focus(); + }, 5); + }, close: function(){ - $dialog.dialog("destroy").hide(); + $dialog.dialog("destroy").remove(); if (!rcmail.busy) fc.fullCalendar('refetchEvents'); } @@ -1767,26 +2738,6 @@ function rcube_calendar_ui(settings) $('#agenda-listsections').val(fc.fullCalendar('option', 'listSections')); } - /*** fullcalendar event handlers ***/ - - var fc_event_render = function(event, element, view) { - if (view.name != 'list' && view.name != 'table') { - var prefix = event.sensitivity && event.sensitivity != 'public' ? String(sensitivitylabels[event.sensitivity]).toUpperCase()+': ' : ''; - element.attr('title', prefix + event.title); - } - if (view.name != 'month') { - if (event.location) { - element.find('div.fc-event-title').after('
@ ' + Q(event.location) + '
'); - } - if (event.sensitivity && event.sensitivity != 'public') - element.find('div.fc-event-time').append(''); - if (event.recurrence) - element.find('div.fc-event-time').append(''); - if (event.alarms) - element.find('div.fc-event-time').append(''); - } - }; - /*** public methods ***/ @@ -1840,37 +2791,130 @@ function rcube_calendar_ui(settings) me.fisheye_date = null; } }) - .fullCalendar({ + .fullCalendar($.extend({}, fullcalendar_defaults, { + defaultView: 'agendaDay', header: { left: '', center: '', right: '' }, height: h - 50, - defaultView: 'agendaDay', date: date.getDate(), month: date.getMonth(), year: date.getFullYear(), - ignoreTimezone: true, // will treat the given date strings as in local (browser's) timezone - eventSources: sources, - monthNames : settings['months'], - monthNamesShort : settings['months_short'], - dayNames : settings['days'], - dayNamesShort : settings['days_short'], - firstDay : settings['first_day'], - firstHour : settings['first_hour'], - slotMinutes : 60/settings['timeslots'], - timeFormat: { '': settings['time_format'] }, - axisFormat : settings['time_format'], - columnFormat: { day: 'dddd ' + settings['date_short'] }, - titleFormat: { day: 'dddd ' + settings['date_long'] }, - allDayText: rcmail.gettext('all-day', 'calendar'), - currentTimeIndicator: settings.time_indicator, - eventRender: fc_event_render, - eventClick: function(event) { - event_show_dialog(event); - } - }); + eventSources: sources + })); this.fisheye_date = date; }; + // opens the given calendar in a popup dialog + this.quickview = function(id, shift) + { + var src, in_quickview = false; + $.each(this.quickview_sources, function(i,cal) { + if (cal.id == id) { + in_quickview = true; + src = cal; + } + }); + + // remove source from quickview + if (in_quickview && shift) { + this.quickview_sources = $.grep(this.quickview_sources, function(src) { return src.id != id; }); + } + else { + if (!shift) { + // remove all current quickview event sources + if (this.quickview_active) { + fc.fullCalendar('removeEventSources'); + } + + this.quickview_sources = []; + + // uncheck all active quickview icons + calendars_list.container.find('div.focusview') + .add('#calendars .searchresults div.focusview') + .removeClass('focusview') + .find('a.quickview').attr('aria-checked', 'false'); + } + + if (!in_quickview) { + // clone and modify calendar properties + src = $.extend({}, this.calendars[id]); + src.url += '&_quickview=1'; + this.quickview_sources.push(src); + } + } + + // disable quickview + if (this.quickview_active && !this.quickview_sources.length) { + // register regular calendar event sources + $.each(this.calendars, function(k, cal) { + if (cal.active) + fc.fullCalendar('addEventSource', cal); + }); + + this.quickview_active = false; + $('body').removeClass('quickview-active'); + + // uncheck all active quickview icons + calendars_list.container.find('div.focusview') + .add('#calendars .searchresults div.focusview') + .removeClass('focusview') + .find('a.quickview').attr('aria-checked', 'false'); + } + // activate quickview + else if (!this.quickview_active) { + // remove regular calendar event sources + fc.fullCalendar('removeEventSources'); + + // register quickview event sources + $.each(this.quickview_sources, function(i, src) { + fc.fullCalendar('addEventSource', src); + }); + + this.quickview_active = true; + $('body').addClass('quickview-active'); + } + // update quickview sources + else if (in_quickview) { + fc.fullCalendar('removeEventSource', src); + } + else if (src) { + fc.fullCalendar('addEventSource', src); + } + + // activate quickview icon + if (this.quickview_active) { + $(calendars_list.get_item(id)).find('.calendar').first() + .add('#calendars .searchresults .cal-' + id) + [in_quickview ? 'removeClass' : 'addClass']('focusview') + .find('a.quickview').attr('aria-checked', in_quickview ? 'false' : 'true'); + } + }; + + // disable quickview mode + function reset_quickview() + { + // remove all current quickview event sources + if (me.quickview_active) { + fc.fullCalendar('removeEventSources'); + me.quickview_sources = []; + } + + // register regular calendar event sources + $.each(me.calendars, function(k, cal) { + if (cal.active) + fc.fullCalendar('addEventSource', cal); + }); + + // uncheck all active quickview icons + calendars_list.container.find('div.focusview') + .add('#calendars .searchresults div.focusview') + .removeClass('focusview') + .find('a.quickview').attr('aria-checked', 'false'); + + me.quickview_active = false; + $('body').removeClass('quickview-active'); + }; + //public method to show the print dialog. this.print_calendars = function(view) { @@ -1972,6 +3016,9 @@ function rcube_calendar_ui(settings) resizable: true, closeOnEscape: false, title: rcmail.gettext((calendar.id ? 'editcalendar' : 'createcalendar'), 'calendar'), + open: function() { + $dialog.parent().find('.ui-dialog-buttonset .ui-button').first().addClass('mainaction'); + }, close: function() { $dialog.html('').dialog("destroy").hide(); }, @@ -1983,9 +3030,16 @@ function rcube_calendar_ui(settings) }; this.calendar_remove = function(calendar) + { + this.calendar_destroy_source(calendar.id); + rcmail.http_post('calendar', { action:'subscribe', c:{ id:calendar.id, active:0, permanent:0, recursive:1 } }); + return true; + }; + + this.calendar_delete = function(calendar) { if (confirm(rcmail.gettext(calendar.children ? 'deletecalendarconfirmrecursive' : 'deletecalendarconfirm', 'calendar'))) { - rcmail.http_post('calendar', { action:'remove', c:{ id:calendar.id } }); + rcmail.http_post('calendar', { action:'delete', c:{ id:calendar.id } }); return true; } return false; @@ -2011,8 +3065,8 @@ function rcube_calendar_ui(settings) // delete all calendars in the list for (var i=0; i < delete_ids.length; i++) { id = delete_ids[i]; + calendars_list.remove(id); fc.fullCalendar('removeEventSource', this.calendars[id]); - $(rcmail.get_folder_li(id, 'rcmlical')).remove(); $('#edit-calendar option[value="'+id+'"]').remove(); delete this.calendars[id]; } @@ -2065,6 +3119,9 @@ function rcube_calendar_ui(settings) resizable: false, closeOnEscape: false, title: rcmail.gettext('importevents', 'calendar'), + open: function() { + $dialog.parent().find('.ui-dialog-buttonset .ui-button').first().addClass('mainaction'); + }, close: function() { $('.ui-dialog-buttonpane button', $dialog.parent()).button('enable'); $dialog.dialog("destroy").hide(); @@ -2091,6 +3148,7 @@ function rcube_calendar_ui(settings) this.import_error = function(p) { this.import_succeeded = false; + rcmail.set_busy(false, null, me.saving_lock); rcmail.display_message(p.message || rcmail.get_label('importerror', 'calendar'), 'error'); } @@ -2125,7 +3183,7 @@ function rcube_calendar_ui(settings) if (range == 'custom') start = date2unixtime(parse_datetime('00:00', $('#event-export-startdate').val())); else if (range > 0) - start = 'today -' + range + '^months'; + start = 'today -' + range + ' months'; rcmail.goto_url('export_events', { source:source, start:start, attachments:attachmt?1:0 }); } @@ -2141,6 +3199,9 @@ function rcube_calendar_ui(settings) resizable: false, closeOnEscape: false, title: rcmail.gettext('exporttitle', 'calendar'), + open: function() { + $dialog.parent().find('.ui-dialog-buttonset .ui-button').first().addClass('mainaction'); + }, close: function() { $('.ui-dialog-buttonpane button', $dialog.parent()).button('enable'); $dialog.dialog("destroy").hide(); @@ -2151,6 +3212,23 @@ function rcube_calendar_ui(settings) }; + // download the selected event as iCal + this.event_download = function(event) + { + if (event && event.id) { + rcmail.goto_url('export_events', { source:event.calendar, id:event.id, attachments:1 }); + } + }; + + // open the message compose step with a calendar_event parameter referencing the selected event. + // the server-side plugin hook will pick that up and attach the event to the message. + this.event_sendbymail = function(event, e) + { + if (event && event.id) { + rcmail.command('compose', { _calendar_event:event._id }, e ? e.target : null, e); + } + }; + // show URL of the given calendar in a dialog box this.showurl = function(calendar) { @@ -2187,41 +3265,70 @@ function rcube_calendar_ui(settings) { var source = me.calendars[p.source]; + // helper function to update the given fullcalendar view + function update_view(view, event, source) { + var existing = view.fullCalendar('clientEvents', event._id); + if (existing.length) { + $.extend(existing[0], event); + view.fullCalendar('updateEvent', existing[0]); + // remove old recurrence instances + if (event.recurrence && !event.recurrence_id) + view.fullCalendar('removeEvents', function(e){ return e._id.indexOf(event._id+'-') == 0; }); + } + else { + event.source = source; // link with source + view.fullCalendar('renderEvent', event); + } + } + + // remove temp events + fc.fullCalendar('removeEvents', function(e){ return e.temp; }); + if (source && (p.refetch || (p.update && !source.active))) { // activate event source if new event was added to an invisible calendar - if (!source.active) { + if (this.quickview_active) { + // map source to the quickview_sources equivalent + $.each(this.quickview_sources, function(src) { + if (src.id == source.id) { + source = src; + return false; + } + }); + fc.fullCalendar('refetchEvents', source, true); + } + else if (!source.active) { source.active = true; fc.fullCalendar('addEventSource', source); - $('#' + rcmail.get_folder_li(source.id, 'rcmlical').id + ' input').prop('checked', true); + $('#rcmlical' + source.id + ' input').prop('checked', true); } else - fc.fullCalendar('refetchEvents', source); + fc.fullCalendar('refetchEvents', source, true); + + fetch_counts(); } // add/update single event object else if (source && p.update) { var event = p.update; event.temp = false; + event.editable = 0; + + // update fish-eye view + if (this.fisheye_date) + update_view($('#fish-eye-view'), event, source); + + // update main view event.editable = source.editable; - var existing = fc.fullCalendar('clientEvents', event._id); - if (existing.length) { - $.extend(existing[0], event); - fc.fullCalendar('updateEvent', existing[0]); - } - else { - event.source = source; // link with source - fc.fullCalendar('renderEvent', event); - } - // refresh fish-eye view - if (me.fisheye_date) - me.fisheye_view(me.fisheye_date); + update_view(fc, event, source); + + // update the currently displayed event dialog + if ($('#eventshow').is(':visible') && me.selected_event && me.selected_event.id == event.id) + event_show_dialog(event) } // refetch all calendars else if (p.refetch) { - fc.fullCalendar('refetchEvents'); + fc.fullCalendar('refetchEvents', undefined, true); + fetch_counts(); } - - // remove temp events - fc.fullCalendar('removeEvents', function(e){ return e.temp; }); }; // modify query parameters for refresh requests @@ -2238,6 +3345,75 @@ function rcube_calendar_ui(settings) return query; }; + // callback from server providing event counts + this.update_counts = function(p) + { + $.each(p.counts, function(cal, count) { + var li = calendars_list.get_item(cal), + bubble = $(li).children('.calendar').find('span.count'); + + if (!bubble.length && count > 0) { + bubble = $('') + .addClass('count') + .appendTo($(li).children('.calendar').first()) + } + + if (count > 0) { + bubble.text(count).show(); + } + else { + bubble.text('').hide(); + } + }); + }; + + // callback after an iTip message event was imported + this.itip_message_processed = function(data) + { + // remove temporary iTip source + fc.fullCalendar('removeEventSource', this.calendars['--invitation--itip']); + + $('#eventshow:ui-dialog').dialog('close'); + this.selected_event = null; + + // refresh destination calendar source + this.refresh({ source:data.calendar, refetch:true }); + + this.unlock_saving(); + + // process 'after_action' in mail task + if (window.opener && window.opener.rcube_libcalendaring) + window.opener.rcube_libcalendaring.itip_message_processed(data); + }; + + // reload the calendar view by keeping the current date/view selection + this.reload_view = function() + { + var query = { view: fc.fullCalendar('getView').name }, + date = fc.fullCalendar('getDate'); + if (date) + query.date = date2unixtime(date); + rcmail.redirect(rcmail.url('', query)); + } + + // update browser location to remember current view + this.update_state = function() + { + var query = { view: current_view }, + date = fc.fullCalendar('getDate'); + if (date) + query.date = date2unixtime(date); + + if (window.history.replaceState) + window.history.replaceState({}, document.title, rcmail.url('', query).replace('&_action=', '')); + }; + + this.resource_search = resource_search; + this.reset_resource_search = reset_resource_search; + this.add_resource2event = add_resource2event; + this.resource_data_load = resource_data_load; + this.resource_owner_load = resource_owner_load; + /*** event searching ***/ @@ -2250,12 +3426,15 @@ function rcube_calendar_ui(settings) var id = 'search-'+q; var sources = []; + if (me.quickview_active) + reset_quickview(); + if (this._search_message) rcmail.hide_message(this._search_message); for (var sid in this.calendars) { if (this.calendars[sid]) { - this.calendars[sid].url = this.calendars[sid].url.replace(/&q=.+/, '') + '&q='+escape(q); + this.calendars[sid].url = this.calendars[sid].url.replace(/&q=.+/, '') + '&q=' + urlencode(q); sources.push(sid); } } @@ -2384,143 +3563,260 @@ function rcube_calendar_ui(settings) fc.fullCalendar('option', 'height', $('#calendar').height() - footer); }; + // mark the given calendar folder as selected + this.select_calendar = function(id, nolistupdate) + { + if (!nolistupdate) + calendars_list.select(id); + + // trigger event hook + rcmail.triggerEvent('selectfolder', { folder:id, prefix:'rcmlical' }); + + this.selected_calendar = id; + }; + + // register the given calendar to the current view + var add_calendar_source = function(cal) + { + var color, brightness, select, id = cal.id; + + me.calendars[id] = $.extend({ + url: rcmail.url('calendar/load_events', { source: id }), + className: 'fc-event-cal-'+id, + id: id + }, cal); + + // choose black text color when background is bright, white otherwise + if (color = settings.event_coloring % 2 ? '' : '#' + cal.color) { + if (/^#([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})$/i.test(color)) { + // use information about brightness calculation found at + // http://javascriptrules.com/2009/08/05/css-color-brightness-contrast-using-javascript/ + brightness = (parseInt(RegExp.$1, 16) * 299 + parseInt(RegExp.$2, 16) * 587 + parseInt(RegExp.$3, 16) * 114) / 1000; + if (brightness > 125) + me.calendars[id].textColor = 'black'; + } + + me.calendars[id].color = color; + } + + if (fc && (cal.active || cal.subscribed)) { + if (cal.active) + fc.fullCalendar('addEventSource', me.calendars[id]); + + var submit = { id: id, active: cal.active ? 1 : 0 }; + if (cal.subscribed !== undefined) + submit.permanent = cal.subscribed ? 1 : 0; + rcmail.http_post('calendar', { action:'subscribe', c:submit }); + } + + // insert to #calendar-select options if writeable + select = $('#edit-calendar'); + if (fc && has_permission(cal, 'i') && select.length && !select.find('option[value="'+id+'"]').length) { + $('