33 commits - plugins/calendar plugins/libcalendaring plugins/libkolab

Thomas Brüderli bruederli at kolabsys.com
Fri Feb 20 11:46:10 CET 2015


 plugins/calendar/calendar.php                      |  229 +++++++--
 plugins/calendar/calendar_ui.js                    |  105 +++-
 plugins/calendar/drivers/calendar_driver.php       |   24 -
 plugins/calendar/drivers/kolab/kolab_calendar.php  |  185 ++-----
 plugins/calendar/drivers/kolab/kolab_driver.php    |  494 ++++++++++++++++++---
 plugins/calendar/lib/calendar_recurrence.php       |    5 
 plugins/calendar/print.js                          |    3 
 plugins/calendar/skins/larry/calendar.css          |   17 
 plugins/calendar/skins/larry/print.css             |   22 
 plugins/calendar/skins/larry/templates/print.html  |    1 
 plugins/libcalendaring/lib/libcalendaring_itip.php |   64 ++
 plugins/libcalendaring/libcalendaring.js           |   38 +
 plugins/libcalendaring/libcalendaring.php          |   34 +
 plugins/libcalendaring/libvcalendar.php            |   15 
 plugins/libcalendaring/localization/en_US.inc      |    8 
 plugins/libkolab/config.inc.php.dist               |    2 
 plugins/libkolab/lib/kolab_date_recurrence.php     |    6 
 plugins/libkolab/lib/kolab_format_event.php        |   25 -
 plugins/libkolab/lib/kolab_format_task.php         |   26 -
 plugins/libkolab/lib/kolab_format_xcal.php         |   74 ++-
 plugins/libkolab/lib/kolab_storage_folder.php      |    7 
 21 files changed, 1059 insertions(+), 325 deletions(-)

New commits:
commit 52bbf63a8e0c6a785facc395206d1a1f11ee7d05
Merge: 5966cff f972f4a
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Fri Feb 20 10:18:59 2015 +0100

    Merge branch 'dev/recurring-invitations'



commit f972f4a511a7c3710ec5f5cee001133837797923
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Fri Feb 20 10:18:21 2015 +0100

    Disable the 'future' savemode for event deletion if attendees are involved

diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js
index 5a26c18..eb57fa7 100644
--- a/plugins/calendar/calendar_ui.js
+++ b/plugins/calendar/calendar_ui.js
@@ -2603,25 +2603,19 @@ function rcube_calendar_ui(settings)
       
       // recurring event: user needs to select the savemode
       if (event.recurrence) {
-        var disabled_state = '', message_label = (action == 'remove' ? 'removerecurringeventwarning' : 'changerecurringeventwarning');
-/*
-        if (_has_attendees) {
-          if (action == 'remove') {
-            if (!_is_organizer) {
-              message_label = 'removerecurringallonly';
-              disabled_state = ' disabled';
-            }
-          }
-          else if (is_organizer(event)) {
-            disabled_state = ' disabled';
-          }
+        var future_disabled = '', message_label = (action == 'remove' ? 'removerecurringeventwarning' : 'changerecurringeventwarning');
+
+        // disable the 'future' savemode if attendees are involved
+        // reason: no calendaring system supports the thisandfuture range parameter
+        if (action == 'remove' && _has_attendees && is_organizer(event)) {
+          future_disabled = ' disabled';
         }
-*/
+
         html += '<div class="message"><span class="ui-icon ui-icon-alert"></span>' +
           rcmail.gettext(message_label, 'calendar') + '</div>' +
           '<div class="savemode">' +
-            '<a href="#current" class="button' + disabled_state + '">' + rcmail.gettext('currentevent', 'calendar') + '</a>' +
-            '<a href="#future" class="button' + disabled_state + '">' + rcmail.gettext('futurevents', 'calendar') + '</a>' +
+            '<a href="#current" class="button">' + rcmail.gettext('currentevent', 'calendar') + '</a>' +
+            '<a href="#future" class="button' + future_disabled + '">' + rcmail.gettext('futurevents', 'calendar') + '</a>' +
             '<a href="#all" class="button">' + rcmail.gettext('allevents', 'calendar') + '</a>' +
             (action != 'remove' ? '<a href="#new" class="button">' + rcmail.gettext('saveasnew', 'calendar') + '</a>' : '') +
           '</div>';


commit 515a7d9ef6dcdc1c1de2488745a511bdac256896
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Fri Feb 20 09:25:24 2015 +0100

    Small fixes to recurring event invitations (#4387)

diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php
index 8be5956..f6079db 100644
--- a/plugins/calendar/calendar.php
+++ b/plugins/calendar/calendar.php
@@ -1171,11 +1171,12 @@ class calendar extends rcube_plugin
         $sent = $this->notify_attendees($master, null, $action, $event['_comment']);
         if ($sent < 0)
           $this->rc->output->show_message('calendar.errornotifying', 'error');
+
+        $event['attendees'] = $master['attendees'];  // this tricks us into the next if clause
       }
 
       $event['id'] = $success;
       $event['_savemode'] = 'all';
-      $event['attendees'] = $master['attendees'];  // this tricks us into the next if clause
       $old = null;
     }
 
diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index d6fb55a..88c2a38 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -993,24 +993,37 @@ class kolab_driver extends calendar_driver
   
         // remove recurrence exceptions on re-scheduling
         if ($reschedule) {
-          unset($event['recurrence']['EXCEPTIONS']);
+          unset($event['recurrence']['EXCEPTIONS'], $master['recurrence']['EXDATE']);
         }
         else if (is_array($event['recurrence']['EXCEPTIONS'])) {
           // only keep relevant exceptions
           $event['recurrence']['EXCEPTIONS'] = array_filter($event['recurrence']['EXCEPTIONS'], function($exception) use ($event) {
             return $exception['start'] > $event['start'];
           });
+          if (is_array($event['recurrence']['EXDATE'])) {
+            $event['recurrence']['EXDATE'] = array_filter($event['recurrence']['EXDATE'], function($exdate) use ($event) {
+              return $exdate > $event['start'];
+            });
+          }
         }
 
         // compute remaining occurrences
         if ($event['recurrence']['COUNT']) {
           if (!$old['_count'])
-            $old['_count'] = $this->get_recurrence_count($object, $event['start']);
+            $old['_count'] = $this->get_recurrence_count($object, $old['start']);
           $event['recurrence']['COUNT'] -= intval($old['_count']);
         }
 
+        // remove fixed weekday when date changed
+        if ($old['start']->format('Y-m-d') != $event['start']->format('Y-m-d')) {
+          if (strlen($event['recurrence']['BYDAY']) == 2)
+            unset($event['recurrence']['BYDAY']);
+          if ($old['recurrence']['BYMONTH'] == $old['start']->format('n'))
+            unset($event['recurrence']['BYMONTH']);
+        }
+
         // set until-date on master event
-        $master['recurrence']['UNTIL'] = clone $event['start'];
+        $master['recurrence']['UNTIL'] = clone $old['start'];
         $master['recurrence']['UNTIL']->sub(new DateInterval('P1D'));
         unset($master['recurrence']['COUNT']);
 
@@ -1020,6 +1033,11 @@ class kolab_driver extends calendar_driver
             return $exception['start'] < $event['start'];
           });
         }
+        if (is_array($master['recurrence']['EXDATE'])) {
+          $master['recurrence']['EXDATE'] = array_filter($master['recurrence']['EXDATE'], function($exdate) use ($event) {
+            return $exdate < $event['start'];
+          });
+        }
 
         // save new event
         if ($success = $storage->insert_event($event)) {
@@ -1115,7 +1133,7 @@ class kolab_driver extends calendar_driver
 
         // when saving an instance in 'all' mode, copy recurrence exceptions over
         if ($old['recurrence_id']) {
-          $event['recurrence'] = $master['recurrence'];
+          $event['recurrence']['EXCEPTIONS'] = $master['recurrence']['EXCEPTIONS'];
         }
 
         // TODO: forward changes to exceptions (which do not yet have differing values stored)


commit 02ef2e60505a6319599042c9e6e92a7a2f42de8c
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Fri Feb 20 00:11:40 2015 +0100

    Split recurring event into a new series when modifying with this-and-future option (#4386); optimize copying of attachments into new event

diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php
index f5c2c04..8be5956 100644
--- a/plugins/calendar/calendar.php
+++ b/plugins/calendar/calendar.php
@@ -865,6 +865,7 @@ class calendar extends rcube_plugin
           $event['id'] = $event['uid'];
           $event['_savemode'] = 'all';
           $this->cleanup_event($event);
+          $this->event_save_success($event, null, $action, true);
         }
         $reload = $success && $event['recurrence'] ? 2 : 1;
         break;
@@ -873,21 +874,15 @@ class calendar extends rcube_plugin
         $this->write_preprocess($event, $action);
         if ($success = $this->driver->edit_event($event)) {
           $this->cleanup_event($event);
-          if ($success !== true) {
-            $event['id'] = $success;
-            $old = null;
-          }
+          $this->event_save_success($event, $old, $action, $success);
         }
-        $reload =  $success && ($event['recurrence'] || $event['_savemode'] || $event['_fromcalendar']) ? 2 : 1;
+        $reload = $success && ($event['recurrence'] || $event['_savemode'] || $event['_fromcalendar']) ? 2 : 1;
         break;
       
       case "resize":
         $this->write_preprocess($event, $action);
         if ($success = $this->driver->resize_event($event)) {
-          if ($success !== true) {
-            $event['id'] = $success;
-            $old = null;
-          }
+          $this->event_save_success($event, $old, $action, $success);
         }
         $reload = $event['_savemode'] ? 2 : 1;
         break;
@@ -895,10 +890,7 @@ class calendar extends rcube_plugin
       case "move":
         $this->write_preprocess($event, $action);
         if ($success = $this->driver->move_event($event)) {
-          if ($success !== true) {
-            $event['id'] = $success;
-            $old = null;
-          }
+          $this->event_save_success($event, $old, $action, $success);
         }
         $reload  = $success && $event['_savemode'] ? 2 : 1;
         break;
@@ -958,6 +950,9 @@ class calendar extends rcube_plugin
           else
             $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error');
         }
+        else if ($success) {
+          $this->event_save_success($event, $old, $action, $success);
+        }
         break;
 
       case "undo":
@@ -1000,8 +995,8 @@ class calendar extends rcube_plugin
         $event = $ev;
 
         // compose a list of attendees affected by this change
