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); $event = rcube_utils::get_input_value('e', rcube_utils::INPUT_POST, true);
$success = $reload = $got_msg = false; $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 // 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; $event['_notify'] = 1;
// read old event data in order to find changes // 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); $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) { switch ($action) {
case "new": case "new":
// create UID for new event // create UID for new event
@ -859,7 +863,9 @@ class calendar extends rcube_plugin
$this->write_preprocess($event, $action); $this->write_preprocess($event, $action);
if ($success = $this->driver->new_event($event)) { if ($success = $this->driver->new_event($event)) {
$event['id'] = $event['uid']; $event['id'] = $event['uid'];
$event['_savemode'] = 'all';
$this->cleanup_event($event); $this->cleanup_event($event);
$this->event_save_success($event, null, $action, true);
} }
$reload = $success && $event['recurrence'] ? 2 : 1; $reload = $success && $event['recurrence'] ? 2 : 1;
break; break;
@ -868,10 +874,7 @@ class calendar extends rcube_plugin
$this->write_preprocess($event, $action); $this->write_preprocess($event, $action);
if ($success = $this->driver->edit_event($event)) { if ($success = $this->driver->edit_event($event)) {
$this->cleanup_event($event); $this->cleanup_event($event);
if ($success !== true) { $this->event_save_success($event, $old, $action, $success);
$event['id'] = $success;
$old = null;
}
} }
$reload = $success && ($event['recurrence'] || $event['_savemode'] || $event['_fromcalendar']) ? 2 : 1; $reload = $success && ($event['recurrence'] || $event['_savemode'] || $event['_fromcalendar']) ? 2 : 1;
break; break;
@ -879,10 +882,7 @@ class calendar extends rcube_plugin
case "resize": case "resize":
$this->write_preprocess($event, $action); $this->write_preprocess($event, $action);
if ($success = $this->driver->resize_event($event)) { if ($success = $this->driver->resize_event($event)) {
if ($success !== true) { $this->event_save_success($event, $old, $action, $success);
$event['id'] = $success;
$old = null;
}
} }
$reload = $event['_savemode'] ? 2 : 1; $reload = $event['_savemode'] ? 2 : 1;
break; break;
@ -890,10 +890,7 @@ class calendar extends rcube_plugin
case "move": case "move":
$this->write_preprocess($event, $action); $this->write_preprocess($event, $action);
if ($success = $this->driver->move_event($event)) { if ($success = $this->driver->move_event($event)) {
if ($success !== true) { $this->event_save_success($event, $old, $action, $success);
$event['id'] = $success;
$old = null;
}
} }
$reload = $success && $event['_savemode'] ? 2 : 1; $reload = $success && $event['_savemode'] ? 2 : 1;
break; break;
@ -928,8 +925,14 @@ class calendar extends rcube_plugin
$got_msg = true; $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 // send iTIP reply that participant has declined the event
if ($success && $event['decline']) { if ($success && $event['_decline']) {
$emails = $this->get_user_emails(); $emails = $this->get_user_emails();
foreach ($old['attendees'] as $i => $attendee) { foreach ($old['attendees'] as $i => $attendee) {
if ($attendee['role'] == 'ORGANIZER') if ($attendee['role'] == 'ORGANIZER')
@ -947,6 +950,9 @@ class calendar extends rcube_plugin
else else
$this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error');
} }
else if ($success) {
$this->event_save_success($event, $old, $action, $success);
}
break; break;
case "undo": case "undo":
@ -967,18 +973,20 @@ class calendar extends rcube_plugin
case "rsvp": case "rsvp":
$itip_sending = $this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']); $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']; $reply_comment = $event['comment'];
$this->write_preprocess($event, 'edit'); $this->write_preprocess($event, 'edit');
$ev = $this->driver->get_event($event); $ev = $this->driver->get_event($event);
$ev['attendees'] = $event['attendees']; $ev['attendees'] = $event['attendees'];
$ev['free_busy'] = $event['free_busy']; $ev['free_busy'] = $event['free_busy'];
$ev['_savemode'] = $event['_savemode'];
// send invitation to delegatee + add it as attendee // send invitation to delegatee + add it as attendee
if ($status == 'delegated' && $event['to']) { if ($status == 'delegated' && $event['to']) {
$itip = $this->load_itip(); $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'); $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation');
$noreply = false; $noreply = false;
} }
@ -986,10 +994,15 @@ class calendar extends rcube_plugin
$event = $ev; $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 = rcube_utils::get_input_value('noreply', rcube_utils::INPUT_GPC);
$noreply = intval($noreply) || $status == 'needs-action' || $itip_sending === 0; $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; $organizer = null;
$emails = $this->get_user_emails(); $emails = $this->get_user_emails();
@ -1006,11 +1019,18 @@ class calendar extends rcube_plugin
$itip = $this->load_itip(); $itip = $this->load_itip();
$itip->set_sender_email($reply_sender); $itip->set_sender_email($reply_sender);
$event['comment'] = $reply_comment; $event['comment'] = $reply_comment;
$event['thisandfuture'] = $event['_savemode'] == 'future';
if ($organizer && $itip->send_itip_message($event, 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status)) 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'); $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ? $organizer['name'] : $organizer['email']))), 'confirmation');
else else
$this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); $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; break;
@ -1122,27 +1142,6 @@ class calendar extends rcube_plugin
$this->rc->output->show_message('calendar.errorsaving', 'error'); $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 // unlock client
$this->rc->output->command('plugin.unlock_saving'); $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 * Handler for load-requests from fullcalendar
* This will return pure JSON formatted output * This will return pure JSON formatted output
@ -1663,6 +1718,7 @@ class calendar extends rcube_plugin
if ($event['recurrence']) { if ($event['recurrence']) {
$event['recurrence_text'] = $this->lib->recurrence_text($event['recurrence']); $event['recurrence_text'] = $this->lib->recurrence_text($event['recurrence']);
$event['recurrence'] = $this->lib->to_client_recurrence($event['recurrence'], $event['allday']); $event['recurrence'] = $this->lib->to_client_recurrence($event['recurrence'], $event['allday']);
unset($event['recurrence_date']);
} }
foreach ((array)$event['attachments'] as $k => $attachment) { foreach ((array)$event['attachments'] as $k => $attachment) {
@ -1687,6 +1743,9 @@ class calendar extends rcube_plugin
if ($attendee['status'] == 'DELEGATED' && $attendee['rsvp'] == false) { if ($attendee['status'] == 'DELEGATED' && $attendee['rsvp'] == false) {
$event['attendees'][$i]['noreply'] = true; $event['attendees'][$i]['noreply'] = true;
} }
else {
unset($event['attendees'][$i]['noreply']);
}
} }
if ($organizer === null && !empty($event['organizer'])) { if ($organizer === null && !empty($event['organizer'])) {
@ -1945,6 +2004,9 @@ class calendar extends rcube_plugin
// add comment to the iTip attachment // add comment to the iTip attachment
$event['comment'] = $comment; $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 // compose multipart message using PEAR:Mail_Mime
$method = $action == 'remove' ? 'CANCEL' : 'REQUEST'; $method = $action == 'remove' ? 'CANCEL' : 'REQUEST';
$message = $itip->compose_itip_message($event, $method, $event['sequence'] > $old['sequence']); $message = $itip->compose_itip_message($event, $method, $event['sequence'] > $old['sequence']);
@ -1987,6 +2049,8 @@ class calendar extends rcube_plugin
$sent = -100; $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 // send CANCEL message to removed attendees
foreach ((array)$old['attendees'] as $attendee) { foreach ((array)$old['attendees'] as $attendee) {
if ($attendee['ROLE'] == 'ORGANIZER' || !$attendee['email'] || in_array(strtolower($attendee['email']), $current)) 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) public static function event_diff($a, $b)
{ {
$diff = array(); $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) { foreach (array_unique(array_merge(array_keys($a), array_keys($b))) as $key) {
if (!$ignore[$key] && $a[$key] != $b[$key]) if (!$ignore[$key] && $a[$key] != $b[$key])
$diff[] = $key; $diff[] = $key;
@ -2405,9 +2469,12 @@ class calendar extends rcube_plugin
{ {
$success = false; $success = false;
$uid = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST); $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 // 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); $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)); $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 = intval(rcube_utils::get_input_value('_noreply', rcube_utils::INPUT_POST));
$noreply = $noreply || $status == 'needs-action' || $itip_sending === 0; $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'); $error_msg = $this->gettext('errorimportingevent');
$success = false; $success = false;
@ -2722,12 +2791,13 @@ class calendar extends rcube_plugin
// save to calendar // save to calendar
if ($calendar && !$calendar['readonly']) { if ($calendar && !$calendar['readonly']) {
$event['calendar'] = $calendar['id'];
// check for existing event with the same UID // 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) { if ($existing) {
// forward savemode for correct updates of recurring events
$existing['_savemode'] = $savemode ?: $event['_savemode'];
// only update attendee status // only update attendee status
if ($event['_method'] == 'REPLY') { if ($event['_method'] == 'REPLY') {
// try to identify the attendee using the email sender address // try to identify the attendee using the email sender address
@ -2740,9 +2810,11 @@ class calendar extends rcube_plugin
} }
} }
$event_attendee = null; $event_attendee = null;
$update_attendees = array();
foreach ($event['attendees'] as $attendee) { foreach ($event['attendees'] as $attendee) {
if ($event['_sender'] && ($attendee['email'] == $event['_sender'] || $attendee['email'] == $event['_sender_utf'])) { if ($event['_sender'] && ($attendee['email'] == $event['_sender'] || $attendee['email'] == $event['_sender_utf'])) {
$event_attendee = $attendee; $event_attendee = $attendee;
$update_attendees[] = $attendee;
$metadata['fallback'] = $attendee['status']; $metadata['fallback'] = $attendee['status'];
$metadata['attendee'] = $attendee['email']; $metadata['attendee'] = $attendee['email'];
$metadata['rsvp'] = $attendee['rsvp'] || $attendee['role'] != 'NON-PARTICIPANT'; $metadata['rsvp'] = $attendee['rsvp'] || $attendee['role'] != 'NON-PARTICIPANT';
@ -2752,11 +2824,14 @@ class calendar extends rcube_plugin
} }
// also copy delegate attendee // also copy delegate attendee
else if (!empty($attendee['delegated-from']) && else if (!empty($attendee['delegated-from']) &&
(stripos($attendee['delegated-from'], $event['_sender']) !== false || stripos($attendee['delegated-from'], $event['_sender_utf']) !== false) && (stripos($attendee['delegated-from'], $event['_sender']) !== false ||
(!in_array($attendee['email'], $existing_attendee_emails))) { stripos($attendee['delegated-from'], $event['_sender_utf']) !== false)) {
$update_attendees[] = $attendee;
if (!in_array($attendee['email'], $existing_attendee_emails)) {
$existing['attendees'][] = $attendee; $existing['attendees'][] = $attendee;
} }
} }
}
// if delegatee has declined, set delegator's RSVP=True // if delegatee has declined, set delegator's RSVP=True
if ($event_attendee && $event_attendee['status'] == 'DECLINED' && $event_attendee['delegated-from']) { 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 // found matching attendee entry in both existing and new events
if ($existing_attendee >= 0 && $event_attendee) { if ($existing_attendee >= 0 && $event_attendee) {
$existing['attendees'][$existing_attendee] = $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 // update the entire attendees block
else if (($event['sequence'] >= $existing['sequence'] || $event['changed'] >= $existing['changed']) && $event_attendee) { else if (($event['sequence'] >= $existing['sequence'] || $event['changed'] >= $existing['changed']) && $event_attendee) {
$existing['attendees'][] = $event_attendee; $existing['attendees'][] = $event_attendee;
$success = $this->driver->edit_event($existing); $success = $this->driver->update_attendees($existing, $update_attendees);
} }
else { else {
$error_msg = $this->gettext('newerversionexists'); $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') { if ($status == 'declined' || $event['status'] == 'CANCELLED' || $event_attendee['role'] == 'NON-PARTICIPANT') {
$event['free_busy'] = 'free'; $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); $success = $this->driver->new_event($event);
} }
}
else if ($status == 'declined') else if ($status == 'declined')
$error_msg = null; $error_msg = null;
} }

View file

@ -554,6 +554,14 @@ function rcube_calendar_ui(settings)
$('#event-rsvp a.reply-comment-toggle').show(); $('#event-rsvp a.reply-comment-toggle').show();
$('#event-rsvp .itip-reply-comment textarea').hide().val(''); $('#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 = []; var buttons = [];
@ -596,13 +604,16 @@ function rcube_calendar_ui(settings)
}, },
close: function() { close: function() {
$dialog.dialog('destroy').attr('aria-hidden', 'true').hide(); $dialog.dialog('destroy').attr('aria-hidden', 'true').hide();
rcmail.command('menu-close','eventoptionsmenu') rcmail.command('menu-close','eventoptionsmenu');
$('.libcal-rsvp-replymode').hide();
}, },
dragStart: function() { dragStart: function() {
rcmail.command('menu-close','eventoptionsmenu') rcmail.command('menu-close','eventoptionsmenu');
$('.libcal-rsvp-replymode').hide();
}, },
resizeStart: function() { resizeStart: function() {
rcmail.command('menu-close','eventoptionsmenu') rcmail.command('menu-close','eventoptionsmenu');
$('.libcal-rsvp-replymode').hide();
}, },
buttons: buttons, buttons: buttons,
minWidth: 320, minWidth: 320,
@ -683,6 +694,8 @@ function rcube_calendar_ui(settings)
// reset dialog first // reset dialog first
$('#eventtabs').get(0).reset(); $('#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 // allow other plugins to do actions when event form is opened
rcmail.triggerEvent('calendar-event-init', {o: event}); rcmail.triggerEvent('calendar-event-init', {o: event});
@ -742,11 +755,9 @@ function rcube_calendar_ui(settings)
// show warning if editing a recurring event // show warning if editing a recurring event
if (event.id && event.recurrence) { if (event.id && event.recurrence) {
var allow_exceptions = !has_attendees(event) || !is_organizer(event), var sel = event._savemode || (event.thisandfuture ? 'future' : (event.isexception ? 'current' : 'all'));
sel = event._savemode || (allow_exceptions && event.thisandfuture ? 'future' : (allow_exceptions && event.isexception ? 'current' : 'all'));
$('#edit-recurring-warning').show(); $('#edit-recurring-warning').show();
$('input.edit-recurring-savemode[value="'+sel+'"]').prop('checked', true); $('input.edit-recurring-savemode[value="'+sel+'"]').prop('checked', true).change();
$('input.edit-recurring-savemode[value="current"], input.edit-recurring-savemode[value="future"]').prop('disabled', !allow_exceptions);
} }
else else
$('#edit-recurring-warning').hide(); $('#edit-recurring-warning').hide();
@ -797,7 +808,7 @@ function rcube_calendar_ui(settings)
// attachments // attachments
var load_attachments_tab = function() 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 = []; rcmail.env.deleted_attachments = [];
// we're sharing some code for uploads handling with app.js // we're sharing some code for uploads handling with app.js
rcmail.env.attachments = []; rcmail.env.attachments = [];
@ -2370,19 +2381,37 @@ function rcube_calendar_ui(settings)
} }
// when the user accepts or declines an event invitation // 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) { if (me.selected_event && me.selected_event.attendees && response) {
// bring up delegation dialog // bring up delegation dialog
if (response == 'delegated' && !delegate) { if (response == 'delegated' && !delegate) {
rcube_libcalendaring.itip_delegate_dialog(function(data) { rcube_libcalendaring.itip_delegate_dialog(function(data) {
data.rsvp = data.rsvp ? 1 : ''; data.rsvp = data.rsvp ? 1 : '';
event_rsvp('delegated', data); event_rsvp('delegated', data, replymode);
}); });
return; return;
} }
// update attendee status // update attendee status
attendees = [];
for (var data, i=0; i < me.selected_event.attendees.length; i++) { for (var data, i=0; i < me.selected_event.attendees.length; i++) {
data = me.selected_event.attendees[i]; data = me.selected_event.attendees[i];
if (settings.identity.emails.indexOf(';'+String(data.email).toLowerCase()) >= 0) { if (settings.identity.emails.indexOf(';'+String(data.email).toLowerCase()) >= 0) {
@ -2391,6 +2420,7 @@ function rcube_calendar_ui(settings)
if (data.status == 'DELEGATED') { if (data.status == 'DELEGATED') {
data['delegated-to'] = delegate.to; data['delegated-to'] = delegate.to;
data.rsvp = delegate.rsvp
} }
else { else {
if (data['delegated-to']) { if (data['delegated-to']) {
@ -2399,6 +2429,12 @@ function rcube_calendar_ui(settings)
data.role = 'REQ-PARTICIPANT'; 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) // set free_busy status to transparent if declined (#4425)
@ -2411,7 +2447,7 @@ function rcube_calendar_ui(settings)
} }
// submit status change to server // 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; noreply = $('#noreply-event-rsvp:checked').length ? 1 : 0;
// import event from mail (temporary iTip event) // import event from mail (temporary iTip event)
@ -2425,15 +2461,17 @@ function rcube_calendar_ui(settings)
_to: (delegate ? delegate.to : null), _to: (delegate ? delegate.to : null),
_rsvp: (delegate && delegate.rsvp) ? 1 : 0, _rsvp: (delegate && delegate.rsvp) ? 1 : 0,
_noreply: noreply, _noreply: noreply,
_comment: submit_data.comment _comment: submit_data.comment,
_instance: submit_data._instance,
_savemode: submit_data._savemode
}); });
} }
else if (settings.invitation_calendars) { 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 { else {
me.saving_lock = rcmail.set_busy(true, 'calendar.savingdata'); 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); event_show_dialog(me.selected_event);
@ -2501,7 +2539,7 @@ function rcube_calendar_ui(settings)
// mark all recurring instances as temp // mark all recurring instances as temp
if (event.recurrence || event.recurrence_id) { 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) { $.each(fc.fullCalendar('clientEvents', function(e){ return e.id == base_id || e.recurrence_id == base_id; }), function(i,ev) {
ev.temp = true; ev.temp = true;
ev.editable = false; ev.editable = false;
@ -2565,25 +2603,19 @@ function rcube_calendar_ui(settings)
// recurring event: user needs to select the savemode // recurring event: user needs to select the savemode
if (event.recurrence) { if (event.recurrence) {
var disabled_state = '', message_label = (action == 'remove' ? 'removerecurringeventwarning' : 'changerecurringeventwarning'); var future_disabled = '', message_label = (action == 'remove' ? 'removerecurringeventwarning' : 'changerecurringeventwarning');
if (_has_attendees) { // disable the 'future' savemode if attendees are involved
if (action == 'remove') { // reason: no calendaring system supports the thisandfuture range parameter
if (!_is_organizer) { if (action == 'remove' && _has_attendees && is_organizer(event)) {
message_label = 'removerecurringallonly'; future_disabled = ' disabled';
disabled_state = ' disabled';
}
}
else if (is_organizer(event)) {
disabled_state = ' disabled';
}
} }
html += '<div class="message"><span class="ui-icon ui-icon-alert"></span>' + html += '<div class="message"><span class="ui-icon ui-icon-alert"></span>' +
rcmail.gettext(message_label, 'calendar') + '</div>' + rcmail.gettext(message_label, 'calendar') + '</div>' +
'<div class="savemode">' + '<div class="savemode">' +
'<a href="#current" class="button' + disabled_state + '">' + rcmail.gettext('currentevent', 'calendar') + '</a>' + '<a href="#current" class="button">' + rcmail.gettext('currentevent', 'calendar') + '</a>' +
'<a href="#future" class="button' + disabled_state + '">' + rcmail.gettext('futurevents', 'calendar') + '</a>' + '<a href="#future" class="button' + future_disabled + '">' + rcmail.gettext('futurevents', 'calendar') + '</a>' +
'<a href="#all" class="button">' + rcmail.gettext('allevents', 'calendar') + '</a>' + '<a href="#all" class="button">' + rcmail.gettext('allevents', 'calendar') + '</a>' +
(action != 'remove' ? '<a href="#new" class="button">' + rcmail.gettext('saveasnew', 'calendar') + '</a>' : '') + (action != 'remove' ? '<a href="#new" class="button">' + rcmail.gettext('saveasnew', 'calendar') + '</a>' : '') +
'</div>'; '</div>';
@ -2606,8 +2638,10 @@ function rcube_calendar_ui(settings)
else { else {
if ($dialog.find('input.confirm-attendees-donotify').length) if ($dialog.find('input.confirm-attendees-donotify').length)
data._notify = $dialog.find('input.confirm-attendees-donotify').get(0).checked ? 1 : 0; data._notify = $dialog.find('input.confirm-attendees-donotify').get(0).checked ? 1 : 0;
if (decline && $dialog.find('input.confirm-attendees-decline:checked').length) if (decline) {
data.decline = 1; data._decline = $dialog.find('input.confirm-attendees-decline:checked').length;
data._notify = 0;
}
update_event(action, data); update_event(action, data);
} }
@ -2622,7 +2656,7 @@ function rcube_calendar_ui(settings)
text: rcmail.gettext((action == 'remove' ? 'delete' : 'save'), 'calendar'), text: rcmail.gettext((action == 'remove' ? 'delete' : 'save'), 'calendar'),
click: function() { click: function() {
data._notify = notify && $dialog.find('input.confirm-attendees-donotify:checked').length ? 1 : 0; data._notify = notify && $dialog.find('input.confirm-attendees-donotify:checked').length ? 1 : 0;
data.decline = decline && $dialog.find('input.confirm-attendees-decline:checked').length ? 1 : 0; data._decline = decline && $dialog.find('input.confirm-attendees-decline:checked').length ? 1 : 0;
update_event(action, data); update_event(action, data);
$(this).dialog("close"); $(this).dialog("close");
} }
@ -4151,9 +4185,16 @@ function rcube_calendar_ui(settings)
}); });
$('#event-rsvp input.button').click(function(e) { $('#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) { $('#eventshow .changersvp').click(function(e) {
var d = $('#eventshow'), var d = $('#eventshow'),
h = -$(this).closest('.event-line').toggle().height(); 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 * '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 * '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', * 'categories' => 'Event category',
* 'free_busy' => 'free|busy|outofoffice|tentative', // Show time as * 'free_busy' => 'free|busy|outofoffice|tentative', // Show time as
* 'status' => 'TENTATIVE|CONFIRMED|CANCELLED', // event status according to RFC 2445 * '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 array Hash array with event properties
* @param string New participant status * @param string New participant status
* @param array List of hash arrays with updated attendees
* @return boolean True on success, False on error * @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); return $this->edit_event($event);
} }
@ -449,6 +463,7 @@ abstract class calendar_driver
$rcmail = rcmail::get_instance(); $rcmail = rcmail::get_instance();
$recurrence = new calendar_recurrence($rcmail->plugins->get_plugin('calendar'), $event); $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 // determine a reasonable end date if none given
if (!$end) { if (!$end) {
@ -464,12 +479,11 @@ abstract class calendar_driver
$i = 0; $i = 0;
while ($next_event = $recurrence->next_instance()) { while ($next_event = $recurrence->next_instance()) {
$next_event['uid'] = $event['uid'] . '-' . ++$i;
// add to output if in range // add to output if in range
if (($next_event['start'] <= $end && $next_event['end'] >= $start)) { 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['recurrence_id'] = $event['uid'];
$next_event['_instance'] = $i;
$events[] = $next_event; $events[] = $next_event;
} }
else if ($next_event['start'] > $end) { // stop loop if out of range else if ($next_event['start'] > $end) { // stop loop if out of range
@ -477,7 +491,7 @@ abstract class calendar_driver
} }
// avoid endless recursion loops // avoid endless recursion loops
if ($i > 1000) { if (++$i > 1000) {
break; 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))) if ($master_id != $id && ($record = $this->storage->get_object($master_id)))
$this->events[$master_id] = $this->_to_rcube_event($record); $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); $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('dtstart', '<=', $end);
$query[] = array('dtend', '>=', $start); $query[] = array('dtend', '>=', $start);
// add query to exclude pending/declined invitations if (is_array($filter_query)) {
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)) {
$query = array_merge($query, $filter_query); $query = array_merge($query, $filter_query);
} }
if (!empty($search)) { if (!empty($search)) {
$search = mb_strtolower($search); $search = mb_strtolower($search);
$words = rcube_utils::tokenize_string($search, 1);
foreach (rcube_utils::normalize_string($search, true) as $word) { foreach (rcube_utils::normalize_string($search, true) as $word) {
$query[] = array('words', 'LIKE', $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(); $events = array();
foreach ($this->storage->select($query) as $record) { 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); $event = $this->_to_rcube_event($record);
$this->events[$event['id']] = $event; $this->events[$event['id']] = $event;
@ -272,35 +272,16 @@ class kolab_calendar extends kolab_storage_folder_api
if ($event['categories']) if ($event['categories'])
$this->categories[$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 // list events in requested time window
if ($event['start'] <= $end && $event['end'] >= $start) { if ($event['start'] <= $end && $event['end'] >= $start) {
unset($event['_attendees']); unset($event['_attendees']);
$add = true; $add = true;
// skip the first instance of a recurring event if listed in exdate // 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'); $event_date = $event['start']->format('Ymd');
$exdates = (array)$event['recurrence']['EXDATE']; $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) { foreach ($exdates as $exdate) {
if ($exdate->format('Ymd') == $event_date) { if ($exdate->format('Ymd') == $event_date) {
$add = false; $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) if ($add)
$events[] = $event; $events[] = $event;
} }
@ -316,20 +309,34 @@ class kolab_calendar extends kolab_storage_folder_api
// resolve recurring events // resolve recurring events
if ($record['recurrence'] && $virtual == 1) { if ($record['recurrence'] && $virtual == 1) {
$events = array_merge($events, $this->get_recurring_events($record, $start, $end)); $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 // post-filter all events by fulltext search and partstat values
if (!empty($search) && $record['recurrence']['EXCEPTIONS']) {
$me = $this; $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; $hits = 0;
foreach ($words as $word) { foreach ($words as $word) {
$hits += $me->_fulltext_match($event, $word, false); $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 // avoid session race conditions that will loose temporary subscriptions
$this->cal->rc->session->nowrite = true; $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['id'] = $event['uid'] . '-' . $exception['_instance'];
$rec_event['isexception'] = 1; $rec_event['isexception'] = 1;
// found the specifically requested instance, exiting... // found the specifically requested instance: register exception (single occurrence wins)
if ($rec_event['id'] == $event_id) { if ($rec_event['id'] == $event_id && (!$this->events[$event_id] || $this->events[$event_id]['thisandfuture'])) {
$rec_event['recurrence'] = $recurrence_rule; $rec_event['recurrence'] = $recurrence_rule;
$rec_event['recurrence_id'] = $event['uid']; $rec_event['recurrence_id'] = $event['uid'];
$events[] = $rec_event;
$this->events[$rec_event['id']] = $rec_event; $this->events[$rec_event['id']] = $rec_event;
return $events;
} }
// remember this exception's date // remember this exception's date
$exdate = substr($exception['_instance'], 0, 8); $exdate = substr($exception['_instance'], 0, 8);
if (!$exdata[$exdate] || $exdata[$exdate]['thisandfuture']) {
$exdata[$exdate] = $rec_event; $exdata[$exdate] = $rec_event;
}
if ($rec_event['thisandfuture']) { if ($rec_event['thisandfuture']) {
$futuredata[$exdate] = $rec_event; $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 // use libkolab to compute recurring events
if (class_exists('kolabcalendaring')) { if (class_exists('kolabcalendaring')) {
$recurrence = new kolab_date_recurrence($object); $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)) { 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 = $this->_to_rcube_event($next_event);
$rec_event['_instance'] = $instance_id; $rec_event['_instance'] = $instance_id;
$rec_event['_count'] = $i + 1;
if ($overlay_data || $exdata[$datestr]) // copy data from exception 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['id'] = $rec_id;
$rec_event['recurrence_id'] = $event['uid']; $rec_event['recurrence_id'] = $event['uid'];
@ -653,38 +666,11 @@ class kolab_calendar extends kolab_storage_folder_api
return $events; 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 * Convert from Kolab_Format to internal representation
*/ */
private function _to_rcube_event($record) private function _to_rcube_event($record)
{ {
$record['id'] = $record['uid'];
$record['calendar'] = $this->id; $record['calendar'] = $this->id;
$record['links'] = $this->get_links($record['uid']); $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()) private function _from_rcube_event($event, $old = array())
{ {
// in kolab_storage attachments are indexed by content-id $event = kolab_driver::from_rcube_event($event, $old);
$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']);
}
// set current user as ORGANIZER // set current user as ORGANIZER
$identity = $this->cal->rc->user->list_emails(true); $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) public function get_event($event, $writeable = false, $active = false, $personal = false)
{ {
if (is_array($event)) { if (is_array($event)) {
$id = $event['id'] ? $event['id'] : $event['uid']; $id = $event['id'] ?: $event['uid'];
$cal = $event['calendar']; $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 { else {
$id = $event; $id = $event;
@ -614,9 +619,21 @@ class kolab_driver extends calendar_driver
* @param string New participant status * @param string New participant status
* @return boolean True on success, False on error * @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 // re-assign to the according (virtual) calendar
if (strtoupper($status) == 'DECLINED') if (strtoupper($status) == 'DECLINED')
$event['calendar'] = self::INVITATIONS_CALENDAR_DECLINED; $event['calendar'] = self::INVITATIONS_CALENDAR_DECLINED;
@ -629,6 +646,48 @@ class kolab_driver extends calendar_driver
return $ret; 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 * 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']))) { if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) {
unset($ev['sequence']); unset($ev['sequence']);
self::clear_attandee_noreply($ev);
return $this->update_event($event + $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']))) { if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) {
unset($ev['sequence']); unset($ev['sequence']);
self::clear_attandee_noreply($ev);
return $this->update_event($event + $ev); return $this->update_event($event + $ev);
} }
@ -687,11 +748,11 @@ class kolab_driver extends calendar_driver
// read master if deleting a recurring event // read master if deleting a recurring event
if ($event['recurrence'] || $event['recurrence_id']) { if ($event['recurrence'] || $event['recurrence_id']) {
$master = $event['recurrence_id'] ? $storage->get_event($event['recurrence_id']) : $event; $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 // 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) { foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) {
if ($exception['_instance'] == $event['_instance']) { if ($exception['_instance'] == $event['_instance']) {
unset($master['recurrence']['EXCEPTIONS'][$i]); 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 // modify a recurring event, check submitted savemode to do the right things
if ($old['recurrence'] || $old['recurrence_id']) { if ($old['recurrence'] || $old['recurrence_id']) {
$master = $old['recurrence_id'] ? $fromcalendar->get_event($old['recurrence_id']) : $old; $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 // 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']) else if ($old['recurrence']['EXCEPTIONS'])
$event['recurrence']['EXCEPTIONS'] = $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) { switch ($savemode) {
case 'new': case 'new':
// save submitted data as new (non-recurring) event // save submitted data as new (non-recurring) event
$event['recurrence'] = array(); $event['recurrence'] = array();
$event['_copyfrom'] = $object['_msguid'];
$event['_mailbox'] = $object['_mailbox'];
$event['uid'] = $this->cal->generate_uid(); $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 // copy attachment metadata to new event
foreach ((array)$event['attachments'] as $idx => $attachment) { $event = self::from_rcube_event($event, $object);
if (!$attachment['content'])
$event['attachments'][$idx]['content'] = $this->get_attachment_body($attachment['id'], $master);
}
self::clear_attandee_noreply($event);
if ($success = $storage->insert_event($event)) if ($success = $storage->insert_event($event))
$success = $event['uid']; $success = $event['uid'];
break; break;
case 'future': 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': case 'current':
// recurring instances shall not store recurrence rules // recurring instances shall not store recurrence rules and attachments
$event['recurrence'] = array(); $event['recurrence'] = array();
$event['thisandfuture'] = $savemode == 'future'; $event['thisandfuture'] = $savemode == 'future';
unset($event['attachments'], $event['id']);
// increment sequence of this instance if scheduling is affected // increment sequence of this instance if scheduling is affected
if ($reschedule) { 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 // save properties to a recurrence exception instance
if ($old['recurrence_id'] && is_array($master['recurrence']['EXCEPTIONS'])) { if ($old['_instance'] && is_array($master['recurrence']['EXCEPTIONS'])) {
foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) { if ($this->update_recurrence_exceptions($master, $event, $old, $savemode)) {
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']); $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 // save as new exception to master event
if ($add_exception) { if ($add_exception) {
$event['_instance'] = $old['_instance']; $event['_instance'] = $old['_instance'];
$event['recurrence_date'] = $old['recurrence_date'] ?: $old['start'];
$master['recurrence']['EXCEPTIONS'][] = $event; $master['recurrence']['EXCEPTIONS'][] = $event;
} }
$success = $storage->update_event($master); $success = $storage->update_event($master);
@ -996,9 +1131,32 @@ class kolab_driver extends calendar_driver
$event['end'] = $master['end']; $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 // 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) && 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'; $recurrence_id_format = $event['allday'] ? 'Ymd' : 'Ymd\THis';
foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) {
$recurrence_id = is_a($exception['recurrence_date'], 'DateTime') ? $exception['recurrence_date'] : $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 _dateonly flags in (cached) date objects
unset($event['start']->_dateonly, $event['end']->_dateonly); 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) public function check_scheduling(&$event, $old, $update = true)
{ {
$reschedule = false;
// skip this check when importing iCal/iTip events // skip this check when importing iCal/iTip events
if (isset($event['sequence']) || !empty($event['_method'])) { if (isset($event['sequence']) || !empty($event['_method'])) {
return $reschedule; return false;
} }
// iterate through the list of properties considered 'significant' for scheduling // iterate through the list of properties considered 'significant' for scheduling
foreach (kolab_format_event::$scheduling_properties as $prop) { $kolab_event = $old['_formatobj'] ?: new kolab_format_event();
$a = $old[$prop]; $reschedule = $kolab_event->check_rescheduling($event, $old);
$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;
}
}
// reset all attendee status to needs-action (#4360) // reset all attendee status to needs-action (#4360)
if ($update && $reschedule && is_array($event['attendees'])) { if ($update && $reschedule && is_array($event['attendees'])) {
@ -1074,6 +1221,171 @@ class kolab_driver extends calendar_driver
return $reschedule; 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. * Get events from source.
* *
@ -1352,6 +1664,29 @@ class kolab_driver extends calendar_driver
return $storage->get_recurring_events($event, $start, $end); 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 * 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'])) if (empty($record['recurrence']))
unset($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 // remove internals
unset($record['_mailbox'], $record['_msguid'], $record['_formatobj'], $record['_attachments'], $record['x-custom']); unset($record['_mailbox'], $record['_msguid'], $record['_formatobj'], $record['_attachments'], $record['x-custom']);
return $record; 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 * 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()) { if ($next_start = $this->next()) {
$next = $this->event; $next = $this->event;
$next['recurrence_id'] = $next_start->format('Y-m-d');
$next['start'] = $next_start; $next['start'] = $next_start;
if ($this->duration) { if ($this->duration) {
@ -75,6 +74,10 @@ class calendar_recurrence extends libcalendaring_recurrence
$next['end']->add($this->duration); $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']); unset($next['_formatobj']);
return $next; return $next;

View file

@ -43,6 +43,9 @@ window.rcmail && rcmail.addEventListener('init', function(evt) {
var src, event_sources = []; var src, event_sources = [];
var add_url = (rcmail.env.search ? '&q='+escape(rcmail.env.search) : ''); var add_url = (rcmail.env.search ? '&q='+escape(rcmail.env.search) : '');
for (var id in rcmail.env.calendars) { for (var id in rcmail.env.calendars) {
if (!rcmail.env.calendars[id].active)
continue;
source = $.extend({ source = $.extend({
url: "./?_task=calendar&_action=load_events&source=" + escape(id) + add_url, url: "./?_task=calendar&_action=load_events&source=" + escape(id) + add_url,
className: 'fc-event-cal-'+id, className: 'fc-event-cal-'+id,

View file

@ -676,6 +676,10 @@ a.miniColors-trigger {
outline: none; outline: none;
} }
#event-panel-attachments.disabled .attachmentslist li a.delete {
visibility: hidden;
}
.event-attendees span.attendee { .event-attendees span.attendee {
padding-right: 18px; padding-right: 18px;
margin-right: 0.5em; margin-right: 0.5em;
@ -1059,6 +1063,10 @@ td.topalign {
text-align: center; text-align: center;
} }
.libcal-rsvp-replymode li a {
cursor: default;
}
#event-rsvp, #event-rsvp,
#edit-attendees-notify { #edit-attendees-notify {
margin: 0.6em 0 0.3em 0; margin: 0.6em 0 0.3em 0;
@ -2159,6 +2167,15 @@ div.calendar-invitebox td.sensitivity {
font-weight: bold; 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, #event-rsvp .rsvp-buttons,
div.calendar-invitebox .itip-buttons div { div.calendar-invitebox .itip-buttons div {
margin-top: 0.5em; margin-top: 0.5em;

View file

@ -1,7 +1,7 @@
/*** Printing styles for Calendar plugin ***/ /*** Printing styles for Calendar plugin ***/
body { body {
margin: 0; margin: 0 0 1em 0;
color: #000; color: #000;
background: #fff; background: #fff;
} }
@ -54,18 +54,31 @@ body, td, th, div, p, h3, select, input, textarea {
} }
#calendarlist { #calendarlist {
list-style-type: square; list-style: none;
margin: 2em 0; margin: 2em 0;
padding-left: 1em; padding-left: 1em;
} }
#calendarlist li { #calendarlist ul {
float: left;
list-style: none;
padding-left: 0; padding-left: 0;
padding-right: 3em; }
#calendarlist li {
float: left;
padding-left: 0;
padding-right: 0;
margin-left: 0; margin-left: 0;
font-weight: bold; font-weight: bold;
} }
#calendarlist li div {
float: left;
padding-right: 3em;
padding-bottom: 1em;
}
#calendarlist input, #calendarlist input,
#calendarlist .handle { #calendarlist .handle {
display: none; display: none;
@ -207,6 +220,9 @@ body, td, th, div, p, h3, select, input, textarea {
font-style: italic; font-style: italic;
} }
.fc-view-month .fc-event-hori .fc-event-inner {
background: #fff !important;
}
.fc-view-table col.fc-event-location { .fc-view-table col.fc-event-location {
width: 20%; width: 20%;

View file

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

View file

@ -98,8 +98,10 @@ class libcalendaring_itip
if (!$this->sender['name']) if (!$this->sender['name'])
$this->sender['name'] = $this->sender['email']; $this->sender['name'] = $this->sender['email'];
if (!$message) if (!$message) {
libcalendaring::identify_recurrence_instance($event);
$message = $this->compose_itip_message($event, $method, $rsvp); $message = $this->compose_itip_message($event, $method, $rsvp);
}
$mailto = rcube_idn_to_ascii($recipient['email']); $mailto = rcube_idn_to_ascii($recipient['email']);
@ -121,12 +123,19 @@ class libcalendaring_itip
($attendee['name'] ? $attendee['name'] : $attendee['email']); ($attendee['name'] ? $attendee['name'] : $attendee['email']);
} }
$recurrence_info = '';
if (!empty($event['recurrence_id'])) {
$recurrence_info = "\n\n** " . $this->gettext($event['thisandfuture'] ? 'itipmessagefutureoccurrence' : 'itipmessagesingleoccurrence') . ' **';
}
else if (!empty($event['recurrence'])) {
$recurrence_info = sprintf("\n%s: %s", $this->gettext('recurring'), $this->lib->recurrence_text($event['recurrence']));
}
$mailbody = $this->gettext(array( $mailbody = $this->gettext(array(
'name' => $bodytext, 'name' => $bodytext,
'vars' => array( 'vars' => array(
'title' => $event['title'], 'title' => $event['title'],
'date' => $this->lib->event_date_text($event, true) . 'date' => $this->lib->event_date_text($event, true) . $recurrence_info,
(empty($event['recurrence']) ? '' : sprintf("\n%s: %s", $this->gettext('recurring'), $this->lib->recurrence_text($event['recurrence']))),
'attendees' => join(",\n ", $attendees_list), 'attendees' => join(",\n ", $attendees_list),
'sender' => $this->sender['name'], 'sender' => $this->sender['name'],
'organizer' => $this->sender['name'], 'organizer' => $this->sender['name'],
@ -151,6 +160,10 @@ class libcalendaring_itip
$message->headers($headers, true); $message->headers($headers, true);
$message->setTXTBody(rcube_mime::format_flowed($mailbody, 79)); $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 // finally send the message
$this->itip_send = true; $this->itip_send = true;
$sent = $this->rc->deliver_message($message, $headers['X-Sender'], $mailto, $smtp_error); $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); array_unshift($reply_attendees, $replying_attendee);
$event['attendees'] = $reply_attendees; $event['attendees'] = $reply_attendees;
} }
if ($event['recurrence']) {
unset($event['recurrence']['EXCEPTIONS']);
}
} }
// set RSVP for every attendee // set RSVP for every attendee
else if ($method == 'REQUEST') { else if ($method == 'REQUEST') {
foreach ($event['attendees'] as $i => $attendee) { foreach ($event['attendees'] as $i => $attendee) {
if ($attendee['status'] != 'DELEGATED') { 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 // compose multipart message using PEAR:Mail_Mime
$message = new Mail_mime("\r\n"); $message = new Mail_mime("\r\n");
@ -276,9 +297,10 @@ class libcalendaring_itip
* @param array Event object to delegate * @param array Event object to delegate
* @param mixed Delegatee as string or hash array with keys 'name' and 'mailto' * @param mixed Delegatee as string or hash array with keys 'name' and 'mailto'
* @param boolean The delegator's RSVP flag * @param boolean The delegator's RSVP flag
* @param array List with indexes of new/updated attendees
* @return boolean True on success, False on failure * @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)) { if (is_string($delegate)) {
$delegates = rcube_mime::decode_address_list($delegate, 1, false); $delegates = rcube_mime::decode_address_list($delegate, 1, false);
@ -324,6 +346,8 @@ class libcalendaring_itip
$delegate_attendee['delegated-from'] = $me['email']; $delegate_attendee['delegated-from'] = $me['email'];
$event['attendees'][$delegate_index] = $delegate_attendee; $event['attendees'][$delegate_index] = $delegate_attendee;
$attendees[] = $delegate_index;
$this->set_sender_email($me['email']); $this->set_sender_email($me['email']);
return $this->send_itip_message($event, 'REQUEST', $delegate_attendee, 'itipsubjectdelegatedto', 'itipmailbodydelegatedto'); 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 // check if the given itip object matches the last state
if ($existing) { 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']); (!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; $changed = is_object($event['changed']) ? $event['changed'] : $message_date;
$metadata = array( $metadata = array(
'uid' => $event['uid'], 'uid' => $event['uid'],
'_instance' => $event['_instance'],
'changed' => $changed ? $changed->format('U') : 0, 'changed' => $changed ? $changed->format('U') : 0,
'sequence' => intval($event['sequence']), 'sequence' => intval($event['sequence']),
'method' => $method, 'method' => $method,
@ -580,12 +605,17 @@ class libcalendaring_itip
// for CANCEL messages, we can: // for CANCEL messages, we can:
else if ($method == 'CANCEL') { else if ($method == 'CANCEL') {
$title = $this->gettext('itipcancellation'); $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 // 1. remove the event from our calendar
$button_remove = html::tag('input', array( $button_remove = html::tag('input', array(
'type' => 'button', 'type' => 'button',
'class' => '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'), '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 // add localized texts for the delegation dialog
if (in_array('delegated', $actions)) { if (in_array('delegated', $actions)) {
foreach (array('itipdelegated','itipcomment','delegateinvitation', 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, return html::div($attrib,
html::div('label', $this->gettext('acceptinvitation')) . 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('label', $this->gettext('date'));
$table->add('date', Q($this->lib->event_date_text($event))); $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('label', $this->gettext('recurring'));
$table->add('recurrence', $this->lib->recurrence_text($event['recurrence'])); $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; 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))) { if (confirm(rcmail.gettext('itip.deleteobjectconfirm').replace('$title', title))) {
rcmail.http_post(task + '/itip-remove', rcmail.http_post(task + '/itip-remove',
{ uid: uid }, event,
rcmail.set_busy(true, 'itip.savingdata') rcmail.set_busy(true, 'itip.savingdata')
); );
} }

View file

@ -1310,6 +1310,9 @@ class libcalendaring extends rcube_plugin
$charset = $part->ctype_parameters['charset'] ?: RCMAIL_CHARSET; $charset = $part->ctype_parameters['charset'] ?: RCMAIL_CHARSET;
$this->mail_ical_parser->import($this->ical_message->get_part_body($mime_id, true), $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 // stop on the part that has an iTip method specified
if (count($this->mail_ical_parser->objects) && $this->mail_ical_parser->method) { if (count($this->mail_ical_parser->objects) && $this->mail_ical_parser->method) {
$this->mail_ical_parser->message_date = $this->ical_message->headers->date; $this->mail_ical_parser->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'] = preg_match(self::$email_regex, $headers->from, $m) ? $m[1] : '';
$object['_sender_utf'] = rcube_utils::idn_to_utf8($object['_sender']); $object['_sender_utf'] = rcube_utils::idn_to_utf8($object['_sender']);
// check if this is an instance of a recurring event/task
self::identify_recurrence_instance($object);
return $object; return $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 *********/ /********* Attendee handling functions *********/

View file

@ -41,7 +41,7 @@ class libvcalendar implements Iterator
{ {
private $timezone; private $timezone;
private $attach_uri = null; 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 $type_component_map = array('event' => 'VEVENT', 'task' => 'VTODO');
private $attendee_keymap = array('name' => 'CN', 'status' => 'PARTSTAT', 'role' => 'ROLE', private $attendee_keymap = array('name' => 'CN', 'status' => 'PARTSTAT', 'role' => 'ROLE',
'cutype' => 'CUTYPE', 'rsvp' => 'RSVP', 'delegated-from' => 'DELEGATED-FROM', 'delegated-to' => 'DELEGATED-TO'); 'cutype' => 'CUTYPE', 'rsvp' => 'RSVP', 'delegated-from' => 'DELEGATED-FROM', 'delegated-to' => 'DELEGATED-TO');
@ -64,7 +64,7 @@ class libvcalendar implements Iterator
function __construct($tz = null) function __construct($tz = null)
{ {
$this->timezone = $tz; $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 'ATTENDEE':
case 'ORGANIZER': case 'ORGANIZER':
$params = array(); $params = array('rsvp' => false);
foreach ($prop->parameters as $param) { foreach ($prop->parameters as $param) {
switch ($param->name) { switch ($param->name) {
case 'RSVP': $params[$param->name] = strtolower($param->value) == 'true'; break; case 'RSVP': $params[$param->name] = strtolower($param->value) == 'true'; break;
@ -948,6 +948,13 @@ class libvcalendar implements Iterator
if (!empty($event['due'])) if (!empty($event['due']))
$ve->add($this->datetime_prop('DUE', $event['due'], false)); $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) if ($recurrence_id)
$ve->add($recurrence_id); $ve->add($recurrence_id);
@ -1081,7 +1088,7 @@ class libvcalendar implements Iterator
} }
else if (!empty($attendee['email'])) { else if (!empty($attendee['email'])) {
if (isset($attendee['rsvp'])) 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))); $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['acceptattendee'] = 'Accept participant';
$labels['declineattendee'] = 'Decline participant'; $labels['declineattendee'] = 'Decline participant';
$labels['declineattendeeconfirm'] = 'Enter a message to the declined participant (optional):'; $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['youhaveaccepted'] = 'You have accepted this invitation';
$labels['youhavetentative'] = 'You have tentatively 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 // 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 // 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 // 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 // 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_end->add($this->duration);
$next = $this->object->to_array(); $next = $this->object->to_array();
$next['recurrence_id'] = $next_start->format('Y-m-d');
$next['start'] = $next_start; $next['start'] = $next_start;
$next['end'] = $next_end; $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']); unset($next['_formatobj']);
return $next; return $next;

View file

@ -26,7 +26,7 @@ class kolab_format_event extends kolab_format_xcal
{ {
public $CTYPEv2 = 'application/x-vnd.kolab.event'; 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 $objclass = 'Event';
protected $read_func = 'readEvent'; protected $read_func = 'readEvent';
@ -44,6 +44,9 @@ class kolab_format_event extends kolab_format_xcal
$this->obj = $data; $this->obj = $data;
$this->loaded = true; $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); $vexceptions->push($exevent->obj);
// write cleaned-up exception data back to memory/cache // 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); $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 // cache this data
$this->data = $object; $this->data = $object;
@ -220,15 +226,16 @@ class kolab_format_event extends kolab_format_xcal
* *
* @return array List of tags to save in cache * @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); $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 // preserve this property for date serialization
$exception['allday'] = $master['allday']; $exception['allday'] = $master['allday'];

View file

@ -32,6 +32,16 @@ class kolab_format_task extends kolab_format_xcal
protected $read_func = 'readTodo'; protected $read_func = 'readTodo';
protected $write_func = 'writeTodo'; 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 * 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 * @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'; $tags[] = 'x-complete';
if ($this->data['priority'] == 1) if ($object['priority'] == 1)
$tags[] = 'x-flagged'; $tags[] = 'x-flagged';
if ($this->data['parent_id']) if ($object['parent_id'])
$tags[] = 'x-parent:' . $this->data['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'); public static $scheduling_properties = array('start', 'end', 'location');
protected $_scheduling_properties = null;
protected $sensitivity_map = array( protected $sensitivity_map = array(
'public' => kolabformat::ClassPublic, 'public' => kolabformat::ClassPublic,
'private' => kolabformat::ClassPrivate, 'private' => kolabformat::ClassPrivate,
@ -317,21 +319,11 @@ abstract class kolab_format_xcal extends kolab_format
} }
else { else {
$object['sequence'] = $old_sequence; $object['sequence'] = $old_sequence;
$old = $this->data['uid'] ? $this->data : $this->to_array();
// increment sequence when updating properties relevant for scheduling. // 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." // RFC 5545: "It is incremented [...] each time the Organizer makes a significant revision to the calendar component."
foreach (self::$scheduling_properties as $prop) { if ($this->check_rescheduling($object)) {
$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']++; $object['sequence']++;
break;
}
} }
} }
} }
@ -365,7 +357,7 @@ abstract class kolab_format_xcal extends kolab_format
// set attendee RSVP if missing // set attendee RSVP if missing
if (!isset($attendee['rsvp'])) { if (!isset($attendee['rsvp'])) {
$object['attendees'][$i]['rsvp'] = $attendee['rsvp'] = true; $object['attendees'][$i]['rsvp'] = $attendee['rsvp'] = $reschedule;
} }
$att = new Attendee; $att = new Attendee;
@ -619,22 +611,68 @@ abstract class kolab_format_xcal extends kolab_format
* *
* @return array List of tags to save in cache * @return array List of tags to save in cache
*/ */
public function get_tags() public function get_tags($obj = null)
{ {
$tags = array(); $tags = array();
$object = $obj ?: $this->data;
if (!empty($this->data['valarms'])) { if (!empty($object['valarms'])) {
$tags[] = 'x-has-alarms'; $tags[] = 'x-has-alarms';
} }
// create tags reflecting participant status // create tags reflecting participant status
if (is_array($this->data['attendees'])) { if (is_array($object['attendees'])) {
foreach ($this->data['attendees'] as $attendee) { foreach ($object['attendees'] as $attendee) {
if (!empty($attendee['email']) && !empty($attendee['status'])) if (!empty($attendee['email']) && !empty($attendee['status']))
$tags[] = 'x-partstat:' . $attendee['email'] . ':' . strtolower($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; $type = $this->type;
// copy attachments from old message // 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) { foreach ((array)$old['_attachments'] as $key => $att) {
if (!isset($object['_attachments'][$key])) { if (!isset($object['_attachments'][$key])) {
$object['_attachments'][$key] = $old['_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 // 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']) { else if ($type == 'contact' && ($key == 'photo.attachment' || $key == 'kolab-picture.png') && $att['id']) {
if (!isset($object['photo'])) 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]); unset($object['_attachments'][$key]);
} }
} }
@ -1010,7 +1011,7 @@ class kolab_storage_folder extends kolab_storage_folder_api
foreach ((array)$object['_attachments'] as $key => $att) { foreach ((array)$object['_attachments'] as $key => $att) {
if (empty($att['content']) && !empty($att['id'])) { if (empty($att['content']) && !empty($att['id'])) {
// @TODO: use IMAP CATENATE to skip attachment fetch+push operation // @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) { if ($is_file) {
$att['path'] = tempnam($temp_dir, 'rcmAttmnt'); $att['path'] = tempnam($temp_dir, 'rcmAttmnt');
if (($fp = fopen($att['path'], 'w')) && $this->get_attachment($msguid, $att['id'], $object['_mailbox'], false, $fp, true)) { if (($fp = fopen($att['path'], 'w')) && $this->get_attachment($msguid, $att['id'], $object['_mailbox'], false, $fp, true)) {