diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index 51381cb4..f6079db1 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -841,17 +841,21 @@ 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); + // 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 @@ -859,7 +863,9 @@ 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); + $this->event_save_success($event, null, $action, true); } $reload = $success && $event['recurrence'] ? 2 : 1; break; @@ -868,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; @@ -890,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; @@ -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')) @@ -947,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": @@ -967,18 +973,20 @@ 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'); $ev = $this->driver->get_event($event); $ev['attendees'] = $event['attendees']; $ev['free_busy'] = $event['free_busy']; + $ev['_savemode'] = $event['_savemode']; // send invitation to delegatee + add it as attendee if ($status == 'delegated' && $event['to']) { $itip = $this->load_itip(); - if ($itip->delegate_to($ev, $event['to'], (bool)$event['rsvp'])) { + if ($itip->delegate_to($ev, $event['to'], (bool)$event['rsvp'], $attendees)) { $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation'); $noreply = false; } @@ -986,10 +994,15 @@ 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 ($event) { + return $event['attendees'][$j]; + }, $attendees)); + + if ($success = $this->driver->edit_rsvp($event, $status, $updated_attendees)) { $noreply = rcube_utils::get_input_value('noreply', rcube_utils::INPUT_GPC); $noreply = intval($noreply) || $status == 'needs-action' || $itip_sending === 0; - $reload = $event['calendar'] != $ev['calendar'] ? 2 : 1; + $reload = $event['calendar'] != $ev['calendar'] || $event['recurrence'] ? 2 : 1; $organizer = null; $emails = $this->get_user_emails(); @@ -1006,11 +1019,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; @@ -1122,27 +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'])) { - // 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'; - } - - // 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'); @@ -1157,6 +1156,62 @@ 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['attendees'] = $master['attendees']; // this tricks us into the next if clause + } + + $event['id'] = $success; + $event['_savemode'] = 'all'; + $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 @@ -1663,6 +1718,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) { @@ -1687,6 +1743,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'])) { @@ -1945,6 +2004,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']); @@ -1987,6 +2049,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)) @@ -2212,7 +2276,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; @@ -2403,11 +2467,14 @@ class calendar extends rcube_plugin */ function event_itip_remove() { - $success = false; - $uid = rcube_utils::get_input_value('uid', 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), true)) { + if ($event = $this->driver->get_event(array('uid' => $uid, '_instance' => $instance), true)) { + $event['_savemode'] = $savemode; $success = $this->driver->remove_event($event, true); } @@ -2635,6 +2702,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; @@ -2722,12 +2791,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'] = $savemode ?: $event['_savemode']; + // only update attendee status if ($event['_method'] == 'REPLY') { // try to identify the attendee using the email sender address @@ -2740,9 +2810,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'; @@ -2752,9 +2824,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; + } } } @@ -2771,12 +2846,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'); @@ -2829,7 +2904,43 @@ class calendar extends rcube_plugin if ($status == 'declined' || $event['status'] == 'CANCELLED' || $event_attendee['role'] == 'NON-PARTICIPANT') { $event['free_busy'] = 'free'; } - $success = $this->driver->new_event($event); + + // if the RSVP reply only refers to a single instance: + // store unmodified master event with current instance as exception + if (!empty($instance) && !empty($savemode) && $savemode != 'all') { + $master = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event'); + if ($master['recurrence'] && !$master['_instance']) { + // compute recurring events until this instance's date + if ($recurrence_date = rcube_utils::anytodatetime($instance, $master['start']->getTimezone())) { + $recurrence_date->setTime(23,59,59); + + foreach ($this->driver->get_recurring_events($master, $master['start'], $recurrence_date) as $recurring) { + if ($recurring['_instance'] == $instance) { + // copy attendees block with my partstat to exception + $recurring['attendees'] = $event['attendees']; + $master['recurrence']['EXCEPTIONS'][] = $recurring; + $event = $recurring; // set reference for iTip reply + break; + } + } + + $master['calendar'] = $event['calendar'] = $calendar['id']; + $success = $this->driver->new_event($master); + } + else { + $master = null; + } + } + else { + $master = null; + } + } + + // save to the selected/default calendar + if (!$master) { + $event['calendar'] = $calendar['id']; + $success = $this->driver->new_event($event); + } } else if ($status == 'declined') $error_msg = null; diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js index c1e3d556..eb57fa71 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 .rsvp-buttons').addClass('recurring'); + } + else { + $('#event-rsvp .rsvp-buttons').removeClass('recurring'); + } } var buttons = []; @@ -596,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, @@ -683,6 +694,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}); @@ -742,11 +755,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); + $('input.edit-recurring-savemode[value="'+sel+'"]').prop('checked', true).change(); } else $('#edit-recurring-warning').hide(); @@ -797,7 +808,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 = []; @@ -2370,19 +2381,37 @@ 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; } // 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) { @@ -2391,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']) { @@ -2399,6 +2429,12 @@ function rcube_calendar_ui(settings) data.role = 'REQ-PARTICIPANT'; } } + + attendees.push(i) + } + else if (response != 'DELEGATED' && data['delegated-from'] && + settings.identity.emails.indexOf(';'+String(data['delegated-from']).toLowerCase()) >= 0) { + delete data['delegated-from']; } // set free_busy status to transparent if declined (#4425) @@ -2411,7 +2447,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: replymode || 'all' }, (delegate || {})), noreply = $('#noreply-event-rsvp:checked').length ? 1 : 0; // import event from mail (temporary iTip event) @@ -2425,15 +2461,17 @@ 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, + _instance: submit_data._instance, + _savemode: submit_data._savemode }); } 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); @@ -2501,7 +2539,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; @@ -2565,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'); + var future_disabled = '', 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'; - } + // 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') + '
' + '
' + - '' + rcmail.gettext('currentevent', 'calendar') + '' + - '' + rcmail.gettext('futurevents', 'calendar') + '' + + '' + rcmail.gettext('currentevent', 'calendar') + '' + + '' + rcmail.gettext('futurevents', 'calendar') + '' + '' + rcmail.gettext('allevents', 'calendar') + '' + (action != 'remove' ? '' + rcmail.gettext('saveasnew', 'calendar') + '' : '') + '
'; @@ -2606,8 +2638,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 +2656,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"); } @@ -4151,9 +4185,16 @@ 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) { + 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/calendar_driver.php b/plugins/calendar/drivers/calendar_driver.php index 24e7a2e8..659f4a0e 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 @@ -196,9 +197,22 @@ 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); + } + + /** + * 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); } @@ -449,6 +463,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) { @@ -464,12 +479,11 @@ 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']; - $next_event['_instance'] = $i; $events[] = $next_event; } else if ($next_event['start'] > $end) { // stop loop if out of range @@ -477,7 +491,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 2b50ac8a..64bb0826 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); } } @@ -236,35 +240,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,35 +272,16 @@ 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']); $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; @@ -309,28 +290,54 @@ class kolab_calendar extends kolab_storage_folder_api } } + // find and merge exception for the first instance + 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); + if ($exdate == $event_date) { + $event['_instance'] = $exception['_instance']; + kolab_driver::merge_exception_data($event, $exception); + } + } + } + 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; @@ -585,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,9 +639,10 @@ 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 - $this->_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']; @@ -653,38 +666,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','created','changed','recurrence','organizer','attendees','sequence'); - - 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']); @@ -702,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 98318f25..88c2a384 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 instances + if (!$event['id'] && $event['_instance']) { + $id .= '-' . $event['_instance']; + } } else { $id = $event; @@ -614,9 +619,21 @@ 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')) { + $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; @@ -629,6 +646,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']) && !$event['recurrence_id'])) { + if (!($storage = $this->get_calendar($event['calendar']))) + return false; + + // load master event + $master = $event['recurrence_id'] ? $storage->get_event($event['recurrence_id']) : $event; + + // apply attendee update to each existing exception + if ($master['recurrence'] && !empty($master['recurrence']['EXCEPTIONS'])) { + $saved = false; + foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { + // merge the new event properties onto future exceptions + if ($exception['_instance'] >= $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 @@ -640,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); } @@ -656,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); } @@ -687,11 +748,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 +941,12 @@ 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'); + $object = $fromcalendar->storage->get_object($master['uid']); + + // 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 @@ -894,47 +960,115 @@ 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['_copyfrom'] = $object['_msguid']; + $event['_mailbox'] = $object['_mailbox']; $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['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'], $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, $old['start']); + $event['recurrence']['COUNT'] -= intval($old['_count']); + } + + // remove fixed weekday when date changed + if ($old['start']->format('Y-m-d') != $event['start']->format('Y-m-d')) { + if (strlen($event['recurrence']['BYDAY']) == 2) + unset($event['recurrence']['BYDAY']); + if ($old['recurrence']['BYMONTH'] == $old['start']->format('n')) + unset($event['recurrence']['BYMONTH']); + } + + // set until-date on master event + $master['recurrence']['UNTIL'] = clone $old['start']; + $master['recurrence']['UNTIL']->sub(new DateInterval('P1D')); + unset($master['recurrence']['COUNT']); + + // remove all exceptions after $event['start'] + if (is_array($master['recurrence']['EXCEPTIONS'])) { + $master['recurrence']['EXCEPTIONS'] = array_filter($master['recurrence']['EXCEPTIONS'], function($exception) use ($event) { + return $exception['start'] < $event['start']; + }); + } + if (is_array($master['recurrence']['EXDATE'])) { + $master['recurrence']['EXDATE'] = array_filter($master['recurrence']['EXDATE'], function($exdate) use ($event) { + return $exdate < $event['start']; + }); + } + + // save new event + if ($success = $storage->insert_event($event)) { + $success = $event['uid']; + + // update master event (no rescheduling!) + $master['sequence'] = $object['sequence']; + self::clear_attandee_noreply($master); + $udated = $storage->update_event($master); + } + break; + 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'], $event['id']); // increment sequence of this instance if scheduling is affected if ($reschedule) { - $event['sequence'] = $old['sequence'] + 1; + $event['sequence'] = max($old['sequence'], $master['sequence']) + 1; + } + else if (!isset($event['sequence'])) { + $event['sequence'] = $master['sequence']; } - // 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) { - 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; } } @@ -955,6 +1089,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'] ?: $old['start']; $master['recurrence']['EXCEPTIONS'][] = $event; } $success = $storage->update_event($master); @@ -996,17 +1131,41 @@ class kolab_driver extends calendar_driver $event['end'] = $master['end']; } - // 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'; + // when saving an instance in 'all' mode, copy recurrence exceptions over + if ($old['recurrence_id']) { + $event['recurrence']['EXCEPTIONS'] = $master['recurrence']['EXCEPTIONS']; + } + + // TODO: forward changes to exceptions (which do not yet have differing values stored) + if (is_array($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) { + // determine added and removed attendees + $old_attendees = $current_attendees = $added_attendees = array(); + foreach ((array)$old['attendees'] as $attendee) { + $old_attendees[] = $attendee['email']; + } + foreach ((array)$event['attendees'] as $attendee) { + $current_attendees[] = $attendee['email']; + if (!in_array($attendee['email'], $old_attendees)) { + $added_attendees[] = $attendee; + } + } + $removed_attendees = array_diff($old_attendees, $current_attendees); + foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { - $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); + } } } } @@ -1029,26 +1188,14 @@ 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; - } - } + $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'])) { @@ -1074,6 +1221,171 @@ 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; + + // determine added and removed attendees + $added_attendees = $removed_attendees = array(); + if ($savemode == 'future') { + $old_attendees = $current_attendees = array(); + foreach ((array)$old['attendees'] as $attendee) { + $old_attendees[] = $attendee['email']; + } + foreach ((array)$event['attendees'] as $attendee) { + $current_attendees[] = $attendee['email']; + if (!in_array($attendee['email'], $old_attendees)) { + $added_attendees[] = $attendee; + } + } + $removed_attendees = array_diff($old_attendees, $current_attendees); + } + + foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { + // update a specific instance + if ($exception['_instance'] == $old['_instance']) { + $existing = $i; + + // check savemode against existing exception mode. + // if matches, we can update this existing exception + if ((bool)$exception['thisandfuture'] === ($savemode == 'future')) { + $event['_instance'] = $old['_instance']; + $event['thisandfuture'] = $old['thisandfuture']; + $event['recurrence_date'] = $old['recurrence_date']; + $master['recurrence']['EXCEPTIONS'][$i] = $event; + $saved = true; + } + } + // merge the new event properties onto future exceptions + if ($savemode == 'future' && $exception['_instance'] >= $old['_instance']) { + unset($event['thisandfuture']); + self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event, array('attendees')); + + if (!empty($added_attendees) || !empty($removed_attendees)) { + self::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees); + } + } + } +/* + // we could not update the existing exception due to savemode mismatch... + if (!$saved && $existing !== null && $master['recurrence']['EXCEPTIONS'][$existing]['thisandfuture']) { + // ... try to move the existing this-and-future exception to the next occurrence + foreach ($this->get_recurring_events($master, $existing['start']) as $candidate) { + // our old this-and-future exception is obsolete + if ($candidate['thisandfuture']) { + unset($master['recurrence']['EXCEPTIONS'][$existing]); + $saved = true; + break; + } + // this occurrence doesn't yet have an exception + else if (!$candidate['isexception']) { + $event['_instance'] = $candidate['_instance']; + $event['recurrence_date'] = $candidate['recurrence_date']; + $master['recurrence']['EXCEPTIONS'][$i] = $event; + $saved = true; + break; + } + } + } +*/ + + // returning false here will add a new exception + 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 + * + * @param array The event object to be altered + * @param array The overlay event object to be merged over $event + * @param array List of properties not allowed to be overwritten + */ + public static function merge_exception_data(&$event, $overlay, $blacklist = null) + { + $forbidden = array('id','uid','recurrence','recurrence_date','thisandfuture','organizer','_attachments'); + + if (is_array($blacklist)) + $forbidden = array_merge($forbidden, $blacklist); + + // compute date offset from the exception + if ($overlay['start'] instanceof DateTime && $overlay['recurrence_date'] instanceof DateTime) { + $date_offset = $overlay['recurrence_date']->diff($overlay['start']); + } + + foreach ($overlay as $prop => $value) { + if ($prop == 'start' || $prop == 'end') { + if (is_object($event[$prop]) && $event[$prop] instanceof DateTime) { + // set date value if overlay is an exception of the current instance + if (substr($overlay['_instance'], 0, 8) == substr($event['_instance'], 0, 8)) { + $event[$prop]->setDate(intval($value->format('Y')), intval($value->format('n')), intval($value->format('j'))); + } + // apply date offset + else if ($date_offset) { + $event[$prop]->add($date_offset); + } + // adjust time of the recurring event instance + $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s'))); + } + } + else if ($prop == 'thisandfuture' && $overlay['_instance'] == $event['_instance']) { + $event[$prop] = $value; + } + else if ($prop[0] != '_' && !in_array($prop, $forbidden)) + $event[$prop] = $value; + } + } + + /** + * Update attendee properties on the given event object + * + * @param array The event object to be altered + * @param array List of hash arrays each represeting an updated/added attendee + */ + public static function merge_attendee_data(&$event, $attendees, $removed = null) + { + if (!empty($attendees) && !is_array($attendees[0])) { + $attendees = array($attendees); + } + + foreach ($attendees as $attendee) { + $found = false; + + foreach ($event['attendees'] as $i => $candidate) { + if ($candidate['email'] == $attendee['email']) { + $event['attendees'][$i] = $attendee; + $found = true; + break; + } + } + + if (!$found) { + $event['attendees'][] = $attendee; + } + } + + // filter out removed attendees + if (!empty($removed)) { + $event['attendees'] = array_filter($event['attendees'], function($attendee) use ($removed) { + return !in_array($attendee['email'], $removed); + }); + } + } + /** * Get events from source. * @@ -1352,6 +1664,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 */ @@ -1524,12 +1859,69 @@ class kolab_driver extends calendar_driver if (empty($record['recurrence'])) 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 = $record['allday'] ? 'Ymd' : 'Ymd\THis'; + $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/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/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/calendar.css b/plugins/calendar/skins/larry/calendar.css index 50f8b645..fef16bd8 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; @@ -1059,6 +1063,10 @@ td.topalign { text-align: center; } +.libcal-rsvp-replymode li a { + cursor: default; +} + #event-rsvp, #edit-attendees-notify { margin: 0.6em 0 0.3em 0; @@ -2159,6 +2167,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/calendar/skins/larry/print.css b/plugins/calendar/skins/larry/print.css index 5d190f5b..fc5de975 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,31 @@ 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; + padding-bottom: 1em; +} + #calendarlist input, #calendarlist .handle { display: none; @@ -207,6 +220,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 @@
+
diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php index 3eead6fa..14dacf4d 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($event['thisandfuture'] ? 'itipmessagefutureoccurrence' : 'itipmessagesingleoccurrence') . ' **'; + } + else if (!empty($event['recurrence'])) { + $recurrence_info = sprintf("\n%s: %s", $this->gettext('recurring'), $this->lib->recurrence_text($event['recurrence'])); + } + $mailbody = $this->gettext(array( 'name' => $bodytext, 'vars' => array( 'title' => $event['title'], - 'date' => $this->lib->event_date_text($event, true) . - (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,15 +243,23 @@ 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') { foreach ($event['attendees'] as $i => $attendee) { if ($attendee['status'] != 'DELEGATED') { - $event['attendees'][$i]['rsvp']= $rsvp ? true : null; + $event['attendees'][$i]['rsvp']= (bool)$rsvp; } } } + else if ($method == 'CANCEL') { + if ($event['recurrence']) { + unset($event['recurrence']['EXCEPTIONS']); + } + } // compose multipart message using PEAR:Mail_Mime $message = new Mail_mime("\r\n"); @@ -276,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); @@ -324,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'); } @@ -343,7 +367,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']); } @@ -453,6 +477,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 +605,17 @@ 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'], + '_savemode' => $event['_savemode'], + )); // 1. remove the event from our calendar $button_remove = html::tag('input', array( 'type' => 'button', 'class' => 'button', - 'onclick' => "rcube_libcalendaring.remove_from_itip('" . 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 +676,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 +684,19 @@ 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('rsvp-buttons', + $buttons . + html::div('itip-reply-controls', $this->itip_rsvp_options_ui($attrib['id'])) + ) + ); } /** @@ -705,7 +743,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($event['thisandfuture'] ? 'itipfutureoccurrence' : '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..1c8fe16f 100644 --- a/plugins/libcalendaring/libcalendaring.js +++ b/plugins/libcalendaring/libcalendaring.js @@ -958,14 +958,48 @@ 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') + '') + .addClass('ui-menu-item') + .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); +}; + /** * */ -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..2d9c3cc7 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,34 @@ 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) + { + // for savemode=all, remove recurrence instance identifiers + if (!empty($object['_savemode']) && $object['_savemode'] == 'all' && $object['recurrence']) { + unset($object['_instance'], $object['recurrence_date']); + } + // set instance and 'savemode' according to recurrence-id + else if (!empty($object['recurrence_date']) && is_a($object['recurrence_date'], 'DateTime')) { + $recurrence_id_format = $object['allday'] ? 'Ymd' : 'Ymd\THis'; + $object['_instance'] = $object['recurrence_date']->format($recurrence_id_format); + $object['_savemode'] = $object['thisandfuture'] ? 'future' : 'current'; + } + else if (!empty($object['recurrence_id']) || !empty($object['_instance'])) { + if (strlen($object['_instance']) > 4) { + $object['recurrence_date'] = rcube_utils::anytodatetime($object['_instance'], $object['start']->getTimezone()); + } + else { + $object['recurrence_date'] = clone $object['start']; + } + } + } /********* Attendee handling functions *********/ diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php index 4163cfbe..826e8d84 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; @@ -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); @@ -1081,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))); } } diff --git a/plugins/libcalendaring/localization/en_US.inc b/plugins/libcalendaring/localization/en_US.inc index 3e49838b..e5e04261 100644 --- a/plugins/libcalendaring/localization/en_US.inc +++ b/plugins/libcalendaring/localization/en_US.inc @@ -108,6 +108,14 @@ $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['rsvpmodeall'] = 'The entire series'; +$labels['rsvpmodecurrent'] = 'This occurrence only'; +$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'; +$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'; 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_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..91efb26f 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'; @@ -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; } /** @@ -115,10 +118,13 @@ 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); } + 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; @@ -220,15 +226,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); } /** @@ -244,12 +251,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) { - unset($exception[$prop]); - } - } - // preserve this property for date serialization $exception['allday'] = $master['allday']; diff --git a/plugins/libkolab/lib/kolab_format_task.php b/plugins/libkolab/lib/kolab_format_task.php index ee0ca6a9..46408754 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 @@ -111,19 +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 d0f89b63..4d3a7583 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,21 +319,11 @@ 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." - 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 ($this->check_rescheduling($object)) { + $object['sequence']++; } } } @@ -365,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; @@ -619,22 +611,68 @@ 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); + } + + /** + * Identify changes considered relevant for scheduling + * + * @param array Hash array with NEW object properties + * @param array Hash array with OLD object properties + * + * @return boolean True if changes affect scheduling, False otherwise + */ + public function check_rescheduling($object, $old = null) + { + $reschedule = false; + + 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) { + $a = $a->format('Y-m-d'); + $b = $b->format('Y-m-d'); + } + if ($prop == 'recurrence' && is_array($a) && is_array($b)) { + 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 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)) {