-        $updated_attendees = array_filter(array_map(function($j) use ($ev) {
-          return $ev['attendees'][$j];
+        $updated_attendees = array_filter(array_map(function($j) use ($event) {
+          return $event['attendees'][$j];
         }, $attendees));
 
         if ($success = $this->driver->edit_rsvp($event, $status, $updated_attendees)) {
@@ -1147,24 +1142,63 @@ class calendar extends rcube_plugin
         $this->rc->output->show_message('calendar.errorsaving', 'error');
     }
 
-    // send out notifications
-    if ($success && $event['_notify'] && ($event['attendees'] || $old['attendees'])) {
-      $_savemode = $event['_savemode'];
+    // unlock client
+    $this->rc->output->command('plugin.unlock_saving');
 
-      // make sure we have the complete record
-      $event = $action == 'remove' ? $old : $this->driver->get_event($event);
-      $event['_savemode'] = $_savemode;
+    // update event object on the client or trigger a complete refretch if too complicated
+    if ($reload) {
+      $args = array('source' => $event['calendar']);
+      if ($reload > 1)
+        $args['refetch'] = true;
+      else if ($success && $action != 'remove')
+        $args['update'] = $this->_client_event($this->driver->get_event($event), true);
+      $this->rc->output->command('plugin.refresh_calendar', $args);
+    }
+  }
 
-      if ($old) {
-        $old['thisandfuture'] = $_savemode == 'future';
+  /**
+   * Helper method sending iTip notifications after successful event updates
+   */
+  private function event_save_success(&$event, $old, $action, $success)
+  {
+    // $success is a new event ID
+    if ($success !== true) {
+      // send update notification on the main event
+      if ($event['_savemode'] == 'future' && $event['_notify'] && $old['attendees'] && $old['recurrence_id']) {
+        $master = $this->driver->get_event(array('id' => $old['recurrence_id'], 'calendar' => $old['calendar']));
+        unset($master['_instance'], $master['recurrence_date']);
+
+        $sent = $this->notify_attendees($master, null, $action, $event['_comment']);
+        if ($sent < 0)
+          $this->rc->output->show_message('calendar.errornotifying', 'error');
       }
 
+      $event['id'] = $success;
+      $event['_savemode'] = 'all';
+      $event['attendees'] = $master['attendees'];  // this tricks us into the next if clause
+      $old = null;
+    }
+
+    // send out notifications
+    if ($event['_notify'] && ($event['attendees'] || $old['attendees'])) {
+      $_savemode = $event['_savemode'];
+
       // send notification for the main event when savemode is 'all'
-      if ($_savemode == 'all' && $event['recurrence_id']) {
-        $event['id'] = $event['recurrence_id'];
+      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)) {
@@ -1175,19 +1209,6 @@ class calendar extends rcube_plugin
           $this->rc->output->show_message('calendar.errornotifying', 'error');
       }
     }
-
-    // unlock client
-    $this->rc->output->command('plugin.unlock_saving');
-
-    // update event object on the client or trigger a complete refretch if too complicated
-    if ($reload) {
-      $args = array('source' => $event['calendar']);
-      if ($reload > 1)
-        $args['refetch'] = true;
-      else if ($success && $action != 'remove')
-        $args['update'] = $this->_client_event($this->driver->get_event($event), true);
-      $this->rc->output->command('plugin.refresh_calendar', $args);
-    }
   }
 
   /**
diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js
index e7f55cd..5a26c18 100644
--- a/plugins/calendar/calendar_ui.js
+++ b/plugins/calendar/calendar_ui.js
@@ -2432,6 +2432,10 @@ function rcube_calendar_ui(settings)
 
             attendees.push(i)
           }
+          else if (response != 'DELEGATED' && data['delegated-from'] &&
+              settings.identity.emails.indexOf(';'+String(data['delegated-from']).toLowerCase()) >= 0) {
+            delete data['delegated-from'];
+          }
 
           // set free_busy status to transparent if declined (#4425)
           if (data.status == 'DECLINED' || data.role == 'NON-PARTICIPANT') {
diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php
index a392a9f..64bb082 100644
--- a/plugins/calendar/drivers/kolab/kolab_calendar.php
+++ b/plugins/calendar/drivers/kolab/kolab_calendar.php
@@ -639,6 +639,7 @@ class kolab_calendar extends kolab_storage_folder_api
       if (($next_event['start'] <= $end && $next_event['end'] >= $start) || ($event_id && $rec_id == $event_id)) {
         $rec_event = $this->_to_rcube_event($next_event);
         $rec_event['_instance'] = $instance_id;
+        $rec_event['_count'] = $i + 1;
 
         if ($overlay_data || $exdata[$datestr])  // copy data from exception
           kolab_driver::merge_exception_data($rec_event, $exdata[$datestr] ?: $overlay_data);
@@ -687,38 +688,7 @@ class kolab_calendar extends kolab_storage_folder_api
    */
   private function _from_rcube_event($event, $old = array())
   {
-    // in kolab_storage attachments are indexed by content-id
-    $event['_attachments'] = array();
-    if (is_array($event['attachments'])) {
-      foreach ($event['attachments'] as $attachment) {
-        $key = null;
-        // Roundcube ID has nothing to do with the storage ID, remove it
-        if ($attachment['content'] || $attachment['path']) {
-          unset($attachment['id']);
-        }
-        else {
-          foreach ((array)$old['_attachments'] as $cid => $oldatt) {
-            if ($attachment['id'] == $oldatt['id'])
-              $key = $cid;
-          }
-        }
-
-        // flagged for deletion => set to false
-        if ($attachment['_deleted']) {
-          $event['_attachments'][$key] = false;
-        }
-        // replace existing entry
-        else if ($key) {
-          $event['_attachments'][$key] = $attachment;
-        }
-        // append as new attachment
-        else {
-          $event['_attachments'][] = $attachment;
-        }
-      }
-
-      unset($event['attachments']);
-    }
+    $event = kolab_driver::from_rcube_event($event, $old);
 
     // set current user as ORGANIZER
     $identity = $this->cal->rc->user->list_emails(true);
diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index 48dbafd..d6fb55a 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -621,7 +621,19 @@ class kolab_driver extends calendar_driver
    */
   public function edit_rsvp(&$event, $status, $attendees)
   {
-    if (($ret = $this->update_attendees($event, $attendees)) && $this->rc->config->get('kolab_invitation_calendars')) {
+    $update_event = $event;
+
+    // apply changes to master (and all exceptions)
+    if ($event['_savemode'] == 'all' && $event['recurrence_id']) {
+      if ($storage = $this->get_calendar($event['calendar'])) {
+        $update_event = $storage->get_event($event['recurrence_id']);
+        $update_event['_savemode'] = $event['_savemode'];
+        unset($update_event['recurrence_id']);
+        self::merge_attendee_data($update_event, $attendees);
+      }
+    }
+
+    if (($ret = $this->update_attendees($update_event, $attendees)) && $this->rc->config->get('kolab_invitation_calendars')) {
       // re-assign to the according (virtual) calendar
       if (strtoupper($status) == 'DECLINED')
         $event['calendar'] = self::INVITATIONS_CALENDAR_DECLINED;
@@ -687,6 +699,7 @@ class kolab_driver extends calendar_driver
   {
     if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) {
       unset($ev['sequence']);
+      self::clear_attandee_noreply($ev);
       return $this->update_event($event + $ev);
     }
 
@@ -703,6 +716,7 @@ class kolab_driver extends calendar_driver
   {
     if (($storage = $this->get_calendar($event['calendar'])) && ($ev = $storage->get_event($event['id']))) {
       unset($ev['sequence']);
+      self::clear_attandee_noreply($ev);
       return $this->update_event($event + $ev);
     }
 
@@ -928,6 +942,7 @@ class kolab_driver extends calendar_driver
     if ($old['recurrence'] || $old['recurrence_id']) {
       $master = $old['recurrence_id'] ? $fromcalendar->get_event($old['recurrence_id']) : $old;
       $savemode = $event['_savemode'] ?: ($old['recurrence_id'] ? 'current' : 'all');
+      $object = $fromcalendar->storage->get_object($master['uid']);
 
       // this-and-future on the first instance equals to 'all'
       if (!$old['recurrence_id'] && $savemode == 'future')
@@ -953,20 +968,70 @@ class kolab_driver extends calendar_driver
       case 'new':
         // save submitted data as new (non-recurring) event
         $event['recurrence'] = array();
+        $event['_copyfrom'] = $object['_msguid'];
+        $event['_mailbox'] = $object['_mailbox'];
         $event['uid'] = $this->cal->generate_uid();
-        unset($event['recurrence_id'], $event['_instance'], $event['id']);
+        unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']);
 
-        // copy attachment data to new event
-        foreach ((array)$event['attachments'] as $idx => $attachment) {
-          if (!$attachment['content'])
-            $event['attachments'][$idx]['content'] = $this->get_attachment_body($attachment['id'], $master);
-        }
+        // copy attachment metadata to new event
+        $event = self::from_rcube_event($event, $object);
 
+        self::clear_attandee_noreply($event);
         if ($success = $storage->insert_event($event))
           $success = $event['uid'];
         break;
 
       case 'future':
+        // create a new recurring event
+        $event['_copyfrom'] = $object['_msguid'];
+        $event['_mailbox'] = $object['_mailbox'];
+        $event['uid'] = $this->cal->generate_uid();
+        unset($event['recurrence_id'], $event['recurrence_date'], $event['_instance'], $event['id']);
+
+        // copy attachment metadata to new event
+        $event = self::from_rcube_event($event, $object);
+  
+        // remove recurrence exceptions on re-scheduling
+        if ($reschedule) {
+          unset($event['recurrence']['EXCEPTIONS']);
+        }
+        else if (is_array($event['recurrence']['EXCEPTIONS'])) {
+          // only keep relevant exceptions
+          $event['recurrence']['EXCEPTIONS'] = array_filter($event['recurrence']['EXCEPTIONS'], function($exception) use ($event) {
+            return $exception['start'] > $event['start'];
+          });
+        }
+
+        // compute remaining occurrences
+        if ($event['recurrence']['COUNT']) {
+          if (!$old['_count'])
+            $old['_count'] = $this->get_recurrence_count($object, $event['start']);
+          $event['recurrence']['COUNT'] -= intval($old['_count']);
+        }
+
+        // set until-date on master event
+        $master['recurrence']['UNTIL'] = clone $event['start'];
+        $master['recurrence']['UNTIL']->sub(new DateInterval('P1D'));
+        unset($master['recurrence']['COUNT']);
+
+        // remove all exceptions after $event['start']
+        if (is_array($master['recurrence']['EXCEPTIONS'])) {
+          $master['recurrence']['EXCEPTIONS'] = array_filter($master['recurrence']['EXCEPTIONS'], function($exception) use ($event) {
+            return $exception['start'] < $event['start'];
+          });
+        }
+
+        // save new event
+        if ($success = $storage->insert_event($event)) {
+          $success = $event['uid'];
+
+          // update master event (no rescheduling!)
+          $master['sequence'] = $object['sequence'];
+          self::clear_attandee_noreply($master);
+          $udated = $storage->update_event($master);
+        }
+        break;
+
       case 'current':
         // recurring instances shall not store recurrence rules and attachments
         $event['recurrence'] = array();
@@ -1215,6 +1280,17 @@ class kolab_driver extends calendar_driver
   }
 
   /**
+   * 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
@@ -1571,6 +1647,29 @@ class kolab_driver extends calendar_driver
   }
 
   /**
+   *
+   */
+  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
    */
   public function get_freebusy_list($email, $start, $end)
@@ -1749,6 +1848,13 @@ class kolab_driver extends calendar_driver
       $record['_instance'] = $record['start']->format($recurrence_id_format);
     }
 
+    // clean up exception data
+    if (is_array($record['recurrence']['EXCEPTIONS'])) {
+      array_walk($record['recurrence']['EXCEPTIONS'], function(&$exception) {
+        unset($exception['_mailbox'], $exception['_msguid'], $exception['_formatobj'], $exception['_attachments']);
+      });
+    }
+
     // remove internals
     unset($record['_mailbox'], $record['_msguid'], $record['_formatobj'], $record['_attachments'], $record['x-custom']);
 
@@ -1756,6 +1862,49 @@ class kolab_driver extends calendar_driver
   }
 
   /**
+   *
+   */
+  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
    */
   public static function add_partstat_class($event, $partstats, $user = null)
diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php
index 41148c6..14dacf4 100644
--- a/plugins/libcalendaring/lib/libcalendaring_itip.php
+++ b/plugins/libcalendaring/lib/libcalendaring_itip.php
@@ -250,7 +250,7 @@ class libcalendaring_itip
         // set RSVP for every attendee
         else if ($method == 'REQUEST') {
             foreach ($event['attendees'] as $i => $attendee) {
-                if ($attendee['status'] != 'DELEGATED' && !isset($attendee['rsvp'])) {
+                if ($attendee['status'] != 'DELEGATED') {
                     $event['attendees'][$i]['rsvp']= (bool)$rsvp;
                 }
             }
diff --git a/plugins/libcalendaring/libcalendaring.js b/plugins/libcalendaring/libcalendaring.js
index 71c101d..1c8fe16 100644
--- a/plugins/libcalendaring/libcalendaring.js
+++ b/plugins/libcalendaring/libcalendaring.js
@@ -965,7 +965,7 @@ rcube_libcalendaring.itip_rsvp_recurring = function(btn, callback)
 {
     var mnu = $('<ul></ul>').addClass('popupmenu libcal-rsvp-replymode');
 
-    $.each(['all','current','future'], function(i, mode) {
+    $.each(['all','current'/*,'future'*/], function(i, mode) {
         $('<li><a>' + rcmail.get_label('rsvpmode'+mode, 'libcalendaring') + '</a>')
         .addClass('ui-menu-item')
         .attr('rel', mode)
diff --git a/plugins/libkolab/lib/kolab_storage_folder.php b/plugins/libkolab/lib/kolab_storage_folder.php
index ab3c63f..e0bf52e 100644
--- a/plugins/libkolab/lib/kolab_storage_folder.php
+++ b/plugins/libkolab/lib/kolab_storage_folder.php
@@ -616,7 +616,8 @@ class kolab_storage_folder extends kolab_storage_folder_api
             $type = $this->type;
 
         // copy attachments from old message
-        if (!empty($object['_msguid']) && ($old = $this->cache->get($object['_msguid'], $type, $object['_mailbox']))) {
+        $copyfrom = $object['_copyfrom'] ?: $object['_msguid'];
+        if (!empty($copyfrom) && ($old = $this->cache->get($copyfrom, $type, $object['_mailbox']))) {
             foreach ((array)$old['_attachments'] as $key => $att) {
                 if (!isset($object['_attachments'][$key])) {
                     $object['_attachments'][$key] = $old['_attachments'][$key];
@@ -628,7 +629,7 @@ class kolab_storage_folder extends kolab_storage_folder_api
                 // load photo.attachment from old Kolab2 format to be directly embedded in xcard block
                 else if ($type == 'contact' && ($key == 'photo.attachment' || $key == 'kolab-picture.png') && $att['id']) {
                     if (!isset($object['photo']))
-                        $object['photo'] = $this->get_attachment($object['_msguid'], $att['id'], $object['_mailbox']);
+                        $object['photo'] = $this->get_attachment($copyfrom, $att['id'], $object['_mailbox']);
                     unset($object['_attachments'][$key]);
                 }
             }
@@ -1010,7 +1011,7 @@ class kolab_storage_folder extends kolab_storage_folder_api
         foreach ((array)$object['_attachments'] as $key => $att) {
             if (empty($att['content']) && !empty($att['id'])) {
                 // @TODO: use IMAP CATENATE to skip attachment fetch+push operation
-                $msguid = !empty($object['_msguid']) ? $object['_msguid'] : $object['uid'];
+                $msguid = $object['_copyfrom'] ?: ($object['_msguid'] ?: $object['uid']);
                 if ($is_file) {
                     $att['path'] = tempnam($temp_dir, 'rcmAttmnt');
                     if (($fp = fopen($att['path'], 'w')) && $this->get_attachment($msguid, $att['id'], $object['_mailbox'], false, $fp, true)) {


commit 5e176baa0832da051f50ef9740fda7c22cb4702f
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Thu Feb 19 18:09:12 2015 +0100

    Pass a list of updated attendess to the backend driver on RSVP reply from calendar view

diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php
index fc4bbff..f5c2c04 100644
--- a/plugins/calendar/calendar.php
+++ b/plugins/calendar/calendar.php
@@ -978,7 +978,8 @@ class calendar extends rcube_plugin
 
       case "rsvp":
         $itip_sending  = $this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']);
-        $status        = rcube_utils::get_input_value('status', rcube_utils::INPUT_GPC);
+        $status        = rcube_utils::get_input_value('status', rcube_utils::INPUT_POST);
+        $attendees     = rcube_utils::get_input_value('attendees', rcube_utils::INPUT_POST);
         $reply_comment = $event['comment'];
 
         $this->write_preprocess($event, 'edit');
@@ -990,7 +991,7 @@ class calendar extends rcube_plugin
         // send invitation to delegatee + add it as attendee
         if ($status == 'delegated' && $event['to']) {
           $itip = $this->load_itip();
-          if ($itip->delegate_to($ev, $event['to'], (bool)$event['rsvp'])) {
+          if ($itip->delegate_to($ev, $event['to'], (bool)$event['rsvp'], $attendees)) {
             $this->rc->output->show_message('calendar.itipsendsuccess', 'confirmation');
             $noreply = false;
           }
@@ -998,7 +999,12 @@ class calendar extends rcube_plugin
 
         $event = $ev;
 
-        if ($success = $this->driver->edit_rsvp($event, $status)) {
+        // compose a list of attendees affected by this change
+        $updated_attendees = array_filter(array_map(function($j) use ($ev) {
+          return $ev['attendees'][$j];
+        }, $attendees));
+
+        if ($success = $this->driver->edit_rsvp($event, $status, $updated_attendees)) {
           $noreply = rcube_utils::get_input_value('noreply', rcube_utils::INPUT_GPC);
           $noreply = intval($noreply) || $status == 'needs-action' || $itip_sending === 0;
           $reload  = $event['calendar'] != $ev['calendar'] || $event['recurrence'] ? 2 : 1;
diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js
index c9334ec..e7f55cd 100644
--- a/plugins/calendar/calendar_ui.js
+++ b/plugins/calendar/calendar_ui.js
@@ -2411,6 +2411,7 @@ function rcube_calendar_ui(settings)
         }
 
         // update attendee status
+        attendees = [];
         for (var data, i=0; i < me.selected_event.attendees.length; i++) {
           data = me.selected_event.attendees[i];
           if (settings.identity.emails.indexOf(';'+String(data.email).toLowerCase()) >= 0) {
@@ -2419,6 +2420,7 @@ function rcube_calendar_ui(settings)
 
             if (data.status == 'DELEGATED') {
               data['delegated-to'] = delegate.to;
+              data.rsvp = delegate.rsvp
             }
             else {
               if (data['delegated-to']) {
@@ -2427,6 +2429,8 @@ function rcube_calendar_ui(settings)
                   data.role = 'REQ-PARTICIPANT';
               }
             }
+
+            attendees.push(i)
           }
 
           // set free_busy status to transparent if declined (#4425)
@@ -2459,11 +2463,11 @@ function rcube_calendar_ui(settings)
           });
         }
         else if (settings.invitation_calendars) {
-          update_event('rsvp', submit_data, { status:response, noreply:noreply });
+          update_event('rsvp', submit_data, { status:response, noreply:noreply, attendees:attendees });
         }
         else {
           me.saving_lock = rcmail.set_busy(true, 'calendar.savingdata');
-          rcmail.http_post('event', { action:'rsvp', e:submit_data, status:response, noreply:noreply });
+          rcmail.http_post('event', { action:'rsvp', e:submit_data, status:response, attendees:attendees, noreply:noreply });
         }
 
         event_show_dialog(me.selected_event);
diff --git a/plugins/calendar/drivers/calendar_driver.php b/plugins/calendar/drivers/calendar_driver.php
index aecd2e1..659f4a0 100644
--- a/plugins/calendar/drivers/calendar_driver.php
+++ b/plugins/calendar/drivers/calendar_driver.php
@@ -197,9 +197,10 @@ abstract class calendar_driver
    *
    * @param array  Hash array with event properties
    * @param string New participant status
+   * @param array  List of hash arrays with updated attendees
    * @return boolean True on success, False on error
    */
-  public function edit_rsvp(&$event, $status)
+  public function edit_rsvp(&$event, $status, $attendees)
   {
     return $this->edit_event($event);
   }
diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index 5e6f3b3..48dbafd 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -619,9 +619,9 @@ class kolab_driver extends calendar_driver
    * @param string New participant status
    * @return boolean True on success, False on error
    */
-  public function edit_rsvp(&$event, $status)
+  public function edit_rsvp(&$event, $status, $attendees)
   {
-    if (($ret = $this->update_event($event)) && $this->rc->config->get('kolab_invitation_calendars')) {
+    if (($ret = $this->update_attendees($event, $attendees)) && $this->rc->config->get('kolab_invitation_calendars')) {
       // re-assign to the according (virtual) calendar
       if (strtoupper($status) == 'DECLINED')
         $event['calendar'] = self::INVITATIONS_CALENDAR_DECLINED;
diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php
index e7de5c8..41148c6 100644
--- a/plugins/libcalendaring/lib/libcalendaring_itip.php
+++ b/plugins/libcalendaring/lib/libcalendaring_itip.php
@@ -297,9 +297,10 @@ class libcalendaring_itip
      * @param array Event object to delegate
      * @param mixed Delegatee as string or hash array with keys 'name' and 'mailto'
      * @param boolean The delegator's RSVP flag
+     * @param array List with indexes of new/updated attendees
      * @return boolean True on success, False on failure
      */
-    public function delegate_to(&$event, $delegate, $rsvp = false)
+    public function delegate_to(&$event, $delegate, $rsvp = false, &$attendees = array())
     {
         if (is_string($delegate)) {
             $delegates = rcube_mime::decode_address_list($delegate, 1, false);
@@ -345,6 +346,8 @@ class libcalendaring_itip
         $delegate_attendee['delegated-from'] = $me['email'];
         $event['attendees'][$delegate_index] = $delegate_attendee;
 
+        $attendees[] = $delegate_index;
+
         $this->set_sender_email($me['email']);
         return $this->send_itip_message($event, 'REQUEST', $delegate_attendee, 'itipsubjectdelegatedto', 'itipmailbodydelegatedto');
     }


commit 026d62d2351c58b5e28777ccc8767415490b4985
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Thu Feb 19 15:58:32 2015 +0100

    Avoid comparison errors if recurrence is set to '' (used to unset recurrence rules)

diff --git a/plugins/libkolab/lib/kolab_format_xcal.php b/plugins/libkolab/lib/kolab_format_xcal.php
index e6507d6..4d3a758 100644
--- a/plugins/libkolab/lib/kolab_format_xcal.php
+++ b/plugins/libkolab/lib/kolab_format_xcal.php
@@ -661,7 +661,7 @@ abstract class kolab_format_xcal extends kolab_format
                 $a = $a->format('Y-m-d');
                 $b = $b->format('Y-m-d');
             }
-            if ($prop == 'recurrence') {
+            if ($prop == 'recurrence' && is_array($a) && is_array($b)) {
                 unset($a['EXCEPTIONS']);
                 unset($b['EXCEPTIONS']);
                 $a = array_filter($a);


commit db637619c3cfa5fc73e55c7f60adba9c2c635e0a
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Thu Feb 19 15:57:37 2015 +0100

    Omit RSVP flag in iCal export if not true

diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php
index 07612d5..826e8d8 100644
--- a/plugins/libcalendaring/libvcalendar.php
+++ b/plugins/libcalendaring/libvcalendar.php
@@ -1088,7 +1088,7 @@ class libvcalendar implements Iterator
             }
             else if (!empty($attendee['email'])) {
                 if (isset($attendee['rsvp']))
-                    $attendee['rsvp'] = $attendee['rsvp'] ? 'TRUE' : 'FALSE';
+                    $attendee['rsvp'] = $attendee['rsvp'] ? 'TRUE' : null;
                 $ve->add('ATTENDEE', 'mailto:' . $attendee['email'], array_filter(self::map_keys($attendee, $this->attendee_keymap)));
             }
         }


commit 61037eb97c8580d385fc625db064f3855d8a823f
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Thu Feb 19 15:56:46 2015 +0100

    Fix RSVP flag in iTip REQUESTS

diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php
index b4a7aee..e7de5c8 100644
--- a/plugins/libcalendaring/lib/libcalendaring_itip.php
+++ b/plugins/libcalendaring/lib/libcalendaring_itip.php
@@ -250,8 +250,8 @@ class libcalendaring_itip
         // set RSVP for every attendee
         else if ($method == 'REQUEST') {
             foreach ($event['attendees'] as $i => $attendee) {
-                if ($attendee['status'] != 'DELEGATED') {
-                    $event['attendees'][$i]['rsvp']= $rsvp ? true : null;
+                if ($attendee['status'] != 'DELEGATED' && !isset($attendee['rsvp'])) {
+                    $event['attendees'][$i]['rsvp']= (bool)$rsvp;
                 }
             }
         }
diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php
index 0c90e85..91efb26 100644
--- a/plugins/libkolab/lib/kolab_format_event.php
+++ b/plugins/libkolab/lib/kolab_format_event.php
@@ -118,7 +118,7 @@ class kolab_format_event extends kolab_format_xcal
                 $vexceptions->push($exevent->obj);
 
                 // write cleaned-up exception data back to memory/cache
-                $object['recurrence']['EXCEPTIONS'][$i] = $this->expand_exception($compacted, $object);
+                $object['recurrence']['EXCEPTIONS'][$i] = $this->expand_exception($exevent->data, $object);
             }
             $this->obj->setExceptions($vexceptions);
         }


commit 95ed84c9326f55e85f9e8513cd8284e05ec42e17
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Thu Feb 19 15:13:41 2015 +0100

    Copy the master's sequence to a new exception

diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index 7b4c639..5e6f3b3 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -977,6 +977,9 @@ class kolab_driver extends calendar_driver
         if ($reschedule) {
           $event['sequence'] = max($old['sequence'], $master['sequence']) + 1;
         }
+        else if (!isset($event['sequence'])) {
+          $event['sequence'] = $master['sequence'];
+        }
 
         // save properties to a recurrence exception instance
         if ($old['_instance'] && is_array($master['recurrence']['EXCEPTIONS'])) {


commit ac2bd4700f55cdd48337f55240bc37066d309320
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Thu Feb 19 15:13:13 2015 +0100

    Store recurrence-id for single (non-recurring) events and use for iTip replies

diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php
index 8038add..b4a7aee 100644
--- a/plugins/libcalendaring/lib/libcalendaring_itip.php
+++ b/plugins/libcalendaring/lib/libcalendaring_itip.php
@@ -364,7 +364,7 @@ class libcalendaring_itip
 
       // check if the given itip object matches the last state
       if ($existing) {
-        $latest = (isset($event['sequence']) && $existing['sequence'] == $event['sequence']) ||
+        $latest = (isset($event['sequence']) && intval($existing['sequence']) == intval($event['sequence'])) ||
                   (!isset($event['sequence']) && $existing['changed'] && $existing['changed'] >= $event['changed']);
       }
 
diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php
index c203854..2d9c3cc 100644
--- a/plugins/libcalendaring/libcalendaring.php
+++ b/plugins/libcalendaring/libcalendaring.php
@@ -1411,7 +1411,7 @@ class libcalendaring extends rcube_plugin
     public static function identify_recurrence_instance(&$object)
     {
         // for savemode=all, remove recurrence instance identifiers
-        if (!empty($object['_savemode']) && $object['_savemode'] == 'all') {
+        if (!empty($object['_savemode']) && $object['_savemode'] == 'all' && $object['recurrence']) {
             unset($object['_instance'], $object['recurrence_date']);
         }
         // set instance and 'savemode' according to recurrence-id
diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php
index 979b33b..0c90e85 100644
--- a/plugins/libkolab/lib/kolab_format_event.php
+++ b/plugins/libkolab/lib/kolab_format_event.php
@@ -122,6 +122,9 @@ class kolab_format_event extends kolab_format_xcal
             }
             $this->obj->setExceptions($vexceptions);
         }
+        else if ($object['recurrence_date'] && $object['recurrence_date'] instanceof DateTime) {
+            $this->obj->setRecurrenceID(self::get_datetime($object['recurrence_date'], null, $object['allday']), (bool)$object['thisandfuture']);
+        }
 
         // cache this data
         $this->data = $object;


commit b47b13a35ebb65cecbe028a177deed10752ee7cc
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Wed Feb 18 11:30:16 2015 +0100

    Dynamically update attendees on exceptions in 'all' and 'future' save mode

diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index f2a9269..7b4c639 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -1045,19 +1045,41 @@ class kolab_driver extends calendar_driver
           $event['end'] = $master['end'];
         }
 
+        // when saving an instance in 'all' mode, copy recurrence exceptions over
+        if ($old['recurrence_id']) {
+          $event['recurrence'] = $master['recurrence'];
+        }
+
         // TODO: forward changes to exceptions (which do not yet have differing values stored)
+        if (is_array($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) {
+          // determine added and removed attendees
+          $old_attendees = $current_attendees = $added_attendees = array();
+          foreach ((array)$old['attendees'] as $attendee) {
+            $old_attendees[] = $attendee['email'];
+          }
+          foreach ((array)$event['attendees'] as $attendee) {
+            $current_attendees[] = $attendee['email'];
+            if (!in_array($attendee['email'], $old_attendees)) {
+              $added_attendees[] = $attendee;
+            }
+          }
+          $removed_attendees = array_diff($old_attendees, $current_attendees);
 
-        // adjust recurrence-id when start changed and therefore the entire recurrence chain changes
-        if (($old_start_date != $new_start_date || $old_start_time != $new_start_time) &&
-              is_array($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) {
-          $recurrence_id_format = $event['allday'] ? 'Ymd' : 'Ymd\THis';
           foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) {
-            $recurrence_id = is_a($exception['recurrence_date'], 'DateTime') ? $exception['recurrence_date'] :
-                rcube_utils::anytodatetime($exception['_instance'], $old['start']->getTimezone());
-            if (is_a($recurrence_id, 'DateTime')) {
-              $recurrence_id->add($date_shift);
-              $event['recurrence']['EXCEPTIONS'][$i]['recurrence_date'] = $recurrence_id;
-              $event['recurrence']['EXCEPTIONS'][$i]['_instance'] = $recurrence_id->format($recurrence_id_format);
+            self::merge_attendee_data($event['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees);
+          }
+
+          // adjust recurrence-id when start changed and therefore the entire recurrence chain changes
+          if ($old_start_date != $new_start_date || $old_start_time != $new_start_time) {
+            $recurrence_id_format = $event['allday'] ? 'Ymd' : 'Ymd\THis';
+            foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) {
+              $recurrence_id = is_a($exception['recurrence_date'], 'DateTime') ? $exception['recurrence_date'] :
+                  rcube_utils::anytodatetime($exception['_instance'], $old['start']->getTimezone());
+              if (is_a($recurrence_id, 'DateTime')) {
+                $recurrence_id->add($date_shift);
+                $event['recurrence']['EXCEPTIONS'][$i]['recurrence_date'] = $recurrence_id;
+                $event['recurrence']['EXCEPTIONS'][$i]['_instance'] = $recurrence_id->format($recurrence_id_format);
+              }
             }
           }
         }
@@ -1121,6 +1143,22 @@ class kolab_driver extends calendar_driver
     $saved = false;
     $existing = null;
 
+    // determine added and removed attendees
+    $added_attendees = $removed_attendees = array();
+    if ($savemode == 'future') {
+      $old_attendees = $current_attendees = array();
+      foreach ((array)$old['attendees'] as $attendee) {
+        $old_attendees[] = $attendee['email'];
+      }
+      foreach ((array)$event['attendees'] as $attendee) {
+        $current_attendees[] = $attendee['email'];
+        if (!in_array($attendee['email'], $old_attendees)) {
+          $added_attendees[] = $attendee;
+        }
+      }
+      $removed_attendees = array_diff($old_attendees, $current_attendees);
+    }
+
     foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) {
       // update a specific instance
       if ($exception['_instance'] == $old['_instance']) {
@@ -1139,7 +1177,11 @@ class kolab_driver extends calendar_driver
       // merge the new event properties onto future exceptions
       if ($savemode == 'future' && $exception['_instance'] >= $old['_instance']) {
         unset($event['thisandfuture']);
-        self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event);
+        self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event, array('attendees'));
+
+        if (!empty($added_attendees) || !empty($removed_attendees)) {
+          self::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $added_attendees, $removed_attendees);
+        }
       }
     }
 /*
@@ -1174,10 +1216,14 @@ class kolab_driver extends calendar_driver
    *
    * @param array The event object to be altered
    * @param array The overlay event object to be merged over $event
+   * @param array List of properties not allowed to be overwritten
    */
-  public static function merge_exception_data(&$event, $overlay)
+  public static function merge_exception_data(&$event, $overlay, $blacklist = null)
   {
-    static $forbidden = array('id','uid','recurrence','recurrence_date','thisandfuture','organizer','_attachments');
+    $forbidden = array('id','uid','recurrence','recurrence_date','thisandfuture','organizer','_attachments');
+
+    if (is_array($blacklist))
+      $forbidden = array_merge($forbidden, $blacklist);
 
     // compute date offset from the exception
     if ($overlay['start'] instanceof DateTime && $overlay['recurrence_date'] instanceof DateTime) {
@@ -1213,7 +1259,7 @@ class kolab_driver extends calendar_driver
    * @param array The event object to be altered
    * @param array List of hash arrays each represeting an updated/added attendee
    */
-  public static function merge_attendee_data(&$event, $attendees)
+  public static function merge_attendee_data(&$event, $attendees, $removed = null)
   {
     if (!empty($attendees) && !is_array($attendees[0])) {
       $attendees = array($attendees);
@@ -1234,6 +1280,13 @@ class kolab_driver extends calendar_driver
         $event['attendees'][] = $attendee;
       }
     }
+
+    // filter out removed attendees
+    if (!empty($removed)) {
+      $event['attendees'] = array_filter($event['attendees'], function($attendee) use ($removed) {
+        return !in_array($attendee['email'], $removed);
+      });
+    }
   }
 
   /**


commit 422bb0a298c1867fba7626b953b7f263492a3ab1
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Wed Feb 18 10:21:15 2015 +0100

    Fix RSVP flag in iCal and storage (defaults to false); remove redundant information from ical PRODID

diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php
index 10c2223..07612d5 100644
--- a/plugins/libcalendaring/libvcalendar.php
+++ b/plugins/libcalendaring/libvcalendar.php
@@ -41,7 +41,7 @@ class libvcalendar implements Iterator
 {
     private $timezone;
     private $attach_uri = null;
-    private $prodid = '-//Roundcube//Roundcube libcalendaring//Sabre//Sabre VObject//EN';
+    private $prodid = '-//Roundcube libcalendaring//Sabre//Sabre VObject//EN';
     private $type_component_map = array('event' => 'VEVENT', 'task' => 'VTODO');
     private $attendee_keymap = array('name' => 'CN', 'status' => 'PARTSTAT', 'role' => 'ROLE',
         'cutype' => 'CUTYPE', 'rsvp' => 'RSVP', 'delegated-from' => 'DELEGATED-FROM', 'delegated-to' => 'DELEGATED-TO');
@@ -64,7 +64,7 @@ class libvcalendar implements Iterator
     function __construct($tz = null)
     {
         $this->timezone = $tz;
-        $this->prodid = '-//Roundcube//Roundcube libcalendaring ' . RCUBE_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN';
+        $this->prodid = '-//Roundcube libcalendaring ' . RCUBE_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN';
     }
 
     /**
@@ -502,7 +502,7 @@ class libvcalendar implements Iterator
 
             case 'ATTENDEE':
             case 'ORGANIZER':
-                $params = array();
+                $params = array('rsvp' => false);
                 foreach ($prop->parameters as $param) {
                     switch ($param->name) {
                         case 'RSVP': $params[$param->name] = strtolower($param->value) == 'true'; break;
diff --git a/plugins/libkolab/lib/kolab_format_xcal.php b/plugins/libkolab/lib/kolab_format_xcal.php
index 3d7bc27..e6507d6 100644
--- a/plugins/libkolab/lib/kolab_format_xcal.php
+++ b/plugins/libkolab/lib/kolab_format_xcal.php
@@ -357,7 +357,7 @@ abstract class kolab_format_xcal extends kolab_format
 
                 // set attendee RSVP if missing
                 if (!isset($attendee['rsvp'])) {
-                    $object['attendees'][$i]['rsvp'] = $attendee['rsvp'] = true;
+                    $object['attendees'][$i]['rsvp'] = $attendee['rsvp'] = $reschedule;
                 }
 
                 $att = new Attendee;


commit c7df74d5d0cdf3b292a1034c9a0bb3007e9e0b23
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Wed Feb 18 10:20:00 2015 +0100

    Fix updating attendees (do not accidentally set exceptions to thisandfuture)

diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index ff02fbb..f2a9269 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -642,7 +642,7 @@ class kolab_driver extends calendar_driver
   public function update_attendees(&$event, $attendees)
   {
     // for this-and-future updates, merge the updated attendees onto all exceptions in range
-    if (($event['_savemode'] == 'future' && $event['recurrence_id']) || !empty($event['recurrence'])) {
+    if (($event['_savemode'] == 'future' && $event['recurrence_id']) || (!empty($event['recurrence']) && !$event['recurrence_id'])) {
       if (!($storage = $this->get_calendar($event['calendar'])))
         return false;
 


commit f7e7df62a28541b0d3bcecf77ffc2819e006924f
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Tue Feb 17 15:49:14 2015 +0100

    Apply date offset from exceptions to recurring occurrences (#4386)

diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php
index 4316542..a392a9f 100644
--- a/plugins/calendar/drivers/kolab/kolab_calendar.php
+++ b/plugins/calendar/drivers/kolab/kolab_calendar.php
@@ -297,7 +297,7 @@ class kolab_calendar extends kolab_storage_folder_api
             $exdate = $exception['recurrence_date'] ? $exception['recurrence_date']->format('Ymd') : substr($exception['_instance'], 0, 8);
             if ($exdate == $event_date) {
               $event['_instance'] = $exception['_instance'];
-              kolab_driver::merge_event_data($event, $exception);
+              kolab_driver::merge_exception_data($event, $exception);
             }
           }
         }
@@ -641,7 +641,7 @@ class kolab_calendar extends kolab_storage_folder_api
         $rec_event['_instance'] = $instance_id;
 
         if ($overlay_data || $exdata[$datestr])  // copy data from exception
-          kolab_driver::merge_event_data($rec_event, $exdata[$datestr] ?: $overlay_data);
+          kolab_driver::merge_exception_data($rec_event, $exdata[$datestr] ?: $overlay_data);
 
         $rec_event['id'] = $rec_id;
         $rec_event['recurrence_id'] = $event['uid'];
diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index 0a9790c..ff02fbb 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -928,6 +928,10 @@ class kolab_driver extends calendar_driver
     if ($old['recurrence'] || $old['recurrence_id']) {
       $master = $old['recurrence_id'] ? $fromcalendar->get_event($old['recurrence_id']) : $old;
       $savemode = $event['_savemode'] ?: ($old['recurrence_id'] ? 'current' : 'all');
+
+      // this-and-future on the first instance equals to 'all'
+      if (!$old['recurrence_id'] && $savemode == 'future')
+        $savemode = 'all';
     }
 
     // check if update affects scheduling and update attendee status accordingly
@@ -1135,7 +1139,7 @@ class kolab_driver extends calendar_driver
       // merge the new event properties onto future exceptions
       if ($savemode == 'future' && $exception['_instance'] >= $old['_instance']) {
         unset($event['thisandfuture']);
-        self::merge_event_data($master['recurrence']['EXCEPTIONS'][$i], $event);
+        self::merge_exception_data($master['recurrence']['EXCEPTIONS'][$i], $event);
       }
     }
 /*
@@ -1171,19 +1175,28 @@ class kolab_driver extends calendar_driver
    * @param array The event object to be altered
    * @param array The overlay event object to be merged over $event
    */
-  public static function merge_event_data(&$event, $overlay)
+  public static function merge_exception_data(&$event, $overlay)
   {
     static $forbidden = array('id','uid','recurrence','recurrence_date','thisandfuture','organizer','_attachments');
 
+    // compute date offset from the exception
+    if ($overlay['start'] instanceof DateTime && $overlay['recurrence_date'] instanceof DateTime) {
+      $date_offset = $overlay['recurrence_date']->diff($overlay['start']);
+    }
+
     foreach ($overlay as $prop => $value) {
-      // adjust time of the recurring event instance
       if ($prop == 'start' || $prop == 'end') {
-        if (is_object($event[$prop]) && is_a($event[$prop], 'DateTime')) {
-          $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s')));
+        if (is_object($event[$prop]) && $event[$prop] instanceof DateTime) {
           // set date value if overlay is an exception of the current instance
           if (substr($overlay['_instance'], 0, 8) == substr($event['_instance'], 0, 8)) {
             $event[$prop]->setDate(intval($value->format('Y')), intval($value->format('n')), intval($value->format('j')));
           }
+          // apply date offset
+          else if ($date_offset) {
+            $event[$prop]->add($date_offset);
+          }
+          // adjust time of the recurring event instance
+          $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s')));
         }
       }
       else if ($prop == 'thisandfuture' && $overlay['_instance'] == $event['_instance']) {


commit 4d534ea7868538c517fc7af4ae80d6db411a961a
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Tue Feb 17 15:47:12 2015 +0100

    Forward savemode when removing a cancelled event

diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php
index 2eec27c..8038add 100644
--- a/plugins/libcalendaring/lib/libcalendaring_itip.php
+++ b/plugins/libcalendaring/lib/libcalendaring_itip.php
@@ -602,7 +602,11 @@ class libcalendaring_itip
         // for CANCEL messages, we can:
         else if ($method == 'CANCEL') {
             $title = $this->gettext('itipcancellation');
-            $event_prop = array_filter(array('uid' => $event['uid'], '_instance' => $event['_instance']));
+            $event_prop = array_filter(array(
+              'uid' => $event['uid'],
+              '_instance' => $event['_instance'],
+              '_savemode' => $event['_savemode'],
+            ));
 
             // 1. remove the event from our calendar
             $button_remove = html::tag('input', array(


commit 46866e76cc00433e34a8c6c7c2967d5dbf6be6d0
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Tue Feb 17 15:03:39 2015 +0100

    Report cancellation to removed attendees with this-and-future parameter

diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php
index 571e68b..fc4bbff 100644
--- a/plugins/calendar/calendar.php
+++ b/plugins/calendar/calendar.php
@@ -1149,6 +1149,10 @@ class calendar extends rcube_plugin
       $event = $action == 'remove' ? $old : $this->driver->get_event($event);
       $event['_savemode'] = $_savemode;
 
+      if ($old) {
+        $old['thisandfuture'] = $_savemode == 'future';
+      }
+
       // send notification for the main event when savemode is 'all'
       if ($_savemode == 'all' && $event['recurrence_id']) {
         $event['id'] = $event['recurrence_id'];


commit 3ea6d4357926092de2080667cae7c6c516e33097
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Tue Feb 17 14:54:12 2015 +0100

    Fix deletion/cancellation of this-and-future instances

diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php
index 8e6a1a3..571e68b 100644
--- a/plugins/calendar/calendar.php
+++ b/plugins/calendar/calendar.php
@@ -1711,6 +1711,9 @@ class calendar extends rcube_plugin
       if ($attendee['status'] == 'DELEGATED' && $attendee['rsvp'] == false) {
         $event['attendees'][$i]['noreply'] = true;
       }
+      else {
+        unset($event['attendees'][$i]['noreply']);
+      }
     }
 
     if ($organizer === null && !empty($event['organizer'])) {
@@ -2432,12 +2435,14 @@ class calendar extends rcube_plugin
    */
   function event_itip_remove()
   {
-    $success = false;
-    $uid     = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST);
-    $inst    = rcube_utils::get_input_value('_instance', rcube_utils::INPUT_POST);
+    $success  = false;
+    $uid      = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST);
+    $instance = rcube_utils::get_input_value('_instance', rcube_utils::INPUT_POST);
+    $savemode = rcube_utils::get_input_value('_savemode', rcube_utils::INPUT_POST);
 
     // search for event if only UID is given
-    if ($event = $this->driver->get_event(array('uid' => $uid, '_instance' => $inst), true)) {
+    if ($event = $this->driver->get_event(array('uid' => $uid, '_instance' => $instance), true)) {
+      $event['_savemode'] = $savemode;
       $success = $this->driver->remove_event($event, true);
     }
 
diff --git a/plugins/calendar/skins/larry/print.css b/plugins/calendar/skins/larry/print.css
index ce6e8e7..fc5de97 100644
--- a/plugins/calendar/skins/larry/print.css
+++ b/plugins/calendar/skins/larry/print.css
@@ -76,6 +76,7 @@ body, td, th, div, p, h3, select, input, textarea {
 #calendarlist li div {
 	float: left;
 	padding-right: 3em;
+	padding-bottom: 1em;
 }
 
 #calendarlist input,


commit ba84648fa7031edd41d949e82e62e5fb80e482df
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Tue Feb 17 14:04:57 2015 +0100

    - Onyl print events from active calendars (#4603)
    - Fix colors of events in month view
    - Show calendar color legend as floating list, no hierarchy

diff --git a/plugins/calendar/print.js b/plugins/calendar/print.js
index c3c647c..941d04c 100644
--- a/plugins/calendar/print.js
+++ b/plugins/calendar/print.js
@@ -43,6 +43,9 @@ window.rcmail && rcmail.addEventListener('init', function(evt) {
   var src, event_sources = [];
   var add_url = (rcmail.env.search ? '&q='+escape(rcmail.env.search) : '');
   for (var id in rcmail.env.calendars) {
+    if (!rcmail.env.calendars[id].active)
+      continue;
+
     source = $.extend({
       url: "./?_task=calendar&_action=load_events&source=" + escape(id) + add_url,
       className: 'fc-event-cal-'+id,
diff --git a/plugins/calendar/skins/larry/print.css b/plugins/calendar/skins/larry/print.css
index 5d190f5..ce6e8e7 100644
--- a/plugins/calendar/skins/larry/print.css
+++ b/plugins/calendar/skins/larry/print.css
@@ -1,7 +1,7 @@
 /*** Printing styles for Calendar plugin ***/
 
 body {
-	margin: 0;
+	margin: 0 0 1em 0;
 	color: #000;
 	background: #fff;
 }
@@ -54,18 +54,30 @@ body, td, th, div, p, h3, select, input, textarea {
 }
 
 #calendarlist {
-	list-style-type: square;
+	list-style: none;
 	margin: 2em 0;
 	padding-left: 1em;
 }
 
+#calendarlist ul {
+	float: left;
+	list-style: none;
+	padding-left: 0;
+}
+
 #calendarlist li {
+	float: left;
 	padding-left: 0;
-	padding-right: 3em;
+	padding-right: 0;
 	margin-left: 0;
 	font-weight: bold;
 }
 
+#calendarlist li div {
+	float: left;
+	padding-right: 3em;
+}
+
 #calendarlist input,
 #calendarlist .handle {
 	display: none;
@@ -207,6 +219,9 @@ body, td, th, div, p, h3, select, input, textarea {
 	font-style: italic;
 }
 
+.fc-view-month .fc-event-hori .fc-event-inner {
+	background: #fff !important;
+}
 
 .fc-view-table col.fc-event-location {
 	width: 20%;
diff --git a/plugins/calendar/skins/larry/templates/print.html b/plugins/calendar/skins/larry/templates/print.html
index 8d7789a..e679f72 100644
--- a/plugins/calendar/skins/larry/templates/print.html
+++ b/plugins/calendar/skins/larry/templates/print.html
@@ -19,6 +19,7 @@
 
 <div class="calwidth">
 	<roundcube:object name="plugin.calendar_list" activeonly="true" id="calendarlist" />
+	<br style="clear:both">
 </div>
 
 <roundcube:object name="plugin.calendar_css" printmode="true" />


commit f78af8b09f6f20858d4a8f6a69e590c03befdaa1
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Tue Feb 17 13:10:37 2015 +0100

    Fix ical export after last commit

diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php
index 29bc01e..4316542 100644
--- a/plugins/calendar/drivers/kolab/kolab_calendar.php
+++ b/plugins/calendar/drivers/kolab/kolab_calendar.php
@@ -291,7 +291,7 @@ class kolab_calendar extends kolab_storage_folder_api
         }
 
         // find and merge exception for the first instance
-        if (!empty($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS'])) {
+        if ($virtual && !empty($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS'])) {
           $event_date = $event['start']->format('Ymd');
           foreach ($event['recurrence']['EXCEPTIONS'] as $exception) {
             $exdate = $exception['recurrence_date'] ? $exception['recurrence_date']->format('Ymd') : substr($exception['_instance'], 0, 8);
diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index 3e3f0fc..0a9790c 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -1674,10 +1674,10 @@ class kolab_driver extends calendar_driver
       unset($record['recurrence']);
 
     // add instance identifier to first occurrence (master event)
+    // do not add 'recurrence_date' though in order to keep the master even being exported as such
     if ($record['recurrence'] && !$record['recurrence_id'] && !$record['_instance']) {
-      $recurrence_id_format = $event['allday'] ? 'Ymd' : 'Ymd\THis';
-      $record['recurrence_date'] = $record['start'];
-      $record['_instance'] = $record['recurrence_date']->format($recurrence_id_format);
+      $recurrence_id_format = $record['allday'] ? 'Ymd' : 'Ymd\THis';
+      $record['_instance'] = $record['start']->format($recurrence_id_format);
     }
 
     // remove internals


commit 8a90069071821f4cf2cdac6d7fce82cf12a32c11
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Tue Feb 17 11:36:01 2015 +0100

    - Support exceptions and iTip messages with thisansfuture range
    - Store two exceptions for the same occurence if necessary (with differing range)
    - Update attendee status from iTip REPLY to all exceptions stored for the event
    - Correctly handle exceptions on the first instance (main event)

diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php
index 786cd76..8e6a1a3 100644
--- a/plugins/calendar/calendar.php
+++ b/plugins/calendar/calendar.php
@@ -863,6 +863,7 @@ class calendar extends rcube_plugin
         $this->write_preprocess($event, $action);
         if ($success = $this->driver->new_event($event)) {
           $event['id'] = $event['uid'];
+          $event['_savemode'] = 'all';
           $this->cleanup_event($event);
         }
         $reload = $success && $event['recurrence'] ? 2 : 1;
@@ -1017,11 +1018,18 @@ class calendar extends rcube_plugin
             $itip = $this->load_itip();
             $itip->set_sender_email($reply_sender);
             $event['comment'] = $reply_comment;
+            $event['thisandfuture'] = $event['_savemode'] == 'future';
             if ($organizer && $itip->send_itip_message($event, 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status))
               $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ? $organizer['name'] : $organizer['email']))), 'confirmation');
             else
               $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error');
           }
+
+          // refresh all calendars
+          if ($event['calendar'] != $ev['calendar']) {
+            $this->rc->output->command('plugin.refresh_calendar', array('source' => null, 'refetch' => true));
+            $reload = 0;
+          }
         }
         break;
 
@@ -1139,11 +1147,13 @@ class calendar extends rcube_plugin
 
       // make sure we have the complete record
       $event = $action == 'remove' ? $old : $this->driver->get_event($event);
+      $event['_savemode'] = $_savemode;
 
       // send notification for the main event when savemode is 'all'
       if ($_savemode == 'all' && $event['recurrence_id']) {
         $event['id'] = $event['recurrence_id'];
         $event = $this->driver->get_event($event);
+        unset($event['_instance'], $event['recurrence_date']);
       }
 
       // only notify if data really changed (TODO: do diff check on client already)
@@ -2763,9 +2773,11 @@ class calendar extends rcube_plugin
               }
             }
             $event_attendee = null;
+            $update_attendees = array();
             foreach ($event['attendees'] as $attendee) {
               if ($event['_sender'] && ($attendee['email'] == $event['_sender'] || $attendee['email'] == $event['_sender_utf'])) {
                 $event_attendee = $attendee;
+                $update_attendees[] = $attendee;
                 $metadata['fallback'] = $attendee['status'];
                 $metadata['attendee'] = $attendee['email'];
                 $metadata['rsvp'] = $attendee['rsvp'] || $attendee['role'] != 'NON-PARTICIPANT';
@@ -2775,9 +2787,12 @@ class calendar extends rcube_plugin
               }
               // also copy delegate attendee
               else if (!empty($attendee['delegated-from']) &&
-                       (stripos($attendee['delegated-from'], $event['_sender']) !== false || stripos($attendee['delegated-from'], $event['_sender_utf']) !== false) &&
-                       (!in_array($attendee['email'], $existing_attendee_emails))) {
-                $existing['attendees'][] = $attendee;
+                       (stripos($attendee['delegated-from'], $event['_sender']) !== false ||
+                        stripos($attendee['delegated-from'], $event['_sender_utf']) !== false)) {
+                $update_attendees[] = $attendee;
+                if (!in_array($attendee['email'], $existing_attendee_emails)) {
+                  $existing['attendees'][] = $attendee;
+                }
               }
             }
 
@@ -2794,12 +2809,12 @@ class calendar extends rcube_plugin
             // found matching attendee entry in both existing and new events
             if ($existing_attendee >= 0 && $event_attendee) {
               $existing['attendees'][$existing_attendee] = $event_attendee;
-              $success = $this->driver->edit_event($existing);
+              $success = $this->driver->update_attendees($existing, $update_attendees);
             }
             // update the entire attendees block
             else if (($event['sequence'] >= $existing['sequence'] || $event['changed'] >= $existing['changed']) && $event_attendee) {
               $existing['attendees'][] = $event_attendee;
-              $success = $this->driver->edit_event($existing);
+              $success = $this->driver->update_attendees($existing, $update_attendees);
             }
             else {
               $error_msg = $this->gettext('newerversionexists');
@@ -2855,7 +2870,7 @@ class calendar extends rcube_plugin
 
           // if the RSVP reply only refers to a single instance:
           // store unmodified master event with current instance as exception
-          if (!empty($instance) && $savemode != 'all') {
+          if (!empty($instance) && !empty($savemode) && $savemode != 'all') {
             $master = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event');
             if ($master['recurrence'] && !$master['_instance']) {
               // compute recurring events until this instance's date
diff --git a/plugins/calendar/drivers/calendar_driver.php b/plugins/calendar/drivers/calendar_driver.php
index 742830b..aecd2e1 100644
--- a/plugins/calendar/drivers/calendar_driver.php
+++ b/plugins/calendar/drivers/calendar_driver.php
@@ -205,6 +205,18 @@ abstract class calendar_driver
   }
 
   /**
+   * Update the participant status for the given attendee
+   *
+   * @param array  Hash array with event properties
+   * @param array  List of hash arrays each represeting an updated attendee
+   * @return boolean True on success, False on error
+   */
+  public function update_attendees(&$event, $attendees)
+  {
+    return $this->edit_event($event);
+  }
+
+  /**
    * Move a single event
    *
    * @param array Hash array with event properties:
diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php
index b5b272f..29bc01e 100644
--- a/plugins/calendar/drivers/kolab/kolab_calendar.php
+++ b/plugins/calendar/drivers/kolab/kolab_calendar.php
@@ -195,7 +195,11 @@ class kolab_calendar extends kolab_storage_folder_api
       if ($master_id != $id && ($record = $this->storage->get_object($master_id)))
         $this->events[$master_id] = $this->_to_rcube_event($record);
 
-      if (($master = $this->events[$master_id]) && $master['recurrence']) {
+      // check for match on the first instance already
+      if (($_instance = $this->events[$master_id]['_instance']) && $id == $master_id . '-' . $_instance) {
+        $this->events[$id] = $this->events[$master_id];
+      }
+      else if (($master = $this->events[$master_id]) && $master['recurrence']) {
         $this->get_recurring_events($record, $master['start'], null, $id);
       }
     }
@@ -274,17 +278,10 @@ class kolab_calendar extends kolab_storage_folder_api
         $add = true;
 
         // skip the first instance of a recurring event if listed in exdate
-        if ($virtual && (!empty($event['recurrence']['EXDATE']) || !empty($event['recurrence']['EXCEPTIONS']))) {
+        if ($virtual && !empty($event['recurrence']['EXDATE'])) {
           $event_date = $event['start']->format('Ymd');
           $exdates = (array)$event['recurrence']['EXDATE'];
 
-          // add dates from exceptions to list
-          if (is_array($event['recurrence']['EXCEPTIONS'])) {
-              foreach ($event['recurrence']['EXCEPTIONS'] as $exception) {
-                  $exdates[] = clone $exception['start'];
-              }
-          }
-
           foreach ($exdates as $exdate) {
             if ($exdate->format('Ymd') == $event_date) {
               $add = false;
@@ -293,6 +290,18 @@ class kolab_calendar extends kolab_storage_folder_api
           }
         }
 
+        // find and merge exception for the first instance
+        if (!empty($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS'])) {
+          $event_date = $event['start']->format('Ymd');
+          foreach ($event['recurrence']['EXCEPTIONS'] as $exception) {
+            $exdate = $exception['recurrence_date'] ? $exception['recurrence_date']->format('Ymd') : substr($exception['_instance'], 0, 8);
+            if ($exdate == $event_date) {
+              $event['_instance'] = $exception['_instance'];
+              kolab_driver::merge_event_data($event, $exception);
+            }
+          }
+        }
+
         if ($add)
           $events[] = $event;
       }
@@ -583,24 +592,29 @@ class kolab_calendar extends kolab_storage_folder_api
         $rec_event['id'] = $event['uid'] . '-' . $exception['_instance'];
         $rec_event['isexception'] = 1;
 
-        // found the specifically requested instance, exiting...
-        if ($rec_event['id'] == $event_id) {
+        // found the specifically requested instance: register exception (single occurrence wins)
+        if ($rec_event['id'] == $event_id && (!$this->events[$event_id] || $this->events[$event_id]['thisandfuture'])) {
           $rec_event['recurrence'] = $recurrence_rule;
           $rec_event['recurrence_id'] = $event['uid'];
-          $events[] = $rec_event;
           $this->events[$rec_event['id']] = $rec_event;
-          return $events;
         }
 
         // remember this exception's date
         $exdate = substr($exception['_instance'], 0, 8);
-        $exdata[$exdate] = $rec_event;
+        if (!$exdata[$exdate] || $exdata[$exdate]['thisandfuture']) {
+          $exdata[$exdate] = $rec_event;
+        }
         if ($rec_event['thisandfuture']) {
           $futuredata[$exdate] = $rec_event;
         }
       }
     }
 
+    // found the specifically requested instance, exiting...
+    if ($event_id && !empty($this->events[$event_id])) {
+      return array($this->events[$event_id]);
+    }
+
     // use libkolab to compute recurring events
     if (class_exists('kolabcalendaring')) {
         $recurrence = new kolab_date_recurrence($object);
@@ -627,7 +641,7 @@ class kolab_calendar extends kolab_storage_folder_api
         $rec_event['_instance'] = $instance_id;
 
         if ($overlay_data || $exdata[$datestr])  // copy data from exception
-          $this->_merge_event_data($rec_event, $exdata[$datestr] ?: $overlay_data);
+          kolab_driver::merge_event_data($rec_event, $exdata[$datestr] ?: $overlay_data);
 
         $rec_event['id'] = $rec_id;
         $rec_event['recurrence_id'] = $event['uid'];
@@ -652,37 +666,10 @@ class kolab_calendar extends kolab_storage_folder_api
   }
 
   /**
-   * Merge certain properties from the overlay event to the base event object
-   *
-   * @param array The event object to be altered
-   * @param array The overlay event object to be merged over $event
-   */
-  private function _merge_event_data(&$event, $overlay)
-  {
-    static $forbidden = array('id','uid','recurrence','recurrence_date','organizer','_attachments');
-
-    foreach ($overlay as $prop => $value) {
-      // adjust time of the recurring event instance
-      if ($prop == 'start' || $prop == 'end') {
-        if (is_object($event[$prop]) && is_a($event[$prop], 'DateTime')) {
-          $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s')));
-          // set date value if overlay is an exception of the current instance
-          if (substr($overlay['_instance'], 0, 8) == substr($event['_instance'], 0, 8)) {
-            $event[$prop]->setDate(intval($value->format('Y')), intval($value->format('n')), intval($value->format('j')));
-          }
-        }
-      }
-      else if ($prop[0] != '_' && !in_array($prop, $forbidden))
-        $event[$prop] = $value;
-    }
-  }
-
-  /**
    * Convert from Kolab_Format to internal representation
    */
   private function _to_rcube_event($record)
   {
-    $record['id'] = $record['uid'];
     $record['calendar'] = $this->id;
     $record['links'] = $this->get_links($record['uid']);
 
diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index 0cd962d..3e3f0fc 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -537,7 +537,7 @@ class kolab_driver extends calendar_driver
       $id = $event['id'] ?: $event['uid'];
       $cal = $event['calendar'];
 
-      // we're looking for a recurring instance: expand the ID to our internal convention for recurring instanced
+      // we're looking for a recurring instance: expand the ID to our internal convention for recurring instances
       if (!$event['id'] && $event['_instance']) {
         $id .= '-' . $event['_instance'];
       }
@@ -634,6 +634,48 @@ class kolab_driver extends calendar_driver
     return $ret;
   }
 
+  /**
+   * Update the participant status for the given attendees
+   *
+   * @see calendar_driver::update_attendees()
+   */
+  public function update_attendees(&$event, $attendees)
+  {
+    // for this-and-future updates, merge the updated attendees onto all exceptions in range
+    if (($event['_savemode'] == 'future' && $event['recurrence_id']) || !empty($event['recurrence'])) {
+      if (!($storage = $this->get_calendar($event['calendar'])))
+        return false;
+
+      // load master event
+      $master = $event['recurrence_id'] ? $storage->get_event($event['recurrence_id']) : $event;
+
+      // apply attendee update to each existing exception
+      if ($master['recurrence'] && !empty($master['recurrence']['EXCEPTIONS'])) {
+        $saved = false;
+        foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) {
+          // merge the new event properties onto future exceptions
+          if ($exception['_instance'] >= $event['_instance']) {
+            self::merge_attendee_data($master['recurrence']['EXCEPTIONS'][$i], $attendees);
+          }
+          // update a specific instance
+          if ($exception['_instance'] == $event['_instance'] && $exception['thisandfuture']) {
+            $saved = true;
+          }
+        }
+
+        // add the given event as new exception
+        if (!$saved && $event['id'] != $master['id']) {
+          $event['thisandfuture'] = true;
+          $master['recurrence']['EXCEPTIONS'][] = $event;
+        }
+
+        return $this->update_event($master);
+      }
+    }
+
+    // just update the given event (instance)
+    return $this->update_event($event);
+  }
 
   /**
    * Move a single event
@@ -933,15 +975,10 @@ class kolab_driver extends calendar_driver
         }
 
         // save properties to a recurrence exception instance
-        if ($old['recurrence_id'] && is_array($master['recurrence']['EXCEPTIONS'])) {
-          foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) {
-            if ($exception['_instance'] == $old['_instance']) {
-              $event['_instance'] = $old['_instance'];
-              $event['recurrence_date'] = $old['recurrence_date'];
-              $master['recurrence']['EXCEPTIONS'][$i] = $event;
-              $success = $storage->update_event($master, $old['id']);
-              break 2;
-            }
+        if ($old['_instance'] && is_array($master['recurrence']['EXCEPTIONS'])) {
+          if ($this->update_recurrence_exceptions($master, $event, $old, $savemode)) {
+            $success = $storage->update_event($master, $old['id']);
+            break;
           }
         }
 
@@ -962,7 +999,7 @@ class kolab_driver extends calendar_driver
         // save as new exception to master event
         if ($add_exception) {
           $event['_instance'] = $old['_instance'];
-          $event['recurrence_date'] = $old['recurrence_date'];
+          $event['recurrence_date'] = $old['recurrence_date'] ?: $old['start'];
           $master['recurrence']['EXCEPTIONS'][] = $event;
         }
         $success = $storage->update_event($master);
@@ -1004,6 +1041,8 @@ class kolab_driver extends calendar_driver
           $event['end'] = $master['end'];
         }
 
+        // TODO: forward changes to exceptions (which do not yet have differing values stored)
+
         // adjust recurrence-id when start changed and therefore the entire recurrence chain changes
         if (($old_start_date != $new_start_date || $old_start_time != $new_start_time) &&
               is_array($event['recurrence']) && is_array($event['recurrence']['EXCEPTIONS']) && !$with_exceptions) {
@@ -1071,6 +1110,120 @@ class kolab_driver extends calendar_driver
   }
 
   /**
+   * Apply the given changes to already existing exceptions
+   */
+  protected function update_recurrence_exceptions(&$master, $event, $old, $savemode)
+  {
+    $saved = false;
+    $existing = null;
+
+    foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) {
+      // update a specific instance
+      if ($exception['_instance'] == $old['_instance']) {
+        $existing = $i;
+
+        // check savemode against existing exception mode.
+        // if matches, we can update this existing exception
+        if ((bool)$exception['thisandfuture'] === ($savemode == 'future')) {
+          $event['_instance'] = $old['_instance'];
+          $event['thisandfuture'] = $old['thisandfuture'];
+          $event['recurrence_date'] = $old['recurrence_date'];
+          $master['recurrence']['EXCEPTIONS'][$i] = $event;
+          $saved = true;
+        }
+      }
+      // merge the new event properties onto future exceptions
+      if ($savemode == 'future' && $exception['_instance'] >= $old['_instance']) {
+        unset($event['thisandfuture']);
+        self::merge_event_data($master['recurrence']['EXCEPTIONS'][$i], $event);
+      }
+    }
+/*
+    // we could not update the existing exception due to savemode mismatch...
+    if (!$saved && $existing !== null && $master['recurrence']['EXCEPTIONS'][$existing]['thisandfuture']) {
+      // ... try to move the existing this-and-future exception to the next occurrence
+      foreach ($this->get_recurring_events($master, $existing['start']) as $candidate) {
+        // our old this-and-future exception is obsolete
+        if ($candidate['thisandfuture']) {
+          unset($master['recurrence']['EXCEPTIONS'][$existing]);
+          $saved = true;
+          break;
+        }
+        // this occurrence doesn't yet have an exception
+        else if (!$candidate['isexception']) {
+          $event['_instance'] = $candidate['_instance'];
+          $event['recurrence_date'] = $candidate['recurrence_date'];
+          $master['recurrence']['EXCEPTIONS'][$i] = $event;
+          $saved = true;
+          break;
+        }
+      }
+    }
+*/
+
+    // returning false here will add a new exception
+    return $saved;
+  }
+
+  /**
+   * Merge certain properties from the overlay event to the base event object
+   *
+   * @param array The event object to be altered
+   * @param array The overlay event object to be merged over $event
+   */
+  public static function merge_event_data(&$event, $overlay)
+  {
+    static $forbidden = array('id','uid','recurrence','recurrence_date','thisandfuture','organizer','_attachments');
+
+    foreach ($overlay as $prop => $value) {
+      // adjust time of the recurring event instance
+      if ($prop == 'start' || $prop == 'end') {
+        if (is_object($event[$prop]) && is_a($event[$prop], 'DateTime')) {
+          $event[$prop]->setTime($value->format('G'), intval($value->format('i')), intval($value->format('s')));
+          // set date value if overlay is an exception of the current instance
+          if (substr($overlay['_instance'], 0, 8) == substr($event['_instance'], 0, 8)) {
+            $event[$prop]->setDate(intval($value->format('Y')), intval($value->format('n')), intval($value->format('j')));
+          }
+        }
+      }
+      else if ($prop == 'thisandfuture' && $overlay['_instance'] == $event['_instance']) {
+        $event[$prop] = $value;
+      }
+      else if ($prop[0] != '_' && !in_array($prop, $forbidden))
+        $event[$prop] = $value;
+    }
+  }
+
+  /**
+   * Update attendee properties on the given event object
+   *
+   * @param array The event object to be altered
+   * @param array List of hash arrays each represeting an updated/added attendee
+   */
+  public static function merge_attendee_data(&$event, $attendees)
+  {
+    if (!empty($attendees) && !is_array($attendees[0])) {
+      $attendees = array($attendees);
+    }
+
+    foreach ($attendees as $attendee) {
+      $found = false;
+
+      foreach ($event['attendees'] as $i => $candidate) {
+        if ($candidate['email'] == $attendee['email']) {
+          $event['attendees'][$i] = $attendee;
+          $found = true;
+          break;
+        }
+      }
+
+      if (!$found) {
+        $event['attendees'][] = $attendee;
+      }
+    }
+  }
+
+  /**
    * Get events from source.
    *
    * @param  integer Event's new start (unix timestamp)
@@ -1520,6 +1673,13 @@ class kolab_driver extends calendar_driver
     if (empty($record['recurrence']))
       unset($record['recurrence']);
 
+    // add instance identifier to first occurrence (master event)
+    if ($record['recurrence'] && !$record['recurrence_id'] && !$record['_instance']) {
+      $recurrence_id_format = $event['allday'] ? 'Ymd' : 'Ymd\THis';
+      $record['recurrence_date'] = $record['start'];
+      $record['_instance'] = $record['recurrence_date']->format($recurrence_id_format);
+    }
+
     // remove internals
     unset($record['_mailbox'], $record['_msguid'], $record['_formatobj'], $record['_attachments'], $record['x-custom']);
 
diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php
index 63a0548..c203854 100644
--- a/plugins/libcalendaring/libcalendaring.php
+++ b/plugins/libcalendaring/libcalendaring.php
@@ -1410,8 +1410,12 @@ class libcalendaring extends rcube_plugin
      */
     public static function identify_recurrence_instance(&$object)
     {
+        // for savemode=all, remove recurrence instance identifiers
+        if (!empty($object['_savemode']) && $object['_savemode'] == 'all') {
+            unset($object['_instance'], $object['recurrence_date']);
+        }
         // set instance and 'savemode' according to recurrence-id
-        if (!empty($object['recurrence_date']) && is_a($object['recurrence_date'], 'DateTime')) {
+        else if (!empty($object['recurrence_date']) && is_a($object['recurrence_date'], 'DateTime')) {
             $recurrence_id_format = $object['allday'] ? 'Ymd' : 'Ymd\THis';
             $object['_instance'] = $object['recurrence_date']->format($recurrence_id_format);
             $object['_savemode'] = $object['thisandfuture'] ? 'future' : 'current';
diff --git a/plugins/libcalendaring/localization/en_US.inc b/plugins/libcalendaring/localization/en_US.inc
index ca7d1fd..e5e0426 100644
--- a/plugins/libcalendaring/localization/en_US.inc
+++ b/plugins/libcalendaring/localization/en_US.inc
@@ -109,7 +109,7 @@ $labels['acceptattendee'] = 'Accept participant';
 $labels['declineattendee'] = 'Decline participant';
 $labels['declineattendeeconfirm'] = 'Enter a message to the declined participant (optional):';
 $labels['rsvpmodeall'] = 'The entire series';
-$labels['rsvpmodecurrent'] = 'This occurrence';
+$labels['rsvpmodecurrent'] = 'This occurrence only';
 $labels['rsvpmodefuture'] = 'This and future occurrences';
 
 $labels['itipsingleoccurrence'] = 'This is a <em>single occurrence</em> out of a series of events';


commit 7fd2eb873dbeed5fb955e459b20e1134c09892b0
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Feb 16 18:45:25 2015 +0100

    Hide RSVP-mode menu when moving/resizing event dialog

diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js
index fcd360a..c9334ec 100644
--- a/plugins/calendar/calendar_ui.js
+++ b/plugins/calendar/calendar_ui.js
@@ -604,13 +604,16 @@ function rcube_calendar_ui(settings)
         },
         close: function() {
           $dialog.dialog('destroy').attr('aria-hidden', 'true').hide();
-          rcmail.command('menu-close','eventoptionsmenu')
+          rcmail.command('menu-close','eventoptionsmenu');
+          $('.libcal-rsvp-replymode').hide();
         },
         dragStart: function() {
-          rcmail.command('menu-close','eventoptionsmenu')
+          rcmail.command('menu-close','eventoptionsmenu');
+          $('.libcal-rsvp-replymode').hide();
         },
         resizeStart: function() {
-          rcmail.command('menu-close','eventoptionsmenu')
+          rcmail.command('menu-close','eventoptionsmenu');
+          $('.libcal-rsvp-replymode').hide();
         },
         buttons: buttons,
         minWidth: 320,
diff --git a/plugins/libcalendaring/libcalendaring.js b/plugins/libcalendaring/libcalendaring.js
index cd59f66..71c101d 100644
--- a/plugins/libcalendaring/libcalendaring.js
+++ b/plugins/libcalendaring/libcalendaring.js
@@ -967,6 +967,7 @@ rcube_libcalendaring.itip_rsvp_recurring = function(btn, callback)
 
     $.each(['all','current','future'], function(i, mode) {
         $('<li><a>' + rcmail.get_label('rsvpmode'+mode, 'libcalendaring') + '</a>')
+        .addClass('ui-menu-item')
         .attr('rel', mode)
         .appendTo(mnu);
     });


commit 6a5a8148348d0eddb452ff33affe4af87e9359cc
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Feb 16 15:59:50 2015 +0100

    Don't remove properties from exceptions which are equal to the master event. KE17 says:
    
    A recurrence exception SHALL copy ALL properties of the base event, and adjust as required,
    and it SHALL NOT be applied on top of the orginial event properties (The exception replaces
    the complete original event definition for the specific occurrence).

diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php
index 0fda1e3..979b33b 100644
--- a/plugins/libkolab/lib/kolab_format_event.php
+++ b/plugins/libkolab/lib/kolab_format_event.php
@@ -241,7 +241,6 @@ class kolab_format_event extends kolab_format_xcal
     private function compact_exception($exception, $master)
     {
         $forbidden = array('recurrence','organizer','_attachments');
-        $whitelist = array('start','end');
 
         foreach ($forbidden as $prop) {
             if (array_key_exists($prop, $exception)) {
@@ -249,12 +248,6 @@ class kolab_format_event extends kolab_format_xcal
             }
         }
 
-        foreach ($master as $prop => $value) {
-            if (isset($exception[$prop]) && gettype($exception[$prop]) == gettype($value) && $exception[$prop] == $value && !in_array($prop, $whitelist)) {
-                unset($exception[$prop]);
-            }
-        }
-
         // preserve this property for date serialization
         $exception['allday'] = $master['allday'];
 


commit fe64e05e48c11b809bde1a32161dcdce3e1e2ea3
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Feb 16 15:36:25 2015 +0100

    Render a menu to select the RSVP mode for recurring events instead of using radio buttons

diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js
index ec83d1b..fcd360a 100644
--- a/plugins/calendar/calendar_ui.js
+++ b/plugins/calendar/calendar_ui.js
@@ -557,11 +557,11 @@ function rcube_calendar_ui(settings)
 
         if (event.recurrence && event.id) {
           var sel = event._savemode || (event.thisandfuture ? 'future' : (event.isexception ? 'current' : 'all'));
-          $('#event-rsvp input.rsvp-replymode[value="'+sel+'"]').prop('checked', true);
-          $('#event-rsvp .rsvp-replymode-message').show();
+          $('#event-rsvp .rsvp-buttons').addClass('recurring');
+        }
+        else {
+          $('#event-rsvp .rsvp-buttons').removeClass('recurring');
         }
-        else
-          $('#event-rsvp .rsvp-replymode-message').hide();
       }
 
       var buttons = [];
@@ -2378,14 +2378,31 @@ function rcube_calendar_ui(settings)
     }
 
     // when the user accepts or declines an event invitation
-    var event_rsvp = function(response, delegate)
+    var event_rsvp = function(response, delegate, replymode)
     {
+      var btn;
+      if (typeof response == 'object') {
+        btn = $(response);
+        response = btn.attr('rel')
+      }
+      else {
+        btn = $('#event-rsvp input.button[rel='+response+']');
+      }
+
+      // show menu to select rsvp reply mode (current or all)
+      if (me.selected_event && me.selected_event.recurrence && !replymode) {
+        rcube_libcalendaring.itip_rsvp_recurring(btn, function(resp, mode) {
+          event_rsvp(resp, null, mode);
+        });
+        return;
+      }
+
       if (me.selected_event && me.selected_event.attendees && response) {
         // bring up delegation dialog
         if (response == 'delegated' && !delegate) {
           rcube_libcalendaring.itip_delegate_dialog(function(data) {
             data.rsvp = data.rsvp ? 1 : '';
-            event_rsvp('delegated', data);
+            event_rsvp('delegated', data, replymode);
           });
           return;
         }
@@ -2419,7 +2436,7 @@ function rcube_calendar_ui(settings)
         }
 
         // submit status change to server
-        var submit_data = $.extend({}, me.selected_event, { source:null, comment:$('#reply-comment-event-rsvp').val(), _savemode: $('input.rsvp-replymode:checked').val() }, (delegate || {})),
+        var submit_data = $.extend({}, me.selected_event, { source:null, comment:$('#reply-comment-event-rsvp').val(), _savemode: replymode || 'all' }, (delegate || {})),
           noreply = $('#noreply-event-rsvp:checked').length ? 1 : 0;
 
         // import event from mail (temporary iTip event)
@@ -4163,7 +4180,7 @@ function rcube_calendar_ui(settings)
       });
 
       $('#event-rsvp input.button').click(function(e) {
-        event_rsvp($(this).attr('rel'))
+        event_rsvp(this)
       });
 
       $('#eventedit input.edit-recurring-savemode').change(function(e) {
diff --git a/plugins/calendar/skins/larry/calendar.css b/plugins/calendar/skins/larry/calendar.css
index 0fec69c..fef16bd 100644
--- a/plugins/calendar/skins/larry/calendar.css
+++ b/plugins/calendar/skins/larry/calendar.css
@@ -1063,24 +1063,8 @@ td.topalign {
 	text-align: center;
 }
 
-.event-dialog-message .rsvp-replymode-message {
-	margin-top: 0.8em;
-	margin-bottom: 0.6em;
-}
-
-.event-dialog-message .rsvp-replymode-message .replymode-select {
-	padding-left: 22px;
-}
-
-.event-dialog-message .rsvp-replymode-message label {
-	color: inherit;
-	margin-right: 0.4em;
-	white-space: nowrap;
-	min-width: 4em;
-}
-
-.event-dialog-message .rsvp-replymode-message input.rsvp-replymode {
-	margin-right: 0.4em;
+.libcal-rsvp-replymode li a {
+	cursor: default;
 }
 
 #event-rsvp,
diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php
index f56463c..2eec27c 100644
--- a/plugins/libcalendaring/lib/libcalendaring_itip.php
+++ b/plugins/libcalendaring/lib/libcalendaring_itip.php
@@ -677,20 +677,16 @@ class libcalendaring_itip
             }
         }
 
+        foreach (array('all','current','future') as $mode) {
+            $this->rc->output->command('add_label', "rsvpmode$mode", $this->gettext("rsvpmode$mode"));
+        }
+
         $savemode_radio = new html_radiobutton(array('name' => '_rsvpmode', 'class' => 'rsvp-replymode'));
 
         return html::div($attrib,
             html::div('label', $this->gettext('acceptinvitation')) .
             html::div('rsvp-buttons',
                 $buttons .
-                html::div(array('class' => 'rsvp-replymode-message', 'style' => 'display:none'),
-                    html::div('message', html::span('ui-icon ui-icon-alert', '') . $this->gettext('rsvprecurringevent')) .
-                    html::div('replymode-select', 
-                        html::label(null, $savemode_radio->show('all', array('value' => 'all')) . $this->gettext('allevents')) .
-                        html::label(null, $savemode_radio->show(null, array('value' => 'current')) . $this->gettext('currentevent')) .
-                        html::label(null, $savemode_radio->show(null, array('value' => 'future')) . $this->gettext('futurevents'))
-                    )
-                ) .
                 html::div('itip-reply-controls', $this->itip_rsvp_options_ui($attrib['id']))
             )
         );
diff --git a/plugins/libcalendaring/libcalendaring.js b/plugins/libcalendaring/libcalendaring.js
index a13ebf7..cd59f66 100644
--- a/plugins/libcalendaring/libcalendaring.js
+++ b/plugins/libcalendaring/libcalendaring.js
@@ -959,6 +959,39 @@ rcube_libcalendaring.itip_delegate_dialog = function(callback, selector)
 };
 
 /**
+ * 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>')
+        .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(event, task, title)
diff --git a/plugins/libcalendaring/localization/en_US.inc b/plugins/libcalendaring/localization/en_US.inc
index 992113a..ca7d1fd 100644
--- a/plugins/libcalendaring/localization/en_US.inc
+++ b/plugins/libcalendaring/localization/en_US.inc
@@ -108,7 +108,9 @@ $labels['acceptinvitation'] = 'Do you accept this invitation?';
 $labels['acceptattendee'] = 'Accept participant';
 $labels['declineattendee'] = 'Decline participant';
 $labels['declineattendeeconfirm'] = 'Enter a message to the declined participant (optional):';
-$labels['rsvprecurringevent'] = 'This is a series of events! Does your response apply to all, this occurrence only or this and future occurrences?';
+$labels['rsvpmodeall'] = 'The entire series';
+$labels['rsvpmodecurrent'] = 'This occurrence';
+$labels['rsvpmodefuture'] = 'This and future occurrences';
 
 $labels['itipsingleoccurrence'] = 'This is a <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';


commit d7733e7879c7c16074d11ebf58515c50dddf53bb
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Feb 16 11:06:42 2015 +0100

    Allow to RSVP reply on a single occurence when viewing the event in the calendar preview
    
    This will copy the main event from the iTip invitation with unchanged partstat into the
    user calendar and register a recurrence exception with the selected partsat and send a
    reply for this occurrence only.

diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php
index 7682ba0..786cd76 100644
--- a/plugins/calendar/calendar.php
+++ b/plugins/calendar/calendar.php
@@ -2655,6 +2655,8 @@ class calendar extends rcube_plugin
     $delete  = intval(rcube_utils::get_input_value('_del', rcube_utils::INPUT_POST));
     $noreply = intval(rcube_utils::get_input_value('_noreply', rcube_utils::INPUT_POST));
     $noreply = $noreply || $status == 'needs-action' || $itip_sending === 0;
+    $instance = rcube_utils::get_input_value('_instance', rcube_utils::INPUT_POST);
+    $savemode = rcube_utils::get_input_value('_savemode', rcube_utils::INPUT_POST);
 
     $error_msg = $this->gettext('errorimportingevent');
     $success = false;
@@ -2747,7 +2749,7 @@ class calendar extends rcube_plugin
 
         if ($existing) {
           // forward savemode for correct updates of recurring events
-          $existing['_savemode'] = $event['_savemode'];
+          $existing['_savemode'] = $savemode ?: $event['_savemode'];
 
           // only update attendee status
           if ($event['_method'] == 'REPLY') {
@@ -2850,9 +2852,43 @@ class calendar extends rcube_plugin
           if ($status == 'declined' || $event['status'] == 'CANCELLED' || $event_attendee['role'] == 'NON-PARTICIPANT') {
             $event['free_busy'] = 'free';
           }
+
+          // if the RSVP reply only refers to a single instance:
+          // store unmodified master event with current instance as exception
+          if (!empty($instance) && $savemode != 'all') {
+            $master = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'event');
+            if ($master['recurrence'] && !$master['_instance']) {
+              // compute recurring events until this instance's date
+              if ($recurrence_date = rcube_utils::anytodatetime($instance, $master['start']->getTimezone())) {
+                $recurrence_date->setTime(23,59,59);
+
+                foreach ($this->driver->get_recurring_events($master, $master['start'], $recurrence_date) as $recurring) {
+                  if ($recurring['_instance'] == $instance) {
+                    // copy attendees block with my partstat to exception
+                    $recurring['attendees'] = $event['attendees'];
+                    $master['recurrence']['EXCEPTIONS'][] = $recurring;
+                    $event = $recurring;  // set reference for iTip reply
+                    break;
+                  }
+                }
+
+                $master['calendar'] = $event['calendar'] = $calendar['id'];
+                $success = $this->driver->new_event($master);
+              }
+              else {
+                $master = null;
+              }
+            }
+            else {
+              $master = null;
+            }
+          }
+
           // save to the selected/default calendar
-          $event['calendar'] = $calendar['id'];
-          $success = $this->driver->new_event($event);
+          if (!$master) {
+            $event['calendar'] = $calendar['id'];
+            $success = $this->driver->new_event($event);
+          }
         }
         else if ($status == 'declined')
           $error_msg = null;
diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js
index f9e0d28..ec83d1b 100644
--- a/plugins/calendar/calendar_ui.js
+++ b/plugins/calendar/calendar_ui.js
@@ -2434,6 +2434,7 @@ function rcube_calendar_ui(settings)
             _rsvp: (delegate && delegate.rsvp) ? 1 : 0,
             _noreply: noreply,
             _comment: submit_data.comment,
+            _instance: submit_data._instance,
             _savemode: submit_data._savemode
           });
         }


commit aaaa9c58185d96a120893f2f3e3c361d44309e8a
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Feb 16 11:00:26 2015 +0100

    Take differing parstat values in recurrence exceptions into account when querying for pending/declined/regular events:
    
    - Colelct partstat tags from recurrence exceptions when caching
    - Querying for 'tags != x-partstat:<email>:needs-action' may miss some valid records
    - Do post-filtering on all events, including recurring instances

diff --git a/plugins/calendar/drivers/calendar_driver.php b/plugins/calendar/drivers/calendar_driver.php
index e402db9..742830b 100644
--- a/plugins/calendar/drivers/calendar_driver.php
+++ b/plugins/calendar/drivers/calendar_driver.php
@@ -450,6 +450,7 @@ abstract class calendar_driver
 
       $rcmail = rcmail::get_instance();
       $recurrence = new calendar_recurrence($rcmail->plugins->get_plugin('calendar'), $event);
+      $recurrence_id_format = $event['allday'] ? 'Ymd' : 'Ymd\THis';
 
       // determine a reasonable end date if none given
       if (!$end) {
@@ -465,10 +466,10 @@ abstract class calendar_driver
 
       $i = 0;
       while ($next_event = $recurrence->next_instance()) {
-        $next_event['uid'] = $event['uid'] . '-' . ++$i;
         // add to output if in range
         if (($next_event['start'] <= $end && $next_event['end'] >= $start)) {
-          $next_event['id'] = $next_event['uid'];
+          $next_event['_instance'] = $next_event['start']->format($recurrence_id_format);
+          $next_event['id'] = $next_event['uid'] . '-' . $exception['_instance'];
           $next_event['recurrence_id'] = $event['uid'];
           $events[] = $next_event;
         }
@@ -477,7 +478,7 @@ abstract class calendar_driver
         }
 
         // avoid endless recursion loops
-        if ($i > 1000) {
+        if (++$i > 1000) {
           break;
         }
       }
diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php
index 06a8244..b5b272f 100644
--- a/plugins/calendar/drivers/kolab/kolab_calendar.php
+++ b/plugins/calendar/drivers/kolab/kolab_calendar.php
@@ -236,35 +236,31 @@ class kolab_calendar extends kolab_storage_folder_api
     $query[] = array('dtstart', '<=', $end);
     $query[] = array('dtend',   '>=', $start);
 
-    // add query to exclude pending/declined invitations
-    if (empty($filter_query) && $this->get_namespace() != 'other') {
-      foreach ($user_emails as $email) {
-        $query[] = array('tags', '!=', 'x-partstat:' . $email . ':needs-action');
-        $query[] = array('tags', '!=', 'x-partstat:' . $email . ':declined');
-      }
-    }
-    else if (is_array($filter_query)) {
+    if (is_array($filter_query)) {
       $query = array_merge($query, $filter_query);
     }
 
     if (!empty($search)) {
         $search = mb_strtolower($search);
+        $words = rcube_utils::tokenize_string($search, 1);
         foreach (rcube_utils::normalize_string($search, true) as $word) {
             $query[] = array('words', 'LIKE', $word);
         }
     }
+    else {
+      $words = array();
+    }
+
+    // set partstat filter to skip pending and declined invitations
+    if (empty($filter_query) && $this->get_namespace() != 'other') {
+      $partstat_exclude = array('NEEDS-ACTION','DECLINED');
+    }
+    else {
+      $partstat_exclude = array();
+    }
 
     $events = array();
     foreach ($this->storage->select($query) as $record) {
-      // post-filter events to skip pending and declined invitations
-      if (empty($filter_query) && is_array($record['attendees']) && $this->get_namespace() != 'other') {
-        foreach ($record['attendees'] as $attendee) {
-          if (in_array($attendee['email'], $user_emails) && in_array($attendee['status'], array('NEEDS-ACTION','DECLINED'))) {
-            continue 2;
-          }
-        }
-      }
-
       $event = $this->_to_rcube_event($record);
       $this->events[$event['id']] = $event;
 
@@ -272,18 +268,6 @@ class kolab_calendar extends kolab_storage_folder_api
       if ($event['categories'])
         $this->categories[$event['categories']]++;
 
-      // filter events by search query
-      if (!empty($search)) {
-        $hits = 0;
-        $words = rcube_utils::tokenize_string($search, 1);
-        foreach ($words as $word) {
-          $hits += $this->_fulltext_match($event, $word);
-        }
-
-        if ($hits < count($words))  // skip this event if not match with search term
-          continue;
-      }
-
       // list events in requested time window
       if ($event['start'] <= $end && $event['end'] >= $start) {
         unset($event['_attendees']);
@@ -312,24 +296,38 @@ class kolab_calendar extends kolab_storage_folder_api
         if ($add)
           $events[] = $event;
       }
-      
+
       // resolve recurring events
       if ($record['recurrence'] && $virtual == 1) {
         $events = array_merge($events, $this->get_recurring_events($record, $start, $end));
+      }
+    }
 
-        // when searching, only recurrence exceptions may match the query so post-filter the results again
-        if (!empty($search) && $record['recurrence']['EXCEPTIONS']) {
-          $me = $this;
-          $events = array_filter($events, function($event) use ($words, $me) {
-            $hits = 0;
-            foreach ($words as $word) {
-              $hits += $me->_fulltext_match($event, $word, false);
-            }
-            return $hits >= count($words);
-          });
+    // post-filter all events by fulltext search and partstat values
+    $me = $this;
+    $events = array_filter($events, function($event) use ($words, $partstat_exclude, $user_emails, $me) {
+      // fulltext search
+      if (count($words)) {
+        $hits = 0;
+        foreach ($words as $word) {
+          $hits += $me->_fulltext_match($event, $word, false);
+        }
+        if ($hits < count($words)) {
+          return false;
         }
       }
-    }
+
+      // partstat filter
+      if (count($partstat_exclude) && is_array($event['attendees'])) {
+        foreach ($event['attendees'] as $attendee) {
+          if (in_array($attendee['email'], $user_emails) && in_array($attendee['status'], $partstat_exclude)) {
+            return false;
+          }
+        }
+      }
+
+      return true;
+    });
 
     // avoid session race conditions that will loose temporary subscriptions
     $this->cal->rc->session->nowrite = true;
diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php
index a5b0f73..0fda1e3 100644
--- a/plugins/libkolab/lib/kolab_format_event.php
+++ b/plugins/libkolab/lib/kolab_format_event.php
@@ -223,15 +223,16 @@ class kolab_format_event extends kolab_format_xcal
      *
      * @return array List of tags to save in cache
      */
-    public function get_tags()
+    public function get_tags($obj = null)
     {
-        $tags = parent::get_tags();
+        $tags = parent::get_tags($obj);
+        $object = $obj ?: $this->data;
 
-        foreach ((array)$this->data['categories'] as $cat) {
+        foreach ((array)$object['categories'] as $cat) {
             $tags[] = rcube_utils::normalize_string($cat);
         }
 
-        return $tags;
+        return array_unique($tags);
     }
 
     /**
diff --git a/plugins/libkolab/lib/kolab_format_task.php b/plugins/libkolab/lib/kolab_format_task.php
index d3ddfe9..4640875 100644
--- a/plugins/libkolab/lib/kolab_format_task.php
+++ b/plugins/libkolab/lib/kolab_format_task.php
@@ -121,20 +121,21 @@ class kolab_format_task extends kolab_format_xcal
      *
      * @return array List of tags to save in cache
      */
-    public function get_tags()
+    public function get_tags($obj = null)
     {
-        $tags = parent::get_tags();
+        $tags = parent::get_tags($obj);
+        $object = $obj ?: $this->data;
 
-        if ($this->data['status'] == 'COMPLETED' || ($this->data['complete'] == 100 && empty($this->data['status'])))
+        if ($object['status'] == 'COMPLETED' || ($object['complete'] == 100 && empty($object['status'])))
             $tags[] = 'x-complete';
 
-        if ($this->data['priority'] == 1)
+        if ($object['priority'] == 1)
             $tags[] = 'x-flagged';
 
-        if ($this->data['parent_id'])
-            $tags[] = 'x-parent:' . $this->data['parent_id'];
+        if ($object['parent_id'])
+            $tags[] = 'x-parent:' . $object['parent_id'];
 
-        return $tags;
+        return array_unique($tags);
     }
 
 }
diff --git a/plugins/libkolab/lib/kolab_format_xcal.php b/plugins/libkolab/lib/kolab_format_xcal.php
index 6d49ad1..3d7bc27 100644
--- a/plugins/libkolab/lib/kolab_format_xcal.php
+++ b/plugins/libkolab/lib/kolab_format_xcal.php
@@ -611,23 +611,31 @@ abstract class kolab_format_xcal extends kolab_format
      *
      * @return array List of tags to save in cache
      */
-    public function get_tags()
+    public function get_tags($obj = null)
     {
         $tags = array();
+        $object = $obj ?: $this->data;
 
-        if (!empty($this->data['valarms'])) {
+        if (!empty($object['valarms'])) {
             $tags[] = 'x-has-alarms';
         }
 
         // create tags reflecting participant status
-        if (is_array($this->data['attendees'])) {
-            foreach ($this->data['attendees'] as $attendee) {
+        if (is_array($object['attendees'])) {
+            foreach ($object['attendees'] as $attendee) {
                 if (!empty($attendee['email']) && !empty($attendee['status']))
                     $tags[] = 'x-partstat:' . $attendee['email'] . ':' . strtolower($attendee['status']);
             }
         }
 
-        return $tags;
+        // collect tags from recurrence exceptions
+        if (is_array($object['recurrence']) && $object['recurrence']['EXCEPTIONS']) {
+            foreach((array)$object['recurrence']['EXCEPTIONS'] as $exception) {
+                $tags = array_merge($tags, $this->get_tags($exception));
+            }
+        }
+
+        return array_unique($tags);
     }
 
     /**


commit 8a74dc2d28aba4af89d123228fb638e4b4999dbd
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Sun Feb 15 19:09:10 2015 +0100

    Don't copy recurrence_date to future occurrences

diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php
index f61b681..7682ba0 100644
--- a/plugins/calendar/calendar.php
+++ b/plugins/calendar/calendar.php
@@ -1676,6 +1676,7 @@ class calendar extends rcube_plugin
     if ($event['recurrence']) {
       $event['recurrence_text'] = $this->lib->recurrence_text($event['recurrence']);
       $event['recurrence'] = $this->lib->to_client_recurrence($event['recurrence'], $event['allday']);
+      unset($event['recurrence_date']);
     }
 
     foreach ((array)$event['attachments'] as $k => $attachment) {
diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php
index 15f030d..06a8244 100644
--- a/plugins/calendar/drivers/kolab/kolab_calendar.php
+++ b/plugins/calendar/drivers/kolab/kolab_calendar.php
@@ -661,7 +661,7 @@ class kolab_calendar extends kolab_storage_folder_api
    */
   private function _merge_event_data(&$event, $overlay)
   {
-    static $forbidden = array('id','uid','recurrence','organizer','_attachments');
+    static $forbidden = array('id','uid','recurrence','recurrence_date','organizer','_attachments');
 
     foreach ($overlay as $prop => $value) {
       // adjust time of the recurring event instance
diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index e414b87..0cd962d 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -962,6 +962,7 @@ class kolab_driver extends calendar_driver
         // save as new exception to master event
         if ($add_exception) {
           $event['_instance'] = $old['_instance'];
+          $event['recurrence_date'] = $old['recurrence_date'];
           $master['recurrence']['EXCEPTIONS'][] = $event;
         }
         $success = $storage->update_event($master);


commit dbdce67e1e0c6675dc4bbaaa0d9056189328e1f0
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Sun Feb 15 18:39:28 2015 +0100

    Better distinction of 'current' and 'future' itip messages in UI and message text

diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php
index 53284a4..f56463c 100644
--- a/plugins/libcalendaring/lib/libcalendaring_itip.php
+++ b/plugins/libcalendaring/lib/libcalendaring_itip.php
@@ -125,7 +125,7 @@ class libcalendaring_itip
 
         $recurrence_info = '';
         if (!empty($event['recurrence_id'])) {
-            $recurrence_info = "\n\n** " . $this->gettext('itip'.strtolower($method).'occurrenceonly') . ' **';
+            $recurrence_info = "\n\n** " . $this->gettext($event['thisandfuture'] ? 'itipmessagefutureoccurrence' : 'itipmessagesingleoccurrence') . ' **';
         }
         else if (!empty($event['recurrence'])) {
             $recurrence_info = sprintf("\n%s: %s", $this->gettext('recurring'), $this->lib->recurrence_text($event['recurrence']));
@@ -742,7 +742,7 @@ class libcalendaring_itip
         }
         if (!empty($event['recurrence_date'])) {
             $table->add('label', '');
-            $table->add('recurrence-id', $this->gettext('itipsingleoccurrence'));
+            $table->add('recurrence-id', $this->gettext($event['thisandfuture'] ? 'itipfutureoccurrence' : 'itipsingleoccurrence'));
         }
         else if (!empty($event['recurrence'])) {
             $table->add('label', $this->gettext('recurring'));
diff --git a/plugins/libcalendaring/localization/en_US.inc b/plugins/libcalendaring/localization/en_US.inc
index 31f08fd..992113a 100644
--- a/plugins/libcalendaring/localization/en_US.inc
+++ b/plugins/libcalendaring/localization/en_US.inc
@@ -111,9 +111,9 @@ $labels['declineattendeeconfirm'] = 'Enter a message to the declined participant
 $labels['rsvprecurringevent'] = 'This is a series of events! Does your response apply to all, this occurrence only or this and future occurrences?';
 
 $labels['itipsingleoccurrence'] = 'This is a <em>single occurrence</em> out of a series of events';
-$labels['itiprequestoccurrenceonly'] = 'The invitation only refers to this single occurrence';
-$labels['itipreplyoccurrenceonly'] = 'The response only refers to this single occurrence';
-$labels['itipcanceloccurrenceonly'] = 'The cancellation only refers to this single occurrence';
+$labels['itipfutureoccurrence'] = 'Refers to <em>this and all future occurrences</em> of a series of events';
+$labels['itipmessagesingleoccurrence'] = 'The message only refers to this single occurrence';
+$labels['itipmessagefutureoccurrence'] = 'The message refers to this and all future occurrences';
 
 $labels['youhaveaccepted'] = 'You have accepted this invitation';
 $labels['youhavetentative'] = 'You have tentatively accepted this invitation';


commit 108fae9dd038c9756f6edfaff7ed9f39e4200d7d
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Sun Feb 15 18:27:38 2015 +0100

    Correctly save 'this-and-future' replies; remove some internal properties before saving (to cache)

diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index d4a3436..e414b87 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -899,12 +899,16 @@ class kolab_driver extends calendar_driver
     else if ($old['recurrence']['EXCEPTIONS'])
       $event['recurrence']['EXCEPTIONS'] = $old['recurrence']['EXCEPTIONS'];
 
+    // remove some internal properties which should not be saved
+    unset($event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_owner'],
+        $event['_notify'], $event['_method'], $event['_sender'], $event['_sender_utf'], $event['_size']);
+
     switch ($savemode) {
       case 'new':
         // save submitted data as new (non-recurring) event
         $event['recurrence'] = array();
         $event['uid'] = $this->cal->generate_uid();
-        unset($event['recurrence_id'], $event['id'], $event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_notify']);
+        unset($event['recurrence_id'], $event['_instance'], $event['id']);
 
         // copy attachment data to new event
         foreach ((array)$event['attachments'] as $idx => $attachment) {
@@ -921,16 +925,13 @@ class kolab_driver extends calendar_driver
         // recurring instances shall not store recurrence rules and attachments
         $event['recurrence'] = array();
         $event['thisandfuture'] = $savemode == 'future';
-        unset($event['attachments']);
+        unset($event['attachments'], $event['id']);
 
         // increment sequence of this instance if scheduling is affected
         if ($reschedule) {
           $event['sequence'] = max($old['sequence'], $master['sequence']) + 1;
         }
 
-        // remove some internal properties which should not be saved
-        unset($event['id'], $event['_savemode'], $event['_fromcalendar'], $event['_identity'], $event['_notify']);
-
         // save properties to a recurrence exception instance
         if ($old['recurrence_id'] && is_array($master['recurrence']['EXCEPTIONS'])) {
           foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) {
diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php
index c3bf625..63a0548 100644
--- a/plugins/libcalendaring/libcalendaring.php
+++ b/plugins/libcalendaring/libcalendaring.php
@@ -1414,7 +1414,7 @@ class libcalendaring extends rcube_plugin
         if (!empty($object['recurrence_date']) && is_a($object['recurrence_date'], 'DateTime')) {
             $recurrence_id_format = $object['allday'] ? 'Ymd' : 'Ymd\THis';
             $object['_instance'] = $object['recurrence_date']->format($recurrence_id_format);
-            $object['_savemode'] = $event['thisandfuture'] ? 'future' : 'current';
+            $object['_savemode'] = $object['thisandfuture'] ? 'future' : 'current';
         }
         else if (!empty($object['recurrence_id']) || !empty($object['_instance'])) {
             if (strlen($object['_instance']) > 4) {
diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php
index fe10f9d..a5b0f73 100644
--- a/plugins/libkolab/lib/kolab_format_event.php
+++ b/plugins/libkolab/lib/kolab_format_event.php
@@ -103,7 +103,6 @@ class kolab_format_event extends kolab_format_xcal
             foreach((array)$object['recurrence']['EXCEPTIONS'] as $i => $exception) {
                 $exevent = new kolab_format_event;
                 $exevent->set(($compacted = $this->compact_exception($exception, $object)));  // only save differing values
-                console('COMPACTED', $compacted);
 
                 // get value for recurrence-id
                 if (!empty($exception['recurrence_date']) && is_a($exception['recurrence_date'], 'DateTime')) {


commit 26381f82a775da9f8876fbcd6a189224d70e12df
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Sun Feb 15 17:21:30 2015 +0100

    Send iTip notifications for main event of savemode is 'all'

diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php
index 880086d..f61b681 100644
--- a/plugins/calendar/calendar.php
+++ b/plugins/calendar/calendar.php
@@ -846,9 +846,16 @@ class calendar extends rcube_plugin
       $event['_notify'] = 1;
 
     // read old event data in order to find changes
-    if (($event['_notify'] || $event['_decline']) && $action != 'new')
+    if (($event['_notify'] || $event['_decline']) && $action != 'new') {
       $old = $this->driver->get_event($event);
 
+      // load main event when savemode is 'all'
+      if ($event['_savemode'] == 'all' && $old['recurrence_id']) {
+        $old['id'] = $old['recurrence_id'];
+        $old = $this->driver->get_event($old);
+      }
+    }
+
     switch ($action) {
       case "new":
         // create UID for new event
@@ -1128,9 +1135,17 @@ class calendar extends rcube_plugin
 
     // send out notifications
     if ($success && $event['_notify'] && ($event['attendees'] || $old['attendees'])) {
+      $_savemode = $event['_savemode'];
+
       // make sure we have the complete record
       $event = $action == 'remove' ? $old : $this->driver->get_event($event);
 
+      // send notification for the main event when savemode is 'all'
+      if ($_savemode == 'all' && $event['recurrence_id']) {
+        $event['id'] = $event['recurrence_id'];
+        $event = $this->driver->get_event($event);
+      }
+
       // only notify if data really changed (TODO: do diff check on client already)
       if (!$old || $action == 'remove' || self::event_diff($event, $old)) {
         $sent = $this->notify_attendees($event, $old, $action, $event['_comment']);


commit d564e23aa345ce67c7c8cabca1a324628a94fcba
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Sun Feb 15 17:10:22 2015 +0100

    Use the right list of properties relevenat for scheduling (follow-up of commit 12591358). Static vars don't work here as intended

diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index 815f51e..d4a3436 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -1041,7 +1041,8 @@ class kolab_driver extends calendar_driver
     }
 
     // iterate through the list of properties considered 'significant' for scheduling
-    $reschedule = kolab_format_event::check_rescheduling($event, $old);
+    $kolab_event = $old['_formatobj'] ?: new kolab_format_event();
+    $reschedule = $kolab_event->check_rescheduling($event, $old);
 
     // reset all attendee status to needs-action (#4360)
     if ($update && $reschedule && is_array($event['attendees'])) {
diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php
index f3c52df..fe10f9d 100644
--- a/plugins/libkolab/lib/kolab_format_event.php
+++ b/plugins/libkolab/lib/kolab_format_event.php
@@ -44,6 +44,9 @@ class kolab_format_event extends kolab_format_xcal
             $this->obj = $data;
             $this->loaded = true;
         }
+
+        // copy static property overriden by this class
+        $this->_scheduling_properties = self::$scheduling_properties;
     }
 
     /**
@@ -275,13 +278,4 @@ class kolab_format_event extends kolab_format_xcal
         return $exception;
     }
 
-    /**
-     * Identify changes considered relevant for scheduling
-     *
-     * @see kolab_format_xcal::check_rescheduling()
-     */
-    public static function check_rescheduling($object, $old, $checks = null)
-    {
-        return parent::check_rescheduling($object, $old, $checks ?: self::$scheduling_properties);
-    }
 }
diff --git a/plugins/libkolab/lib/kolab_format_task.php b/plugins/libkolab/lib/kolab_format_task.php
index 2c0cda5..d3ddfe9 100644
--- a/plugins/libkolab/lib/kolab_format_task.php
+++ b/plugins/libkolab/lib/kolab_format_task.php
@@ -32,6 +32,16 @@ class kolab_format_task extends kolab_format_xcal
     protected $read_func = 'readTodo';
     protected $write_func = 'writeTodo';
 
+    /**
+     * Default constructor
+     */
+    function __construct($data = null, $version = 3.0)
+    {
+        parent::__construct(is_string($data) ? $data : null, $version);
+
+        // copy static property overriden by this class
+        $this->_scheduling_properties = self::$scheduling_properties;
+    }
 
     /**
      * Set properties to the kolabformat object
@@ -127,13 +137,4 @@ class kolab_format_task extends kolab_format_xcal
         return $tags;
     }
 
-    /**
-     * Identify changes considered relevant for scheduling
-     *
-     * @see kolab_format_xcal::check_rescheduling()
-     */
-    public static function check_rescheduling($object, $old, $checks = null)
-    {
-        return parent::check_rescheduling($object, $old, $checks ?: self::$scheduling_properties);
-    }
 }
diff --git a/plugins/libkolab/lib/kolab_format_xcal.php b/plugins/libkolab/lib/kolab_format_xcal.php
index 8d751a6..6d49ad1 100644
--- a/plugins/libkolab/lib/kolab_format_xcal.php
+++ b/plugins/libkolab/lib/kolab_format_xcal.php
@@ -32,6 +32,8 @@ abstract class kolab_format_xcal extends kolab_format
 
     public static $scheduling_properties = array('start', 'end', 'location');
 
+    protected $_scheduling_properties = null;
+
     protected $sensitivity_map = array(
         'public'       => kolabformat::ClassPublic,
         'private'      => kolabformat::ClassPrivate,
@@ -317,11 +319,10 @@ abstract class kolab_format_xcal extends kolab_format
             }
             else {
                 $object['sequence'] = $old_sequence;
-                $old = $this->data['uid'] ? $this->data : $this->to_array();
 
                 // increment sequence when updating properties relevant for scheduling.
                 // RFC 5545: "It is incremented [...] each time the Organizer makes a significant revision to the calendar component."
-                if (self::check_rescheduling($object, $old)) {
+                if ($this->check_rescheduling($object)) {
                     $object['sequence']++;
                 }
             }
@@ -634,15 +635,18 @@ abstract class kolab_format_xcal extends kolab_format
      * 
      * @param array Hash array with NEW object properties
      * @param array Hash array with OLD object properties
-     * @param array List of object properties to check for changes
      *
      * @return boolean True if changes affect scheduling, False otherwise
      */
-    public static function check_rescheduling($object, $old, $checks = null)
+    public function check_rescheduling($object, $old = null)
     {
         $reschedule = false;
 
-        foreach ($checks ?: self::$scheduling_properties as $prop) {
+        if (!is_array($old)) {
+            $old = $this->data['uid'] ? $this->data : $this->to_array();
+        }
+
+        foreach ($this->_scheduling_properties ?: self::$scheduling_properties as $prop) {
             $a = $old[$prop];
             $b = $object[$prop];
             if ($object['allday'] && ($prop == 'start' || $prop == 'end') && $a instanceof DateTime && $b instanceof DateTime) {


commit 12591358e600dd3b0ad91a8f29345354e34000ac
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Sun Feb 15 16:33:39 2015 +0100

    Consider a change in recurrence rule significant for rescheduling (#4366)

diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php
index f1e66ec..880086d 100644
--- a/plugins/calendar/calendar.php
+++ b/plugins/calendar/calendar.php
@@ -993,7 +993,7 @@ class calendar extends rcube_plugin
         if ($success = $this->driver->edit_rsvp($event, $status)) {
           $noreply = rcube_utils::get_input_value('noreply', rcube_utils::INPUT_GPC);
           $noreply = intval($noreply) || $status == 'needs-action' || $itip_sending === 0;
-          $reload  = $event['calendar'] != $ev['calendar'] ? 2 : 1;
+          $reload  = $event['calendar'] != $ev['calendar'] || $event['recurrence'] ? 2 : 1;
           $organizer = null;
           $emails = $this->get_user_emails();
 
@@ -1131,8 +1131,6 @@ class calendar extends rcube_plugin
       // make sure we have the complete record
       $event = $action == 'remove' ? $old : $this->driver->get_event($event);
 
-      // TODO: on change of a recurring (main) event, also send updates to differing attendess of recurrence exceptions
-
       // only notify if data really changed (TODO: do diff check on client already)
       if (!$old || $action == 'remove' || self::event_diff($event, $old)) {
         $sent = $this->notify_attendees($event, $old, $action, $event['_comment']);
@@ -1990,6 +1988,8 @@ class calendar extends rcube_plugin
         $sent = -100;
     }
 
+    // TODO: on change of a recurring (main) event, also send updates to differing attendess of recurrence exceptions
+
     // send CANCEL message to removed attendees
     foreach ((array)$old['attendees'] as $attendee) {
       if ($attendee['ROLE'] == 'ORGANIZER' || !$attendee['email'] || in_array(strtolower($attendee['email']), $current))
@@ -2215,7 +2215,7 @@ class calendar extends rcube_plugin
   public static function event_diff($a, $b)
   {
     $diff = array();
-    $ignore = array('changed' => 1, 'attachments' => 1, 'recurrence' => 1, '_notify' => 1, '_owner' => 1);
+    $ignore = array('changed' => 1, 'attachments' => 1, '_notify' => 1, '_owner' => 1, '_savemode' => 1);
     foreach (array_unique(array_merge(array_keys($a), array_keys($b))) as $key) {
       if (!$ignore[$key] && $a[$key] != $b[$key])
         $diff[] = $key;
diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index 4b450f8..815f51e 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -1035,26 +1035,13 @@ class kolab_driver extends calendar_driver
    */
   public function check_scheduling(&$event, $old, $update = true)
   {
-    $reschedule = false;
-
     // skip this check when importing iCal/iTip events
     if (isset($event['sequence']) || !empty($event['_method'])) {
-      return $reschedule;
+      return false;
     }
 
     // iterate through the list of properties considered 'significant' for scheduling
-    foreach (kolab_format_event::$scheduling_properties as $prop) {
-      $a = $old[$prop];
-      $b = $event[$prop];
-      if ($event['allday'] && ($prop == 'start' || $prop == 'end') && $a instanceof DateTime && $b instanceof DateTime) {
-        $a = $a->format('Y-m-d');
-        $b = $b->format('Y-m-d');
-      }
-      if ($a != $b) {
-        $reschedule = true;
-        break;
-      }
-    }
+    $reschedule = kolab_format_event::check_rescheduling($event, $old);
 
     // reset all attendee status to needs-action (#4360)
     if ($update && $reschedule && is_array($event['attendees'])) {
diff --git a/plugins/libkolab/config.inc.php.dist b/plugins/libkolab/config.inc.php.dist
index 6e4b613..3a8476c 100644
--- a/plugins/libkolab/config.inc.php.dist
+++ b/plugins/libkolab/config.inc.php.dist
@@ -41,7 +41,7 @@ $config['kolab_messages_cache_bypass'] = 0;
 
 // These event properties contribute to a significant revision to the calendar component
 // and if changed will increment the sequence number relevant for scheduling according to RFC 5545
-$config['kolab_event_scheduling_properties'] = array('start', 'end', 'allday', 'location', 'status', 'cancelled');
+$config['kolab_event_scheduling_properties'] = array('start', 'end', 'allday', 'recurrence', 'location', 'status', 'cancelled');
 
 // These task properties contribute to a significant revision to the calendar component
 // and if changed will increment the sequence number relevant for scheduling according to RFC 5545
diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php
index bf17149..f3c52df 100644
--- a/plugins/libkolab/lib/kolab_format_event.php
+++ b/plugins/libkolab/lib/kolab_format_event.php
@@ -26,7 +26,7 @@ class kolab_format_event extends kolab_format_xcal
 {
     public $CTYPEv2 = 'application/x-vnd.kolab.event';
 
-    public static $scheduling_properties = array('start', 'end', 'allday', 'location', 'status', 'cancelled');
+    public static $scheduling_properties = array('start', 'end', 'allday', 'recurrence', 'location', 'status', 'cancelled');
 
     protected $objclass = 'Event';
     protected $read_func = 'readEvent';
@@ -100,6 +100,7 @@ class kolab_format_event extends kolab_format_xcal
             foreach((array)$object['recurrence']['EXCEPTIONS'] as $i => $exception) {
                 $exevent = new kolab_format_event;
                 $exevent->set(($compacted = $this->compact_exception($exception, $object)));  // only save differing values
+                console('COMPACTED', $compacted);
 
                 // get value for recurrence-id
                 if (!empty($exception['recurrence_date']) && is_a($exception['recurrence_date'], 'DateTime')) {
@@ -274,4 +275,13 @@ class kolab_format_event extends kolab_format_xcal
         return $exception;
     }
 
+    /**
+     * Identify changes considered relevant for scheduling
+     *
+     * @see kolab_format_xcal::check_rescheduling()
+     */
+    public static function check_rescheduling($object, $old, $checks = null)
+    {
+        return parent::check_rescheduling($object, $old, $checks ?: self::$scheduling_properties);
+    }
 }
diff --git a/plugins/libkolab/lib/kolab_format_task.php b/plugins/libkolab/lib/kolab_format_task.php
index ee0ca6a..2c0cda5 100644
--- a/plugins/libkolab/lib/kolab_format_task.php
+++ b/plugins/libkolab/lib/kolab_format_task.php
@@ -126,4 +126,14 @@ class kolab_format_task extends kolab_format_xcal
 
         return $tags;
     }
+
+    /**
+     * Identify changes considered relevant for scheduling
+     *
+     * @see kolab_format_xcal::check_rescheduling()
+     */
+    public static function check_rescheduling($object, $old, $checks = null)
+    {
+        return parent::check_rescheduling($object, $old, $checks ?: self::$scheduling_properties);
+    }
 }
diff --git a/plugins/libkolab/lib/kolab_format_xcal.php b/plugins/libkolab/lib/kolab_format_xcal.php
index d0f89b6..8d751a6 100644
--- a/plugins/libkolab/lib/kolab_format_xcal.php
+++ b/plugins/libkolab/lib/kolab_format_xcal.php
@@ -321,17 +321,8 @@ abstract class kolab_format_xcal extends kolab_format
 
                 // increment sequence when updating properties relevant for scheduling.
                 // RFC 5545: "It is incremented [...] each time the Organizer makes a significant revision to the calendar component."
-                foreach (self::$scheduling_properties as $prop) {
-                    $a = $old[$prop];
-                    $b = $object[$prop];
-                    if ($object['allday'] && ($prop == 'start' || $prop == 'end') && $a instanceof DateTime && $b instanceof DateTime) {
-                        $a = $a->format('Y-m-d');
-                        $b = $b->format('Y-m-d');
-                    }
-                    if ($a != $b) {
-                        $object['sequence']++;
-                        break;
-                    }
+                if (self::check_rescheduling($object, $old)) {
+                    $object['sequence']++;
                 }
             }
         }
@@ -637,4 +628,39 @@ abstract class kolab_format_xcal extends kolab_format
 
         return $tags;
     }
+
+    /**
+     * Identify changes considered relevant for scheduling
+     * 
+     * @param array Hash array with NEW object properties
+     * @param array Hash array with OLD object properties
+     * @param array List of object properties to check for changes
+     *
+     * @return boolean True if changes affect scheduling, False otherwise
+     */
+    public static function check_rescheduling($object, $old, $checks = null)
+    {
+        $reschedule = false;
+
+        foreach ($checks ?: self::$scheduling_properties as $prop) {
+            $a = $old[$prop];
+            $b = $object[$prop];
+            if ($object['allday'] && ($prop == 'start' || $prop == 'end') && $a instanceof DateTime && $b instanceof DateTime) {
+                $a = $a->format('Y-m-d');
+                $b = $b->format('Y-m-d');
+            }
+            if ($prop == 'recurrence') {
+                unset($a['EXCEPTIONS']);
+                unset($b['EXCEPTIONS']);
+                $a = array_filter($a);
+                $b = array_filter($b);
+            }
+            if ($a != $b) {
+                $reschedule = true;
+                break;
+            }
+        }
+
+        return $reschedule;
+    }
 }
\ No newline at end of file


commit f09948eefe6b7b1d275bc6d5465c02e8c0dbe9c2
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Sun Feb 15 15:12:08 2015 +0100

    Disable recurrence and attachments forms when editing a single recurrence instance. These properties cannot be stored in recurrence exceptions

diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js
index 1f9f399..f9e0d28 100644
--- a/plugins/calendar/calendar_ui.js
+++ b/plugins/calendar/calendar_ui.js
@@ -691,6 +691,8 @@ function rcube_calendar_ui(settings)
 
       // reset dialog first
       $('#eventtabs').get(0).reset();
+      $('#event-panel-recurrence input, #event-panel-recurrence select, #event-panel-attachments input').prop('disabled', false);
+      $('#event-panel-recurrence, #event-panel-attachments').removeClass('disabled');
 
       // allow other plugins to do actions when event form is opened
       rcmail.triggerEvent('calendar-event-init', {o: event});
@@ -752,7 +754,7 @@ function rcube_calendar_ui(settings)
       if (event.id && event.recurrence) {
         var sel = event._savemode || (event.thisandfuture ? 'future' : (event.isexception ? 'current' : 'all'));
         $('#edit-recurring-warning').show();
-        $('input.edit-recurring-savemode[value="'+sel+'"]').prop('checked', true);
+        $('input.edit-recurring-savemode[value="'+sel+'"]').prop('checked', true).change();
       }
       else
         $('#edit-recurring-warning').hide();
@@ -803,7 +805,7 @@ function rcube_calendar_ui(settings)
       // attachments
       var load_attachments_tab = function()
       {
-        rcmail.enable_command('remove-attachment', !calendar.readonly);
+        rcmail.enable_command('remove-attachment', !calendar.readonly && !event.recurrence_id);
         rcmail.env.deleted_attachments = [];
         // we're sharing some code for uploads handling with app.js
         rcmail.env.attachments = [];
@@ -4163,6 +4165,13 @@ function rcube_calendar_ui(settings)
         event_rsvp($(this).attr('rel'))
       });
 
+      $('#eventedit input.edit-recurring-savemode').change(function(e) {
+        var sel = $('input.edit-recurring-savemode:checked').val(),
+          disabled = sel == 'current' || sel == 'future';
+        $('#event-panel-recurrence input, #event-panel-recurrence select, #event-panel-attachments input').prop('disabled', disabled);
+        $('#event-panel-recurrence, #event-panel-attachments')[(disabled?'addClass':'removeClass')]('disabled');
+      })
+
       $('#eventshow .changersvp').click(function(e) {
         var d = $('#eventshow'),
           h = -$(this).closest('.event-line').toggle().height();
diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index 1e3f0ea..4b450f8 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -918,9 +918,10 @@ class kolab_driver extends calendar_driver
 
       case 'future':
       case 'current':
-        // recurring instances shall not store recurrence rules
+        // recurring instances shall not store recurrence rules and attachments
         $event['recurrence'] = array();
         $event['thisandfuture'] = $savemode == 'future';
+        unset($event['attachments']);
 
         // increment sequence of this instance if scheduling is affected
         if ($reschedule) {
diff --git a/plugins/calendar/skins/larry/calendar.css b/plugins/calendar/skins/larry/calendar.css
index 1c2eca5..0fec69c 100644
--- a/plugins/calendar/skins/larry/calendar.css
+++ b/plugins/calendar/skins/larry/calendar.css
@@ -676,6 +676,10 @@ a.miniColors-trigger {
 	outline: none;
 }
 
+#event-panel-attachments.disabled .attachmentslist li a.delete {
+	visibility: hidden;
+}
+
 .event-attendees span.attendee {
 	padding-right: 18px;
 	margin-right: 0.5em;


commit 78622133a94c444b9c2c205649a564c37739f4f9
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Sun Feb 15 14:32:31 2015 +0100

    Reliably identify recurrence instances throughout the application to support invitations of recurring events (#4387)

diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php
index 51381cb..f1e66ec 100644
--- a/plugins/calendar/calendar.php
+++ b/plugins/calendar/calendar.php
@@ -841,15 +841,12 @@ class calendar extends rcube_plugin
     $event  = rcube_utils::get_input_value('e', rcube_utils::INPUT_POST, true);
     $success = $reload = $got_msg = false;
     
-    // don't notify if modifying a recurring instance (really?)
-    if ($event['_savemode'] && in_array($event['_savemode'], array('current','future')) && $event['_notify'] && $action != 'remove')
-      unset($event['_notify']);
     // force notify if hidden + active
-    else if ((int)$this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']) === 1)
+    if ((int)$this->rc->config->get('calendar_itip_send_option', $this->defaults['calendar_itip_send_option']) === 1)
       $event['_notify'] = 1;
 
     // read old event data in order to find changes
-    if (($event['_notify'] || $event['decline']) && $action != 'new')
+    if (($event['_notify'] || $event['_decline']) && $action != 'new')
       $old = $this->driver->get_event($event);
 
     switch ($action) {
@@ -928,8 +925,14 @@ class calendar extends rcube_plugin
           $got_msg = true;
         }
 
+        // send cancellation for the main event
+        if ($event['_savemode'] == 'all')
+          unset($old['_instance'], $old['recurrence_date'], $old['recurrence_id']);
+        else if ($event['_savemode'] == 'future')
+          $old['thisandfuture'] = true;
+
         // send iTIP reply that participant has declined the event
-        if ($success && $event['decline']) {
+        if ($success && $event['_decline']) {
           $emails = $this->get_user_emails();
           foreach ($old['attendees'] as $i => $attendee) {
             if ($attendee['role'] == 'ORGANIZER')
@@ -939,7 +942,7 @@ class calendar extends rcube_plugin
               $reply_sender = $attendee['email'];
             }
           }
-          
+
           $itip = $this->load_itip();
           $itip->set_sender_email($reply_sender);
           if ($organizer && $itip->send_itip_message($old, 'REPLY', $organizer, 'itipsubjectdeclined', 'itipmailbodydeclined'))
@@ -974,6 +977,7 @@ class calendar extends rcube_plugin
         $ev = $this->driver->get_event($event);
         $ev['attendees'] = $event['attendees'];
         $ev['free_busy'] = $event['free_busy'];
+        $ev['_savemode'] = $event['_savemode'];
 
         // send invitation to delegatee + add it as attendee
         if ($status == 'delegated' && $event['to']) {
@@ -1127,11 +1131,7 @@ class calendar extends rcube_plugin
       // make sure we have the complete record
       $event = $action == 'remove' ? $old : $this->driver->get_event($event);
 
-      // sending notification on a recurrence instance -> re-send the main event
-      if ($event['recurrence_id']) {
-        $event = $this->driver->get_event(array('id' => $event['recurrence_id'], 'cal' => $event['calendar']));
-        $action = 'edit';
-      }
+      // TODO: on change of a recurring (main) event, also send updates to differing attendess of recurrence exceptions
 
       // only notify if data really changed (TODO: do diff check on client already)
       if (!$old || $action == 'remove' || self::event_diff($event, $old)) {
@@ -1945,6 +1945,9 @@ class calendar extends rcube_plugin
     // add comment to the iTip attachment
     $event['comment'] = $comment;
 
+    // set a valid recurrence-id if this is a recurrence instance
+    libcalendaring::identify_recurrence_instance($event);
+
     // compose multipart message using PEAR:Mail_Mime
     $method = $action == 'remove' ? 'CANCEL' : 'REQUEST';
     $message = $itip->compose_itip_message($event, $method, $event['sequence'] > $old['sequence']);
@@ -2405,9 +2408,10 @@ class calendar extends rcube_plugin
   {
     $success = false;
     $uid     = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST);
+    $inst    = rcube_utils::get_input_value('_instance', rcube_utils::INPUT_POST);
 
     // search for event if only UID is given
-    if ($event = $this->driver->get_event(array('uid' => $uid), true)) {
+    if ($event = $this->driver->get_event(array('uid' => $uid, '_instance' => $inst), true)) {
       $success = $this->driver->remove_event($event, true);
     }
 
@@ -2722,12 +2726,13 @@ class calendar extends rcube_plugin
       
       // save to calendar
       if ($calendar && !$calendar['readonly']) {
-        $event['calendar'] = $calendar['id'];
-        
-        // check for existing event with the same UID
-        $existing = $this->driver->get_event($event['uid'], true, false, true);
-        
+          // check for existing event with the same UID
+        $existing = $this->driver->get_event($event, true, false, true);
+
         if ($existing) {
+          // forward savemode for correct updates of recurring events
+          $existing['_savemode'] = $event['_savemode'];
+
           // only update attendee status
           if ($event['_method'] == 'REPLY') {
             // try to identify the attendee using the email sender address
@@ -2829,6 +2834,8 @@ class calendar extends rcube_plugin
           if ($status == 'declined' || $event['status'] == 'CANCELLED' || $event_attendee['role'] == 'NON-PARTICIPANT') {
             $event['free_busy'] = 'free';
           }
+          // save to the selected/default calendar
+          $event['calendar'] = $calendar['id'];
           $success = $this->driver->new_event($event);
         }
         else if ($status == 'declined')
diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js
index c1e3d55..1f9f399 100644
--- a/plugins/calendar/calendar_ui.js
+++ b/plugins/calendar/calendar_ui.js
@@ -554,6 +554,14 @@ function rcube_calendar_ui(settings)
 
         $('#event-rsvp a.reply-comment-toggle').show();
         $('#event-rsvp .itip-reply-comment textarea').hide().val('');
+
+        if (event.recurrence && event.id) {
+          var sel = event._savemode || (event.thisandfuture ? 'future' : (event.isexception ? 'current' : 'all'));
+          $('#event-rsvp input.rsvp-replymode[value="'+sel+'"]').prop('checked', true);
+          $('#event-rsvp .rsvp-replymode-message').show();
+        }
+        else
+          $('#event-rsvp .rsvp-replymode-message').hide();
       }
 
       var buttons = [];
@@ -742,11 +750,9 @@ function rcube_calendar_ui(settings)
 
       // show warning if editing a recurring event
       if (event.id && event.recurrence) {
-        var allow_exceptions = !has_attendees(event) || !is_organizer(event),
-          sel = event._savemode || (allow_exceptions && event.thisandfuture ? 'future' : (allow_exceptions && event.isexception ? 'current' : 'all'));
+        var sel = event._savemode || (event.thisandfuture ? 'future' : (event.isexception ? 'current' : 'all'));
         $('#edit-recurring-warning').show();
         $('input.edit-recurring-savemode[value="'+sel+'"]').prop('checked', true);
-        $('input.edit-recurring-savemode[value="current"], input.edit-recurring-savemode[value="future"]').prop('disabled', !allow_exceptions);
       }
       else
         $('#edit-recurring-warning').hide();
@@ -2411,7 +2417,7 @@ function rcube_calendar_ui(settings)
         }
 
         // submit status change to server
-        var submit_data = $.extend({}, me.selected_event, { source:null, comment:$('#reply-comment-event-rsvp').val() }, (delegate || {})),
+        var submit_data = $.extend({}, me.selected_event, { source:null, comment:$('#reply-comment-event-rsvp').val(), _savemode: $('input.rsvp-replymode:checked').val() }, (delegate || {})),
           noreply = $('#noreply-event-rsvp:checked').length ? 1 : 0;
 
         // import event from mail (temporary iTip event)
@@ -2425,7 +2431,8 @@ function rcube_calendar_ui(settings)
             _to: (delegate ? delegate.to : null),
             _rsvp: (delegate && delegate.rsvp) ? 1 : 0,
             _noreply: noreply,
-            _comment: submit_data.comment
+            _comment: submit_data.comment,
+            _savemode: submit_data._savemode
           });
         }
         else if (settings.invitation_calendars) {
@@ -2501,7 +2508,7 @@ function rcube_calendar_ui(settings)
 
         // mark all recurring instances as temp
         if (event.recurrence || event.recurrence_id) {
-          var base_id = event.recurrence_id ? event.recurrence_id.replace(/-\d+$/, '') : event.id;
+          var base_id = event.recurrence_id ? event.recurrence_id.replace(/-\d+(T\d{6})?$/, '') : event.id;
           $.each(fc.fullCalendar('clientEvents', function(e){ return e.id == base_id || e.recurrence_id == base_id; }), function(i,ev) {
             ev.temp = true;
             ev.editable = false;
@@ -2566,7 +2573,7 @@ function rcube_calendar_ui(settings)
       // recurring event: user needs to select the savemode
       if (event.recurrence) {
         var disabled_state = '', message_label = (action == 'remove' ? 'removerecurringeventwarning' : 'changerecurringeventwarning');
-
+/*
         if (_has_attendees) {
           if (action == 'remove') {
             if (!_is_organizer) {
@@ -2578,7 +2585,7 @@ function rcube_calendar_ui(settings)
             disabled_state = ' disabled';
           }
         }
-
+*/
         html += '<div class="message"><span class="ui-icon ui-icon-alert"></span>' +
           rcmail.gettext(message_label, 'calendar') + '</div>' +
           '<div class="savemode">' +
@@ -2606,8 +2613,10 @@ function rcube_calendar_ui(settings)
           else {
             if ($dialog.find('input.confirm-attendees-donotify').length)
               data._notify = $dialog.find('input.confirm-attendees-donotify').get(0).checked ? 1 : 0;
-            if (decline && $dialog.find('input.confirm-attendees-decline:checked').length)
-              data.decline = 1;
+            if (decline) {
+              data._decline = $dialog.find('input.confirm-attendees-decline:checked').length;
+              data._notify = 0;
+            }
             update_event(action, data);
           }
 
@@ -2622,7 +2631,7 @@ function rcube_calendar_ui(settings)
             text: rcmail.gettext((action == 'remove' ? 'delete' : 'save'), 'calendar'),
             click: function() {
               data._notify = notify && $dialog.find('input.confirm-attendees-donotify:checked').length ? 1 : 0;
-              data.decline = decline && $dialog.find('input.confirm-attendees-decline:checked').length ? 1 : 0;
+              data._decline = decline && $dialog.find('input.confirm-attendees-decline:checked').length ? 1 : 0;
               update_event(action, data);
               $(this).dialog("close");
             }
diff --git a/plugins/calendar/drivers/calendar_driver.php b/plugins/calendar/drivers/calendar_driver.php
index 24e7a2e..e402db9 100644
--- a/plugins/calendar/drivers/calendar_driver.php
+++ b/plugins/calendar/drivers/calendar_driver.php
@@ -50,6 +50,7 @@
  *      'EXCEPTIONS' => array(<event>),  list of event objects which denote exceptions in the recurrence chain
  *    ),
  * 'recurrence_id' => 'ID of the recurrence group',   // usually the ID of the starting event
+ *     '_instance' => 'ID of the recurring instance',   // identifies an instance within a recurrence chain
  *    'categories' => 'Event category',
  *     'free_busy' => 'free|busy|outofoffice|tentative',  // Show time as
  *        'status' => 'TENTATIVE|CONFIRMED|CANCELLED',    // event status according to RFC 2445
@@ -469,7 +470,6 @@ abstract class calendar_driver
         if (($next_event['start'] <= $end && $next_event['end'] >= $start)) {
           $next_event['id'] = $next_event['uid'];
           $next_event['recurrence_id'] = $event['uid'];
-          $next_event['_instance'] = $i;
           $events[] = $next_event;
         }
         else if ($next_event['start'] > $end) {  // stop loop if out of range
diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php
index 2b50ac8..15f030d 100644
--- a/plugins/calendar/drivers/kolab/kolab_calendar.php
+++ b/plugins/calendar/drivers/kolab/kolab_calendar.php
@@ -661,7 +661,7 @@ class kolab_calendar extends kolab_storage_folder_api
    */
   private function _merge_event_data(&$event, $overlay)
   {
-    static $forbidden = array('id','uid','created','changed','recurrence','organizer','attendees','sequence');
+    static $forbidden = array('id','uid','recurrence','organizer','_attachments');
 
     foreach ($overlay as $prop => $value) {
       // adjust time of the recurring event instance
diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index 98318f2..1e3f0ea 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -534,8 +534,13 @@ class kolab_driver extends calendar_driver
   public function get_event($event, $writeable = false, $active = false, $personal = false)
   {
     if (is_array($event)) {
-      $id = $event['id'] ? $event['id'] : $event['uid'];
+      $id = $event['id'] ?: $event['uid'];
       $cal = $event['calendar'];
+
+      // we're looking for a recurring instance: expand the ID to our internal convention for recurring instanced
+      if (!$event['id'] && $event['_instance']) {
+        $id .= '-' . $event['_instance'];
+      }
     }
     else {
       $id = $event;
@@ -687,11 +692,11 @@ class kolab_driver extends calendar_driver
       // read master if deleting a recurring event
       if ($event['recurrence'] || $event['recurrence_id']) {
         $master = $event['recurrence_id'] ? $storage->get_event($event['recurrence_id']) : $event;
-        $savemode = $event['_savemode'];
+        $savemode = $event['_savemode'] ?: ($event['_instance'] ? 'current' : 'all');
       }
 
       // removing an exception instance
-      if ($event['recurrence_id']) {
+      if ($event['recurrence_id'] && $master['recurrence'] && is_array($master['recurrence']['EXCEPTIONS'])) {
         foreach ($master['recurrence']['EXCEPTIONS'] as $i => $exception) {
           if ($exception['_instance'] == $event['_instance']) {
             unset($master['recurrence']['EXCEPTIONS'][$i]);
@@ -880,7 +885,7 @@ class kolab_driver extends calendar_driver
     // modify a recurring event, check submitted savemode to do the right things
     if ($old['recurrence'] || $old['recurrence_id']) {
       $master = $old['recurrence_id'] ? $fromcalendar->get_event($old['recurrence_id']) : $old;
-      $savemode = $event['_savemode'];
+      $savemode = $event['_savemode'] ?: ($old['recurrence_id'] ? 'current' : 'all');
     }
 
     // check if update affects scheduling and update attendee status accordingly
@@ -919,7 +924,7 @@ class kolab_driver extends calendar_driver
 
         // increment sequence of this instance if scheduling is affected
         if ($reschedule) {
-          $event['sequence'] = $old['sequence'] + 1;
+          $event['sequence'] = max($old['sequence'], $master['sequence']) + 1;
         }
 
         // remove some internal properties which should not be saved
diff --git a/plugins/calendar/lib/calendar_recurrence.php b/plugins/calendar/lib/calendar_recurrence.php
index fae98bb..d3af94d 100644
--- a/plugins/calendar/lib/calendar_recurrence.php
+++ b/plugins/calendar/lib/calendar_recurrence.php
@@ -67,7 +67,6 @@ class calendar_recurrence extends libcalendaring_recurrence
   {
     if ($next_start = $this->next()) {
       $next = $this->event;
-      $next['recurrence_id'] = $next_start->format('Y-m-d');
       $next['start'] = $next_start;
 
       if ($this->duration) {
@@ -75,6 +74,10 @@ class calendar_recurrence extends libcalendaring_recurrence
         $next['end']->add($this->duration);
       }
 
+      $recurrence_id_format = $next['allday'] ? 'Ymd' : 'Ymd\THis';
+      $next['recurrence_date'] = clone $next_start;
+      $next['_instance'] = $next_start->format($recurrence_id_format);
+
       unset($next['_formatobj']);
 
       return $next;
diff --git a/plugins/calendar/skins/larry/calendar.css b/plugins/calendar/skins/larry/calendar.css
index 50f8b64..1c2eca5 100644
--- a/plugins/calendar/skins/larry/calendar.css
+++ b/plugins/calendar/skins/larry/calendar.css
@@ -1059,6 +1059,26 @@ td.topalign {
 	text-align: center;
 }
 
+.event-dialog-message .rsvp-replymode-message {
+	margin-top: 0.8em;
+	margin-bottom: 0.6em;
+}
+
+.event-dialog-message .rsvp-replymode-message .replymode-select {
+	padding-left: 22px;
+}
+
+.event-dialog-message .rsvp-replymode-message label {
+	color: inherit;
+	margin-right: 0.4em;
+	white-space: nowrap;
+	min-width: 4em;
+}
+
+.event-dialog-message .rsvp-replymode-message input.rsvp-replymode {
+	margin-right: 0.4em;
+}
+
 #event-rsvp,
 #edit-attendees-notify {
 	margin: 0.6em 0 0.3em 0;
@@ -2159,6 +2179,15 @@ div.calendar-invitebox td.sensitivity {
 	font-weight: bold;
 }
 
+div.calendar-invitebox td.recurrence-id {
+	text-transform: uppercase;
+	font-style: italic;
+}
+
+div.calendar-invitebox td em {
+	font-weight: bold;
+}
+
 #event-rsvp .rsvp-buttons,
 div.calendar-invitebox .itip-buttons div {
 	margin-top: 0.5em;
diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php
index 3eead6f..53284a4 100644
--- a/plugins/libcalendaring/lib/libcalendaring_itip.php
+++ b/plugins/libcalendaring/lib/libcalendaring_itip.php
@@ -98,8 +98,10 @@ class libcalendaring_itip
         if (!$this->sender['name'])
             $this->sender['name'] = $this->sender['email'];
 
-        if (!$message)
+        if (!$message) {
+            libcalendaring::identify_recurrence_instance($event);
             $message = $this->compose_itip_message($event, $method, $rsvp);
+        }
 
         $mailto = rcube_idn_to_ascii($recipient['email']);
 
@@ -121,12 +123,19 @@ class libcalendaring_itip
                 ($attendee['name'] ? $attendee['name'] : $attendee['email']);
         }
 
+        $recurrence_info = '';
+        if (!empty($event['recurrence_id'])) {
+            $recurrence_info = "\n\n** " . $this->gettext('itip'.strtolower($method).'occurrenceonly') . ' **';
+        }
+        else if (!empty($event['recurrence'])) {
+            $recurrence_info = sprintf("\n%s: %s", $this->gettext('recurring'), $this->lib->recurrence_text($event['recurrence']));
+        }
+
         $mailbody = $this->gettext(array(
             'name' => $bodytext,
             'vars' => array(
                 'title' => $event['title'],
-                'date' => $this->lib->event_date_text($event, true) .
-                    (empty($event['recurrence']) ? '' : sprintf("\n%s: %s", $this->gettext('recurring'), $this->lib->recurrence_text($event['recurrence']))),
+                'date' => $this->lib->event_date_text($event, true) . $recurrence_info,
                 'attendees' => join(",\n ", $attendees_list),
                 'sender' => $this->sender['name'],
                 'organizer' => $this->sender['name'],
@@ -151,6 +160,10 @@ class libcalendaring_itip
         $message->headers($headers, true);
         $message->setTXTBody(rcube_mime::format_flowed($mailbody, 79));
 
+        if ($this->rc->config->get('libcalendaring_itip_debug', false)) {
+            console('iTip ' . $method, $message->txtHeaders() . "\n\r" . $message->get());
+        }
+
         // finally send the message
         $this->itip_send = true;
         $sent = $this->rc->deliver_message($message, $headers['X-Sender'], $mailto, $smtp_error);
@@ -230,6 +243,9 @@ class libcalendaring_itip
                 array_unshift($reply_attendees, $replying_attendee);
                 $event['attendees'] = $reply_attendees;
             }
+            if ($event['recurrence']) {
+                unset($event['recurrence']['EXCEPTIONS']);
+            }
         }
         // set RSVP for every attendee
         else if ($method == 'REQUEST') {
@@ -239,6 +255,11 @@ class libcalendaring_itip
                 }
             }
         }
+        else if ($method == 'CANCEL') {
+            if ($event['recurrence']) {
+                unset($event['recurrence']['EXCEPTIONS']);
+            }
+        }
 
         // compose multipart message using PEAR:Mail_Mime
         $message = new Mail_mime("\r\n");
@@ -453,6 +474,7 @@ class libcalendaring_itip
         $changed = is_object($event['changed']) ? $event['changed'] : $message_date;
         $metadata = array(
             'uid'      => $event['uid'],
+            '_instance' => $event['_instance'],
             'changed'  => $changed ? $changed->format('U') : 0,
             'sequence' => intval($event['sequence']),
             'method'   => $method,
@@ -580,12 +602,13 @@ class libcalendaring_itip
         // for CANCEL messages, we can:
         else if ($method == 'CANCEL') {
             $title = $this->gettext('itipcancellation');
+            $event_prop = array_filter(array('uid' => $event['uid'], '_instance' => $event['_instance']));
 
             // 1. remove the event from our calendar
             $button_remove = html::tag('input', array(
                 'type' => 'button',
                 'class' => 'button',
-                'onclick' => "rcube_libcalendaring.remove_from_itip('" . JQ($event['uid']) . "', '$task', '" . JQ($event['title']) . "')",
+                'onclick' => "rcube_libcalendaring.remove_from_itip(" . rcube_output::json_serialize($event_prop) . ", '$task', '" . JQ($event['title']) . "')",
                 'value' => $this->gettext('removefromcalendar'),
             ));
 
@@ -646,8 +669,6 @@ class libcalendaring_itip
             ));
         }
 
-        $buttons .= html::div('itip-reply-controls', $this->itip_rsvp_options_ui($attrib['id']));
-
         // add localized texts for the delegation dialog
         if (in_array('delegated', $actions)) {
             foreach (array('itipdelegated','itipcomment','delegateinvitation',
@@ -656,9 +677,23 @@ class libcalendaring_itip
             }
         }
 
+        $savemode_radio = new html_radiobutton(array('name' => '_rsvpmode', 'class' => 'rsvp-replymode'));
+
         return html::div($attrib,
             html::div('label', $this->gettext('acceptinvitation')) .
-            html::div('rsvp-buttons', $buttons));
+            html::div('rsvp-buttons',
+                $buttons .
+                html::div(array('class' => 'rsvp-replymode-message', 'style' => 'display:none'),
+                    html::div('message', html::span('ui-icon ui-icon-alert', '') . $this->gettext('rsvprecurringevent')) .
+                    html::div('replymode-select', 
+                        html::label(null, $savemode_radio->show('all', array('value' => 'all')) . $this->gettext('allevents')) .
+                        html::label(null, $savemode_radio->show(null, array('value' => 'current')) . $this->gettext('currentevent')) .
+                        html::label(null, $savemode_radio->show(null, array('value' => 'future')) . $this->gettext('futurevents'))
+                    )
+                ) .
+                html::div('itip-reply-controls', $this->itip_rsvp_options_ui($attrib['id']))
+            )
+        );
     }
 
     /**
@@ -705,7 +740,11 @@ class libcalendaring_itip
             $table->add('label', $this->gettext('date'));
             $table->add('date', Q($this->lib->event_date_text($event)));
         }
-        if (!empty($event['recurrence'])) {
+        if (!empty($event['recurrence_date'])) {
+            $table->add('label', '');
+            $table->add('recurrence-id', $this->gettext('itipsingleoccurrence'));
+        }
+        else if (!empty($event['recurrence'])) {
             $table->add('label', $this->gettext('recurring'));
             $table->add('recurrence', $this->lib->recurrence_text($event['recurrence']));
         }
diff --git a/plugins/libcalendaring/libcalendaring.js b/plugins/libcalendaring/libcalendaring.js
index 0a2949d..a13ebf7 100644
--- a/plugins/libcalendaring/libcalendaring.js
+++ b/plugins/libcalendaring/libcalendaring.js
@@ -961,11 +961,11 @@ rcube_libcalendaring.itip_delegate_dialog = function(callback, selector)
 /**
  *
  */
-rcube_libcalendaring.remove_from_itip = function(uid, task, title)
+rcube_libcalendaring.remove_from_itip = function(event, task, title)
 {
     if (confirm(rcmail.gettext('itip.deleteobjectconfirm').replace('$title', title))) {
         rcmail.http_post(task + '/itip-remove',
-            { uid: uid },
+            event,
             rcmail.set_busy(true, 'itip.savingdata')
         );
     }
diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php
index f1c4514..c3bf625 100644
--- a/plugins/libcalendaring/libcalendaring.php
+++ b/plugins/libcalendaring/libcalendaring.php
@@ -1310,6 +1310,9 @@ class libcalendaring extends rcube_plugin
                 $charset = $part->ctype_parameters['charset'] ?: RCMAIL_CHARSET;
                 $this->mail_ical_parser->import($this->ical_message->get_part_body($mime_id, true), $charset);
 
+                // check if the parsed object is an instance of a recurring event/task
+                array_walk($this->mail_ical_parser->objects, 'libcalendaring::identify_recurrence_instance');
+
                 // stop on the part that has an iTip method specified
                 if (count($this->mail_ical_parser->objects) && $this->mail_ical_parser->method) {
                     $this->mail_ical_parser->message_date = $this->ical_message->headers->date;
@@ -1374,6 +1377,9 @@ class libcalendaring extends rcube_plugin
             $object['_sender'] = preg_match(self::$email_regex, $headers->from, $m) ? $m[1] : '';
             $object['_sender_utf'] = rcube_utils::idn_to_utf8($object['_sender']);
 
+            // check if this is an instance of a recurring event/task
+            self::identify_recurrence_instance($object);
+
             return $object;
         }
 
@@ -1395,6 +1401,30 @@ class libcalendaring extends rcube_plugin
         );
     }
 
+    /**
+     * Single occourrences of recurring events are identified by their RECURRENCE-ID property
+     * in iCal which is represented as 'recurrence_date' in our internal data structure.
+     *
+     * Check if such a property exists and derive the '_instance' identifier and '_savemode'
+     * attributes which are used in the storage backend to identify the nested exception item.
+     */
+    public static function identify_recurrence_instance(&$object)
+    {
+        // set instance and 'savemode' according to recurrence-id
+        if (!empty($object['recurrence_date']) && is_a($object['recurrence_date'], 'DateTime')) {
+            $recurrence_id_format = $object['allday'] ? 'Ymd' : 'Ymd\THis';
+            $object['_instance'] = $object['recurrence_date']->format($recurrence_id_format);
+            $object['_savemode'] = $event['thisandfuture'] ? 'future' : 'current';
+        }
+        else if (!empty($object['recurrence_id']) || !empty($object['_instance'])) {
+            if (strlen($object['_instance']) > 4) {
+                $object['recurrence_date'] = rcube_utils::anytodatetime($object['_instance'], $object['start']->getTimezone());
+            }
+            else {
+                $object['recurrence_date'] = clone $object['start'];
+            }
+        }
+    }
 
     /*********  Attendee handling functions  *********/
 
diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php
index 4163cfb..10c2223 100644
--- a/plugins/libcalendaring/libvcalendar.php
+++ b/plugins/libcalendaring/libvcalendar.php
@@ -948,6 +948,13 @@ class libvcalendar implements Iterator
         if (!empty($event['due']))
             $ve->add($this->datetime_prop('DUE',   $event['due'], false));
 
+        // we're exporting a recurrence instance only
+        if (!$recurrence_id && $event['recurrence_date'] && $event['recurrence_date'] instanceof DateTime) {
+            $recurrence_id = $this->datetime_prop('RECURRENCE-ID', $event['recurrence_date'], false, (bool)$event['allday']);
+            if ($event['thisandfuture'])
+                $recurrence_id->add('RANGE', 'THISANDFUTURE');
+        }
+
         if ($recurrence_id)
             $ve->add($recurrence_id);
 
diff --git a/plugins/libcalendaring/localization/en_US.inc b/plugins/libcalendaring/localization/en_US.inc
index 3e49838..31f08fd 100644
--- a/plugins/libcalendaring/localization/en_US.inc
+++ b/plugins/libcalendaring/localization/en_US.inc
@@ -108,6 +108,12 @@ $labels['acceptinvitation'] = 'Do you accept this invitation?';
 $labels['acceptattendee'] = 'Accept participant';
 $labels['declineattendee'] = 'Decline participant';
 $labels['declineattendeeconfirm'] = 'Enter a message to the declined participant (optional):';
+$labels['rsvprecurringevent'] = 'This is a series of events! Does your response apply to all, this occurrence only or this and future occurrences?';
+
+$labels['itipsingleoccurrence'] = 'This is a <em>single occurrence</em> out of a series of events';
+$labels['itiprequestoccurrenceonly'] = 'The invitation only refers to this single occurrence';
+$labels['itipreplyoccurrenceonly'] = 'The response only refers to this single occurrence';
+$labels['itipcanceloccurrenceonly'] = 'The cancellation only refers to this single occurrence';
 
 $labels['youhaveaccepted'] = 'You have accepted this invitation';
 $labels['youhavetentative'] = 'You have tentatively accepted this invitation';
diff --git a/plugins/libkolab/lib/kolab_date_recurrence.php b/plugins/libkolab/lib/kolab_date_recurrence.php
index 06dd331..b2511f2 100644
--- a/plugins/libkolab/lib/kolab_date_recurrence.php
+++ b/plugins/libkolab/lib/kolab_date_recurrence.php
@@ -87,9 +87,13 @@ class kolab_date_recurrence
             $next_end->add($this->duration);
 
             $next = $this->object->to_array();
-            $next['recurrence_id'] = $next_start->format('Y-m-d');
             $next['start'] = $next_start;
             $next['end'] = $next_end;
+
+            $recurrence_id_format = $next['allday'] ? 'Ymd' : 'Ymd\THis';
+            $next['recurrence_date'] = clone $next_start;
+            $next['_instance'] = $next_start->format($recurrence_id_format);
+
             unset($next['_formatobj']);
 
             return $next;
diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php
index 075c517..bf17149 100644
--- a/plugins/libkolab/lib/kolab_format_event.php
+++ b/plugins/libkolab/lib/kolab_format_event.php
@@ -237,6 +237,7 @@ class kolab_format_event extends kolab_format_xcal
     private function compact_exception($exception, $master)
     {
         $forbidden = array('recurrence','organizer','_attachments');
+        $whitelist = array('start','end');
 
         foreach ($forbidden as $prop) {
             if (array_key_exists($prop, $exception)) {
@@ -245,7 +246,7 @@ class kolab_format_event extends kolab_format_xcal
         }
 
         foreach ($master as $prop => $value) {
-            if (isset($exception[$prop]) && gettype($exception[$prop]) == gettype($value) && $exception[$prop] == $value) {
+            if (isset($exception[$prop]) && gettype($exception[$prop]) == gettype($value) && $exception[$prop] == $value && !in_array($prop, $whitelist)) {
                 unset($exception[$prop]);
             }
         }




More information about the commits mailing list