From 78622133a94c444b9c2c205649a564c37739f4f9 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Sun, 15 Feb 2015 14:32:31 +0100 Subject: [PATCH 01/32] Reliably identify recurrence instances throughout the application to support invitations of recurring events (#4387) --- plugins/calendar/calendar.php | 43 +++++++++------ plugins/calendar/calendar_ui.js | 31 +++++++---- plugins/calendar/drivers/calendar_driver.php | 2 +- .../calendar/drivers/kolab/kolab_calendar.php | 2 +- .../calendar/drivers/kolab/kolab_driver.php | 15 +++-- plugins/calendar/lib/calendar_recurrence.php | 5 +- plugins/calendar/skins/larry/calendar.css | 29 ++++++++++ .../lib/libcalendaring_itip.php | 55 ++++++++++++++++--- plugins/libcalendaring/libcalendaring.js | 4 +- plugins/libcalendaring/libcalendaring.php | 30 ++++++++++ plugins/libcalendaring/libvcalendar.php | 7 +++ plugins/libcalendaring/localization/en_US.inc | 6 ++ .../libkolab/lib/kolab_date_recurrence.php | 6 +- plugins/libkolab/lib/kolab_format_event.php | 3 +- 14 files changed, 189 insertions(+), 49 deletions(-) diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index 51381cb4..f1e66ec8 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -841,15 +841,12 @@ class calendar extends rcube_plugin $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'] && in_array($event['_savemode'], array('current','future')) && $event['_notify'] && $action != 'remove') - unset($event['_notify']); // force notify if hidden + active - else if ((int)$this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']) === 1) + 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); switch ($action) { @@ -928,8 +925,14 @@ 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']); + else if ($event['_savemode'] == 'future') + $old['thisandfuture'] = 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') @@ -939,7 +942,7 @@ class calendar extends rcube_plugin $reply_sender = $attendee['email']; } } - + $itip = $this->load_itip(); $itip->set_sender_email($reply_sender); if ($organizer && $itip->send_itip_message($old, 'REPLY', $organizer, 'itipsubjectdeclined', 'itipmailbodydeclined')) @@ -974,6 +977,7 @@ class calendar extends rcube_plugin $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']) { @@ -1127,11 +1131,7 @@ class calendar extends rcube_plugin // make sure we have the complete record $event = $action == 'remove' ? $old : $this->driver->get_event($event); - // sending notification on a recurrence instance -> re-send the main event - if ($event['recurrence_id']) { - $event = $this->driver->get_event(array('id' => $event['recurrence_id'], 'cal' => $event['calendar'])); - $action = 'edit'; - } + // TODO: on change of a recurring (main) event, also send updates to differing attendess of recurrence exceptions // only notify if data really changed (TODO: do diff check on client already) if (!$old || $action == 'remove' || self::event_diff($event, $old)) { @@ -1945,6 +1945,9 @@ class calendar extends rcube_plugin // 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, $event['sequence'] > $old['sequence']); @@ -2405,9 +2408,10 @@ class calendar extends rcube_plugin { $success = false; $uid = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST); + $inst = rcube_utils::get_input_value('_instance', rcube_utils::INPUT_POST); // search for event if only UID is given - if ($event = $this->driver->get_event(array('uid' => $uid), true)) { + if ($event = $this->driver->get_event(array('uid' => $uid, '_instance' => $inst), true)) { $success = $this->driver->remove_event($event, true); } @@ -2722,12 +2726,13 @@ class calendar extends rcube_plugin // save to calendar if ($calendar && !$calendar['readonly']) { - $event['calendar'] = $calendar['id']; - - // check for existing event with the same UID - $existing = $this->driver->get_event($event['uid'], true, false, true); - + // check for existing event with the same UID + $existing = $this->driver->get_event($event, true, false, true); + if ($existing) { + // forward savemode for correct updates of recurring events + $existing['_savemode'] = $event['_savemode']; + // only update attendee status if ($event['_method'] == 'REPLY') { // try to identify the attendee using the email sender address @@ -2829,6 +2834,8 @@ class calendar extends rcube_plugin if ($status == 'declined' || $event['status'] == 'CANCELLED' || $event_attendee['role'] == 'NON-PARTICIPANT') { $event['free_busy'] = 'free'; } + // save to the selected/default calendar + $event['calendar'] = $calendar['id']; $success = $this->driver->new_event($event); } else if ($status == 'declined') diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js index c1e3d556..1f9f399f 100644 --- a/plugins/calendar/calendar_ui.js +++ b/plugins/calendar/calendar_ui.js @@ -554,6 +554,14 @@ function rcube_calendar_ui(settings) $('#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 input.rsvp-replymode[value="'+sel+'"]').prop('checked', true); + $('#event-rsvp .rsvp-replymode-message').show(); + } + else + $('#event-rsvp .rsvp-replymode-message').hide(); } var buttons = []; @@ -742,11 +750,9 @@ function rcube_calendar_ui(settings) // show warning if editing a recurring event if (event.id && event.recurrence) { - var allow_exceptions = !has_attendees(event) || !is_organizer(event), - sel = event._savemode || (allow_exceptions && event.thisandfuture ? 'future' : (allow_exceptions && event.isexception ? 'current' : 'all')); + var sel = event._savemode || (event.thisandfuture ? 'future' : (event.isexception ? 'current' : 'all')); $('#edit-recurring-warning').show(); $('input.edit-recurring-savemode[value="'+sel+'"]').prop('checked', true); - $('input.edit-recurring-savemode[value="current"], input.edit-recurring-savemode[value="future"]').prop('disabled', !allow_exceptions); } else $('#edit-recurring-warning').hide(); @@ -2411,7 +2417,7 @@ function rcube_calendar_ui(settings) } // submit status change to server - var submit_data = $.extend({}, me.selected_event, { source:null, comment:$('#reply-comment-event-rsvp').val() }, (delegate || {})), + var submit_data = $.extend({}, me.selected_event, { source:null, comment:$('#reply-comment-event-rsvp').val(), _savemode: $('input.rsvp-replymode:checked').val() }, (delegate || {})), noreply = $('#noreply-event-rsvp:checked').length ? 1 : 0; // import event from mail (temporary iTip event) @@ -2425,7 +2431,8 @@ function rcube_calendar_ui(settings) _to: (delegate ? delegate.to : null), _rsvp: (delegate && delegate.rsvp) ? 1 : 0, _noreply: noreply, - _comment: submit_data.comment + _comment: submit_data.comment, + _savemode: submit_data._savemode }); } else if (settings.invitation_calendars) { @@ -2501,7 +2508,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.replace(/-\d+(T\d{6})?$/, '') : event.id; $.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; @@ -2566,7 +2573,7 @@ function rcube_calendar_ui(settings) // recurring event: user needs to select the savemode if (event.recurrence) { var disabled_state = '', message_label = (action == 'remove' ? 'removerecurringeventwarning' : 'changerecurringeventwarning'); - +/* if (_has_attendees) { if (action == 'remove') { if (!_is_organizer) { @@ -2578,7 +2585,7 @@ function rcube_calendar_ui(settings) disabled_state = ' disabled'; } } - +*/ html += '
' + rcmail.gettext(message_label, 'calendar') + '
' + '
' + @@ -2606,8 +2613,10 @@ function rcube_calendar_ui(settings) else { if ($dialog.find('input.confirm-attendees-donotify').length) data._notify = $dialog.find('input.confirm-attendees-donotify').get(0).checked ? 1 : 0; - if (decline && $dialog.find('input.confirm-attendees-decline:checked').length) - data.decline = 1; + if (decline) { + data._decline = $dialog.find('input.confirm-attendees-decline:checked').length; + data._notify = 0; + } update_event(action, data); } @@ -2622,7 +2631,7 @@ function rcube_calendar_ui(settings) text: rcmail.gettext((action == 'remove' ? 'delete' : 'save'), 'calendar'), click: function() { data._notify = notify && $dialog.find('input.confirm-attendees-donotify:checked').length ? 1 : 0; - data.decline = decline && $dialog.find('input.confirm-attendees-decline:checked').length ? 1 : 0; + data._decline = decline && $dialog.find('input.confirm-attendees-decline:checked').length ? 1 : 0; update_event(action, data); $(this).dialog("close"); } diff --git a/plugins/calendar/drivers/calendar_driver.php b/plugins/calendar/drivers/calendar_driver.php index 24e7a2e8..e402db9a 100644 --- a/plugins/calendar/drivers/calendar_driver.php +++ b/plugins/calendar/drivers/calendar_driver.php @@ -50,6 +50,7 @@ * 'EXCEPTIONS' => array(), list of event objects which denote exceptions in the recurrence chain * ), * 'recurrence_id' => 'ID of the recurrence group', // usually the ID of the starting event + * '_instance' => 'ID of the recurring instance', // identifies an instance within a recurrence chain * 'categories' => 'Event category', * 'free_busy' => 'free|busy|outofoffice|tentative', // Show time as * 'status' => 'TENTATIVE|CONFIRMED|CANCELLED', // event status according to RFC 2445 @@ -469,7 +470,6 @@ abstract class calendar_driver if (($next_event['start'] <= $end && $next_event['end'] >= $start)) { $next_event['id'] = $next_event['uid']; $next_event['recurrence_id'] = $event['uid']; - $next_event['_instance'] = $i; $events[] = $next_event; } else if ($next_event['start'] > $end) { // stop loop if out of range diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php index 2b50ac8a..15f030db 100644 --- a/plugins/calendar/drivers/kolab/kolab_calendar.php +++ b/plugins/calendar/drivers/kolab/kolab_calendar.php @@ -661,7 +661,7 @@ class kolab_calendar extends kolab_storage_folder_api */ private function _merge_event_data(&$event, $overlay) { - static $forbidden = array('id','uid','created','changed','recurrence','organizer','attendees','sequence'); + static $forbidden = array('id','uid','recurrence','organizer','_attachments'); foreach ($overlay as $prop => $value) { // adjust time of the recurring event instance diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index 98318f25..1e3f0ea6 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -534,8 +534,13 @@ class kolab_driver extends calendar_driver public function get_event($event, $writeable = false, $active = false, $personal = false) { if (is_array($event)) { - $id = $event['id'] ? $event['id'] : $event['uid']; + $id = $event['id'] ?: $event['uid']; $cal = $event['calendar']; + + // we're looking for a recurring instance: expand the ID to our internal convention for recurring instanced + if (!$event['id'] && $event['_instance']) { + $id .= '-' . $event['_instance']; + } } else { $id = $event; @@ -687,11 +692,11 @@ class kolab_driver extends calendar_driver // read master if deleting a recurring event if ($event['recurrence'] || $event['recurrence_id']) { $master = $event['recurrence_id'] ? $storage->get_event($event['recurrence_id']) : $event; - $savemode = $event['_savemode']; + $savemode = $event['_savemode'] ?: ($event['_instance'] ? 'current' : 'all'); } // removing an exception instance - if ($event['recurrence_id']) { + if ($event['recurrence_id'] && $master['recurrence'] && is_array($master['recurrence']['EXCEPTIONS'])) { foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { if ($exception['_instance'] == $event['_instance']) { unset($master['recurrence']['EXCEPTIONS'][$i]); @@ -880,7 +885,7 @@ class kolab_driver extends calendar_driver // modify a recurring event, check submitted savemode to do the right things if ($old['recurrence'] || $old['recurrence_id']) { $master = $old['recurrence_id'] ? $fromcalendar->get_event($old['recurrence_id']) : $old; - $savemode = $event['_savemode']; + $savemode = $event['_savemode'] ?: ($old['recurrence_id'] ? 'current' : 'all'); } // check if update affects scheduling and update attendee status accordingly @@ -919,7 +924,7 @@ class kolab_driver extends calendar_driver // increment sequence of this instance if scheduling is affected if ($reschedule) { - $event['sequence'] = $old['sequence'] + 1; + $event['sequence'] = max($old['sequence'], $master['sequence']) + 1; } // remove some internal properties which should not be saved diff --git a/plugins/calendar/lib/calendar_recurrence.php b/plugins/calendar/lib/calendar_recurrence.php index fae98bbe..d3af94dc 100644 --- a/plugins/calendar/lib/calendar_recurrence.php +++ b/plugins/calendar/lib/calendar_recurrence.php @@ -67,7 +67,6 @@ class calendar_recurrence extends libcalendaring_recurrence { if ($next_start = $this->next()) { $next = $this->event; - $next['recurrence_id'] = $next_start->format('Y-m-d'); $next['start'] = $next_start; if ($this->duration) { @@ -75,6 +74,10 @@ class calendar_recurrence extends libcalendaring_recurrence $next['end']->add($this->duration); } + $recurrence_id_format = $next['allday'] ? 'Ymd' : 'Ymd\THis'; + $next['recurrence_date'] = clone $next_start; + $next['_instance'] = $next_start->format($recurrence_id_format); + unset($next['_formatobj']); return $next; diff --git a/plugins/calendar/skins/larry/calendar.css b/plugins/calendar/skins/larry/calendar.css index 50f8b645..1c2eca5f 100644 --- a/plugins/calendar/skins/larry/calendar.css +++ b/plugins/calendar/skins/larry/calendar.css @@ -1059,6 +1059,26 @@ td.topalign { text-align: center; } +.event-dialog-message .rsvp-replymode-message { + margin-top: 0.8em; + margin-bottom: 0.6em; +} + +.event-dialog-message .rsvp-replymode-message .replymode-select { + padding-left: 22px; +} + +.event-dialog-message .rsvp-replymode-message label { + color: inherit; + margin-right: 0.4em; + white-space: nowrap; + min-width: 4em; +} + +.event-dialog-message .rsvp-replymode-message input.rsvp-replymode { + margin-right: 0.4em; +} + #event-rsvp, #edit-attendees-notify { margin: 0.6em 0 0.3em 0; @@ -2159,6 +2179,15 @@ div.calendar-invitebox td.sensitivity { font-weight: bold; } +div.calendar-invitebox td.recurrence-id { + text-transform: uppercase; + font-style: italic; +} + +div.calendar-invitebox td em { + font-weight: bold; +} + #event-rsvp .rsvp-buttons, div.calendar-invitebox .itip-buttons div { margin-top: 0.5em; diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php index 3eead6fa..53284a4e 100644 --- a/plugins/libcalendaring/lib/libcalendaring_itip.php +++ b/plugins/libcalendaring/lib/libcalendaring_itip.php @@ -98,8 +98,10 @@ class libcalendaring_itip if (!$this->sender['name']) $this->sender['name'] = $this->sender['email']; - if (!$message) + if (!$message) { + libcalendaring::identify_recurrence_instance($event); $message = $this->compose_itip_message($event, $method, $rsvp); + } $mailto = rcube_idn_to_ascii($recipient['email']); @@ -121,12 +123,19 @@ class libcalendaring_itip ($attendee['name'] ? $attendee['name'] : $attendee['email']); } + $recurrence_info = ''; + if (!empty($event['recurrence_id'])) { + $recurrence_info = "\n\n** " . $this->gettext('itip'.strtolower($method).'occurrenceonly') . ' **'; + } + else if (!empty($event['recurrence'])) { + $recurrence_info = sprintf("\n%s: %s", $this->gettext('recurring'), $this->lib->recurrence_text($event['recurrence'])); + } + $mailbody = $this->gettext(array( 'name' => $bodytext, 'vars' => array( 'title' => $event['title'], - 'date' => $this->lib->event_date_text($event, true) . - (empty($event['recurrence']) ? '' : sprintf("\n%s: %s", $this->gettext('recurring'), $this->lib->recurrence_text($event['recurrence']))), + 'date' => $this->lib->event_date_text($event, true) . $recurrence_info, 'attendees' => join(",\n ", $attendees_list), 'sender' => $this->sender['name'], 'organizer' => $this->sender['name'], @@ -151,6 +160,10 @@ class libcalendaring_itip $message->headers($headers, true); $message->setTXTBody(rcube_mime::format_flowed($mailbody, 79)); + if ($this->rc->config->get('libcalendaring_itip_debug', false)) { + console('iTip ' . $method, $message->txtHeaders() . "\n\r" . $message->get()); + } + // finally send the message $this->itip_send = true; $sent = $this->rc->deliver_message($message, $headers['X-Sender'], $mailto, $smtp_error); @@ -230,6 +243,9 @@ class libcalendaring_itip array_unshift($reply_attendees, $replying_attendee); $event['attendees'] = $reply_attendees; } + if ($event['recurrence']) { + unset($event['recurrence']['EXCEPTIONS']); + } } // set RSVP for every attendee else if ($method == 'REQUEST') { @@ -239,6 +255,11 @@ class libcalendaring_itip } } } + else if ($method == 'CANCEL') { + if ($event['recurrence']) { + unset($event['recurrence']['EXCEPTIONS']); + } + } // compose multipart message using PEAR:Mail_Mime $message = new Mail_mime("\r\n"); @@ -453,6 +474,7 @@ class libcalendaring_itip $changed = is_object($event['changed']) ? $event['changed'] : $message_date; $metadata = array( 'uid' => $event['uid'], + '_instance' => $event['_instance'], 'changed' => $changed ? $changed->format('U') : 0, 'sequence' => intval($event['sequence']), 'method' => $method, @@ -580,12 +602,13 @@ class libcalendaring_itip // for CANCEL messages, we can: else if ($method == 'CANCEL') { $title = $this->gettext('itipcancellation'); + $event_prop = array_filter(array('uid' => $event['uid'], '_instance' => $event['_instance'])); // 1. remove the event from our calendar $button_remove = html::tag('input', array( 'type' => 'button', 'class' => 'button', - 'onclick' => "rcube_libcalendaring.remove_from_itip('" . JQ($event['uid']) . "', '$task', '" . JQ($event['title']) . "')", + 'onclick' => "rcube_libcalendaring.remove_from_itip(" . rcube_output::json_serialize($event_prop) . ", '$task', '" . JQ($event['title']) . "')", 'value' => $this->gettext('removefromcalendar'), )); @@ -646,8 +669,6 @@ class libcalendaring_itip )); } - $buttons .= html::div('itip-reply-controls', $this->itip_rsvp_options_ui($attrib['id'])); - // add localized texts for the delegation dialog if (in_array('delegated', $actions)) { foreach (array('itipdelegated','itipcomment','delegateinvitation', @@ -656,9 +677,23 @@ class libcalendaring_itip } } + $savemode_radio = new html_radiobutton(array('name' => '_rsvpmode', 'class' => 'rsvp-replymode')); + return html::div($attrib, html::div('label', $this->gettext('acceptinvitation')) . - html::div('rsvp-buttons', $buttons)); + html::div('rsvp-buttons', + $buttons . + html::div(array('class' => 'rsvp-replymode-message', 'style' => 'display:none'), + html::div('message', html::span('ui-icon ui-icon-alert', '') . $this->gettext('rsvprecurringevent')) . + html::div('replymode-select', + html::label(null, $savemode_radio->show('all', array('value' => 'all')) . $this->gettext('allevents')) . + html::label(null, $savemode_radio->show(null, array('value' => 'current')) . $this->gettext('currentevent')) . + html::label(null, $savemode_radio->show(null, array('value' => 'future')) . $this->gettext('futurevents')) + ) + ) . + html::div('itip-reply-controls', $this->itip_rsvp_options_ui($attrib['id'])) + ) + ); } /** @@ -705,7 +740,11 @@ class libcalendaring_itip $table->add('label', $this->gettext('date')); $table->add('date', Q($this->lib->event_date_text($event))); } - if (!empty($event['recurrence'])) { + if (!empty($event['recurrence_date'])) { + $table->add('label', ''); + $table->add('recurrence-id', $this->gettext('itipsingleoccurrence')); + } + else if (!empty($event['recurrence'])) { $table->add('label', $this->gettext('recurring')); $table->add('recurrence', $this->lib->recurrence_text($event['recurrence'])); } diff --git a/plugins/libcalendaring/libcalendaring.js b/plugins/libcalendaring/libcalendaring.js index 0a2949d6..a13ebf7e 100644 --- a/plugins/libcalendaring/libcalendaring.js +++ b/plugins/libcalendaring/libcalendaring.js @@ -961,11 +961,11 @@ rcube_libcalendaring.itip_delegate_dialog = function(callback, selector) /** * */ -rcube_libcalendaring.remove_from_itip = function(uid, task, title) +rcube_libcalendaring.remove_from_itip = function(event, task, title) { if (confirm(rcmail.gettext('itip.deleteobjectconfirm').replace('$title', title))) { rcmail.http_post(task + '/itip-remove', - { uid: uid }, + event, rcmail.set_busy(true, 'itip.savingdata') ); } diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php index f1c4514c..c3bf6256 100644 --- a/plugins/libcalendaring/libcalendaring.php +++ b/plugins/libcalendaring/libcalendaring.php @@ -1310,6 +1310,9 @@ class libcalendaring extends rcube_plugin $charset = $part->ctype_parameters['charset'] ?: RCMAIL_CHARSET; $this->mail_ical_parser->import($this->ical_message->get_part_body($mime_id, true), $charset); + // check if the parsed object is an instance of a recurring event/task + array_walk($this->mail_ical_parser->objects, 'libcalendaring::identify_recurrence_instance'); + // stop on the part that has an iTip method specified if (count($this->mail_ical_parser->objects) && $this->mail_ical_parser->method) { $this->mail_ical_parser->message_date = $this->ical_message->headers->date; @@ -1374,6 +1377,9 @@ class libcalendaring extends rcube_plugin $object['_sender'] = preg_match(self::$email_regex, $headers->from, $m) ? $m[1] : ''; $object['_sender_utf'] = rcube_utils::idn_to_utf8($object['_sender']); + // check if this is an instance of a recurring event/task + self::identify_recurrence_instance($object); + return $object; } @@ -1395,6 +1401,30 @@ class libcalendaring extends rcube_plugin ); } + /** + * Single occourrences of recurring events are identified by their RECURRENCE-ID property + * in iCal which is represented as 'recurrence_date' in our internal data structure. + * + * Check if such a property exists and derive the '_instance' identifier and '_savemode' + * attributes which are used in the storage backend to identify the nested exception item. + */ + public static function identify_recurrence_instance(&$object) + { + // set instance and 'savemode' according to recurrence-id + if (!empty($object['recurrence_date']) && is_a($object['recurrence_date'], 'DateTime')) { + $recurrence_id_format = $object['allday'] ? 'Ymd' : 'Ymd\THis'; + $object['_instance'] = $object['recurrence_date']->format($recurrence_id_format); + $object['_savemode'] = $event['thisandfuture'] ? 'future' : 'current'; + } + else if (!empty($object['recurrence_id']) || !empty($object['_instance'])) { + if (strlen($object['_instance']) > 4) { + $object['recurrence_date'] = rcube_utils::anytodatetime($object['_instance'], $object['start']->getTimezone()); + } + else { + $object['recurrence_date'] = clone $object['start']; + } + } + } /********* Attendee handling functions *********/ diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php index 4163cfbe..10c2223e 100644 --- a/plugins/libcalendaring/libvcalendar.php +++ b/plugins/libcalendaring/libvcalendar.php @@ -948,6 +948,13 @@ class libvcalendar implements Iterator if (!empty($event['due'])) $ve->add($this->datetime_prop('DUE', $event['due'], false)); + // we're exporting a recurrence instance only + if (!$recurrence_id && $event['recurrence_date'] && $event['recurrence_date'] instanceof DateTime) { + $recurrence_id = $this->datetime_prop('RECURRENCE-ID', $event['recurrence_date'], false, (bool)$event['allday']); + if ($event['thisandfuture']) + $recurrence_id->add('RANGE', 'THISANDFUTURE'); + } + if ($recurrence_id) $ve->add($recurrence_id); diff --git a/plugins/libcalendaring/localization/en_US.inc b/plugins/libcalendaring/localization/en_US.inc index 3e49838b..31f08fd0 100644 --- a/plugins/libcalendaring/localization/en_US.inc +++ b/plugins/libcalendaring/localization/en_US.inc @@ -108,6 +108,12 @@ $labels['acceptinvitation'] = 'Do you accept this invitation?'; $labels['acceptattendee'] = 'Accept participant'; $labels['declineattendee'] = 'Decline participant'; $labels['declineattendeeconfirm'] = 'Enter a message to the declined participant (optional):'; +$labels['rsvprecurringevent'] = 'This is a series of events! Does your response apply to all, this occurrence only or this and future occurrences?'; + +$labels['itipsingleoccurrence'] = 'This is a single occurrence out of a series of events'; +$labels['itiprequestoccurrenceonly'] = 'The invitation only refers to this single occurrence'; +$labels['itipreplyoccurrenceonly'] = 'The response only refers to this single occurrence'; +$labels['itipcanceloccurrenceonly'] = 'The cancellation only refers to this single occurrence'; $labels['youhaveaccepted'] = 'You have accepted this invitation'; $labels['youhavetentative'] = 'You have tentatively accepted this invitation'; diff --git a/plugins/libkolab/lib/kolab_date_recurrence.php b/plugins/libkolab/lib/kolab_date_recurrence.php index 06dd3316..b2511f2b 100644 --- a/plugins/libkolab/lib/kolab_date_recurrence.php +++ b/plugins/libkolab/lib/kolab_date_recurrence.php @@ -87,9 +87,13 @@ class kolab_date_recurrence $next_end->add($this->duration); $next = $this->object->to_array(); - $next['recurrence_id'] = $next_start->format('Y-m-d'); $next['start'] = $next_start; $next['end'] = $next_end; + + $recurrence_id_format = $next['allday'] ? 'Ymd' : 'Ymd\THis'; + $next['recurrence_date'] = clone $next_start; + $next['_instance'] = $next_start->format($recurrence_id_format); + unset($next['_formatobj']); return $next; diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php index 075c5178..bf171496 100644 --- a/plugins/libkolab/lib/kolab_format_event.php +++ b/plugins/libkolab/lib/kolab_format_event.php @@ -237,6 +237,7 @@ class kolab_format_event extends kolab_format_xcal private function compact_exception($exception, $master) { $forbidden = array('recurrence','organizer','_attachments'); + $whitelist = array('start','end'); foreach ($forbidden as $prop) { if (array_key_exists($prop, $exception)) { @@ -245,7 +246,7 @@ class kolab_format_event extends kolab_format_xcal } foreach ($master as $prop => $value) { - if (isset($exception[$prop]) && gettype($exception[$prop]) == gettype($value) && $exception[$prop] == $value) { + if (isset($exception[$prop]) && gettype($exception[$prop]) == gettype($value) && $exception[$prop] == $value && !in_array($prop, $whitelist)) { unset($exception[$prop]); } } From f09948eefe6b7b1d275bc6d5465c02e8c0dbe9c2 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Sun, 15 Feb 2015 15:12:08 +0100 Subject: [PATCH 02/32] Disable recurrence and attachments forms when editing a single recurrence instance. These properties cannot be stored in recurrence exceptions --- plugins/calendar/calendar_ui.js | 13 +++++++++++-- plugins/calendar/drivers/kolab/kolab_driver.php | 3 ++- plugins/calendar/skins/larry/calendar.css | 4 ++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js index 1f9f399f..f9e0d285 100644 --- a/plugins/calendar/calendar_ui.js +++ b/plugins/calendar/calendar_ui.js @@ -691,6 +691,8 @@ function rcube_calendar_ui(settings) // 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}); @@ -752,7 +754,7 @@ function rcube_calendar_ui(settings) if (event.id && event.recurrence) { var sel = event._savemode || (event.thisandfuture ? 'future' : (event.isexception ? 'current' : 'all')); $('#edit-recurring-warning').show(); - $('input.edit-recurring-savemode[value="'+sel+'"]').prop('checked', true); + $('input.edit-recurring-savemode[value="'+sel+'"]').prop('checked', true).change(); } else $('#edit-recurring-warning').hide(); @@ -803,7 +805,7 @@ function rcube_calendar_ui(settings) // attachments var load_attachments_tab = function() { - rcmail.enable_command('remove-attachment', !calendar.readonly); + rcmail.enable_command('remove-attachment', !calendar.readonly && !event.recurrence_id); rcmail.env.deleted_attachments = []; // we're sharing some code for uploads handling with app.js rcmail.env.attachments = []; @@ -4163,6 +4165,13 @@ function rcube_calendar_ui(settings) event_rsvp($(this).attr('rel')) }); + $('#eventedit input.edit-recurring-savemode').change(function(e) { + var sel = $('input.edit-recurring-savemode:checked').val(), + disabled = sel == 'current' || sel == 'future'; + $('#event-panel-recurrence input, #event-panel-recurrence select, #event-panel-attachments input').prop('disabled', disabled); + $('#event-panel-recurrence, #event-panel-attachments')[(disabled?'addClass':'removeClass')]('disabled'); + }) + $('#eventshow .changersvp').click(function(e) { var d = $('#eventshow'), h = -$(this).closest('.event-line').toggle().height(); diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index 1e3f0ea6..4b450f87 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -918,9 +918,10 @@ class kolab_driver extends calendar_driver case 'future': case 'current': - // recurring instances shall not store recurrence rules + // recurring instances shall not store recurrence rules and attachments $event['recurrence'] = array(); $event['thisandfuture'] = $savemode == 'future'; + unset($event['attachments']); // increment sequence of this instance if scheduling is affected if ($reschedule) { diff --git a/plugins/calendar/skins/larry/calendar.css b/plugins/calendar/skins/larry/calendar.css index 1c2eca5f..0fec69cb 100644 --- a/plugins/calendar/skins/larry/calendar.css +++ b/plugins/calendar/skins/larry/calendar.css @@ -676,6 +676,10 @@ a.miniColors-trigger { outline: none; } +#event-panel-attachments.disabled .attachmentslist li a.delete { + visibility: hidden; +} + .event-attendees span.attendee { padding-right: 18px; margin-right: 0.5em; From 12591358e600dd3b0ad91a8f29345354e34000ac Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Sun, 15 Feb 2015 16:33:39 +0100 Subject: [PATCH 03/32] Consider a change in recurrence rule significant for rescheduling (#4366) --- plugins/calendar/calendar.php | 8 ++-- .../calendar/drivers/kolab/kolab_driver.php | 17 +------ plugins/libkolab/config.inc.php.dist | 2 +- plugins/libkolab/lib/kolab_format_event.php | 12 ++++- plugins/libkolab/lib/kolab_format_task.php | 10 ++++ plugins/libkolab/lib/kolab_format_xcal.php | 48 ++++++++++++++----- 6 files changed, 65 insertions(+), 32 deletions(-) diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index f1e66ec8..880086de 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -993,7 +993,7 @@ class calendar extends rcube_plugin if ($success = $this->driver->edit_rsvp($event, $status)) { $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'] ? 2 : 1; + $reload = $event['calendar'] != $ev['calendar'] || $event['recurrence'] ? 2 : 1; $organizer = null; $emails = $this->get_user_emails(); @@ -1131,8 +1131,6 @@ class calendar extends rcube_plugin // make sure we have the complete record $event = $action == 'remove' ? $old : $this->driver->get_event($event); - // TODO: on change of a recurring (main) event, also send updates to differing attendess of recurrence exceptions - // 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']); @@ -1990,6 +1988,8 @@ class calendar extends rcube_plugin $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)) @@ -2215,7 +2215,7 @@ class calendar extends rcube_plugin public static function event_diff($a, $b) { $diff = array(); - $ignore = array('changed' => 1, 'attachments' => 1, 'recurrence' => 1, '_notify' => 1, '_owner' => 1); + $ignore = array('changed' => 1, 'attachments' => 1, '_notify' => 1, '_owner' => 1, '_savemode' => 1); foreach (array_unique(array_merge(array_keys($a), array_keys($b))) as $key) { if (!$ignore[$key] && $a[$key] != $b[$key]) $diff[] = $key; diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index 4b450f87..815f51ee 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -1035,26 +1035,13 @@ class kolab_driver extends calendar_driver */ public function check_scheduling(&$event, $old, $update = true) { - $reschedule = false; - // skip this check when importing iCal/iTip events if (isset($event['sequence']) || !empty($event['_method'])) { - return $reschedule; + return false; } // iterate through the list of properties considered 'significant' for scheduling - foreach (kolab_format_event::$scheduling_properties as $prop) { - $a = $old[$prop]; - $b = $event[$prop]; - if ($event['allday'] && ($prop == 'start' || $prop == 'end') && $a instanceof DateTime && $b instanceof DateTime) { - $a = $a->format('Y-m-d'); - $b = $b->format('Y-m-d'); - } - if ($a != $b) { - $reschedule = true; - break; - } - } + $reschedule = kolab_format_event::check_rescheduling($event, $old); // reset all attendee status to needs-action (#4360) if ($update && $reschedule && is_array($event['attendees'])) { diff --git a/plugins/libkolab/config.inc.php.dist b/plugins/libkolab/config.inc.php.dist index 6e4b613e..3a8476c3 100644 --- a/plugins/libkolab/config.inc.php.dist +++ b/plugins/libkolab/config.inc.php.dist @@ -41,7 +41,7 @@ $config['kolab_messages_cache_bypass'] = 0; // These event properties contribute to a significant revision to the calendar component // and if changed will increment the sequence number relevant for scheduling according to RFC 5545 -$config['kolab_event_scheduling_properties'] = array('start', 'end', 'allday', 'location', 'status', 'cancelled'); +$config['kolab_event_scheduling_properties'] = array('start', 'end', 'allday', 'recurrence', 'location', 'status', 'cancelled'); // These task properties contribute to a significant revision to the calendar component // and if changed will increment the sequence number relevant for scheduling according to RFC 5545 diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php index bf171496..f3c52df8 100644 --- a/plugins/libkolab/lib/kolab_format_event.php +++ b/plugins/libkolab/lib/kolab_format_event.php @@ -26,7 +26,7 @@ class kolab_format_event extends kolab_format_xcal { public $CTYPEv2 = 'application/x-vnd.kolab.event'; - public static $scheduling_properties = array('start', 'end', 'allday', 'location', 'status', 'cancelled'); + public static $scheduling_properties = array('start', 'end', 'allday', 'recurrence', 'location', 'status', 'cancelled'); protected $objclass = 'Event'; protected $read_func = 'readEvent'; @@ -100,6 +100,7 @@ class kolab_format_event extends kolab_format_xcal foreach((array)$object['recurrence']['EXCEPTIONS'] as $i => $exception) { $exevent = new kolab_format_event; $exevent->set(($compacted = $this->compact_exception($exception, $object))); // only save differing values + console('COMPACTED', $compacted); // get value for recurrence-id if (!empty($exception['recurrence_date']) && is_a($exception['recurrence_date'], 'DateTime')) { @@ -274,4 +275,13 @@ class kolab_format_event extends kolab_format_xcal return $exception; } + /** + * Identify changes considered relevant for scheduling + * + * @see kolab_format_xcal::check_rescheduling() + */ + public static function check_rescheduling($object, $old, $checks = null) + { + return parent::check_rescheduling($object, $old, $checks ?: self::$scheduling_properties); + } } diff --git a/plugins/libkolab/lib/kolab_format_task.php b/plugins/libkolab/lib/kolab_format_task.php index ee0ca6a9..2c0cda5e 100644 --- a/plugins/libkolab/lib/kolab_format_task.php +++ b/plugins/libkolab/lib/kolab_format_task.php @@ -126,4 +126,14 @@ class kolab_format_task extends kolab_format_xcal return $tags; } + + /** + * Identify changes considered relevant for scheduling + * + * @see kolab_format_xcal::check_rescheduling() + */ + public static function check_rescheduling($object, $old, $checks = null) + { + return parent::check_rescheduling($object, $old, $checks ?: self::$scheduling_properties); + } } diff --git a/plugins/libkolab/lib/kolab_format_xcal.php b/plugins/libkolab/lib/kolab_format_xcal.php index d0f89b63..8d751a6a 100644 --- a/plugins/libkolab/lib/kolab_format_xcal.php +++ b/plugins/libkolab/lib/kolab_format_xcal.php @@ -321,17 +321,8 @@ abstract class kolab_format_xcal extends kolab_format // increment sequence when updating properties relevant for scheduling. // RFC 5545: "It is incremented [...] each time the Organizer makes a significant revision to the calendar component." - foreach (self::$scheduling_properties as $prop) { - $a = $old[$prop]; - $b = $object[$prop]; - if ($object['allday'] && ($prop == 'start' || $prop == 'end') && $a instanceof DateTime && $b instanceof DateTime) { - $a = $a->format('Y-m-d'); - $b = $b->format('Y-m-d'); - } - if ($a != $b) { - $object['sequence']++; - break; - } + if (self::check_rescheduling($object, $old)) { + $object['sequence']++; } } } @@ -637,4 +628,39 @@ abstract class kolab_format_xcal extends kolab_format return $tags; } + + /** + * Identify changes considered relevant for scheduling + * + * @param array Hash array with NEW object properties + * @param array Hash array with OLD object properties + * @param array List of object properties to check for changes + * + * @return boolean True if changes affect scheduling, False otherwise + */ + public static function check_rescheduling($object, $old, $checks = null) + { + $reschedule = false; + + foreach ($checks ?: self::$scheduling_properties as $prop) { + $a = $old[$prop]; + $b = $object[$prop]; + if ($object['allday'] && ($prop == 'start' || $prop == 'end') && $a instanceof DateTime && $b instanceof DateTime) { + $a = $a->format('Y-m-d'); + $b = $b->format('Y-m-d'); + } + if ($prop == 'recurrence') { + unset($a['EXCEPTIONS']); + unset($b['EXCEPTIONS']); + $a = array_filter($a); + $b = array_filter($b); + } + if ($a != $b) { + $reschedule = true; + break; + } + } + + return $reschedule; + } } \ No newline at end of file From d564e23aa345ce67c7c8cabca1a324628a94fcba Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Sun, 15 Feb 2015 17:10:22 +0100 Subject: [PATCH 04/32] Use the right list of properties relevenat for scheduling (follow-up of commit 12591358). Static vars don't work here as intended --- .../calendar/drivers/kolab/kolab_driver.php | 3 ++- plugins/libkolab/lib/kolab_format_event.php | 12 +++--------- plugins/libkolab/lib/kolab_format_task.php | 19 ++++++++++--------- plugins/libkolab/lib/kolab_format_xcal.php | 14 +++++++++----- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index 815f51ee..d4a3436a 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -1041,7 +1041,8 @@ class kolab_driver extends calendar_driver } // iterate through the list of properties considered 'significant' for scheduling - $reschedule = kolab_format_event::check_rescheduling($event, $old); + $kolab_event = $old['_formatobj'] ?: new kolab_format_event(); + $reschedule = $kolab_event->check_rescheduling($event, $old); // reset all attendee status to needs-action (#4360) if ($update && $reschedule && is_array($event['attendees'])) { diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php index f3c52df8..fe10f9d6 100644 --- a/plugins/libkolab/lib/kolab_format_event.php +++ b/plugins/libkolab/lib/kolab_format_event.php @@ -44,6 +44,9 @@ class kolab_format_event extends kolab_format_xcal $this->obj = $data; $this->loaded = true; } + + // copy static property overriden by this class + $this->_scheduling_properties = self::$scheduling_properties; } /** @@ -275,13 +278,4 @@ class kolab_format_event extends kolab_format_xcal return $exception; } - /** - * Identify changes considered relevant for scheduling - * - * @see kolab_format_xcal::check_rescheduling() - */ - public static function check_rescheduling($object, $old, $checks = null) - { - return parent::check_rescheduling($object, $old, $checks ?: self::$scheduling_properties); - } } diff --git a/plugins/libkolab/lib/kolab_format_task.php b/plugins/libkolab/lib/kolab_format_task.php index 2c0cda5e..d3ddfe93 100644 --- a/plugins/libkolab/lib/kolab_format_task.php +++ b/plugins/libkolab/lib/kolab_format_task.php @@ -32,6 +32,16 @@ class kolab_format_task extends kolab_format_xcal protected $read_func = 'readTodo'; protected $write_func = 'writeTodo'; + /** + * Default constructor + */ + function __construct($data = null, $version = 3.0) + { + parent::__construct(is_string($data) ? $data : null, $version); + + // copy static property overriden by this class + $this->_scheduling_properties = self::$scheduling_properties; + } /** * Set properties to the kolabformat object @@ -127,13 +137,4 @@ class kolab_format_task extends kolab_format_xcal return $tags; } - /** - * Identify changes considered relevant for scheduling - * - * @see kolab_format_xcal::check_rescheduling() - */ - public static function check_rescheduling($object, $old, $checks = null) - { - return parent::check_rescheduling($object, $old, $checks ?: self::$scheduling_properties); - } } diff --git a/plugins/libkolab/lib/kolab_format_xcal.php b/plugins/libkolab/lib/kolab_format_xcal.php index 8d751a6a..6d49ad18 100644 --- a/plugins/libkolab/lib/kolab_format_xcal.php +++ b/plugins/libkolab/lib/kolab_format_xcal.php @@ -32,6 +32,8 @@ abstract class kolab_format_xcal extends kolab_format public static $scheduling_properties = array('start', 'end', 'location'); + protected $_scheduling_properties = null; + protected $sensitivity_map = array( 'public' => kolabformat::ClassPublic, 'private' => kolabformat::ClassPrivate, @@ -317,11 +319,10 @@ abstract class kolab_format_xcal extends kolab_format } else { $object['sequence'] = $old_sequence; - $old = $this->data['uid'] ? $this->data : $this->to_array(); // increment sequence when updating properties relevant for scheduling. // RFC 5545: "It is incremented [...] each time the Organizer makes a significant revision to the calendar component." - if (self::check_rescheduling($object, $old)) { + if ($this->check_rescheduling($object)) { $object['sequence']++; } } @@ -634,15 +635,18 @@ abstract class kolab_format_xcal extends kolab_format * * @param array Hash array with NEW object properties * @param array Hash array with OLD object properties - * @param array List of object properties to check for changes * * @return boolean True if changes affect scheduling, False otherwise */ - public static function check_rescheduling($object, $old, $checks = null) + public function check_rescheduling($object, $old = null) { $reschedule = false; - foreach ($checks ?: self::$scheduling_properties as $prop) { + if (!is_array($old)) { + $old = $this->data['uid'] ? $this->data : $this->to_array(); + } + + foreach ($this->_scheduling_properties ?: self::$scheduling_properties as $prop) { $a = $old[$prop]; $b = $object[$prop]; if ($object['allday'] && ($prop == 'start' || $prop == 'end') && $a instanceof DateTime && $b instanceof DateTime) { From 26381f82a775da9f8876fbcd6a189224d70e12df Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Sun, 15 Feb 2015 17:21:30 +0100 Subject: [PATCH 05/32] Send iTip notifications for main event of savemode is 'all' --- plugins/calendar/calendar.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index 880086de..f61b6813 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -846,9 +846,16 @@ class calendar extends rcube_plugin $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 when savemode is 'all' + if ($event['_savemode'] == 'all' && $old['recurrence_id']) { + $old['id'] = $old['recurrence_id']; + $old = $this->driver->get_event($old); + } + } + switch ($action) { case "new": // create UID for new event @@ -1128,9 +1135,17 @@ class calendar extends rcube_plugin // send out notifications if ($success && $event['_notify'] && ($event['attendees'] || $old['attendees'])) { + $_savemode = $event['_savemode']; + // make sure we have the complete record $event = $action == 'remove' ? $old : $this->driver->get_event($event); + // send notification for the main event when savemode is 'all' + if ($_savemode == 'all' && $event['recurrence_id']) { + $event['id'] = $event['recurrence_id']; + $event = $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, $event['_comment']); From 108fae9dd038c9756f6edfaff7ed9f39e4200d7d Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Sun, 15 Feb 2015 18:27:38 +0100 Subject: [PATCH 06/32] Correctly save 'this-and-future' replies; remove some internal properties before saving (to cache) --- plugins/calendar/drivers/kolab/kolab_driver.php | 11 ++++++----- plugins/libcalendaring/libcalendaring.php | 2 +- plugins/libkolab/lib/kolab_format_event.php | 1 - 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index d4a3436a..e414b871 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -899,12 +899,16 @@ class kolab_driver extends calendar_driver else if ($old['recurrence']['EXCEPTIONS']) $event['recurrence']['EXCEPTIONS'] = $old['recurrence']['EXCEPTIONS']; + // remove some internal properties which should not be saved + unset($event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_owner'], + $event['_notify'], $event['_method'], $event['_sender'], $event['_sender_utf'], $event['_size']); + switch ($savemode) { case 'new': // save submitted data as new (non-recurring) event $event['recurrence'] = array(); $event['uid'] = $this->cal->generate_uid(); - unset($event['recurrence_id'], $event['id'], $event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_notify']); + unset($event['recurrence_id'], $event['_instance'], $event['id']); // copy attachment data to new event foreach ((array)$event['attachments'] as $idx => $attachment) { @@ -921,16 +925,13 @@ class kolab_driver extends calendar_driver // recurring instances shall not store recurrence rules and attachments $event['recurrence'] = array(); $event['thisandfuture'] = $savemode == 'future'; - unset($event['attachments']); + unset($event['attachments'], $event['id']); // increment sequence of this instance if scheduling is affected if ($reschedule) { $event['sequence'] = max($old['sequence'], $master['sequence']) + 1; } - // remove some internal properties which should not be saved - unset($event['id'], $event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_notify']); - // save properties to a recurrence exception instance if ($old['recurrence_id'] && is_array($master['recurrence']['EXCEPTIONS'])) { foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php index c3bf6256..63a05488 100644 --- a/plugins/libcalendaring/libcalendaring.php +++ b/plugins/libcalendaring/libcalendaring.php @@ -1414,7 +1414,7 @@ class libcalendaring extends rcube_plugin if (!empty($object['recurrence_date']) && is_a($object['recurrence_date'], 'DateTime')) { $recurrence_id_format = $object['allday'] ? 'Ymd' : 'Ymd\THis'; $object['_instance'] = $object['recurrence_date']->format($recurrence_id_format); - $object['_savemode'] = $event['thisandfuture'] ? 'future' : 'current'; + $object['_savemode'] = $object['thisandfuture'] ? 'future' : 'current'; } else if (!empty($object['recurrence_id']) || !empty($object['_instance'])) { if (strlen($object['_instance']) > 4) { diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php index fe10f9d6..a5b0f737 100644 --- a/plugins/libkolab/lib/kolab_format_event.php +++ b/plugins/libkolab/lib/kolab_format_event.php @@ -103,7 +103,6 @@ class kolab_format_event extends kolab_format_xcal foreach((array)$object['recurrence']['EXCEPTIONS'] as $i => $exception) { $exevent = new kolab_format_event; $exevent->set(($compacted = $this->compact_exception($exception, $object))); // only save differing values - console('COMPACTED', $compacted); // get value for recurrence-id if (!empty($exception['recurrence_date']) && is_a($exception['recurrence_date'], 'DateTime')) { From dbdce67e1e0c6675dc4bbaaa0d9056189328e1f0 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Sun, 15 Feb 2015 18:39:28 +0100 Subject: [PATCH 07/32] Better distinction of 'current' and 'future' itip messages in UI and message text --- plugins/libcalendaring/lib/libcalendaring_itip.php | 4 ++-- plugins/libcalendaring/localization/en_US.inc | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php index 53284a4e..f56463c3 100644 --- a/plugins/libcalendaring/lib/libcalendaring_itip.php +++ b/plugins/libcalendaring/lib/libcalendaring_itip.php @@ -125,7 +125,7 @@ class libcalendaring_itip $recurrence_info = ''; if (!empty($event['recurrence_id'])) { - $recurrence_info = "\n\n** " . $this->gettext('itip'.strtolower($method).'occurrenceonly') . ' **'; + $recurrence_info = "\n\n** " . $this->gettext($event['thisandfuture'] ? 'itipmessagefutureoccurrence' : 'itipmessagesingleoccurrence') . ' **'; } else if (!empty($event['recurrence'])) { $recurrence_info = sprintf("\n%s: %s", $this->gettext('recurring'), $this->lib->recurrence_text($event['recurrence'])); @@ -742,7 +742,7 @@ class libcalendaring_itip } if (!empty($event['recurrence_date'])) { $table->add('label', ''); - $table->add('recurrence-id', $this->gettext('itipsingleoccurrence')); + $table->add('recurrence-id', $this->gettext($event['thisandfuture'] ? 'itipfutureoccurrence' : 'itipsingleoccurrence')); } else if (!empty($event['recurrence'])) { $table->add('label', $this->gettext('recurring')); diff --git a/plugins/libcalendaring/localization/en_US.inc b/plugins/libcalendaring/localization/en_US.inc index 31f08fd0..992113ad 100644 --- a/plugins/libcalendaring/localization/en_US.inc +++ b/plugins/libcalendaring/localization/en_US.inc @@ -111,9 +111,9 @@ $labels['declineattendeeconfirm'] = 'Enter a message to the declined participant $labels['rsvprecurringevent'] = 'This is a series of events! Does your response apply to all, this occurrence only or this and future occurrences?'; $labels['itipsingleoccurrence'] = 'This is a single occurrence out of a series of events'; -$labels['itiprequestoccurrenceonly'] = 'The invitation only refers to this single occurrence'; -$labels['itipreplyoccurrenceonly'] = 'The response only refers to this single occurrence'; -$labels['itipcanceloccurrenceonly'] = 'The cancellation only refers to this single occurrence'; +$labels['itipfutureoccurrence'] = 'Refers to this and all future occurrences of a series of events'; +$labels['itipmessagesingleoccurrence'] = 'The message only refers to this single occurrence'; +$labels['itipmessagefutureoccurrence'] = 'The message refers to this and all future occurrences'; $labels['youhaveaccepted'] = 'You have accepted this invitation'; $labels['youhavetentative'] = 'You have tentatively accepted this invitation'; From 8a74dc2d28aba4af89d123228fb638e4b4999dbd Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Sun, 15 Feb 2015 19:09:10 +0100 Subject: [PATCH 08/32] Don't copy recurrence_date to future occurrences --- plugins/calendar/calendar.php | 1 + plugins/calendar/drivers/kolab/kolab_calendar.php | 2 +- plugins/calendar/drivers/kolab/kolab_driver.php | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index f61b6813..7682ba0a 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -1676,6 +1676,7 @@ class calendar extends rcube_plugin if ($event['recurrence']) { $event['recurrence_text'] = $this->lib->recurrence_text($event['recurrence']); $event['recurrence'] = $this->lib->to_client_recurrence($event['recurrence'], $event['allday']); + unset($event['recurrence_date']); } foreach ((array)$event['attachments'] as $k => $attachment) { diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php index 15f030db..06a82448 100644 --- a/plugins/calendar/drivers/kolab/kolab_calendar.php +++ b/plugins/calendar/drivers/kolab/kolab_calendar.php @@ -661,7 +661,7 @@ class kolab_calendar extends kolab_storage_folder_api */ private function _merge_event_data(&$event, $overlay) { - static $forbidden = array('id','uid','recurrence','organizer','_attachments'); + static $forbidden = array('id','uid','recurrence','recurrence_date','organizer','_attachments'); foreach ($overlay as $prop => $value) { // adjust time of the recurring event instance diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index e414b871..0cd962df 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -962,6 +962,7 @@ class kolab_driver extends calendar_driver // save as new exception to master event if ($add_exception) { $event['_instance'] = $old['_instance']; + $event['recurrence_date'] = $old['recurrence_date']; $master['recurrence']['EXCEPTIONS'][] = $event; } $success = $storage->update_event($master); From aaaa9c58185d96a120893f2f3e3c361d44309e8a Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Mon, 16 Feb 2015 11:00:26 +0100 Subject: [PATCH 09/32] Take differing parstat values in recurrence exceptions into account when querying for pending/declined/regular events: - Colelct partstat tags from recurrence exceptions when caching - Querying for 'tags != x-partstat::needs-action' may miss some valid records - Do post-filtering on all events, including recurring instances --- plugins/calendar/drivers/calendar_driver.php | 7 +- .../calendar/drivers/kolab/kolab_calendar.php | 82 +++++++++---------- plugins/libkolab/lib/kolab_format_event.php | 9 +- plugins/libkolab/lib/kolab_format_task.php | 15 ++-- plugins/libkolab/lib/kolab_format_xcal.php | 18 ++-- 5 files changed, 70 insertions(+), 61 deletions(-) diff --git a/plugins/calendar/drivers/calendar_driver.php b/plugins/calendar/drivers/calendar_driver.php index e402db9a..742830b6 100644 --- a/plugins/calendar/drivers/calendar_driver.php +++ b/plugins/calendar/drivers/calendar_driver.php @@ -450,6 +450,7 @@ abstract class calendar_driver $rcmail = rcmail::get_instance(); $recurrence = new calendar_recurrence($rcmail->plugins->get_plugin('calendar'), $event); + $recurrence_id_format = $event['allday'] ? 'Ymd' : 'Ymd\THis'; // determine a reasonable end date if none given if (!$end) { @@ -465,10 +466,10 @@ abstract class calendar_driver $i = 0; while ($next_event = $recurrence->next_instance()) { - $next_event['uid'] = $event['uid'] . '-' . ++$i; // add to output if in range if (($next_event['start'] <= $end && $next_event['end'] >= $start)) { - $next_event['id'] = $next_event['uid']; + $next_event['_instance'] = $next_event['start']->format($recurrence_id_format); + $next_event['id'] = $next_event['uid'] . '-' . $exception['_instance']; $next_event['recurrence_id'] = $event['uid']; $events[] = $next_event; } @@ -477,7 +478,7 @@ abstract class calendar_driver } // avoid endless recursion loops - if ($i > 1000) { + if (++$i > 1000) { break; } } diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php index 06a82448..b5b272fd 100644 --- a/plugins/calendar/drivers/kolab/kolab_calendar.php +++ b/plugins/calendar/drivers/kolab/kolab_calendar.php @@ -236,35 +236,31 @@ class kolab_calendar extends kolab_storage_folder_api $query[] = array('dtstart', '<=', $end); $query[] = array('dtend', '>=', $start); - // add query to exclude pending/declined invitations - if (empty($filter_query) && $this->get_namespace() != 'other') { - foreach ($user_emails as $email) { - $query[] = array('tags', '!=', 'x-partstat:' . $email . ':needs-action'); - $query[] = array('tags', '!=', 'x-partstat:' . $email . ':declined'); - } - } - else if (is_array($filter_query)) { + if (is_array($filter_query)) { $query = array_merge($query, $filter_query); } if (!empty($search)) { $search = mb_strtolower($search); + $words = rcube_utils::tokenize_string($search, 1); foreach (rcube_utils::normalize_string($search, true) as $word) { $query[] = array('words', 'LIKE', $word); } } + else { + $words = array(); + } + + // set partstat filter to skip pending and declined invitations + if (empty($filter_query) && $this->get_namespace() != 'other') { + $partstat_exclude = array('NEEDS-ACTION','DECLINED'); + } + else { + $partstat_exclude = array(); + } $events = array(); foreach ($this->storage->select($query) as $record) { - // post-filter events to skip pending and declined invitations - if (empty($filter_query) && is_array($record['attendees']) && $this->get_namespace() != 'other') { - foreach ($record['attendees'] as $attendee) { - if (in_array($attendee['email'], $user_emails) && in_array($attendee['status'], array('NEEDS-ACTION','DECLINED'))) { - continue 2; - } - } - } - $event = $this->_to_rcube_event($record); $this->events[$event['id']] = $event; @@ -272,18 +268,6 @@ class kolab_calendar extends kolab_storage_folder_api if ($event['categories']) $this->categories[$event['categories']]++; - // filter events by search query - if (!empty($search)) { - $hits = 0; - $words = rcube_utils::tokenize_string($search, 1); - foreach ($words as $word) { - $hits += $this->_fulltext_match($event, $word); - } - - if ($hits < count($words)) // skip this event if not match with search term - continue; - } - // list events in requested time window if ($event['start'] <= $end && $event['end'] >= $start) { unset($event['_attendees']); @@ -312,25 +296,39 @@ class kolab_calendar extends kolab_storage_folder_api if ($add) $events[] = $event; } - + // resolve recurring events if ($record['recurrence'] && $virtual == 1) { $events = array_merge($events, $this->get_recurring_events($record, $start, $end)); - - // when searching, only recurrence exceptions may match the query so post-filter the results again - if (!empty($search) && $record['recurrence']['EXCEPTIONS']) { - $me = $this; - $events = array_filter($events, function($event) use ($words, $me) { - $hits = 0; - foreach ($words as $word) { - $hits += $me->_fulltext_match($event, $word, false); - } - return $hits >= count($words); - }); - } } } + // post-filter all events by fulltext search and partstat values + $me = $this; + $events = array_filter($events, function($event) use ($words, $partstat_exclude, $user_emails, $me) { + // fulltext search + if (count($words)) { + $hits = 0; + foreach ($words as $word) { + $hits += $me->_fulltext_match($event, $word, false); + } + if ($hits < count($words)) { + return false; + } + } + + // partstat filter + if (count($partstat_exclude) && is_array($event['attendees'])) { + foreach ($event['attendees'] as $attendee) { + if (in_array($attendee['email'], $user_emails) && in_array($attendee['status'], $partstat_exclude)) { + return false; + } + } + } + + return true; + }); + // avoid session race conditions that will loose temporary subscriptions $this->cal->rc->session->nowrite = true; diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php index a5b0f737..0fda1e39 100644 --- a/plugins/libkolab/lib/kolab_format_event.php +++ b/plugins/libkolab/lib/kolab_format_event.php @@ -223,15 +223,16 @@ class kolab_format_event extends kolab_format_xcal * * @return array List of tags to save in cache */ - public function get_tags() + public function get_tags($obj = null) { - $tags = parent::get_tags(); + $tags = parent::get_tags($obj); + $object = $obj ?: $this->data; - foreach ((array)$this->data['categories'] as $cat) { + foreach ((array)$object['categories'] as $cat) { $tags[] = rcube_utils::normalize_string($cat); } - return $tags; + return array_unique($tags); } /** diff --git a/plugins/libkolab/lib/kolab_format_task.php b/plugins/libkolab/lib/kolab_format_task.php index d3ddfe93..46408754 100644 --- a/plugins/libkolab/lib/kolab_format_task.php +++ b/plugins/libkolab/lib/kolab_format_task.php @@ -121,20 +121,21 @@ class kolab_format_task extends kolab_format_xcal * * @return array List of tags to save in cache */ - public function get_tags() + public function get_tags($obj = null) { - $tags = parent::get_tags(); + $tags = parent::get_tags($obj); + $object = $obj ?: $this->data; - if ($this->data['status'] == 'COMPLETED' || ($this->data['complete'] == 100 && empty($this->data['status']))) + if ($object['status'] == 'COMPLETED' || ($object['complete'] == 100 && empty($object['status']))) $tags[] = 'x-complete'; - if ($this->data['priority'] == 1) + if ($object['priority'] == 1) $tags[] = 'x-flagged'; - if ($this->data['parent_id']) - $tags[] = 'x-parent:' . $this->data['parent_id']; + if ($object['parent_id']) + $tags[] = 'x-parent:' . $object['parent_id']; - return $tags; + return array_unique($tags); } } diff --git a/plugins/libkolab/lib/kolab_format_xcal.php b/plugins/libkolab/lib/kolab_format_xcal.php index 6d49ad18..3d7bc274 100644 --- a/plugins/libkolab/lib/kolab_format_xcal.php +++ b/plugins/libkolab/lib/kolab_format_xcal.php @@ -611,23 +611,31 @@ abstract class kolab_format_xcal extends kolab_format * * @return array List of tags to save in cache */ - public function get_tags() + public function get_tags($obj = null) { $tags = array(); + $object = $obj ?: $this->data; - if (!empty($this->data['valarms'])) { + if (!empty($object['valarms'])) { $tags[] = 'x-has-alarms'; } // create tags reflecting participant status - if (is_array($this->data['attendees'])) { - foreach ($this->data['attendees'] as $attendee) { + if (is_array($object['attendees'])) { + foreach ($object['attendees'] as $attendee) { if (!empty($attendee['email']) && !empty($attendee['status'])) $tags[] = 'x-partstat:' . $attendee['email'] . ':' . strtolower($attendee['status']); } } - return $tags; + // collect tags from recurrence exceptions + if (is_array($object['recurrence']) && $object['recurrence']['EXCEPTIONS']) { + foreach((array)$object['recurrence']['EXCEPTIONS'] as $exception) { + $tags = array_merge($tags, $this->get_tags($exception)); + } + } + + return array_unique($tags); } /** From d7733e7879c7c16074d11ebf58515c50dddf53bb Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Mon, 16 Feb 2015 11:06:42 +0100 Subject: [PATCH 10/32] Allow to RSVP reply on a single occurence when viewing the event in the calendar preview This will copy the main event from the iTip invitation with unchanged partstat into the user calendar and register a recurrence exception with the selected partsat and send a reply for this occurrence only. --- plugins/calendar/calendar.php | 42 ++++++++++++++++++++++++++++++--- plugins/calendar/calendar_ui.js | 1 + 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index 7682ba0a..786cd768 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -2655,6 +2655,8 @@ class calendar extends rcube_plugin $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; @@ -2747,7 +2749,7 @@ class calendar extends rcube_plugin if ($existing) { // forward savemode for correct updates of recurring events - $existing['_savemode'] = $event['_savemode']; + $existing['_savemode'] = $savemode ?: $event['_savemode']; // only update attendee status if ($event['_method'] == 'REPLY') { @@ -2850,9 +2852,43 @@ class calendar extends rcube_plugin 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) && $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 - $event['calendar'] = $calendar['id']; - $success = $this->driver->new_event($event); + if (!$master) { + $event['calendar'] = $calendar['id']; + $success = $this->driver->new_event($event); + } } else if ($status == 'declined') $error_msg = null; diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js index f9e0d285..ec83d1b0 100644 --- a/plugins/calendar/calendar_ui.js +++ b/plugins/calendar/calendar_ui.js @@ -2434,6 +2434,7 @@ function rcube_calendar_ui(settings) _rsvp: (delegate && delegate.rsvp) ? 1 : 0, _noreply: noreply, _comment: submit_data.comment, + _instance: submit_data._instance, _savemode: submit_data._savemode }); } From fe64e05e48c11b809bde1a32161dcdce3e1e2ea3 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Mon, 16 Feb 2015 15:36:25 +0100 Subject: [PATCH 11/32] Render a menu to select the RSVP mode for recurring events instead of using radio buttons --- plugins/calendar/calendar_ui.js | 33 ++++++++++++++----- plugins/calendar/skins/larry/calendar.css | 20 ++--------- .../lib/libcalendaring_itip.php | 12 +++---- plugins/libcalendaring/libcalendaring.js | 33 +++++++++++++++++++ plugins/libcalendaring/localization/en_US.inc | 4 ++- 5 files changed, 67 insertions(+), 35 deletions(-) diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js index ec83d1b0..fcd360ae 100644 --- a/plugins/calendar/calendar_ui.js +++ b/plugins/calendar/calendar_ui.js @@ -557,11 +557,11 @@ function rcube_calendar_ui(settings) if (event.recurrence && event.id) { var sel = event._savemode || (event.thisandfuture ? 'future' : (event.isexception ? 'current' : 'all')); - $('#event-rsvp input.rsvp-replymode[value="'+sel+'"]').prop('checked', true); - $('#event-rsvp .rsvp-replymode-message').show(); + $('#event-rsvp .rsvp-buttons').addClass('recurring'); + } + else { + $('#event-rsvp .rsvp-buttons').removeClass('recurring'); } - else - $('#event-rsvp .rsvp-replymode-message').hide(); } var buttons = []; @@ -2378,14 +2378,31 @@ function rcube_calendar_ui(settings) } // when the user accepts or declines an event invitation - var event_rsvp = function(response, delegate) + 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); + event_rsvp('delegated', data, replymode); }); return; } @@ -2419,7 +2436,7 @@ function rcube_calendar_ui(settings) } // submit status change to server - var submit_data = $.extend({}, me.selected_event, { source:null, comment:$('#reply-comment-event-rsvp').val(), _savemode: $('input.rsvp-replymode:checked').val() }, (delegate || {})), + 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) @@ -4163,7 +4180,7 @@ function rcube_calendar_ui(settings) }); $('#event-rsvp input.button').click(function(e) { - event_rsvp($(this).attr('rel')) + event_rsvp(this) }); $('#eventedit input.edit-recurring-savemode').change(function(e) { diff --git a/plugins/calendar/skins/larry/calendar.css b/plugins/calendar/skins/larry/calendar.css index 0fec69cb..fef16bd8 100644 --- a/plugins/calendar/skins/larry/calendar.css +++ b/plugins/calendar/skins/larry/calendar.css @@ -1063,24 +1063,8 @@ td.topalign { text-align: center; } -.event-dialog-message .rsvp-replymode-message { - margin-top: 0.8em; - margin-bottom: 0.6em; -} - -.event-dialog-message .rsvp-replymode-message .replymode-select { - padding-left: 22px; -} - -.event-dialog-message .rsvp-replymode-message label { - color: inherit; - margin-right: 0.4em; - white-space: nowrap; - min-width: 4em; -} - -.event-dialog-message .rsvp-replymode-message input.rsvp-replymode { - margin-right: 0.4em; +.libcal-rsvp-replymode li a { + cursor: default; } #event-rsvp, diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php index f56463c3..2eec27c8 100644 --- a/plugins/libcalendaring/lib/libcalendaring_itip.php +++ b/plugins/libcalendaring/lib/libcalendaring_itip.php @@ -677,20 +677,16 @@ class libcalendaring_itip } } + foreach (array('all','current','future') as $mode) { + $this->rc->output->command('add_label', "rsvpmode$mode", $this->gettext("rsvpmode$mode")); + } + $savemode_radio = new html_radiobutton(array('name' => '_rsvpmode', 'class' => 'rsvp-replymode')); return html::div($attrib, html::div('label', $this->gettext('acceptinvitation')) . html::div('rsvp-buttons', $buttons . - html::div(array('class' => 'rsvp-replymode-message', 'style' => 'display:none'), - html::div('message', html::span('ui-icon ui-icon-alert', '') . $this->gettext('rsvprecurringevent')) . - html::div('replymode-select', - html::label(null, $savemode_radio->show('all', array('value' => 'all')) . $this->gettext('allevents')) . - html::label(null, $savemode_radio->show(null, array('value' => 'current')) . $this->gettext('currentevent')) . - html::label(null, $savemode_radio->show(null, array('value' => 'future')) . $this->gettext('futurevents')) - ) - ) . html::div('itip-reply-controls', $this->itip_rsvp_options_ui($attrib['id'])) ) ); diff --git a/plugins/libcalendaring/libcalendaring.js b/plugins/libcalendaring/libcalendaring.js index a13ebf7e..cd59f66a 100644 --- a/plugins/libcalendaring/libcalendaring.js +++ b/plugins/libcalendaring/libcalendaring.js @@ -958,6 +958,39 @@ rcube_libcalendaring.itip_delegate_dialog = function(callback, selector) return dialog; }; +/** + * Show a menu for selecting the RSVP reply mode + */ +rcube_libcalendaring.itip_rsvp_recurring = function(btn, callback) +{ + var mnu = $('
    ').addClass('popupmenu libcal-rsvp-replymode'); + + $.each(['all','current','future'], function(i, mode) { + $('
  • ' + rcmail.get_label('rsvpmode'+mode, 'libcalendaring') + '') + .attr('rel', mode) + .appendTo(mnu); + }); + + var action = btn.attr('rel'); + + // open the mennu + mnu.menu({ + select: function(event, ui) { + callback(action, ui.item.attr('rel')); + } + }) + .appendTo(document.body) + .position({ my: 'left top', at: 'left bottom+2', of: btn }) + .data('action', action); + + setTimeout(function() { + $(document).one('click', function() { + mnu.menu('destroy'); + mnu.remove(); + }); + }, 100); +}; + /** * */ diff --git a/plugins/libcalendaring/localization/en_US.inc b/plugins/libcalendaring/localization/en_US.inc index 992113ad..ca7d1fd5 100644 --- a/plugins/libcalendaring/localization/en_US.inc +++ b/plugins/libcalendaring/localization/en_US.inc @@ -108,7 +108,9 @@ $labels['acceptinvitation'] = 'Do you accept this invitation?'; $labels['acceptattendee'] = 'Accept participant'; $labels['declineattendee'] = 'Decline participant'; $labels['declineattendeeconfirm'] = 'Enter a message to the declined participant (optional):'; -$labels['rsvprecurringevent'] = 'This is a series of events! Does your response apply to all, this occurrence only or this and future occurrences?'; +$labels['rsvpmodeall'] = 'The entire series'; +$labels['rsvpmodecurrent'] = 'This occurrence'; +$labels['rsvpmodefuture'] = 'This and future occurrences'; $labels['itipsingleoccurrence'] = 'This is a single occurrence out of a series of events'; $labels['itipfutureoccurrence'] = 'Refers to this and all future occurrences of a series of events'; From 6a5a8148348d0eddb452ff33affe4af87e9359cc Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Mon, 16 Feb 2015 15:59:50 +0100 Subject: [PATCH 12/32] Don't remove properties from exceptions which are equal to the master event. KE17 says: A recurrence exception SHALL copy ALL properties of the base event, and adjust as required, and it SHALL NOT be applied on top of the orginial event properties (The exception replaces the complete original event definition for the specific occurrence). --- plugins/libkolab/lib/kolab_format_event.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php index 0fda1e39..979b33b4 100644 --- a/plugins/libkolab/lib/kolab_format_event.php +++ b/plugins/libkolab/lib/kolab_format_event.php @@ -241,7 +241,6 @@ class kolab_format_event extends kolab_format_xcal private function compact_exception($exception, $master) { $forbidden = array('recurrence','organizer','_attachments'); - $whitelist = array('start','end'); foreach ($forbidden as $prop) { if (array_key_exists($prop, $exception)) { @@ -249,12 +248,6 @@ class kolab_format_event extends kolab_format_xcal } } - foreach ($master as $prop => $value) { - if (isset($exception[$prop]) && gettype($exception[$prop]) == gettype($value) && $exception[$prop] == $value && !in_array($prop, $whitelist)) { - unset($exception[$prop]); - } - } - // preserve this property for date serialization $exception['allday'] = $master['allday']; From 7fd2eb873dbeed5fb955e459b20e1134c09892b0 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Mon, 16 Feb 2015 18:45:25 +0100 Subject: [PATCH 13/32] Hide RSVP-mode menu when moving/resizing event dialog --- plugins/calendar/calendar_ui.js | 9 ++++++--- plugins/libcalendaring/libcalendaring.js | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js index fcd360ae..c9334ec6 100644 --- a/plugins/calendar/calendar_ui.js +++ b/plugins/calendar/calendar_ui.js @@ -604,13 +604,16 @@ function rcube_calendar_ui(settings) }, close: function() { $dialog.dialog('destroy').attr('aria-hidden', 'true').hide(); - rcmail.command('menu-close','eventoptionsmenu') + rcmail.command('menu-close','eventoptionsmenu'); + $('.libcal-rsvp-replymode').hide(); }, dragStart: function() { - rcmail.command('menu-close','eventoptionsmenu') + rcmail.command('menu-close','eventoptionsmenu'); + $('.libcal-rsvp-replymode').hide(); }, resizeStart: function() { - rcmail.command('menu-close','eventoptionsmenu') + rcmail.command('menu-close','eventoptionsmenu'); + $('.libcal-rsvp-replymode').hide(); }, buttons: buttons, minWidth: 320, diff --git a/plugins/libcalendaring/libcalendaring.js b/plugins/libcalendaring/libcalendaring.js index cd59f66a..71c101da 100644 --- a/plugins/libcalendaring/libcalendaring.js +++ b/plugins/libcalendaring/libcalendaring.js @@ -967,6 +967,7 @@ rcube_libcalendaring.itip_rsvp_recurring = function(btn, callback) $.each(['all','current','future'], function(i, mode) { $('
  • ' + rcmail.get_label('rsvpmode'+mode, 'libcalendaring') + '') + .addClass('ui-menu-item') .attr('rel', mode) .appendTo(mnu); }); From 8a90069071821f4cf2cdac6d7fce82cf12a32c11 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Tue, 17 Feb 2015 11:36:01 +0100 Subject: [PATCH 14/32] - Support exceptions and iTip messages with thisansfuture range - Store two exceptions for the same occurence if necessary (with differing range) - Update attendee status from iTip REPLY to all exceptions stored for the event - Correctly handle exceptions on the first instance (main event) --- plugins/calendar/calendar.php | 27 ++- plugins/calendar/drivers/calendar_driver.php | 12 ++ .../calendar/drivers/kolab/kolab_calendar.php | 71 +++---- .../calendar/drivers/kolab/kolab_driver.php | 182 ++++++++++++++++-- plugins/libcalendaring/libcalendaring.php | 6 +- plugins/libcalendaring/localization/en_US.inc | 2 +- 6 files changed, 239 insertions(+), 61 deletions(-) diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index 786cd768..8e6a1a3c 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -863,6 +863,7 @@ class calendar extends rcube_plugin $this->write_preprocess($event, $action); if ($success = $this->driver->new_event($event)) { $event['id'] = $event['uid']; + $event['_savemode'] = 'all'; $this->cleanup_event($event); } $reload = $success && $event['recurrence'] ? 2 : 1; @@ -1017,11 +1018,18 @@ class calendar extends rcube_plugin $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; @@ -1139,11 +1147,13 @@ class calendar extends rcube_plugin // make sure we have the complete record $event = $action == 'remove' ? $old : $this->driver->get_event($event); + $event['_savemode'] = $_savemode; // send notification for the main event when savemode is 'all' if ($_savemode == 'all' && $event['recurrence_id']) { $event['id'] = $event['recurrence_id']; $event = $this->driver->get_event($event); + unset($event['_instance'], $event['recurrence_date']); } // only notify if data really changed (TODO: do diff check on client already) @@ -2763,9 +2773,11 @@ class calendar extends rcube_plugin } } $event_attendee = null; + $update_attendees = array(); foreach ($event['attendees'] as $attendee) { if ($event['_sender'] && ($attendee['email'] == $event['_sender'] || $attendee['email'] == $event['_sender_utf'])) { $event_attendee = $attendee; + $update_attendees[] = $attendee; $metadata['fallback'] = $attendee['status']; $metadata['attendee'] = $attendee['email']; $metadata['rsvp'] = $attendee['rsvp'] || $attendee['role'] != 'NON-PARTICIPANT'; @@ -2775,9 +2787,12 @@ class calendar extends rcube_plugin } // 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) && - (!in_array($attendee['email'], $existing_attendee_emails))) { - $existing['attendees'][] = $attendee; + (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; + } } } @@ -2794,12 +2809,12 @@ class calendar extends rcube_plugin // 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['sequence'] >= $existing['sequence'] || $event['changed'] >= $existing['changed']) && $event_attendee) { $existing['attendees'][] = $event_attendee; - $success = $this->driver->edit_event($existing); + $success = $this->driver->update_attendees($existing, $update_attendees); } else { $error_msg = $this->gettext('newerversionexists'); @@ -2855,7 +2870,7 @@ class calendar extends rcube_plugin // if the RSVP reply only refers to a single instance: // store unmodified master event with current instance as exception - if (!empty($instance) && $savemode != 'all') { + 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 diff --git a/plugins/calendar/drivers/calendar_driver.php b/plugins/calendar/drivers/calendar_driver.php index 742830b6..aecd2e17 100644 --- a/plugins/calendar/drivers/calendar_driver.php +++ b/plugins/calendar/drivers/calendar_driver.php @@ -204,6 +204,18 @@ abstract class calendar_driver return $this->edit_event($event); } + /** + * Update the participant status for the given attendee + * + * @param array Hash array with event properties + * @param array List of hash arrays each represeting an updated attendee + * @return boolean True on success, False on error + */ + public function update_attendees(&$event, $attendees) + { + return $this->edit_event($event); + } + /** * Move a single event * diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php index b5b272fd..29bc01e5 100644 --- a/plugins/calendar/drivers/kolab/kolab_calendar.php +++ b/plugins/calendar/drivers/kolab/kolab_calendar.php @@ -195,7 +195,11 @@ class kolab_calendar extends kolab_storage_folder_api if ($master_id != $id && ($record = $this->storage->get_object($master_id))) $this->events[$master_id] = $this->_to_rcube_event($record); - if (($master = $this->events[$master_id]) && $master['recurrence']) { + // check for match on the first instance already + if (($_instance = $this->events[$master_id]['_instance']) && $id == $master_id . '-' . $_instance) { + $this->events[$id] = $this->events[$master_id]; + } + else if (($master = $this->events[$master_id]) && $master['recurrence']) { $this->get_recurring_events($record, $master['start'], null, $id); } } @@ -274,17 +278,10 @@ class kolab_calendar extends kolab_storage_folder_api $add = true; // skip the first instance of a recurring event if listed in exdate - if ($virtual && (!empty($event['recurrence']['EXDATE']) || !empty($event['recurrence']['EXCEPTIONS']))) { + if ($virtual && !empty($event['recurrence']['EXDATE'])) { $event_date = $event['start']->format('Ymd'); $exdates = (array)$event['recurrence']['EXDATE']; - // add dates from exceptions to list - if (is_array($event['recurrence']['EXCEPTIONS'])) { - foreach ($event['recurrence']['EXCEPTIONS'] as $exception) { - $exdates[] = clone $exception['start']; - } - } - foreach ($exdates as $exdate) { if ($exdate->format('Ymd') == $event_date) { $add = false; @@ -293,6 +290,18 @@ class kolab_calendar extends kolab_storage_folder_api } } + // find and merge exception for the first instance + if (!empty($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS'])) { + $event_date = $event['start']->format('Ymd'); + foreach ($event['recurrence']['EXCEPTIONS'] as $exception) { + $exdate = $exception['recurrence_date'] ? $exception['recurrence_date']->format('Ymd') : substr($exception['_instance'], 0, 8); + if ($exdate == $event_date) { + $event['_instance'] = $exception['_instance']; + kolab_driver::merge_event_data($event, $exception); + } + } + } + if ($add) $events[] = $event; } @@ -583,24 +592,29 @@ class kolab_calendar extends kolab_storage_folder_api $rec_event['id'] = $event['uid'] . '-' . $exception['_instance']; $rec_event['isexception'] = 1; - // found the specifically requested instance, exiting... - if ($rec_event['id'] == $event_id) { + // found the specifically requested instance: register exception (single occurrence wins) + if ($rec_event['id'] == $event_id && (!$this->events[$event_id] || $this->events[$event_id]['thisandfuture'])) { $rec_event['recurrence'] = $recurrence_rule; $rec_event['recurrence_id'] = $event['uid']; - $events[] = $rec_event; $this->events[$rec_event['id']] = $rec_event; - return $events; } // remember this exception's date $exdate = substr($exception['_instance'], 0, 8); - $exdata[$exdate] = $rec_event; + if (!$exdata[$exdate] || $exdata[$exdate]['thisandfuture']) { + $exdata[$exdate] = $rec_event; + } if ($rec_event['thisandfuture']) { $futuredata[$exdate] = $rec_event; } } } + // found the specifically requested instance, exiting... + if ($event_id && !empty($this->events[$event_id])) { + return array($this->events[$event_id]); + } + // use libkolab to compute recurring events if (class_exists('kolabcalendaring')) { $recurrence = new kolab_date_recurrence($object); @@ -627,7 +641,7 @@ class kolab_calendar extends kolab_storage_folder_api $rec_event['_instance'] = $instance_id; if ($overlay_data || $exdata[$datestr]) // copy data from exception - $this->_merge_event_data($rec_event, $exdata[$datestr] ?: $overlay_data); + kolab_driver::merge_event_data($rec_event, $exdata[$datestr] ?: $overlay_data); $rec_event['id'] = $rec_id; $rec_event['recurrence_id'] = $event['uid']; @@ -651,38 +665,11 @@ class kolab_calendar extends kolab_storage_folder_api return $events; } - /** - * Merge certain properties from the overlay event to the base event object - * - * @param array The event object to be altered - * @param array The overlay event object to be merged over $event - */ - private function _merge_event_data(&$event, $overlay) - { - static $forbidden = array('id','uid','recurrence','recurrence_date','organizer','_attachments'); - - foreach ($overlay as $prop => $value) { - // adjust time of the recurring event instance - if ($prop == 'start' || $prop == 'end') { - if (is_object($event[$prop]) && is_a($event[$prop], 'DateTime')) { - $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s'))); - // set date value if overlay is an exception of the current instance - if (substr($overlay['_instance'], 0, 8) == substr($event['_instance'], 0, 8)) { - $event[$prop]->setDate(intval($value->format('Y')), intval($value->format('n')), intval($value->format('j'))); - } - } - } - else if ($prop[0] != '_' && !in_array($prop, $forbidden)) - $event[$prop] = $value; - } - } - /** * Convert from Kolab_Format to internal representation */ private function _to_rcube_event($record) { - $record['id'] = $record['uid']; $record['calendar'] = $this->id; $record['links'] = $this->get_links($record['uid']); diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index 0cd962df..3e3f0fcb 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -537,7 +537,7 @@ class kolab_driver extends calendar_driver $id = $event['id'] ?: $event['uid']; $cal = $event['calendar']; - // we're looking for a recurring instance: expand the ID to our internal convention for recurring instanced + // we're looking for a recurring instance: expand the ID to our internal convention for recurring instances if (!$event['id'] && $event['_instance']) { $id .= '-' . $event['_instance']; } @@ -634,6 +634,48 @@ class kolab_driver extends calendar_driver return $ret; } + /** + * Update the participant status for the given attendees + * + * @see calendar_driver::update_attendees() + */ + public function update_attendees(&$event, $attendees) + { + // for this-and-future updates, merge the updated attendees onto all exceptions in range + if (($event['_savemode'] == 'future' && $event['recurrence_id']) || !empty($event['recurrence'])) { + if (!($storage = $this->get_calendar($event['calendar']))) + return false; + + // load master event + $master = $event['recurrence_id'] ? $storage->get_event($event['recurrence_id']) : $event; + + // apply attendee update to each existing exception + if ($master['recurrence'] && !empty($master['recurrence']['EXCEPTIONS'])) { + $saved = false; + foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { + // merge the new event properties onto future exceptions + if ($exception['_instance'] >= $event['_instance']) { + self::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $attendees); + } + // update a specific instance + if ($exception['_instance'] == $event['_instance'] && $exception['thisandfuture']) { + $saved = true; + } + } + + // add the given event as new exception + if (!$saved && $event['id'] != $master['id']) { + $event['thisandfuture'] = true; + $master['recurrence']['EXCEPTIONS'][] = $event; + } + + return $this->update_event($master); + } + } + + // just update the given event (instance) + return $this->update_event($event); + } /** * Move a single event @@ -933,15 +975,10 @@ class kolab_driver extends calendar_driver } // save properties to a recurrence exception instance - if ($old['recurrence_id'] && is_array($master['recurrence']['EXCEPTIONS'])) { - foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { - if ($exception['_instance'] == $old['_instance']) { - $event['_instance'] = $old['_instance']; - $event['recurrence_date'] = $old['recurrence_date']; - $master['recurrence']['EXCEPTIONS'][$i] = $event; - $success = $storage->update_event($master, $old['id']); - break 2; - } + if ($old['_instance'] && is_array($master['recurrence']['EXCEPTIONS'])) { + if ($this->update_recurrence_exceptions($master, $event, $old, $savemode)) { + $success = $storage->update_event($master, $old['id']); + break; } } @@ -962,7 +999,7 @@ class kolab_driver extends calendar_driver // save as new exception to master event if ($add_exception) { $event['_instance'] = $old['_instance']; - $event['recurrence_date'] = $old['recurrence_date']; + $event['recurrence_date'] = $old['recurrence_date'] ?: $old['start']; $master['recurrence']['EXCEPTIONS'][] = $event; } $success = $storage->update_event($master); @@ -1004,6 +1041,8 @@ class kolab_driver extends calendar_driver $event['end'] = $master['end']; } + // TODO: forward changes to exceptions (which do not yet have differing values stored) + // adjust recurrence-id when start changed and therefore the entire recurrence chain changes if (($old_start_date != $new_start_date || $old_start_time != $new_start_time) && is_array($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) { @@ -1070,6 +1109,120 @@ class kolab_driver extends calendar_driver return $reschedule; } + /** + * Apply the given changes to already existing exceptions + */ + protected function update_recurrence_exceptions(&$master, $event, $old, $savemode) + { + $saved = false; + $existing = null; + + foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { + // update a specific instance + if ($exception['_instance'] == $old['_instance']) { + $existing = $i; + + // check savemode against existing exception mode. + // if matches, we can update this existing exception + if ((bool)$exception['thisandfuture'] === ($savemode == 'future')) { + $event['_instance'] = $old['_instance']; + $event['thisandfuture'] = $old['thisandfuture']; + $event['recurrence_date'] = $old['recurrence_date']; + $master['recurrence']['EXCEPTIONS'][$i] = $event; + $saved = true; + } + } + // merge the new event properties onto future exceptions + if ($savemode == 'future' && $exception['_instance'] >= $old['_instance']) { + unset($event['thisandfuture']); + self::merge_event_data($master['recurrence']['EXCEPTIONS'][$i], $event); + } + } +/* + // we could not update the existing exception due to savemode mismatch... + if (!$saved && $existing !== null && $master['recurrence']['EXCEPTIONS'][$existing]['thisandfuture']) { + // ... try to move the existing this-and-future exception to the next occurrence + foreach ($this->get_recurring_events($master, $existing['start']) as $candidate) { + // our old this-and-future exception is obsolete + if ($candidate['thisandfuture']) { + unset($master['recurrence']['EXCEPTIONS'][$existing]); + $saved = true; + break; + } + // this occurrence doesn't yet have an exception + else if (!$candidate['isexception']) { + $event['_instance'] = $candidate['_instance']; + $event['recurrence_date'] = $candidate['recurrence_date']; + $master['recurrence']['EXCEPTIONS'][$i] = $event; + $saved = true; + break; + } + } + } +*/ + + // returning false here will add a new exception + return $saved; + } + + /** + * Merge certain properties from the overlay event to the base event object + * + * @param array The event object to be altered + * @param array The overlay event object to be merged over $event + */ + public static function merge_event_data(&$event, $overlay) + { + static $forbidden = array('id','uid','recurrence','recurrence_date','thisandfuture','organizer','_attachments'); + + foreach ($overlay as $prop => $value) { + // adjust time of the recurring event instance + if ($prop == 'start' || $prop == 'end') { + if (is_object($event[$prop]) && is_a($event[$prop], 'DateTime')) { + $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s'))); + // set date value if overlay is an exception of the current instance + if (substr($overlay['_instance'], 0, 8) == substr($event['_instance'], 0, 8)) { + $event[$prop]->setDate(intval($value->format('Y')), intval($value->format('n')), intval($value->format('j'))); + } + } + } + else if ($prop == 'thisandfuture' && $overlay['_instance'] == $event['_instance']) { + $event[$prop] = $value; + } + else if ($prop[0] != '_' && !in_array($prop, $forbidden)) + $event[$prop] = $value; + } + } + + /** + * 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) + { + 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; + } + } + } + /** * Get events from source. * @@ -1520,6 +1673,13 @@ class kolab_driver extends calendar_driver if (empty($record['recurrence'])) unset($record['recurrence']); + // add instance identifier to first occurrence (master event) + if ($record['recurrence'] && !$record['recurrence_id'] && !$record['_instance']) { + $recurrence_id_format = $event['allday'] ? 'Ymd' : 'Ymd\THis'; + $record['recurrence_date'] = $record['start']; + $record['_instance'] = $record['recurrence_date']->format($recurrence_id_format); + } + // remove internals unset($record['_mailbox'], $record['_msguid'], $record['_formatobj'], $record['_attachments'], $record['x-custom']); diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php index 63a05488..c2038548 100644 --- a/plugins/libcalendaring/libcalendaring.php +++ b/plugins/libcalendaring/libcalendaring.php @@ -1410,8 +1410,12 @@ class libcalendaring extends rcube_plugin */ public static function identify_recurrence_instance(&$object) { + // for savemode=all, remove recurrence instance identifiers + if (!empty($object['_savemode']) && $object['_savemode'] == 'all') { + unset($object['_instance'], $object['recurrence_date']); + } // set instance and 'savemode' according to recurrence-id - if (!empty($object['recurrence_date']) && is_a($object['recurrence_date'], 'DateTime')) { + else if (!empty($object['recurrence_date']) && is_a($object['recurrence_date'], 'DateTime')) { $recurrence_id_format = $object['allday'] ? 'Ymd' : 'Ymd\THis'; $object['_instance'] = $object['recurrence_date']->format($recurrence_id_format); $object['_savemode'] = $object['thisandfuture'] ? 'future' : 'current'; diff --git a/plugins/libcalendaring/localization/en_US.inc b/plugins/libcalendaring/localization/en_US.inc index ca7d1fd5..e5e04261 100644 --- a/plugins/libcalendaring/localization/en_US.inc +++ b/plugins/libcalendaring/localization/en_US.inc @@ -109,7 +109,7 @@ $labels['acceptattendee'] = 'Accept participant'; $labels['declineattendee'] = 'Decline participant'; $labels['declineattendeeconfirm'] = 'Enter a message to the declined participant (optional):'; $labels['rsvpmodeall'] = 'The entire series'; -$labels['rsvpmodecurrent'] = 'This occurrence'; +$labels['rsvpmodecurrent'] = 'This occurrence only'; $labels['rsvpmodefuture'] = 'This and future occurrences'; $labels['itipsingleoccurrence'] = 'This is a single occurrence out of a series of events'; From f78af8b09f6f20858d4a8f6a69e590c03befdaa1 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Tue, 17 Feb 2015 13:10:37 +0100 Subject: [PATCH 15/32] Fix ical export after last commit --- plugins/calendar/drivers/kolab/kolab_calendar.php | 2 +- plugins/calendar/drivers/kolab/kolab_driver.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php index 29bc01e5..43165421 100644 --- a/plugins/calendar/drivers/kolab/kolab_calendar.php +++ b/plugins/calendar/drivers/kolab/kolab_calendar.php @@ -291,7 +291,7 @@ class kolab_calendar extends kolab_storage_folder_api } // find and merge exception for the first instance - if (!empty($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS'])) { + if ($virtual && !empty($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS'])) { $event_date = $event['start']->format('Ymd'); foreach ($event['recurrence']['EXCEPTIONS'] as $exception) { $exdate = $exception['recurrence_date'] ? $exception['recurrence_date']->format('Ymd') : substr($exception['_instance'], 0, 8); diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index 3e3f0fcb..0a9790c9 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -1674,10 +1674,10 @@ class kolab_driver extends calendar_driver unset($record['recurrence']); // add instance identifier to first occurrence (master event) + // do not add 'recurrence_date' though in order to keep the master even being exported as such if ($record['recurrence'] && !$record['recurrence_id'] && !$record['_instance']) { - $recurrence_id_format = $event['allday'] ? 'Ymd' : 'Ymd\THis'; - $record['recurrence_date'] = $record['start']; - $record['_instance'] = $record['recurrence_date']->format($recurrence_id_format); + $recurrence_id_format = $record['allday'] ? 'Ymd' : 'Ymd\THis'; + $record['_instance'] = $record['start']->format($recurrence_id_format); } // remove internals From ba84648fa7031edd41d949e82e62e5fb80e482df Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Tue, 17 Feb 2015 14:04:57 +0100 Subject: [PATCH 16/32] - Onyl print events from active calendars (#4603) - Fix colors of events in month view - Show calendar color legend as floating list, no hierarchy --- plugins/calendar/print.js | 3 +++ plugins/calendar/skins/larry/print.css | 23 +++++++++++++++---- .../calendar/skins/larry/templates/print.html | 1 + 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/plugins/calendar/print.js b/plugins/calendar/print.js index c3c647ce..941d04c3 100644 --- a/plugins/calendar/print.js +++ b/plugins/calendar/print.js @@ -43,6 +43,9 @@ window.rcmail && rcmail.addEventListener('init', function(evt) { var src, event_sources = []; var add_url = (rcmail.env.search ? '&q='+escape(rcmail.env.search) : ''); for (var id in rcmail.env.calendars) { + if (!rcmail.env.calendars[id].active) + continue; + source = $.extend({ url: "./?_task=calendar&_action=load_events&source=" + escape(id) + add_url, className: 'fc-event-cal-'+id, diff --git a/plugins/calendar/skins/larry/print.css b/plugins/calendar/skins/larry/print.css index 5d190f5b..ce6e8e73 100644 --- a/plugins/calendar/skins/larry/print.css +++ b/plugins/calendar/skins/larry/print.css @@ -1,7 +1,7 @@ /*** Printing styles for Calendar plugin ***/ body { - margin: 0; + margin: 0 0 1em 0; color: #000; background: #fff; } @@ -54,18 +54,30 @@ body, td, th, div, p, h3, select, input, textarea { } #calendarlist { - list-style-type: square; + list-style: none; margin: 2em 0; padding-left: 1em; } -#calendarlist li { +#calendarlist ul { + float: left; + list-style: none; padding-left: 0; - padding-right: 3em; +} + +#calendarlist li { + float: left; + padding-left: 0; + padding-right: 0; margin-left: 0; font-weight: bold; } +#calendarlist li div { + float: left; + padding-right: 3em; +} + #calendarlist input, #calendarlist .handle { display: none; @@ -207,6 +219,9 @@ body, td, th, div, p, h3, select, input, textarea { font-style: italic; } +.fc-view-month .fc-event-hori .fc-event-inner { + background: #fff !important; +} .fc-view-table col.fc-event-location { width: 20%; diff --git a/plugins/calendar/skins/larry/templates/print.html b/plugins/calendar/skins/larry/templates/print.html index 8d7789a0..e679f729 100644 --- a/plugins/calendar/skins/larry/templates/print.html +++ b/plugins/calendar/skins/larry/templates/print.html @@ -19,6 +19,7 @@
    +
    From 3ea6d4357926092de2080667cae7c6c516e33097 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Tue, 17 Feb 2015 14:54:12 +0100 Subject: [PATCH 17/32] Fix deletion/cancellation of this-and-future instances --- plugins/calendar/calendar.php | 13 +++++++++---- plugins/calendar/skins/larry/print.css | 1 + 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index 8e6a1a3c..571e68b9 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -1711,6 +1711,9 @@ class calendar extends rcube_plugin if ($attendee['status'] == 'DELEGATED' && $attendee['rsvp'] == false) { $event['attendees'][$i]['noreply'] = true; } + else { + unset($event['attendees'][$i]['noreply']); + } } if ($organizer === null && !empty($event['organizer'])) { @@ -2432,12 +2435,14 @@ class calendar extends rcube_plugin */ function event_itip_remove() { - $success = false; - $uid = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST); - $inst = rcube_utils::get_input_value('_instance', rcube_utils::INPUT_POST); + $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' => $inst), true)) { + if ($event = $this->driver->get_event(array('uid' => $uid, '_instance' => $instance), true)) { + $event['_savemode'] = $savemode; $success = $this->driver->remove_event($event, true); } diff --git a/plugins/calendar/skins/larry/print.css b/plugins/calendar/skins/larry/print.css index ce6e8e73..fc5de975 100644 --- a/plugins/calendar/skins/larry/print.css +++ b/plugins/calendar/skins/larry/print.css @@ -76,6 +76,7 @@ body, td, th, div, p, h3, select, input, textarea { #calendarlist li div { float: left; padding-right: 3em; + padding-bottom: 1em; } #calendarlist input, From 46866e76cc00433e34a8c6c7c2967d5dbf6be6d0 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Tue, 17 Feb 2015 15:03:39 +0100 Subject: [PATCH 18/32] Report cancellation to removed attendees with this-and-future parameter --- plugins/calendar/calendar.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index 571e68b9..fc4bbfff 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -1149,6 +1149,10 @@ class calendar extends rcube_plugin $event = $action == 'remove' ? $old : $this->driver->get_event($event); $event['_savemode'] = $_savemode; + if ($old) { + $old['thisandfuture'] = $_savemode == 'future'; + } + // send notification for the main event when savemode is 'all' if ($_savemode == 'all' && $event['recurrence_id']) { $event['id'] = $event['recurrence_id']; From 4d534ea7868538c517fc7af4ae80d6db411a961a Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Tue, 17 Feb 2015 15:47:12 +0100 Subject: [PATCH 19/32] Forward savemode when removing a cancelled event --- plugins/libcalendaring/lib/libcalendaring_itip.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php index 2eec27c8..8038add0 100644 --- a/plugins/libcalendaring/lib/libcalendaring_itip.php +++ b/plugins/libcalendaring/lib/libcalendaring_itip.php @@ -602,7 +602,11 @@ class libcalendaring_itip // for CANCEL messages, we can: else if ($method == 'CANCEL') { $title = $this->gettext('itipcancellation'); - $event_prop = array_filter(array('uid' => $event['uid'], '_instance' => $event['_instance'])); + $event_prop = array_filter(array( + 'uid' => $event['uid'], + '_instance' => $event['_instance'], + '_savemode' => $event['_savemode'], + )); // 1. remove the event from our calendar $button_remove = html::tag('input', array( From f7e7df62a28541b0d3bcecf77ffc2819e006924f Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Tue, 17 Feb 2015 15:49:14 +0100 Subject: [PATCH 20/32] Apply date offset from exceptions to recurring occurrences (#4386) --- .../calendar/drivers/kolab/kolab_calendar.php | 4 ++-- .../calendar/drivers/kolab/kolab_driver.php | 23 +++++++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php index 43165421..a392a9f8 100644 --- a/plugins/calendar/drivers/kolab/kolab_calendar.php +++ b/plugins/calendar/drivers/kolab/kolab_calendar.php @@ -297,7 +297,7 @@ class kolab_calendar extends kolab_storage_folder_api $exdate = $exception['recurrence_date'] ? $exception['recurrence_date']->format('Ymd') : substr($exception['_instance'], 0, 8); if ($exdate == $event_date) { $event['_instance'] = $exception['_instance']; - kolab_driver::merge_event_data($event, $exception); + kolab_driver::merge_exception_data($event, $exception); } } } @@ -641,7 +641,7 @@ class kolab_calendar extends kolab_storage_folder_api $rec_event['_instance'] = $instance_id; if ($overlay_data || $exdata[$datestr]) // copy data from exception - kolab_driver::merge_event_data($rec_event, $exdata[$datestr] ?: $overlay_data); + kolab_driver::merge_exception_data($rec_event, $exdata[$datestr] ?: $overlay_data); $rec_event['id'] = $rec_id; $rec_event['recurrence_id'] = $event['uid']; diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index 0a9790c9..ff02fbb4 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -928,6 +928,10 @@ class kolab_driver extends calendar_driver if ($old['recurrence'] || $old['recurrence_id']) { $master = $old['recurrence_id'] ? $fromcalendar->get_event($old['recurrence_id']) : $old; $savemode = $event['_savemode'] ?: ($old['recurrence_id'] ? 'current' : 'all'); + + // this-and-future on the first instance equals to 'all' + if (!$old['recurrence_id'] && $savemode == 'future') + $savemode = 'all'; } // check if update affects scheduling and update attendee status accordingly @@ -1135,7 +1139,7 @@ class kolab_driver extends calendar_driver // merge the new event properties onto future exceptions if ($savemode == 'future' && $exception['_instance'] >= $old['_instance']) { unset($event['thisandfuture']); - self::merge_event_data($master['recurrence']['EXCEPTIONS'][$i], $event); + self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event); } } /* @@ -1171,19 +1175,28 @@ class kolab_driver extends calendar_driver * @param array The event object to be altered * @param array The overlay event object to be merged over $event */ - public static function merge_event_data(&$event, $overlay) + public static function merge_exception_data(&$event, $overlay) { static $forbidden = array('id','uid','recurrence','recurrence_date','thisandfuture','organizer','_attachments'); + // compute date offset from the exception + if ($overlay['start'] instanceof DateTime && $overlay['recurrence_date'] instanceof DateTime) { + $date_offset = $overlay['recurrence_date']->diff($overlay['start']); + } + foreach ($overlay as $prop => $value) { - // adjust time of the recurring event instance if ($prop == 'start' || $prop == 'end') { - if (is_object($event[$prop]) && is_a($event[$prop], 'DateTime')) { - $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s'))); + if (is_object($event[$prop]) && $event[$prop] instanceof DateTime) { // set date value if overlay is an exception of the current instance if (substr($overlay['_instance'], 0, 8) == substr($event['_instance'], 0, 8)) { $event[$prop]->setDate(intval($value->format('Y')), intval($value->format('n')), intval($value->format('j'))); } + // apply date offset + else if ($date_offset) { + $event[$prop]->add($date_offset); + } + // adjust time of the recurring event instance + $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s'))); } } else if ($prop == 'thisandfuture' && $overlay['_instance'] == $event['_instance']) { From c7df74d5d0cdf3b292a1034c9a0bb3007e9e0b23 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Wed, 18 Feb 2015 10:20:00 +0100 Subject: [PATCH 21/32] Fix updating attendees (do not accidentally set exceptions to thisandfuture) --- plugins/calendar/drivers/kolab/kolab_driver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index ff02fbb4..f2a92698 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -642,7 +642,7 @@ class kolab_driver extends calendar_driver public function update_attendees(&$event, $attendees) { // for this-and-future updates, merge the updated attendees onto all exceptions in range - if (($event['_savemode'] == 'future' && $event['recurrence_id']) || !empty($event['recurrence'])) { + if (($event['_savemode'] == 'future' && $event['recurrence_id']) || (!empty($event['recurrence']) && !$event['recurrence_id'])) { if (!($storage = $this->get_calendar($event['calendar']))) return false; From 422bb0a298c1867fba7626b953b7f263492a3ab1 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Wed, 18 Feb 2015 10:21:15 +0100 Subject: [PATCH 22/32] Fix RSVP flag in iCal and storage (defaults to false); remove redundant information from ical PRODID --- plugins/libcalendaring/libvcalendar.php | 6 +++--- plugins/libkolab/lib/kolab_format_xcal.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php index 10c2223e..07612d5a 100644 --- a/plugins/libcalendaring/libvcalendar.php +++ b/plugins/libcalendaring/libvcalendar.php @@ -41,7 +41,7 @@ class libvcalendar implements Iterator { private $timezone; private $attach_uri = null; - private $prodid = '-//Roundcube//Roundcube libcalendaring//Sabre//Sabre VObject//EN'; + private $prodid = '-//Roundcube libcalendaring//Sabre//Sabre VObject//EN'; private $type_component_map = array('event' => 'VEVENT', 'task' => 'VTODO'); private $attendee_keymap = array('name' => 'CN', 'status' => 'PARTSTAT', 'role' => 'ROLE', 'cutype' => 'CUTYPE', 'rsvp' => 'RSVP', 'delegated-from' => 'DELEGATED-FROM', 'delegated-to' => 'DELEGATED-TO'); @@ -64,7 +64,7 @@ class libvcalendar implements Iterator function __construct($tz = null) { $this->timezone = $tz; - $this->prodid = '-//Roundcube//Roundcube libcalendaring ' . RCUBE_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN'; + $this->prodid = '-//Roundcube libcalendaring ' . RCUBE_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN'; } /** @@ -502,7 +502,7 @@ class libvcalendar implements Iterator case 'ATTENDEE': case 'ORGANIZER': - $params = array(); + $params = array('rsvp' => false); foreach ($prop->parameters as $param) { switch ($param->name) { case 'RSVP': $params[$param->name] = strtolower($param->value) == 'true'; break; diff --git a/plugins/libkolab/lib/kolab_format_xcal.php b/plugins/libkolab/lib/kolab_format_xcal.php index 3d7bc274..e6507d6d 100644 --- a/plugins/libkolab/lib/kolab_format_xcal.php +++ b/plugins/libkolab/lib/kolab_format_xcal.php @@ -357,7 +357,7 @@ abstract class kolab_format_xcal extends kolab_format // set attendee RSVP if missing if (!isset($attendee['rsvp'])) { - $object['attendees'][$i]['rsvp'] = $attendee['rsvp'] = true; + $object['attendees'][$i]['rsvp'] = $attendee['rsvp'] = $reschedule; } $att = new Attendee; From b47b13a35ebb65cecbe028a177deed10752ee7cc Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Wed, 18 Feb 2015 11:30:16 +0100 Subject: [PATCH 23/32] Dynamically update attendees on exceptions in 'all' and 'future' save mode --- .../calendar/drivers/kolab/kolab_driver.php | 83 +++++++++++++++---- 1 file changed, 68 insertions(+), 15 deletions(-) diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index f2a92698..7b4c639a 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -1045,19 +1045,41 @@ class kolab_driver extends calendar_driver $event['end'] = $master['end']; } - // TODO: forward changes to exceptions (which do not yet have differing values stored) + // when saving an instance in 'all' mode, copy recurrence exceptions over + if ($old['recurrence_id']) { + $event['recurrence'] = $master['recurrence']; + } + + // TODO: forward changes to exceptions (which do not yet have differing values stored) + if (is_array($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) { + // determine added and removed attendees + $old_attendees = $current_attendees = $added_attendees = array(); + foreach ((array)$old['attendees'] as $attendee) { + $old_attendees[] = $attendee['email']; + } + foreach ((array)$event['attendees'] as $attendee) { + $current_attendees[] = $attendee['email']; + if (!in_array($attendee['email'], $old_attendees)) { + $added_attendees[] = $attendee; + } + } + $removed_attendees = array_diff($old_attendees, $current_attendees); - // adjust recurrence-id when start changed and therefore the entire recurrence chain changes - if (($old_start_date != $new_start_date || $old_start_time != $new_start_time) && - is_array($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) { - $recurrence_id_format = $event['allday'] ? 'Ymd' : 'Ymd\THis'; foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { - $recurrence_id = is_a($exception['recurrence_date'], 'DateTime') ? $exception['recurrence_date'] : - rcube_utils::anytodatetime($exception['_instance'], $old['start']->getTimezone()); - if (is_a($recurrence_id, 'DateTime')) { - $recurrence_id->add($date_shift); - $event['recurrence']['EXCEPTIONS'][$i]['recurrence_date'] = $recurrence_id; - $event['recurrence']['EXCEPTIONS'][$i]['_instance'] = $recurrence_id->format($recurrence_id_format); + self::merge_attendee_data($event['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); + } + + // adjust recurrence-id when start changed and therefore the entire recurrence chain changes + if ($old_start_date != $new_start_date || $old_start_time != $new_start_time) { + $recurrence_id_format = $event['allday'] ? 'Ymd' : 'Ymd\THis'; + foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { + $recurrence_id = is_a($exception['recurrence_date'], 'DateTime') ? $exception['recurrence_date'] : + rcube_utils::anytodatetime($exception['_instance'], $old['start']->getTimezone()); + if (is_a($recurrence_id, 'DateTime')) { + $recurrence_id->add($date_shift); + $event['recurrence']['EXCEPTIONS'][$i]['recurrence_date'] = $recurrence_id; + $event['recurrence']['EXCEPTIONS'][$i]['_instance'] = $recurrence_id->format($recurrence_id_format); + } } } } @@ -1121,6 +1143,22 @@ class kolab_driver extends calendar_driver $saved = false; $existing = null; + // determine added and removed attendees + $added_attendees = $removed_attendees = array(); + if ($savemode == 'future') { + $old_attendees = $current_attendees = array(); + foreach ((array)$old['attendees'] as $attendee) { + $old_attendees[] = $attendee['email']; + } + foreach ((array)$event['attendees'] as $attendee) { + $current_attendees[] = $attendee['email']; + if (!in_array($attendee['email'], $old_attendees)) { + $added_attendees[] = $attendee; + } + } + $removed_attendees = array_diff($old_attendees, $current_attendees); + } + foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { // update a specific instance if ($exception['_instance'] == $old['_instance']) { @@ -1139,7 +1177,11 @@ class kolab_driver extends calendar_driver // merge the new event properties onto future exceptions if ($savemode == 'future' && $exception['_instance'] >= $old['_instance']) { unset($event['thisandfuture']); - self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event); + self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event, array('attendees')); + + if (!empty($added_attendees) || !empty($removed_attendees)) { + self::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); + } } } /* @@ -1174,10 +1216,14 @@ class kolab_driver extends calendar_driver * * @param array The event object to be altered * @param array The overlay event object to be merged over $event + * @param array List of properties not allowed to be overwritten */ - public static function merge_exception_data(&$event, $overlay) + public static function merge_exception_data(&$event, $overlay, $blacklist = null) { - static $forbidden = array('id','uid','recurrence','recurrence_date','thisandfuture','organizer','_attachments'); + $forbidden = array('id','uid','recurrence','recurrence_date','thisandfuture','organizer','_attachments'); + + if (is_array($blacklist)) + $forbidden = array_merge($forbidden, $blacklist); // compute date offset from the exception if ($overlay['start'] instanceof DateTime && $overlay['recurrence_date'] instanceof DateTime) { @@ -1213,7 +1259,7 @@ class kolab_driver extends calendar_driver * @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) + public static function merge_attendee_data(&$event, $attendees, $removed = null) { if (!empty($attendees) && !is_array($attendees[0])) { $attendees = array($attendees); @@ -1234,6 +1280,13 @@ class kolab_driver extends calendar_driver $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); + }); + } } /** From ac2bd4700f55cdd48337f55240bc37066d309320 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Thu, 19 Feb 2015 15:13:13 +0100 Subject: [PATCH 24/32] Store recurrence-id for single (non-recurring) events and use for iTip replies --- plugins/libcalendaring/lib/libcalendaring_itip.php | 2 +- plugins/libcalendaring/libcalendaring.php | 2 +- plugins/libkolab/lib/kolab_format_event.php | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php index 8038add0..b4a7aee4 100644 --- a/plugins/libcalendaring/lib/libcalendaring_itip.php +++ b/plugins/libcalendaring/lib/libcalendaring_itip.php @@ -364,7 +364,7 @@ class libcalendaring_itip // check if the given itip object matches the last state if ($existing) { - $latest = (isset($event['sequence']) && $existing['sequence'] == $event['sequence']) || + $latest = (isset($event['sequence']) && intval($existing['sequence']) == intval($event['sequence'])) || (!isset($event['sequence']) && $existing['changed'] && $existing['changed'] >= $event['changed']); } diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php index c2038548..2d9c3cc7 100644 --- a/plugins/libcalendaring/libcalendaring.php +++ b/plugins/libcalendaring/libcalendaring.php @@ -1411,7 +1411,7 @@ class libcalendaring extends rcube_plugin public static function identify_recurrence_instance(&$object) { // for savemode=all, remove recurrence instance identifiers - if (!empty($object['_savemode']) && $object['_savemode'] == 'all') { + if (!empty($object['_savemode']) && $object['_savemode'] == 'all' && $object['recurrence']) { unset($object['_instance'], $object['recurrence_date']); } // set instance and 'savemode' according to recurrence-id diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php index 979b33b4..0c90e858 100644 --- a/plugins/libkolab/lib/kolab_format_event.php +++ b/plugins/libkolab/lib/kolab_format_event.php @@ -122,6 +122,9 @@ class kolab_format_event extends kolab_format_xcal } $this->obj->setExceptions($vexceptions); } + else if ($object['recurrence_date'] && $object['recurrence_date'] instanceof DateTime) { + $this->obj->setRecurrenceID(self::get_datetime($object['recurrence_date'], null, $object['allday']), (bool)$object['thisandfuture']); + } // cache this data $this->data = $object; From 95ed84c9326f55e85f9e8513cd8284e05ec42e17 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Thu, 19 Feb 2015 15:13:41 +0100 Subject: [PATCH 25/32] Copy the master's sequence to a new exception --- plugins/calendar/drivers/kolab/kolab_driver.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index 7b4c639a..5e6f3b38 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -977,6 +977,9 @@ class kolab_driver extends calendar_driver if ($reschedule) { $event['sequence'] = max($old['sequence'], $master['sequence']) + 1; } + else if (!isset($event['sequence'])) { + $event['sequence'] = $master['sequence']; + } // save properties to a recurrence exception instance if ($old['_instance'] && is_array($master['recurrence']['EXCEPTIONS'])) { From 61037eb97c8580d385fc625db064f3855d8a823f Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Thu, 19 Feb 2015 15:56:46 +0100 Subject: [PATCH 26/32] Fix RSVP flag in iTip REQUESTS --- plugins/libcalendaring/lib/libcalendaring_itip.php | 4 ++-- plugins/libkolab/lib/kolab_format_event.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php index b4a7aee4..e7de5c89 100644 --- a/plugins/libcalendaring/lib/libcalendaring_itip.php +++ b/plugins/libcalendaring/lib/libcalendaring_itip.php @@ -250,8 +250,8 @@ class libcalendaring_itip // set RSVP for every attendee else if ($method == 'REQUEST') { foreach ($event['attendees'] as $i => $attendee) { - if ($attendee['status'] != 'DELEGATED') { - $event['attendees'][$i]['rsvp']= $rsvp ? true : null; + if ($attendee['status'] != 'DELEGATED' && !isset($attendee['rsvp'])) { + $event['attendees'][$i]['rsvp']= (bool)$rsvp; } } } diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php index 0c90e858..91efb26f 100644 --- a/plugins/libkolab/lib/kolab_format_event.php +++ b/plugins/libkolab/lib/kolab_format_event.php @@ -118,7 +118,7 @@ class kolab_format_event extends kolab_format_xcal $vexceptions->push($exevent->obj); // write cleaned-up exception data back to memory/cache - $object['recurrence']['EXCEPTIONS'][$i] = $this->expand_exception($compacted, $object); + $object['recurrence']['EXCEPTIONS'][$i] = $this->expand_exception($exevent->data, $object); } $this->obj->setExceptions($vexceptions); } From db637619c3cfa5fc73e55c7f60adba9c2c635e0a Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Thu, 19 Feb 2015 15:57:37 +0100 Subject: [PATCH 27/32] Omit RSVP flag in iCal export if not true --- plugins/libcalendaring/libvcalendar.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php index 07612d5a..826e8d84 100644 --- a/plugins/libcalendaring/libvcalendar.php +++ b/plugins/libcalendaring/libvcalendar.php @@ -1088,7 +1088,7 @@ class libvcalendar implements Iterator } else if (!empty($attendee['email'])) { if (isset($attendee['rsvp'])) - $attendee['rsvp'] = $attendee['rsvp'] ? 'TRUE' : 'FALSE'; + $attendee['rsvp'] = $attendee['rsvp'] ? 'TRUE' : null; $ve->add('ATTENDEE', 'mailto:' . $attendee['email'], array_filter(self::map_keys($attendee, $this->attendee_keymap))); } } From 026d62d2351c58b5e28777ccc8767415490b4985 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Thu, 19 Feb 2015 15:58:32 +0100 Subject: [PATCH 28/32] Avoid comparison errors if recurrence is set to '' (used to unset recurrence rules) --- plugins/libkolab/lib/kolab_format_xcal.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/libkolab/lib/kolab_format_xcal.php b/plugins/libkolab/lib/kolab_format_xcal.php index e6507d6d..4d3a7583 100644 --- a/plugins/libkolab/lib/kolab_format_xcal.php +++ b/plugins/libkolab/lib/kolab_format_xcal.php @@ -661,7 +661,7 @@ abstract class kolab_format_xcal extends kolab_format $a = $a->format('Y-m-d'); $b = $b->format('Y-m-d'); } - if ($prop == 'recurrence') { + if ($prop == 'recurrence' && is_array($a) && is_array($b)) { unset($a['EXCEPTIONS']); unset($b['EXCEPTIONS']); $a = array_filter($a); From 5e176baa0832da051f50ef9740fda7c22cb4702f Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Thu, 19 Feb 2015 18:09:12 +0100 Subject: [PATCH 29/32] Pass a list of updated attendess to the backend driver on RSVP reply from calendar view --- plugins/calendar/calendar.php | 12 +++++++++--- plugins/calendar/calendar_ui.js | 8 ++++++-- plugins/calendar/drivers/calendar_driver.php | 3 ++- plugins/calendar/drivers/kolab/kolab_driver.php | 4 ++-- plugins/libcalendaring/lib/libcalendaring_itip.php | 5 ++++- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index fc4bbfff..f5c2c043 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -978,7 +978,8 @@ class calendar extends rcube_plugin 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_GPC); + $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'); @@ -990,7 +991,7 @@ class calendar extends rcube_plugin // 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'])) { + if ($itip->delegate_to($ev, $event['to'], (bool)$event['rsvp'], $attendees)) { $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation'); $noreply = false; } @@ -998,7 +999,12 @@ class calendar extends rcube_plugin $event = $ev; - if ($success = $this->driver->edit_rsvp($event, $status)) { + // compose a list of attendees affected by this change + $updated_attendees = array_filter(array_map(function($j) use ($ev) { + return $ev['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; diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js index c9334ec6..e7f55cd0 100644 --- a/plugins/calendar/calendar_ui.js +++ b/plugins/calendar/calendar_ui.js @@ -2411,6 +2411,7 @@ function rcube_calendar_ui(settings) } // 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) { @@ -2419,6 +2420,7 @@ function rcube_calendar_ui(settings) if (data.status == 'DELEGATED') { data['delegated-to'] = delegate.to; + data.rsvp = delegate.rsvp } else { if (data['delegated-to']) { @@ -2427,6 +2429,8 @@ function rcube_calendar_ui(settings) data.role = 'REQ-PARTICIPANT'; } } + + attendees.push(i) } // set free_busy status to transparent if declined (#4425) @@ -2459,11 +2463,11 @@ function rcube_calendar_ui(settings) }); } else if (settings.invitation_calendars) { - update_event('rsvp', submit_data, { status:response, noreply:noreply }); + 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, noreply:noreply }); + rcmail.http_post('event', { action:'rsvp', e:submit_data, status:response, attendees:attendees, noreply:noreply }); } event_show_dialog(me.selected_event); diff --git a/plugins/calendar/drivers/calendar_driver.php b/plugins/calendar/drivers/calendar_driver.php index aecd2e17..659f4a0e 100644 --- a/plugins/calendar/drivers/calendar_driver.php +++ b/plugins/calendar/drivers/calendar_driver.php @@ -197,9 +197,10 @@ abstract class calendar_driver * * @param array Hash array with event properties * @param string New participant status + * @param array List of hash arrays with updated attendees * @return boolean True on success, False on error */ - public function edit_rsvp(&$event, $status) + public function edit_rsvp(&$event, $status, $attendees) { return $this->edit_event($event); } diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index 5e6f3b38..48dbafd0 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -619,9 +619,9 @@ class kolab_driver extends calendar_driver * @param string New participant status * @return boolean True on success, False on error */ - public function edit_rsvp(&$event, $status) + public function edit_rsvp(&$event, $status, $attendees) { - if (($ret = $this->update_event($event)) && $this->rc->config->get('kolab_invitation_calendars')) { + if (($ret = $this->update_attendees($event, $attendees)) && $this->rc->config->get('kolab_invitation_calendars')) { // re-assign to the according (virtual) calendar if (strtoupper($status) == 'DECLINED') $event['calendar'] = self::INVITATIONS_CALENDAR_DECLINED; diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php index e7de5c89..41148c60 100644 --- a/plugins/libcalendaring/lib/libcalendaring_itip.php +++ b/plugins/libcalendaring/lib/libcalendaring_itip.php @@ -297,9 +297,10 @@ class libcalendaring_itip * @param array Event object to delegate * @param mixed Delegatee as string or hash array with keys 'name' and 'mailto' * @param boolean The delegator's RSVP flag + * @param array List with indexes of new/updated attendees * @return boolean True on success, False on failure */ - public function delegate_to(&$event, $delegate, $rsvp = false) + public function delegate_to(&$event, $delegate, $rsvp = false, &$attendees = array()) { if (is_string($delegate)) { $delegates = rcube_mime::decode_address_list($delegate, 1, false); @@ -345,6 +346,8 @@ class libcalendaring_itip $delegate_attendee['delegated-from'] = $me['email']; $event['attendees'][$delegate_index] = $delegate_attendee; + $attendees[] = $delegate_index; + $this->set_sender_email($me['email']); return $this->send_itip_message($event, 'REQUEST', $delegate_attendee, 'itipsubjectdelegatedto', 'itipmailbodydelegatedto'); } From 02ef2e60505a6319599042c9e6e92a7a2f42de8c Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Fri, 20 Feb 2015 00:11:40 +0100 Subject: [PATCH 30/32] Split recurring event into a new series when modifying with this-and-future option (#4386); optimize copying of attachments into new event --- plugins/calendar/calendar.php | 109 +++++++----- plugins/calendar/calendar_ui.js | 4 + .../calendar/drivers/kolab/kolab_calendar.php | 34 +--- .../calendar/drivers/kolab/kolab_driver.php | 163 +++++++++++++++++- .../lib/libcalendaring_itip.php | 2 +- plugins/libcalendaring/libcalendaring.js | 2 +- plugins/libkolab/lib/kolab_storage_folder.php | 7 +- 7 files changed, 233 insertions(+), 88 deletions(-) diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index f5c2c043..8be59564 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -865,6 +865,7 @@ class calendar extends rcube_plugin $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; @@ -873,21 +874,15 @@ class calendar extends rcube_plugin $this->write_preprocess($event, $action); if ($success = $this->driver->edit_event($event)) { $this->cleanup_event($event); - if ($success !== true) { - $event['id'] = $success; - $old = null; - } + $this->event_save_success($event, $old, $action, $success); } - $reload = $success && ($event['recurrence'] || $event['_savemode'] || $event['_fromcalendar']) ? 2 : 1; + $reload = $success && ($event['recurrence'] || $event['_savemode'] || $event['_fromcalendar']) ? 2 : 1; break; case "resize": $this->write_preprocess($event, $action); if ($success = $this->driver->resize_event($event)) { - if ($success !== true) { - $event['id'] = $success; - $old = null; - } + $this->event_save_success($event, $old, $action, $success); } $reload = $event['_savemode'] ? 2 : 1; break; @@ -895,10 +890,7 @@ class calendar extends rcube_plugin case "move": $this->write_preprocess($event, $action); if ($success = $this->driver->move_event($event)) { - if ($success !== true) { - $event['id'] = $success; - $old = null; - } + $this->event_save_success($event, $old, $action, $success); } $reload = $success && $event['_savemode'] ? 2 : 1; break; @@ -958,6 +950,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": @@ -1000,8 +995,8 @@ class calendar extends rcube_plugin $event = $ev; // compose a list of attendees affected by this change - $updated_attendees = array_filter(array_map(function($j) use ($ev) { - return $ev['attendees'][$j]; + $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)) { @@ -1147,35 +1142,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'])) { - $_savemode = $event['_savemode']; - - // make sure we have the complete record - $event = $action == 'remove' ? $old : $this->driver->get_event($event); - $event['_savemode'] = $_savemode; - - if ($old) { - $old['thisandfuture'] = $_savemode == 'future'; - } - - // send notification for the main event when savemode is 'all' - if ($_savemode == 'all' && $event['recurrence_id']) { - $event['id'] = $event['recurrence_id']; - $event = $this->driver->get_event($event); - unset($event['_instance'], $event['recurrence_date']); - } - - // 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'); - } - } - // unlock client $this->rc->output->command('plugin.unlock_saving'); @@ -1190,6 +1156,61 @@ class calendar extends rcube_plugin } } + /** + * 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'])); + 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['id'] = $success; + $event['_savemode'] = 'all'; + $event['attendees'] = $master['attendees']; // this tricks us into the next if clause + $old = null; + } + + // 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' && $old['recurrence_id']) { + $event['id'] = $old['recurrence_id']; + $event = $this->driver->get_event($event); + unset($event['_instance'], $event['recurrence_date']); + } + else { + // make sure we have the complete record + $event = $action == 'remove' ? $old : $this->driver->get_event($event); + } + + $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 diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js index e7f55cd0..5a26c188 100644 --- a/plugins/calendar/calendar_ui.js +++ b/plugins/calendar/calendar_ui.js @@ -2432,6 +2432,10 @@ function rcube_calendar_ui(settings) 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') { diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php index a392a9f8..64bb0826 100644 --- a/plugins/calendar/drivers/kolab/kolab_calendar.php +++ b/plugins/calendar/drivers/kolab/kolab_calendar.php @@ -639,6 +639,7 @@ class kolab_calendar extends kolab_storage_folder_api if (($next_event['start'] <= $end && $next_event['end'] >= $start) || ($event_id && $rec_id == $event_id)) { $rec_event = $this->_to_rcube_event($next_event); $rec_event['_instance'] = $instance_id; + $rec_event['_count'] = $i + 1; if ($overlay_data || $exdata[$datestr]) // copy data from exception kolab_driver::merge_exception_data($rec_event, $exdata[$datestr] ?: $overlay_data); @@ -687,38 +688,7 @@ class kolab_calendar extends kolab_storage_folder_api */ private function _from_rcube_event($event, $old = array()) { - // in kolab_storage attachments are indexed by content-id - $event['_attachments'] = array(); - if (is_array($event['attachments'])) { - foreach ($event['attachments'] as $attachment) { - $key = null; - // Roundcube ID has nothing to do with the storage ID, remove it - if ($attachment['content'] || $attachment['path']) { - unset($attachment['id']); - } - else { - foreach ((array)$old['_attachments'] as $cid => $oldatt) { - if ($attachment['id'] == $oldatt['id']) - $key = $cid; - } - } - - // flagged for deletion => set to false - if ($attachment['_deleted']) { - $event['_attachments'][$key] = false; - } - // replace existing entry - else if ($key) { - $event['_attachments'][$key] = $attachment; - } - // append as new attachment - else { - $event['_attachments'][] = $attachment; - } - } - - unset($event['attachments']); - } + $event = kolab_driver::from_rcube_event($event, $old); // set current user as ORGANIZER $identity = $this->cal->rc->user->list_emails(true); diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index 48dbafd0..d6fb55ac 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -621,7 +621,19 @@ class kolab_driver extends calendar_driver */ public function edit_rsvp(&$event, $status, $attendees) { - if (($ret = $this->update_attendees($event, $attendees)) && $this->rc->config->get('kolab_invitation_calendars')) { + $update_event = $event; + + // apply changes to master (and all exceptions) + if ($event['_savemode'] == 'all' && $event['recurrence_id']) { + if ($storage = $this->get_calendar($event['calendar'])) { + $update_event = $storage->get_event($event['recurrence_id']); + $update_event['_savemode'] = $event['_savemode']; + unset($update_event['recurrence_id']); + self::merge_attendee_data($update_event, $attendees); + } + } + + if (($ret = $this->update_attendees($update_event, $attendees)) && $this->rc->config->get('kolab_invitation_calendars')) { // re-assign to the according (virtual) calendar if (strtoupper($status) == 'DECLINED') $event['calendar'] = self::INVITATIONS_CALENDAR_DECLINED; @@ -687,6 +699,7 @@ class kolab_driver extends calendar_driver { if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { unset($ev['sequence']); + self::clear_attandee_noreply($ev); return $this->update_event($event + $ev); } @@ -703,6 +716,7 @@ class kolab_driver extends calendar_driver { if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) { unset($ev['sequence']); + self::clear_attandee_noreply($ev); return $this->update_event($event + $ev); } @@ -928,6 +942,7 @@ class kolab_driver extends calendar_driver if ($old['recurrence'] || $old['recurrence_id']) { $master = $old['recurrence_id'] ? $fromcalendar->get_event($old['recurrence_id']) : $old; $savemode = $event['_savemode'] ?: ($old['recurrence_id'] ? 'current' : 'all'); + $object = $fromcalendar->storage->get_object($master['uid']); // this-and-future on the first instance equals to 'all' if (!$old['recurrence_id'] && $savemode == 'future') @@ -953,20 +968,70 @@ class kolab_driver extends calendar_driver case 'new': // save submitted data as new (non-recurring) event $event['recurrence'] = array(); + $event['_copyfrom'] = $object['_msguid']; + $event['_mailbox'] = $object['_mailbox']; $event['uid'] = $this->cal->generate_uid(); - unset($event['recurrence_id'], $event['_instance'], $event['id']); + unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); - // copy attachment data to new event - foreach ((array)$event['attachments'] as $idx => $attachment) { - if (!$attachment['content']) - $event['attachments'][$idx]['content'] = $this->get_attachment_body($attachment['id'], $master); - } + // copy attachment metadata to new event + $event = self::from_rcube_event($event, $object); + self::clear_attandee_noreply($event); if ($success = $storage->insert_event($event)) $success = $event['uid']; break; case 'future': + // create a new recurring event + $event['_copyfrom'] = $object['_msguid']; + $event['_mailbox'] = $object['_mailbox']; + $event['uid'] = $this->cal->generate_uid(); + unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']); + + // copy attachment metadata to new event + $event = self::from_rcube_event($event, $object); + + // remove recurrence exceptions on re-scheduling + if ($reschedule) { + unset($event['recurrence']['EXCEPTIONS']); + } + else if (is_array($event['recurrence']['EXCEPTIONS'])) { + // only keep relevant exceptions + $event['recurrence']['EXCEPTIONS'] = array_filter($event['recurrence']['EXCEPTIONS'], function($exception) use ($event) { + return $exception['start'] > $event['start']; + }); + } + + // compute remaining occurrences + if ($event['recurrence']['COUNT']) { + if (!$old['_count']) + $old['_count'] = $this->get_recurrence_count($object, $event['start']); + $event['recurrence']['COUNT'] -= intval($old['_count']); + } + + // set until-date on master event + $master['recurrence']['UNTIL'] = clone $event['start']; + $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); + unset($master['recurrence']['COUNT']); + + // remove all exceptions after $event['start'] + if (is_array($master['recurrence']['EXCEPTIONS'])) { + $master['recurrence']['EXCEPTIONS'] = array_filter($master['recurrence']['EXCEPTIONS'], function($exception) use ($event) { + return $exception['start'] < $event['start']; + }); + } + + // save new event + if ($success = $storage->insert_event($event)) { + $success = $event['uid']; + + // update master event (no rescheduling!) + $master['sequence'] = $object['sequence']; + self::clear_attandee_noreply($master); + $udated = $storage->update_event($master); + } + break; + case 'current': // recurring instances shall not store recurrence rules and attachments $event['recurrence'] = array(); @@ -1214,6 +1279,17 @@ class kolab_driver extends calendar_driver return $saved; } + /** + * Remove the noreply flags from attendees + */ + public static function clear_attandee_noreply(&$event) + { + foreach ((array)$event['attendees'] as $i => $attendee) { + unset($event['attendees'][$i]['noreply']); + } + } + + /** * Merge certain properties from the overlay event to the base event object * @@ -1570,6 +1646,29 @@ class kolab_driver extends calendar_driver return $storage->get_recurring_events($event, $start, $end); } + /** + * + */ + private function get_recurrence_count($event, $dtstart) + { + // use libkolab to compute recurring events + if (class_exists('kolabcalendaring') && $object['_formatobj']) { + $recurrence = new kolab_date_recurrence($object['_formatobj']); + } + else { + // fallback to local recurrence implementation + require_once($this->cal->home . '/lib/calendar_recurrence.php'); + $recurrence = new calendar_recurrence($this->cal, $event); + } + + $count = 0; + while (($next_event = $recurrence->next_instance()) && $next_event['start'] <= $dtstart && $count < 1000) { + $count++; + } + + return $count; + } + /** * Fetch free/busy information from a person within the given range */ @@ -1749,12 +1848,62 @@ class kolab_driver extends calendar_driver $record['_instance'] = $record['start']->format($recurrence_id_format); } + // clean up exception data + if (is_array($record['recurrence']['EXCEPTIONS'])) { + array_walk($record['recurrence']['EXCEPTIONS'], function(&$exception) { + unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj'], $exception['_attachments']); + }); + } + // remove internals unset($record['_mailbox'], $record['_msguid'], $record['_formatobj'], $record['_attachments'], $record['x-custom']); return $record; } + /** + * + */ + public static function from_rcube_event($event, $old = array()) + { + // in kolab_storage attachments are indexed by content-id + if (is_array($event['attachments'])) { + $event['_attachments'] = array(); + + foreach ($event['attachments'] as $attachment) { + $key = null; + // Roundcube ID has nothing to do with the storage ID, remove it + if ($attachment['content'] || $attachment['path']) { + unset($attachment['id']); + } + else { + foreach ((array)$old['_attachments'] as $cid => $oldatt) { + if ($attachment['id'] == $oldatt['id']) + $key = $cid; + } + } + + // flagged for deletion => set to false + if ($attachment['_deleted']) { + $event['_attachments'][$key] = false; + } + // replace existing entry + else if ($key) { + $event['_attachments'][$key] = $attachment; + } + // append as new attachment + else { + $event['_attachments'][] = $attachment; + } + } + + unset($event['attachments']); + } + + return $event; + } + + /** * Set CSS class according to the event's attendde partstat */ diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php index 41148c60..14dacf4d 100644 --- a/plugins/libcalendaring/lib/libcalendaring_itip.php +++ b/plugins/libcalendaring/lib/libcalendaring_itip.php @@ -250,7 +250,7 @@ class libcalendaring_itip // set RSVP for every attendee else if ($method == 'REQUEST') { foreach ($event['attendees'] as $i => $attendee) { - if ($attendee['status'] != 'DELEGATED' && !isset($attendee['rsvp'])) { + if ($attendee['status'] != 'DELEGATED') { $event['attendees'][$i]['rsvp']= (bool)$rsvp; } } diff --git a/plugins/libcalendaring/libcalendaring.js b/plugins/libcalendaring/libcalendaring.js index 71c101da..1c8fe16f 100644 --- a/plugins/libcalendaring/libcalendaring.js +++ b/plugins/libcalendaring/libcalendaring.js @@ -965,7 +965,7 @@ rcube_libcalendaring.itip_rsvp_recurring = function(btn, callback) { var mnu = $('
      ').addClass('popupmenu libcal-rsvp-replymode'); - $.each(['all','current','future'], function(i, mode) { + $.each(['all','current'/*,'future'*/], function(i, mode) { $('
    • ' + rcmail.get_label('rsvpmode'+mode, 'libcalendaring') + '') .addClass('ui-menu-item') .attr('rel', mode) diff --git a/plugins/libkolab/lib/kolab_storage_folder.php b/plugins/libkolab/lib/kolab_storage_folder.php index ab3c63f8..e0bf52e3 100644 --- a/plugins/libkolab/lib/kolab_storage_folder.php +++ b/plugins/libkolab/lib/kolab_storage_folder.php @@ -616,7 +616,8 @@ class kolab_storage_folder extends kolab_storage_folder_api $type = $this->type; // copy attachments from old message - if (!empty($object['_msguid']) && ($old = $this->cache->get($object['_msguid'], $type, $object['_mailbox']))) { + $copyfrom = $object['_copyfrom'] ?: $object['_msguid']; + if (!empty($copyfrom) && ($old = $this->cache->get($copyfrom, $type, $object['_mailbox']))) { foreach ((array)$old['_attachments'] as $key => $att) { if (!isset($object['_attachments'][$key])) { $object['_attachments'][$key] = $old['_attachments'][$key]; @@ -628,7 +629,7 @@ class kolab_storage_folder extends kolab_storage_folder_api // load photo.attachment from old Kolab2 format to be directly embedded in xcard block else if ($type == 'contact' && ($key == 'photo.attachment' || $key == 'kolab-picture.png') && $att['id']) { if (!isset($object['photo'])) - $object['photo'] = $this->get_attachment($object['_msguid'], $att['id'], $object['_mailbox']); + $object['photo'] = $this->get_attachment($copyfrom, $att['id'], $object['_mailbox']); unset($object['_attachments'][$key]); } } @@ -1010,7 +1011,7 @@ class kolab_storage_folder extends kolab_storage_folder_api foreach ((array)$object['_attachments'] as $key => $att) { if (empty($att['content']) && !empty($att['id'])) { // @TODO: use IMAP CATENATE to skip attachment fetch+push operation - $msguid = !empty($object['_msguid']) ? $object['_msguid'] : $object['uid']; + $msguid = $object['_copyfrom'] ?: ($object['_msguid'] ?: $object['uid']); if ($is_file) { $att['path'] = tempnam($temp_dir, 'rcmAttmnt'); if (($fp = fopen($att['path'], 'w')) && $this->get_attachment($msguid, $att['id'], $object['_mailbox'], false, $fp, true)) { From 515a7d9ef6dcdc1c1de2488745a511bdac256896 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Fri, 20 Feb 2015 09:25:24 +0100 Subject: [PATCH 31/32] Small fixes to recurring event invitations (#4387) --- plugins/calendar/calendar.php | 3 ++- .../calendar/drivers/kolab/kolab_driver.php | 26 ++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index 8be59564..f6079db1 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -1171,11 +1171,12 @@ class calendar extends rcube_plugin $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 } $event['id'] = $success; $event['_savemode'] = 'all'; - $event['attendees'] = $master['attendees']; // this tricks us into the next if clause $old = null; } diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index d6fb55ac..88c2a384 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -993,24 +993,37 @@ class kolab_driver extends calendar_driver // remove recurrence exceptions on re-scheduling if ($reschedule) { - unset($event['recurrence']['EXCEPTIONS']); + unset($event['recurrence']['EXCEPTIONS'], $master['recurrence']['EXDATE']); } else if (is_array($event['recurrence']['EXCEPTIONS'])) { // only keep relevant exceptions $event['recurrence']['EXCEPTIONS'] = array_filter($event['recurrence']['EXCEPTIONS'], function($exception) use ($event) { return $exception['start'] > $event['start']; }); + if (is_array($event['recurrence']['EXDATE'])) { + $event['recurrence']['EXDATE'] = array_filter($event['recurrence']['EXDATE'], function($exdate) use ($event) { + return $exdate > $event['start']; + }); + } } // compute remaining occurrences if ($event['recurrence']['COUNT']) { if (!$old['_count']) - $old['_count'] = $this->get_recurrence_count($object, $event['start']); + $old['_count'] = $this->get_recurrence_count($object, $old['start']); $event['recurrence']['COUNT'] -= intval($old['_count']); } + // remove fixed weekday when date changed + if ($old['start']->format('Y-m-d') != $event['start']->format('Y-m-d')) { + if (strlen($event['recurrence']['BYDAY']) == 2) + unset($event['recurrence']['BYDAY']); + if ($old['recurrence']['BYMONTH'] == $old['start']->format('n')) + unset($event['recurrence']['BYMONTH']); + } + // set until-date on master event - $master['recurrence']['UNTIL'] = clone $event['start']; + $master['recurrence']['UNTIL'] = clone $old['start']; $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); unset($master['recurrence']['COUNT']); @@ -1020,6 +1033,11 @@ class kolab_driver extends calendar_driver return $exception['start'] < $event['start']; }); } + if (is_array($master['recurrence']['EXDATE'])) { + $master['recurrence']['EXDATE'] = array_filter($master['recurrence']['EXDATE'], function($exdate) use ($event) { + return $exdate < $event['start']; + }); + } // save new event if ($success = $storage->insert_event($event)) { @@ -1115,7 +1133,7 @@ class kolab_driver extends calendar_driver // when saving an instance in 'all' mode, copy recurrence exceptions over if ($old['recurrence_id']) { - $event['recurrence'] = $master['recurrence']; + $event['recurrence']['EXCEPTIONS'] = $master['recurrence']['EXCEPTIONS']; } // TODO: forward changes to exceptions (which do not yet have differing values stored) From f972f4a511a7c3710ec5f5cee001133837797923 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Fri, 20 Feb 2015 10:18:21 +0100 Subject: [PATCH 32/32] Disable the 'future' savemode for event deletion if attendees are involved --- plugins/calendar/calendar_ui.js | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js index 5a26c188..eb57fa71 100644 --- a/plugins/calendar/calendar_ui.js +++ b/plugins/calendar/calendar_ui.js @@ -2603,25 +2603,19 @@ function rcube_calendar_ui(settings) // recurring event: user needs to select the savemode if (event.recurrence) { - var disabled_state = '', message_label = (action == 'remove' ? 'removerecurringeventwarning' : 'changerecurringeventwarning'); -/* - if (_has_attendees) { - if (action == 'remove') { - if (!_is_organizer) { - message_label = 'removerecurringallonly'; - disabled_state = ' disabled'; - } - } - else if (is_organizer(event)) { - disabled_state = ' disabled'; - } + var future_disabled = '', message_label = (action == 'remove' ? 'removerecurringeventwarning' : 'changerecurringeventwarning'); + + // disable the 'future' savemode if attendees are involved + // reason: no calendaring system supports the thisandfuture range parameter + if (action == 'remove' && _has_attendees && is_organizer(event)) { + future_disabled = ' disabled'; } -*/ + html += '
      ' + rcmail.gettext(message_label, 'calendar') + '
      ' + '';