Merge branch 'dev/recurring-invitations'

This commit is contained in:
Thomas Bruederli 2015-02-20 10:18:59 +01:00
commit 52bbf63a8e
21 changed files with 1062 additions and 328 deletions

View file

@ -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,10 +874,7 @@ 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;
break;
@ -879,10 +882,7 @@ class calendar extends rcube_plugin
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')
@ -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;
@ -2405,9 +2469,12 @@ class calendar extends rcube_plugin
{
$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);
$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,11 +2824,14 @@ 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))) {
(stripos($attendee['delegated-from'], $event['_sender']) !== false ||
stripos($attendee['delegated-from'], $event['_sender_utf']) !== false)) {
$update_attendees[] = $attendee;
if (!in_array($attendee['email'], $existing_attendee_emails)) {
$existing['attendees'][] = $attendee;
}
}
}
// if delegatee has declined, set delegator's RSVP=True
if ($event_attendee && $event_attendee['status'] == 'DECLINED' && $event_attendee['delegated-from']) {
@ -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,8 +2904,44 @@ 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) && !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;
}

View file

@ -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 += '<div class="message"><span class="ui-icon ui-icon-alert"></span>' +
rcmail.gettext(message_label, 'calendar') + '</div>' +
'<div class="savemode">' +
'<a href="#current" class="button' + disabled_state + '">' + rcmail.gettext('currentevent', 'calendar') + '</a>' +
'<a href="#future" class="button' + disabled_state + '">' + rcmail.gettext('futurevents', 'calendar') + '</a>' +
'<a href="#current" class="button">' + rcmail.gettext('currentevent', 'calendar') + '</a>' +
'<a href="#future" class="button' + future_disabled + '">' + rcmail.gettext('futurevents', 'calendar') + '</a>' +
'<a href="#all" class="button">' + rcmail.gettext('allevents', 'calendar') + '</a>' +
(action != 'remove' ? '<a href="#new" class="button">' + rcmail.gettext('saveasnew', 'calendar') + '</a>' : '') +
'</div>';
@ -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();

View file

@ -50,6 +50,7 @@
* 'EXCEPTIONS' => array(<event>), 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;
}
}

View file

@ -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,6 +290,18 @@ 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;
}
@ -316,20 +309,34 @@ class kolab_calendar extends kolab_storage_folder_api
// 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']) {
// post-filter all events by fulltext search and partstat values
$me = $this;
$events = array_filter($events, function($event) use ($words, $me) {
$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);
}
return $hits >= count($words);
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);
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);

View file

@ -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;
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 2;
}
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,9 +1131,32 @@ class kolab_driver extends calendar_driver
$event['end'] = $master['end'];
}
// when saving an instance in 'all' mode, copy recurrence exceptions over
if ($old['recurrence_id']) {
$event['recurrence']['EXCEPTIONS'] = $master['recurrence']['EXCEPTIONS'];
}
// 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) {
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) &&
is_array($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) {
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'] :
@ -1010,6 +1168,7 @@ class kolab_driver extends calendar_driver
}
}
}
}
// unset _dateonly flags in (cached) date objects
unset($event['start']->_dateonly, $event['end']->_dateonly);
@ -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
*/

View file

@ -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;

View file

@ -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,

View file

@ -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;

View file

@ -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%;

View file

@ -19,6 +19,7 @@
<div class="calwidth">
<roundcube:object name="plugin.calendar_list" activeonly="true" id="calendarlist" />
<br style="clear:both">
</div>
<roundcube:object name="plugin.calendar_css" printmode="true" />

View file

@ -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']));
}

View file

@ -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 = $('<ul></ul>').addClass('popupmenu libcal-rsvp-replymode');
$.each(['all','current'/*,'future'*/], function(i, mode) {
$('<li><a>' + rcmail.get_label('rsvpmode'+mode, 'libcalendaring') + '</a>')
.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')
);
}

View file

@ -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 *********/

View file

@ -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)));
}
}

View file

@ -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 <em>single occurrence</em> out of a series of events';
$labels['itipfutureoccurrence'] = 'Refers to <em>this and all future occurrences</em> 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';

View file

@ -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

View file

@ -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;

View file

@ -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'];

View file

@ -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);
}
}

View file

@ -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) {
if ($this->check_rescheduling($object)) {
$object['sequence']++;
break;
}
}
}
}
@ -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;
}
}

View file

@ -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)) {