4 commits - plugins/calendar plugins/libcalendaring plugins/libkolab

Thomas Brüderli bruederli at kolabsys.com
Tue Jul 29 15:35:13 CEST 2014


 plugins/calendar/calendar.php                          |  145 +++
 plugins/calendar/calendar_ui.js                        |  420 +++++++++-
 plugins/calendar/drivers/calendar_driver.php           |   74 +
 plugins/calendar/drivers/kolab/kolab_calendar.php      |   48 -
 plugins/calendar/drivers/kolab/kolab_driver.php        |  268 ++++++
 plugins/calendar/drivers/kolab/kolab_user_calendar.php |    4 
 plugins/calendar/lib/calendar_ui.php                   |   17 
 plugins/calendar/localization/en_US.inc                |   23 
 plugins/calendar/skins/classic/calendar.css            |    4 
 plugins/calendar/skins/classic/templates/calendar.html |    7 
 plugins/calendar/skins/larry/calendar.css              |  144 +++
 plugins/calendar/skins/larry/images/calendars.png      |binary
 plugins/calendar/skins/larry/templates/calendar.html   |  106 ++
 plugins/libcalendaring/libcalendaring.php              |   14 
 plugins/libcalendaring/localization/en_US.inc          |    1 
 plugins/libkolab/config.inc.php.dist                   |    9 
 plugins/libkolab/lib/kolab_bonnie_api.php              |   82 ++
 plugins/libkolab/lib/kolab_bonnie_api_client.php       |  239 +++++
 plugins/libkolab/libkolab.php                          |   11 
 plugins/libkolab/vendor/finediff.php                   |  688 +++++++++++++++++
 plugins/libkolab/vendor/finediff_modifications.diff    |  121 ++
 21 files changed, 2313 insertions(+), 112 deletions(-)

New commits:
commit a68982b0287abbf27b478c16038c5a58ec2e9a0e
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Tue Jul 29 15:28:35 2014 +0200

    Add UI elements to display the history of a calendar event with data from the Bonnie API (#3093, #3094) + new option to download and send single events

diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php
index 343b3e5..6c23741 100644
--- a/plugins/calendar/calendar.php
+++ b/plugins/calendar/calendar.php
@@ -194,6 +194,7 @@ class calendar extends rcube_plugin
       }
 
       $this->add_hook('messages_list', array($this, 'mail_messages_list'));
+      $this->add_hook('message_compose', array($this, 'mail_message_compose'));
     }
     else if ($args['task'] == 'addressbook') {
       if ($this->rc->config->get('calendar_contact_birthdays')) {
@@ -973,6 +974,95 @@ class calendar extends rcube_plugin
                 $success |= $this->driver->dismiss_alarm(substr($id, 4), $event['snooze']);
         }
         break;
+
+      case "changelog":
+        $data = $this->driver->get_event_changelog($event);
+        if (is_array($data) && !empty($data)) {
+          $lib = $this->lib;
+          array_walk($data, function($change) use ($lib) {
+            if ($change['date']) {
+              $dt = $lib->adjust_timezone($change['date']);
+              if ($dt instanceof DateTime)
+                $change['date'] = $dt->format('c');
+            }
+          });
+          $this->rc->output->command('plugin.render_event_changelog', $data);
+        }
+        else {
+          $this->rc->output->command('plugin.render_event_changelog', false);
+          $this->rc->output->command('display_message', $this->gettext('eventchangelognotavailable'), 'error');
+        }
+        $got_msg = true;
+        $reload = false;
+        break;
+
+      case "diff":
+        $data = $this->driver->get_event_diff($event, $event['rev']);
+        if (is_array($data)) {
+          // convert some properties, similar to self::_client_event()
+          $lib = $this->lib;
+          array_walk($data['changes'], function(&$change, $i) use ($event, $lib) {
+            // convert date cols
+            foreach (array('start','end','created','changed') as $col) {
+              if ($change['property'] == $col) {
+                $change['old'] = $this->lib->adjust_timezone($change['old'], strlen($change['old']) == 10)->format('c');
+                $change['new'] = $this->lib->adjust_timezone($change['new'], strlen($change['new']) == 10)->format('c');
+              }
+            }
+            // create textual representation for alarms and recurrence
+            if ($change['property'] == 'alarms') {
+              if (is_array($change['old']))
+                $change['old_'] = libcalendaring::alarm_text($change['old']);
+              if (is_array($change['new']))
+                $change['new_'] = libcalendaring::alarm_text(array_merge((array)$change['old'], $change['new']));
+            }
+            if ($change['property'] == 'recurrence') {
+              if (is_array($change['old']))
+                $change['old_'] = $lib->recurrence_text($change['old']);
+              if (is_array($change['new']))
+                $change['new_'] = $lib->recurrence_text(array_merge((array)$change['old'], $change['new']));
+            }
+            if ($change['property'] == 'attachments') {
+              if (is_array($change['old']))
+                $change['old']['classname'] = rcube_utils::file2class($change['old']['mimetype'], $change['old']['name']);
+              if (is_array($change['new']))
+                $change['new']['classname'] = rcube_utils::file2class($change['new']['mimetype'], $change['new']['name']);
+            }
+            // compute a nice diff of description texts
+            if ($change['property'] == 'description') {
+              $change['diff_'] = libkolab::html_diff($change['old'], $change['new']);
+            }
+          });
+          $this->rc->output->command('plugin.event_show_diff', $data);
+        }
+        else {
+          $this->rc->output->command('display_message', $this->gettext('eventdiffnotavailable'), 'error');
+        }
+        $got_msg = true;
+        $reload = false;
+        break;
+
+      case "show":
+        if ($event = $this->driver->get_event_revison($event, $event['rev'])) {
+          $this->rc->output->command('plugin.event_show_revision', $this->_client_event($event));
+        }
+        else {
+          $this->rc->output->command('display_message', $this->gettext('eventnotfound'), 'error');
+        }
+        $got_msg = true;
+        $reload = false;
+        break;
+
+      case "restore":
+        if ($success = $this->driver->restore_event_revision($event, $event['rev'])) {
+
+        }
+        else {
+          $this->rc->output->command('display_message', 'Not implemented yet', 'error');
+          $got_msg = true;
+        }
+        $reload = false;
+        break;
     }
 
     // show confirmation/error message
@@ -1271,22 +1361,33 @@ class calendar extends rcube_plugin
     if (!is_numeric($end))
       $end = strtotime($end . ' 23:59:59');
 
+    $event_id = get_input_value('id', RCUBE_INPUT_GET);
     $attachments = get_input_value('attachments', RCUBE_INPUT_GET);
-    $calid = $calname = get_input_value('source', RCUBE_INPUT_GET);
+    $calid = $filename = get_input_value('source', RCUBE_INPUT_GET);
 
     $calendars = $this->driver->list_calendars();
+    $events = array();
 
     if ($calendars[$calid]) {
-      $calname = $calendars[$calid]['name'] ? $calendars[$calid]['name'] : $calid;
-      $calname = preg_replace('/[^a-z0-9_.-]/i', '', html_entity_decode($calname));  // to 7bit ascii
-      if (empty($calname)) $calname = $calid;
-      $events = $this->driver->load_events($start, $end, null, $calid, 0);
+      $filename = $calendars[$calid]['name'] ? $calendars[$calid]['name'] : $calid;
+      $filename = asciiwords(html_entity_decode($filename));  // to 7bit ascii
+      if (!empty($event_id)) {
+        if ($event = $this->driver->get_event(array('calendar' => $calid, 'id' => $event_id))) {
+          $events = array($event);
+          $filename = asciiwords($event['title']);
+          if (empty($filename))
+            $filename = 'event';
+        }
+      }
+      else {
+         $events = $this->driver->load_events($start, $end, null, $calid, 0);
+         if (empty($filename))
+           $filename = $calid;
+      }
     }
-    else
-      $events = array();
 
     header("Content-Type: text/calendar");
-    header("Content-Disposition: inline; filename=".$calname.'.ics');
+    header("Content-Disposition: inline; filename=".$filename.'.ics');
 
     $this->get_ical()->export($events, '', true, $attachments ? array($this->driver, 'get_attachment_body') : null);
 
@@ -2672,6 +2773,34 @@ class calendar extends rcube_plugin
     $this->rc->output->send();
   }
 
+  /**
+   * Handler for the 'message_compose' plugin hook. This will check for
+   * a compose parameter 'calendar_event' and create an attachment with the
+   * referenced event in iCal format
+   */
+  public function mail_message_compose($args)
+  {
+    // set the submitted event ID as attachment
+    if (!empty($args['param']['calendar_event'])) {
+      $this->load_driver();
+
+      list($cal, $id) = explode(':', $args['param']['calendar_event'], 2);
+      if ($event = $this->driver->get_event(array('id' => $id, 'calendar' => $cal))) {
+        $filename = asciiwords($event['title']);
+        if (empty($filename))
+          $filename = 'event';
+
+        // save ics to a temp file and register as attachment
+        $tmp_path = tempnam($this->rc->config->get('temp_dir'), 'rcmAttmntCal');
+        file_put_contents($tmp_path, $this->get_ical()->export(array($event), '', false, array($this->driver, 'get_attachment_body')));
+
+        $args['attachments'][] = array('path' => $tmp_path, 'name' => $filename . '.ics', 'mimetype' => 'text/calendar');
+        $args['param']['subject'] = $event['title'];
+      }
+    }
+
+    return $args;
+  }
 
   /**
    * Checks if specified message part is a vcalendar data
diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js
index 8b59ef3..8d4433d 100644
--- a/plugins/calendar/calendar_ui.js
+++ b/plugins/calendar/calendar_ui.js
@@ -252,6 +252,11 @@ function rcube_calendar_ui(settings)
       return date2servertime(date).replace(/[^0-9]/g, '').substr(0, (dateonly ? 8 : 14));
     }
 
+    var format_datetime = function(date, mode, voice)
+    {
+      return me.format_datetime(date, mode, voice);
+    }
+
     var render_link = function(url)
     {
       var islink = false, href = url;
@@ -304,11 +309,13 @@ function rcube_calendar_ui(settings)
 
     var load_attachment = function(event, att)
     {
-      var qstring = '_id='+urlencode(att.id)+'&_event='+urlencode(event.recurrence_id||event.id)+'&_cal='+urlencode(event.calendar);
+      var query = { _id: att.id, _event: event.recurrence_id || event.id, _cal:event.calendar, _frame: 1 };
+      if (event.rev)
+        query._rev = event.rev;
 
       // open attachment in frame if it's of a supported mimetype
       if (id && att.mimetype && $.inArray(att.mimetype, settings.mimetypes)>=0) {
-        if (rcmail.open_window(rcmail.env.comm_path+'&_action=get-attachment&'+qstring+'&_frame=1', true, true)) {
+        if (rcmail.open_window(rcmail.url('get-attachment', query), true, true)) {
           return;
         }
       }
@@ -378,15 +385,23 @@ function rcube_calendar_ui(settings)
     };
 
     // event details dialog (show only)
-    var event_show_dialog = function(event, ev)
+    var event_show_dialog = function(event, ev, temp)
     {
-      var $dialog = $("#eventshow").attr('class', 'uidialog');
+      var $dialog = $("#eventshow");
       var calendar = event.calendar && me.calendars[event.calendar] ? me.calendars[event.calendar] : { editable:false };
-      me.selected_event = event;
+
+      if (!temp)
+        me.selected_event = event;
 
       if ($dialog.is(':ui-dialog'))
         $dialog.dialog('close');
 
+      // convert start/end dates if not done yet by fullcalendar
+      if (typeof event.start == 'string')
+        event.start = parseISO8601(event.start);
+      if (typeof event.end == 'string')
+        event.end = parseISO8601(event.end);
+
       // allow other plugins to do actions when event form is opened
       rcmail.triggerEvent('calendar-event-init', {o: event});
 
@@ -430,6 +445,13 @@ function rcube_calendar_ui(settings)
         $('#event-sensitivity').show().children('.event-text').html(Q(sensitivitylabels[event.sensitivity]));
         $dialog.addClass('sensitivity-'+event.sensitivity);
       }
+      if (event.created || event.changed) {
+        var created = parseISO8601(event.created),
+          changed = parseISO8601(event.changed)
+        $('#event-created-changed .event-created').html(Q(created ? format_datetime(created) : rcmail.gettext('unknown','calendar')))
+        $('#event-created-changed .event-changed').html(Q(changed ? format_datetime(changed) : rcmail.gettext('unknown','calendar')))
+        $('#event-created-changed').show()
+      }
 
       // create attachments list
       if ($.isArray(event.attachments)) {
@@ -451,14 +473,10 @@ function rcube_calendar_ui(settings)
           return (j - k);
         });
 
-        var data, dispname, tooltip, organizer = false, rsvp = false, mystatus = null, line,  morelink, html = '',overflow = '';
+        var data, mystatus = null, rsvp, line, morelink, html = '', overflow = '';
         for (var j=0; j < event.attendees.length; j++) {
           data = event.attendees[j];
-          dispname = Q(data.name || data.email);
-          tooltip = '';
           if (data.email) {
-            tooltip = data.email;
-            dispname = '<a href="mailto:' + data.email + '" class="mailtolink" data-cutype="' + data.cutype + '">' + dispname + '</a>';
             if (data.role == 'ORGANIZER')
               organizer = true;
             else if (settings.identity.emails.indexOf(';'+data.email) >= 0) {
@@ -467,18 +485,14 @@ function rcube_calendar_ui(settings)
                 rsvp = mystatus;
             }
           }
-          
-          if (data['delegated-to'])
-            tooltip = rcmail.gettext('delegatedto', 'calendar') + data['delegated-to'];
-          else if (data['delegated-from'])
-            tooltip = rcmail.gettext('delegatedfrom', 'calendar') + data['delegated-from'];
-          
-          line = '<span class="attendee ' + String(data.role == 'ORGANIZER' ? 'organizer' : data.status).toLowerCase() + '" title="' + Q(tooltip) + '">' + dispname + '</span> ';
+
+          line = event_attendee_html(data);
+
           if (morelink)
             overflow += line;
           else
             html += line;
-          
+
           // stop listing attendees
           if (j == 7 && event.attendees.length >= 7) {
             morelink = $('<a href="#more" class="morelink"></a>').html(rcmail.gettext('andnmore', 'calendar').replace('$nr', event.attendees.length - j - 1));
@@ -522,7 +536,7 @@ function rcube_calendar_ui(settings)
       }
 
       var buttons = {};
-      if (calendar.editable && event.editable !== false) {
+      if (!temp && calendar.editable && event.editable !== false) {
         buttons[rcmail.gettext('edit', 'calendar')] = function() {
           event_edit_dialog('edit', event);
         };
@@ -551,6 +565,13 @@ function rcube_calendar_ui(settings)
         },
         close: function() {
           $dialog.dialog('destroy').attr('aria-hidden', 'true').hide();
+          rcmail.command('menu-close','eventoptionsmenu')
+        },
+        dragStart: function() {
+          rcmail.command('menu-close','eventoptionsmenu')
+        },
+        resizeStart: function() {
+          rcmail.command('menu-close','eventoptionsmenu')
         },
         buttons: buttons,
         minWidth: 320,
@@ -566,15 +587,38 @@ function rcube_calendar_ui(settings)
 
       // set dialog size according to content
       me.dialog_resize($dialog.get(0), $dialog.height(), 420);
-/* 
+
       // add link for "more options" drop-down
-      $('<a>')
-        .attr('href', '#')
-        .html('More Options')
-        .addClass('dropdown-link')
-        .click(function(){ return false; })
-        .insertBefore($dialog.parent().find('.ui-dialog-buttonset').children().first());
-*/
+      if (!temp) {
+        $('<a>')
+          .attr('href', '#')
+          .html(rcmail.gettext('eventoptions','calendar'))
+          .addClass('dropdown-link')
+          .click(function(e) {
+            return rcmail.command('menu-open','eventoptionsmenu', this, e)
+          })
+          .appendTo($dialog.parent().find('.ui-dialog-buttonset'));
+      }
+
+      rcmail.enable_command('event-history', calendar.history)
+    };
+
+    // render HTML code for displaying an attendee record
+    var event_attendee_html = function(data)
+    {
+      var dispname = Q(data.name || data.email), tooltip = '';
+
+      if (data.email) {
+        tooltip = data.email;
+        dispname = '<a href="mailto:' + data.email + '" class="mailtolink" data-cutype="' + data.cutype + '">' + dispname + '</a>';
+      }
+
+      if (data['delegated-to'])
+        tooltip = rcmail.gettext('delegatedto', 'calendar') + data['delegated-to'];
+      else if (data['delegated-from'])
+        tooltip = rcmail.gettext('delegatedfrom', 'calendar') + data['delegated-from'];
+
+      return '<span class="attendee ' + String(data.role == 'ORGANIZER' ? 'organizer' : data.status).toLowerCase() + '" title="' + Q(tooltip) + '">' + dispname + '</span> ';
     };
 
     // event handler for clicks on an attendee link
@@ -586,7 +630,7 @@ function rcube_calendar_ui(settings)
         event_resources_dialog(mailto);
       }
       else {
-        rcmail.redirect(rcmail.url('mail/compose', { _to:mailto }));
+        rcmail.command('compose', mailto, e ? e.target : null, e);
       }
       return false;
     };
@@ -876,6 +920,291 @@ function rcube_calendar_ui(settings)
         window.setTimeout(load_attachments_tab, exec_deferred);
     };
 
+    // show event changelog in a dialog
+    var event_history_dialog = function(event)
+    {
+      if (!event.id)
+        return false
+
+      // render dialog
+      $dialog = $('#eventhistory');
+
+      // close show dialog first
+      if ($dialog.is(':ui-dialog'))
+        $dialog.dialog('close');
+
+      var buttons = {};
+      buttons[rcmail.gettext('close', 'calendar')] = function() {
+        $dialog.dialog('close');
+      };
+
+      // hide and reset changelog table
+      $('#event-changelog-table').children('tbody')
+        .html('<tr><td colspan="6"><span class="loading">'+ rcmail.gettext('loading') +'</span></td></tr>');
+
+      // open jquery UI dialog
+      $dialog.dialog({
+        modal: false,
+        resizable: true,
+        closeOnEscape: true,
+        title: rcmail.gettext('eventchangelog','calendar') + ' - ' + event.title + ', ' + me.event_date_text(event),
+        open: function() {
+          $dialog.attr('aria-hidden', 'false');
+          setTimeout(function(){
+            $dialog.parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().focus();
+          }, 5);
+        },
+        close: function() {
+          $dialog.dialog('destroy').attr('aria-hidden', 'true').hide();
+        },
+        buttons: buttons,
+        minWidth: 450,
+        width: 650,
+        height: 350,
+        minHeight: 200,
+      })
+      .data('event', event)
+      .show().children('.compare-button').hide();
+
+      // set dialog size according to content
+      // me.dialog_resize($dialog.get(0), $dialog.height(), 650);
+
+      // fetch changelog data
+      me.loading_lock = rcmail.set_busy(true, 'loading', me.loading_lock);
+      rcmail.http_post('event', { action:'changelog', e:{ id:event.id, calendar:event.calendar } }, me.loading_lock);
+
+      // initialize event handlers for history dialog UI elements
+      if (!$dialog.data('initialized')) {
+        // compare button
+        $dialog.find('.compare-button input').click(function(e) {
+          var rev1 = $('#event-changelog-table input.diff-rev1:checked').val(),
+            rev2 = $('#event-changelog-table input.diff-rev2:checked').val(),
+            event = $('#eventhistory').data('event');
+
+            if (rev1 && rev2 && rev1 != rev2) {
+              // swap revisions if the user got it wrong
+              if (rev1 > rev2) {
+                var tmp = rev2;
+                rev2 = rev1;
+                rev1 = tmp;
+              }
+
+              me.loading_lock = rcmail.set_busy(true, 'loading', me.loading_lock);
+              rcmail.http_post('event', { action:'diff', e:{ id:event.id, calendar:event.calendar, rev: rev1+':'+rev2 } }, me.loading_lock);
+            }
+            else {
+              alert('Invalid selection!')
+            }
+        });
+
+        // delegate handlers for list actions
+        $('#event-changelog-table tbody').on('click', 'td.actions a', function(e) {
+          var link = $(this),
+            action = link.hasClass('restore') ? 'restore' : 'show',
+            event = $('#eventhistory').data('event'),
+            rev = link.attr('data-rev');
+
+            // ignore clicks on first row (current revision)
+            if (link.closest('tr').hasClass('first')) {
+              return false;
+            }
+
+            // let the user confirm the restore action
+            if (action == 'restore' && !confirm(rcmail.gettext('eventrestoreconfirm','calendar').replace('$rev', rev))) {
+              return false;
+            }
+
+            me.loading_lock = rcmail.set_busy(true, 'loading', me.loading_lock);
+            rcmail.http_post('event', { action:action, e:{ id:event.id, calendar:event.calendar, rev: rev } }, me.loading_lock);
+            return false;
+        });
+
+        $dialog.data('initialized', true);
+      }
+    };
+
+    // callback from server with changelog data
+    var render_event_changelog = function(data)
+    {
+      var $dialog = $('#eventhistory');
+
+      if (data === false || !data.length) {
+        $dialog.dialog('close');
+        return
+      }
+
+      var i, change, accessible, op_append, first = data.length -1, last = 0,
+        op_labels = { APPEND: 'actionappend', MOVE: 'actionmove', DELETE: 'actiondelete' },
+        actions = '<a href="#show" class="iconbutton preview" title="'+ rcmail.gettext('showrevision','calendar') +'" data-rev="{rev}" /> ' +
+          '<a href="#restore" class="iconbutton restore" title="'+ rcmail.gettext('restore','calendar') + '" data-rev="{rev}" />',
+        tbody = $('#event-changelog-table tbody').html('');
+
+      for (i=first; i >= 0; i--) {
+        change = data[i];
+        accessible = change.date && change.user;
+
+        if (change.op == 'MOVE' && change.folder) {
+          op_append = ' ⇢ ' + change.folder;
+        }
+        else {
+          op_append = '';
+        }
+
+        $('<tr class="' + (i == last ? 'last' : (i == first ? 'first' : '')) + (accessible ? '' : 'undisclosed') + '">')
+          .append('<td class="diff">' + (accessible && change.op != 'DELETE' ? 
+            '<input type="radio" name="rev1" class="diff-rev1" value="' + change.rev + '" title="" '+ (i == last ? 'checked="checked"' : '') +' /> '+
+            '<input type="radio" name="rev2" class="diff-rev2" value="' + change.rev + '" title="" '+ (i == first ? 'checked="checked"' : '') +' /></td>'
+            : ''))
+          .append('<td class="revision">' + Q(change.rev) + '</td>')
+          .append('<td class="date">' + Q(change.date ? format_datetime(parseISO8601(change.date)) : '') + '</td>')
+          .append('<td class="user">' + Q(change.user || 'undisclosed') + '</td>')
+          .append('<td class="operation" title="' + op_append + '">' + Q(rcmail.gettext(op_labels[change.op] || '', 'calendar') + (op_append ? ' ...' : '')) + '</td>')
+          .append('<td class="actions">' + (accessible && change.op != 'DELETE' ? actions.replace(/\{rev\}/g, change.rev) : '') + '</td>')
+          .appendTo(tbody);
+      }
+
+      $('#eventhistory .compare-button').fadeIn(200);
+
+      // set dialog size according to content
+      me.dialog_resize($dialog.get(0), $dialog.height(), 600);
+    };
+
+    // callback from server with event diff data
+    var event_show_diff = function(data)
+    {
+      var event = me.selected_event,
+        $dialog = $("#eventdiff");
+
+      $dialog.find('div.event-section, div.event-line, h1.event-title-new').hide().data('set', false).find('.index').html('');
+      $dialog.find('div.event-section.clone, div.event-line.clone').remove();
+
+      // always show event title and date
+      $('.event-title', $dialog).html(Q(event.title)).removeClass('event-text-old').show();
+      $('.event-date', $dialog).html(Q(me.event_date_text(event))).show();
+
+      // show each property change
+      $.each(data.changes, function(i,change) {
+        var prop = change.property, r2, html = false,
+          row = $('div.event-' + prop, $dialog).first();
+
+          // special case: title
+          if (prop == 'title') {
+            $('.event-title', $dialog).addClass('event-text-old').html(Q(change.old || '--'));
+            $('.event-title-new', $dialog).html(Q(change.new || '--')).show();
+          }
+
+          // no display container for this property
+          if (!row.length) {
+            return true;
+          }
+
+          // clone row if already exists
+          if (row.data('set')) {
+            r2 = row.clone().addClass('clone').insertAfter(row);
+            row = r2;
+          }
+
+          // format dates
+          if (['start','end','changed'].indexOf(prop) >= 0) {
+            if (change.old) change.old_ = me.format_datetime(parseISO8601(change.old));
+            if (change.new) change.new_ = me.format_datetime(parseISO8601(change.new));
+          }
+          // render description text
+          else if (prop == 'description') {
+            // TODO: show real text diff
+            if (!change.diff_ && change.old) change.old_ = text2html(change.old);
+            if (!change.diff_ && change.new) change.new_ = text2html(change.new);
+            html = true;
+          }
+          // format attendees struct
+          else if (prop == 'attendees') {
+            if (change.old) change.old_ = event_attendee_html(change.old);
+            if (change.new) change.new_ = event_attendee_html($.extend({}, change.old || {}, change.new));
+            html = true;
+          }
+          // localize priority values
+          else if (prop == 'priority') {
+            var priolabels = [ '', rcmail.gettext('highest'), rcmail.gettext('high'), '', '', rcmail.gettext('normal'), '', '', rcmail.gettext('low'), rcmail.gettext('lowest') ];
+            if (change.old) change.old_ = change.old + ' ' + (priolabels[change.old] || '');
+            if (change.new) change.new_ = change.new + ' ' + (priolabels[change.new] || '');
+          }
+          // localize status
+          else if (prop == 'status') {
+            var status_lc = String(event.status).toLowerCase();
+            if (change.old) change.old_ = rcmail.gettext(String(change.old).toLowerCase(), 'calendar');
+            if (change.new) change.new_ = rcmail.gettext(String(change.new).toLowerCase(), 'calendar');
+          }
+
+          // format attachments struct
+          if (prop == 'attachments') {
+            if (change.old) event_show_attachments([change.old], row.children('.event-text-old'), event, false);
+            else            row.children('.event-text-old').html('--');
+            if (change.new) event_show_attachments([$.extend({}, change.old || {}, change.new)], row.children('.event-text-new'), event, false);
+            else            row.children('.event-text-new').html('--');
+            // remove click handler as we're currentyl not able to display the according attachment contents
+            $('.attachmentslist li a', row).unbind('click').removeAttr('href');
+          }
+          else if (change.diff_) {
+            row.children('.event-text-diff').html(change.diff_);
+            row.children('.event-text-old, .event-text-new').hide();
+          }
+          else {
+            if (!html) {
+              // escape HTML characters
+              change.old_ = Q(change.old_ || change.old || '--')
+              change.new_ = Q(change.new_ || change.new || '--')
+            }
+            row.children('.event-text-old').html(change.old_ || change.old || '--');
+            row.children('.event-text-new').html(change.new_ || change.new || '--');
+          }
+
+          // display index number
+          if (typeof change.index != 'undefined') {
+            row.find('.index').html('(' + change.index + ')');
+          }
+
+          row.show().data('set', true);
+
+          // hide event-date line
+          if (prop == 'start' || prop == 'end')
+            $('.event-date', $dialog).hide();
+      });
+
+      var buttons = {};
+      buttons[rcmail.gettext('close', 'calendar')] = function() {
+        $dialog.dialog('close');
+      };
+
+      // open jquery UI dialog
+      $dialog.dialog({
+        modal: false,
+        resizable: true,
+        closeOnEscape: true,
+        title: rcmail.gettext('eventdiff','calendar').replace('$rev', data.rev) + ' - ' + event.title,
+        open: function() {
+          $dialog.attr('aria-hidden', 'false');
+          setTimeout(function(){
+            $dialog.parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().focus();
+          }, 5);
+        },
+        close: function() {
+          $dialog.dialog('destroy').attr('aria-hidden', 'true').hide();
+        },
+        buttons: buttons,
+        minWidth: 320,
+        width: 450
+      }).show();
+
+      // set dialog size according to content
+      me.dialog_resize($dialog.get(0), $dialog.height(), 400);
+    };
+
+    // exports
+    this.event_show_diff = event_show_diff;
+    this.event_show_dialog = event_show_dialog;
+    this.event_history_dialog = event_history_dialog;
+    this.render_event_changelog = render_event_changelog;
+
     // open a dialog to display detailed free-busy information and to find free slots
     var event_freebusy_dialog = function()
     {
@@ -2055,7 +2384,7 @@ function rcube_calendar_ui(settings)
     var dialog_check = function(e)
     {
       var showd = $("#eventshow");
-      if (showd.is(':visible') && !$(e.target).closest('.ui-dialog').length) {
+      if (showd.is(':visible') && !$(e.target).closest('.ui-dialog').length && !$(e.target).closest('.popupmenu').length) {
         showd.dialog('close');
         e.stopImmediatePropagation();
         ignore_click = true;
@@ -2589,6 +2918,23 @@ function rcube_calendar_ui(settings)
       
     };
 
+    // download the selected event as iCal
+    this.event_download = function(event)
+    {
+      if (event && event.id) {
+        rcmail.goto_url('export_events', { source:event.calendar, id:event.id, attachments:1 });
+      }
+    };
+
+    // open the message compose step with a calendar_event parameter referencing the selected event.
+    // the server-side plugin hook will pick that up and attach the event to the message.
+    this.event_sendbymail = function(event, e)
+    {
+      if (event && event.id) {
+        rcmail.command('compose', { _calendar_event:event._id }, e ? e.target : null, e);
+      }
+    };
+
     // show URL of the given calendar in a dialog box
     this.showurl = function(calendar)
     {
@@ -3452,9 +3798,11 @@ function rcube_calendar_ui(settings)
 
       $('#eventshow .changersvp').click(function(e) {
         var d = $('#eventshow'),
-          h = $('#event-rsvp').show().height();
-        h -= $(this).closest('.event-line').toggle().height();
-        me.dialog_resize(d.get(0), d.height() + h, d.outerWidth() - 50);
+          h = -$(this).closest('.event-line').toggle().height();
+        $('#event-rsvp').slideDown(300, function() {
+          h += $(this).height();
+          me.dialog_resize(d.get(0), d.height() + h, d.outerWidth() - 50);
+        });
         return false;
       })
 
@@ -3508,7 +3856,10 @@ window.rcmail && rcmail.addEventListener('init', function(evt) {
   rcmail.register_command('calendar-remove', function(){ cal.calendar_remove(cal.calendars[cal.selected_calendar]); }, false);
   rcmail.register_command('events-import', function(){ cal.import_events(cal.calendars[cal.selected_calendar]); }, true);
   rcmail.register_command('calendar-showurl', function(){ cal.showurl(cal.calendars[cal.selected_calendar]); }, false);
- 
+  rcmail.register_command('event-download', function(){ cal.event_download(cal.selected_event); }, true);
+  rcmail.register_command('event-sendbymail', function(p, obj, e){ cal.event_sendbymail(cal.selected_event, e); }, true);
+  rcmail.register_command('event-history', function(p, obj, e){ cal.event_history_dialog(cal.selected_event); }, false);
+
   // search and export events
   rcmail.register_command('export', function(){ cal.export_events(cal.calendars[cal.selected_calendar]); }, true);
   rcmail.register_command('search', function(){ cal.quicksearch(); }, true);
@@ -3528,6 +3879,9 @@ window.rcmail && rcmail.addEventListener('init', function(evt) {
   rcmail.addEventListener('plugin.reload_view', function(p){ cal.reload_view(p); });
   rcmail.addEventListener('plugin.resource_data', function(p){ cal.resource_data_load(p); });
   rcmail.addEventListener('plugin.resource_owner', function(p){ cal.resource_owner_load(p); });
+  rcmail.addEventListener('plugin.render_event_changelog', function(data){ cal.render_event_changelog(data); });
+  rcmail.addEventListener('plugin.event_show_diff', function(data){ cal.event_show_diff(data); });
+  rcmail.addEventListener('plugin.event_show_revision', function(data){ cal.event_show_dialog(data, null, true); });
   rcmail.addEventListener('requestrefresh', function(q){ return cal.before_refresh(q); });
 
   // let's go
diff --git a/plugins/calendar/drivers/calendar_driver.php b/plugins/calendar/drivers/calendar_driver.php
index 4c4f516..e213a93 100644
--- a/plugins/calendar/drivers/calendar_driver.php
+++ b/plugins/calendar/drivers/calendar_driver.php
@@ -100,7 +100,8 @@ abstract class calendar_driver
   public $attendees = false;
   public $freebusy = false;
   public $attachments = false;
-  public $undelete = false; // event undelete action
+  public $undelete = false;
+  public $history = false;
   public $categoriesimmutable = false;
   public $alarm_types = array('DISPLAY');
   public $alarm_absolute = true;
@@ -406,6 +407,77 @@ abstract class calendar_driver
   }
 
   /**
+   * Provide a list of revisions for the given event
+   *
+   * @param array  $event Hash array with event properties:
+   *         id: Event identifier
+   *   calendar: Calendar identifier
+   *
+   * @return array List of changes, each as a hash array:
+   *         rev: Revision number
+   *        type: Type of the change (create, update, move, delete)
+   *        date: Change date
+   *        user: The user who executed the change
+   *          ip: Client IP
+   * destination: Destination calendar for 'move' type
+   */
+  public function get_event_changelog($event)
+  {
+    return false;
+  }
+
+  /**
+   * Get a list of property changes beteen two revisions of an event
+   *
+   * @param array  $event Hash array with event properties:
+   *         id: Event identifier
+   *   calendar: Calendar identifier
+   * @param mixed  $rev   Revisions: "from:to"
+   *
+   * @return array List of property changes, each as a hash array:
+   *    property: Revision number
+   *         old: Old property value
+   *         new: Updated property value
+   */
+  public function get_event_diff($event, $rev)
+  {
+    return false;
+  }
+
+  /**
+   * Return full data of a specific revision of an event
+   *
+   * @param mixed  UID string or hash array with event properties:
+   *        id: Event identifier
+   *  calendar: Calendar identifier
+   * @param mixed  $rev Revision number
+   *
+   * @return array Event object as hash array
+   * @see self::get_event()
+   */
+  public function get_event_revison($event, $rev)
+  {
+    return false;
+  }
+
+  /**
+   * Command the backend to restore a certain revision of an event.
+   * This shall replace the current event with an older version.
+   *
+   * @param mixed  UID string or hash array with event properties:
+   *        id: Event identifier
+   *  calendar: Calendar identifier
+   * @param mixed  $rev Revision number
+   *
+   * @return boolean True on success, False on failure
+   */
+  public function restore_event_revision($event, $rev)
+  {
+    return false;
+  }
+
+
+  /**
    * Callback function to produce driver-specific calendar create/edit form
    *
    * @param string Request action 'form-edit|form-new'
diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php
index 706c3cd..ba71846 100644
--- a/plugins/calendar/drivers/kolab/kolab_calendar.php
+++ b/plugins/calendar/drivers/kolab/kolab_calendar.php
@@ -30,6 +30,7 @@ class kolab_calendar extends kolab_storage_folder_api
   public $readonly = true;
   public $attachments = true;
   public $alarms = false;
+  public $history = false;
   public $subscriptions = true;
   public $categories = array();
   public $storage;
@@ -589,53 +590,8 @@ class kolab_calendar extends kolab_storage_folder_api
   {
     $record['id'] = $record['uid'];
     $record['calendar'] = $this->id;
-/*
-    // convert from DateTime to unix timestamp
-    if (is_a($record['start'], 'DateTime'))
-      $record['start'] = $record['start']->format('U');
-    if (is_a($record['end'], 'DateTime'))
-      $record['end'] = $record['end']->format('U');
-*/
-    // all-day events go from 12:00 - 13:00
-    if ($record['end'] <= $record['start'] && $record['allday']) {
-      $record['end'] = clone $record['start'];
-      $record['end']->add(new DateInterval('PT1H'));
-    }
-
-    if (!empty($record['_attachments'])) {
-      foreach ($record['_attachments'] as $key => $attachment) {
-        if ($attachment !== false) {
-          if (!$attachment['name'])
-            $attachment['name'] = $key;
-
-          unset($attachment['path'], $attachment['content']);
-          $attachments[] = $attachment;
-        }
-      }
-
-      $record['attachments'] = $attachments;
-    }
-
-    // Roundcube only supports one category assignment
-    if (is_array($record['categories']))
-      $record['categories'] = $record['categories'][0];
-
-    // the cancelled flag transltes into status=CANCELLED
-    if ($record['cancelled'])
-      $record['status'] = 'CANCELLED';
-
-    // The web client only supports DISPLAY type of alarms
-    if (!empty($record['alarms']))
-      $record['alarms'] = preg_replace('/:[A-Z]+$/', ':DISPLAY', $record['alarms']);
-
-    // remove empty recurrence array
-    if (empty($record['recurrence']))
-      unset($record['recurrence']);
-
-    // remove internals
-    unset($record['_mailbox'], $record['_msguid'], $record['_formatobj'], $record['_attachments'], $record['x-custom']);
 
-    return $record;
+    return kolab_driver::to_rcube_event($record);
   }
 
    /**
diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php
index ba634cb..f921b53 100644
--- a/plugins/calendar/drivers/kolab/kolab_driver.php
+++ b/plugins/calendar/drivers/kolab/kolab_driver.php
@@ -47,6 +47,7 @@ class kolab_driver extends calendar_driver
   private $calendars;
   private $has_writeable = false;
   private $freebusy_trigger = false;
+  private $bonnie_api = false;
 
   /**
    * Default constructor
@@ -69,6 +70,10 @@ class kolab_driver extends calendar_driver
         $this->alarm_absolute = false;
     }
 
+    // get configuration for the Bonnie API
+    if ($bonnie_config = $this->cal->rc->config->get('kolab_bonnie_api', false))
+      $this->bonnie_api = new kolab_bonnie_api($bonnie_config);
+
     // calendar uses fully encoded identifiers
     kolab_storage::$encode_ids = true;
   }
@@ -164,6 +169,7 @@ class kolab_driver extends calendar_driver
           'active'   => $cal->is_active(),
           'title'    => $cal->get_owner(),
           'owner'    => $cal->get_owner(),
+          'history'  => false,
           'virtual'  => false,
           'readonly' => true,
           'group'    => 'other',
@@ -192,6 +198,7 @@ class kolab_driver extends calendar_driver
           'color'    => $cal->get_color(),
           'readonly' => $cal->readonly,
           'showalarms' => $cal->alarms,
+          'history'  => !empty($this->bonnie_api),
           'group'    => $cal->get_namespace(),
           'default'  => $cal->default,
           'active'   => $cal->is_active(),
@@ -222,6 +229,7 @@ class kolab_driver extends calendar_driver
             'color'    => $cal->get_color(),
             'readonly' => $cal->readonly,
             'showalarms' => $cal->alarms,
+            'history'  => !empty($this->bonnie_api),
             'group'    => 'x-invitations',
             'default'  => false,
             'active'   => $cal->is_active(),
@@ -252,6 +260,7 @@ class kolab_driver extends calendar_driver
           'readonly'   => true,
           'default'    => false,
           'children'   => false,
+          'history'    => false,
         );
       }
     }
@@ -1285,6 +1294,265 @@ class kolab_driver extends calendar_driver
     exit;
   }
 
+
+  /**
+   * Convert from Kolab_Format to internal representation
+   */
+  public static function to_rcube_event($record)
+  {
+    $record['id'] = $record['uid'];
+
+    // all-day events go from 12:00 - 13:00
+    if ($record['end'] <= $record['start'] && $record['allday']) {
+      $record['end'] = clone $record['start'];
+      $record['end']->add(new DateInterval('PT1H'));
+    }
+
+    if (!empty($record['_attachments'])) {
+      foreach ($record['_attachments'] as $key => $attachment) {
+        if ($attachment !== false) {
+          if (!$attachment['name'])
+            $attachment['name'] = $key;
+
+          unset($attachment['path'], $attachment['content']);
+          $attachments[] = $attachment;
+        }
+      }
+
+      $record['attachments'] = $attachments;
+    }
+
+    // Roundcube only supports one category assignment
+    if (is_array($record['categories']))
+      $record['categories'] = $record['categories'][0];
+
+    // the cancelled flag transltes into status=CANCELLED
+    if ($record['cancelled'])
+      $record['status'] = 'CANCELLED';
+
+    // The web client only supports DISPLAY type of alarms
+    if (!empty($record['alarms']))
+      $record['alarms'] = preg_replace('/:[A-Z]+$/', ':DISPLAY', $record['alarms']);
+
+    // remove empty recurrence array
+    if (empty($record['recurrence']))
+      unset($record['recurrence']);
+
+    // remove internals
+    unset($record['_mailbox'], $record['_msguid'], $record['_formatobj'], $record['_attachments'], $record['x-custom']);
+
+    return $record;
+  }
+
+
+  /**
+   * Provide a list of revisions for the given event
+   *
+   * @param array  $event Hash array with event properties
+   *
+   * @return array List of changes, each as a hash array
+   * @see calendar_driver::get_event_changelog()
+   */
+  public function get_event_changelog($event)
+  {
+    if (empty($this->bonnie_api)) {
+      return false;
+    }
+
+    list($uid, $folder) = $this->_resolve_event_identity($event);
+
+    $result = $this->bonnie_api->changelog('event', $uid, $folder);
+    if (is_array($result) && $result['uid'] == $uid) {
+      return $result['changes'];
+    }
+
+    return false;
+  }
+
+  /**
+   * Get a list of property changes beteen two revisions of an event
+   *
+   * @param array  $event Hash array with event properties
+   * @param mixed  $rev   Revisions: "from:to"
+   *
+   * @return array List of property changes, each as a hash array
+   * @see calendar_driver::get_event_diff()
+   */
+  public function get_event_diff($event, $rev)
+  {
+    if (empty($this->bonnie_api)) {
+      return false;
+    }
+
+    list($uid, $folder) = $this->_resolve_event_identity($event);
+
+    // call Bonnie API
+    $result = $this->bonnie_api->diff('event', $uid, $rev, $folder);
+    if (is_array($result) && $result['uid'] == $uid) {
+      $result['rev'] = $rev;
+
+      $keymap = array(
+        'dtstart'  => 'start',
+        'dtend'    => 'end',
+        'dstamp'   => 'changed',
+        'summary'  => 'title',
+        'alarm'    => 'alarms',
+        'attendee' => 'attendees',
+        'attach'   => 'attachments',
+        'rrule'    => 'recurrence',
+        'transparency' => 'free_busy',
+        'classification' => 'sensitivity',
+        'lastmodified-date' => 'changed',
+      );
+      $prop_keymaps = array(
+        'attachments' => array('fmttype' => 'mimetype', 'label' => 'name'),
+        'attendees'   => array('partstat' => 'status'),
+      );
+      $special_changes = array();
+
+      // map kolab event properties to keys the client expects
+      array_walk($result['changes'], function(&$change, $i) use ($keymap, $prop_keymaps, $special_changes) {
+        if (array_key_exists($change['property'], $keymap)) {
+          $change['property'] = $keymap[$change['property']];
+        }
+        // translate free_busy values
+        if ($change['property'] == 'free_busy') {
+          $change['old'] = $old['old'] ? 'free' : 'busy';
+          $change['new'] = $old['new'] ? 'free' : 'busy';
+        }
+        // map alarms trigger value
+        if ($change['property'] == 'alarms') {
+          if (is_array($change['old']) && is_array($change['old']['trigger']))
+            $change['old']['trigger'] = $change['old']['trigger']['value'];
+          if (is_array($change['new']) && is_array($change['new']['trigger']))
+            $change['new']['trigger'] = $change['new']['trigger']['value'];
+        }
+        // make all property keys uppercase
+        if ($change['property'] == 'recurrence') {
+          $special_changes['recurrence'] = $i;
+          foreach (array('old','new') as $m) {
+            if (is_array($change[$m])) {
+              $props = array();
+              foreach ($change[$m] as $k => $v)
+                $props[strtoupper($k)] = $v;
+              $change[$m] = $props;
+            }
+          }
+        }
+        // map property keys names
+        if (is_array($prop_keymaps[$change['property']])) {
+          foreach ($prop_keymaps[$change['property']] as $k => $dest) {
+            if (is_array($change['old']) && array_key_exists($k, $change['old'])) {
+              $change['old'][$dest] = $change['old'][$k];
+              unset($change['old'][$k]);
+            }
+            if (is_array($change['new']) && array_key_exists($k, $change['new'])) {
+              $change['new'][$dest] = $change['new'][$k];
+              unset($change['new'][$k]);
+            }
+          }
+        }
+
+        if ($change['property'] == 'exdate') {
+          $special_changes['exdate'] = $i;
+        }
+        else if ($change['property'] == 'rdate') {
+          $special_changes['rdate'] = $i;
+        }
+      });
+
+      // merge some recurrence changes
+      foreach (array('exdate','rdate') as $prop) {
+        if (array_key_exists($prop, $special_changes)) {
+          $exdate = $result['changes'][$special_changes[$prop]];
+          if (array_key_exists('recurrence', $special_changes)) {
+            $recurrence = &$result['changes'][$special_changes['recurrence']];
+          }
+          else {
+            $i = count($result['changes']);
+            $result['changes'][$i] = array('property' => 'recurrence', 'old' => array(), 'new' => array());
+            $recurrence = &$result['changes'][$i]['recurrence'];
+          }
+          $key = strtoupper($prop);
+          $recurrence['old'][$key] = $exdate['old'];
+          $recurrence['new'][$key] = $exdate['new'];
+          unset($result['changes'][$special_changes[$prop]]);
+        }
+      }
+
+      return $result;
+    }
+
+    return false;
+  }
+
+  /**
+   * Return full data of a specific revision of an event
+   *
+   * @param array  Hash array with event properties
+   * @param mixed  $rev Revision number
+   *
+   * @return array Event object as hash array
+   * @see calendar_driver::get_event_revison()
+   */
+  public function get_event_revison($event, $rev)
+  {
+    if (empty($this->bonnie_api)) {
+      return false;
+    }
+
+    $calid = $event['calendar'];
+    list($uid, $folder) = $this->_resolve_event_identity($event);
+
+    // call Bonnie API
+    $result = $this->bonnie_api->get('event', $uid, $rev, $folder);
+    if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) {
+      $format = kolab_format::factory('event');
+      $format->load($result['xml']);
+      $event = $format->to_array();
+
+      if ($format->is_valid()) {
+        if ($result['folder'] && ($cal = $this->get_calendar(kolab_storage::id_encode($result['folder'])))) {
+          $event['calendar'] = $cal->id;
+        }
+        else {
+          $event['calendar'] = $calid;
+        }
+
+        $event['rev'] = $result['rev'];
+        return self::to_rcube_event($event);
+      }
+    }
+
+    return false;
+  }
+
+  /**
+   * Helper method to resolved the given event identifier into uid and folder
+   *
+   * @return array (uid,folder) tuple
+   */
+  private function _resolve_event_identity($event)
+  {
+    $folder = null;
+    if (is_array($event)) {
+      $uid = $event['id'] ?: $event['uid'];
+      if ($cal = $this->get_calendar($event['calendar']) && !($cal instanceof kolab_invitation_calendar)) {
+        $folder = $cal->name;
+      }
+    }
+    else {
+      $uid = $event;
+    }
+
+    // FIXME: hard-code UID for static Bonnie API demo
+    $demo_uids = $this->rc->config->get('kolab_static_bonnie_uids', array('0015c5fe-9baf-0561-11e3-d584fa2894b7'));
+    if (!in_array($uid, $demo_uids))
+      $uid = reset($demo_uids);
+
+    return array($uid, $folder);
+  }
+
   /**
    * Callback function to produce driver-specific calendar create/edit form
    *
diff --git a/plugins/calendar/drivers/kolab/kolab_user_calendar.php b/plugins/calendar/drivers/kolab/kolab_user_calendar.php
index bcfc772..58c2296 100644
--- a/plugins/calendar/drivers/kolab/kolab_user_calendar.php
+++ b/plugins/calendar/drivers/kolab/kolab_user_calendar.php
@@ -391,9 +391,7 @@ class kolab_user_calendar extends kolab_calendar
     $record['id'] = $record['uid'];
     $record['calendar'] = $this->id;
 
-    // TODO: implement this
-
-    return $record;
+    return kolab_driver::to_rcube_event($record);
   }
 
 }
diff --git a/plugins/calendar/lib/calendar_ui.php b/plugins/calendar/lib/calendar_ui.php
index 6e165ca..df15957 100644
--- a/plugins/calendar/lib/calendar_ui.php
+++ b/plugins/calendar/lib/calendar_ui.php
@@ -97,6 +97,7 @@ class calendar_ui
     $this->cal->register_handler('plugin.angenda_options', array($this, 'angenda_options'));
     $this->cal->register_handler('plugin.events_import_form', array($this, 'events_import_form'));
     $this->cal->register_handler('plugin.events_export_form', array($this, 'events_export_form'));
+    $this->cal->register_handler('plugin.event_changelog_table', array($this, 'event_changelog_table'));
     $this->cal->register_handler('plugin.searchform', array($this->rc->output, 'search_form'));  // use generic method from rcube_template
   }
 
@@ -841,6 +842,22 @@ class calendar_ui
   }
 
   /**
+   * Table oultine for event changelog display
+   */
+  function event_changelog_table($attrib = array())
+  {
+    $table = new html_table(array('cols' => 5, 'border' => 0, 'cellspacing' => 0));
+    $table->add_header('diff', '');
+    $table->add_header('revision', $this->cal->gettext('revision'));
+    $table->add_header('date', $this->cal->gettext('date'));
+    $table->add_header('user', $this->cal->gettext('user'));
+    $table->add_header('operation', $this->cal->gettext('operation'));
+    $table->add_header('actions', ' ');
+
+    return $table->show($attrib);
+  }
+
+  /**
    *
    */
   function event_invitebox($attrib = array())
diff --git a/plugins/calendar/localization/en_US.inc b/plugins/calendar/localization/en_US.inc
index f7af672..7d1e7d2 100644
--- a/plugins/calendar/localization/en_US.inc
+++ b/plugins/calendar/localization/en_US.inc
@@ -81,7 +81,12 @@ $labels['private'] = 'private';
 $labels['confidential'] = 'confidential';
 $labels['alarms'] = 'Reminder';
 $labels['comment'] = 'Comment';
+$labels['created'] = 'Created';
+$labels['changed'] = 'Last Modified';
+$labels['unknown'] = 'Unknown';
+$labels['eventoptions'] = 'Options';
 $labels['generated'] = 'generated at';
+$labels['eventhistory'] = 'History';
 $labels['printdescriptions'] = 'Print descriptions';
 $labels['parentcalendar'] = 'Insert inside';
 $labels['searchearlierdates'] = '« Search for earlier events';
@@ -253,6 +258,24 @@ $labels['birthdayscalendarsources'] = 'From these address books';
 $labels['birthdayeventtitle'] = '$name\'s Birthday';
 $labels['birthdayage'] = 'Age $age';
 
+// history dialog
+$labels['eventchangelog'] = 'Change History';
+$labels['eventdiff'] = 'Changes from revisions $rev';
+$labels['revision'] = 'Revision';
+$labels['user'] = 'User';
+$labels['operation'] = 'Action';
+$labels['actionappend'] = 'Saved';
+$labels['actionmove'] = 'Moved';
+$labels['actiondelete'] = 'Deleted';
+$labels['compare'] = 'Compare';
+$labels['showrevision'] = 'Show this version';
+$labels['restore'] = 'Restore this version';
+$labels['eventnotfound'] = 'Failed to load event data';
+$labels['eventchangelognotavailable'] = 'Change history is not available for this event';
+$labels['eventdiffnotavailable'] = 'No comparison possible for the selected revisions';
+$labels['eventrestoreconfirm'] = 'Do you really want to restore revision $rev of this event? This will replace the current event with the old version.';
+
+
 // (hidden) titles and labels for accessibility annotations
 $labels['arialabelminical'] = 'Calendar date selection';
 $labels['arialabelcalendarview'] = 'Calendar view';
diff --git a/plugins/calendar/skins/classic/calendar.css b/plugins/calendar/skins/classic/calendar.css
index 2855635..065cb68 100644
--- a/plugins/calendar/skins/classic/calendar.css
+++ b/plugins/calendar/skins/classic/calendar.css
@@ -526,6 +526,10 @@ a.miniColors-trigger {
 
 /* jQuery UI overrides */
 
+.calendarmain .ui-dialog .ui-dialog-buttonpane .ui-dialog-buttonset {
+	float: left;
+}
+
 #eventshow h1 {
 	font-size: 20px;
 	margin: 0.1em 0 0.4em 0;
diff --git a/plugins/calendar/skins/classic/templates/calendar.html b/plugins/calendar/skins/classic/templates/calendar.html
index 07839f0..2df1ce7 100644
--- a/plugins/calendar/skins/classic/templates/calendar.html
+++ b/plugins/calendar/skins/classic/templates/calendar.html
@@ -115,6 +115,13 @@
   <roundcube:object name="plugin.event_rsvp_buttons" id="event-rsvp" style="display:none" />
 </div>
 
+<div id="eventoptionsmenu" class="popupmenu">
+  <ul>
+    <li><roundcube:button command="event-download" label="download" classAct="active" /></li>
+    <li><roundcube:button command="event-sendbymail" label="send" classAct="active" /></li>
+  </ul>
+</div>
+
 <roundcube:include file="/templates/eventedit.html" />
 
 <div id="eventresourcesdialog" class="uidialog">
diff --git a/plugins/calendar/skins/larry/calendar.css b/plugins/calendar/skins/larry/calendar.css
index 391f4c1..8499177 100644
--- a/plugins/calendar/skins/larry/calendar.css
+++ b/plugins/calendar/skins/larry/calendar.css
@@ -663,53 +663,160 @@ a.miniColors-trigger {
 
 /* jQuery UI overrides */
 
-#eventshow h1 {
+.calendarmain .eventdialog h1 {
 	font-size: 18px;
 	margin: -0.3em 0 0.4em 0;
 }
 
-#eventshow label,
-#eventshow h5.label {
+.calendarmain .eventdialog label,
+.calendarmain .eventdialog h5.label {
 	font-weight: normal;
 	font-size: 1em;
 	color: #999;
 	margin: 0 0 0.2em 0;
 }
 
-#eventshow {
+.calendarmain .eventdialog label span.index,
+.calendarmain .eventdialog h5.label .index {
+	vertical-align: inherit;
+	margin-left: 0.6em;
+}
+
+.calendarmain .eventdialog {
 	margin: 0 -0.2em;
 }
 
-#eventshow.status-cancelled {
+.calendarmain .eventdialog.status-cancelled {
 	background: url(images/badge_cancelled.png) top right no-repeat;
 }
 
-#eventshow.sensitivity-private {
+.calendarmain .eventdialog.sensitivity-private {
 	background: url(images/badge_private.png) top right no-repeat;
 }
 
-#eventshow.sensitivity-confidential {
+.calendarmain .eventdialog.sensitivity-confidential {
 	background: url(images/badge_confidential.png) top right no-repeat;
 }
 
-.sensitivity-private #event-title {
+.calendarmain .sensitivity-private #event-title {
 	margin-right: 50px;
 }
 
-.sensitivity-confidential #event-title {
+.calendarmain .sensitivity-confidential #event-title {
 	margin-right: 60px;
 }
 
-#eventshow div.event-line {
+.calendarmain .eventdialog div.event-line {
 	margin-top: 0.1em;
 	margin-bottom: 0.3em;
 }
 
-#eventshow div.event-line a.iconbutton {
+.calendarmain .eventdialog div.event-line a.iconbutton {
 	margin-left: 0.5em;
 	line-height: 17px;
 }
 
+.calendarmain .eventdialog div.event-line span.event-text + label {
+	margin-left: 2em;
+}
+
+.calendarmain .eventdialog #event-created-changed {
+	margin-top: 0.6em;
+}
+
+.eventdialog .event-text-old,
+.eventdialog .event-text-new,
+.eventdialog .event-text-diff {
+	padding: 2px;
+}
+
+.eventdialog .event-text-diff del,
+.eventdialog .event-text-diff ins {
+	text-decoration: none;
+	color: inherit;
+}
+
+.eventdialog .event-text-old,
+.eventdialog .event-text-diff del {
+	background-color: #fdd;
+	/* text-decoration: line-through; */
+}
+
+.eventdialog .event-text-new,
+.eventdialog .event-text-diff ins {
+	background-color: #dfd;
+}
+
+#eventdiff .attachmentslist li a,
+#eventdiff .attachmentslist li a:hover {
+	cursor: default;
+	text-decoration: none;
+}
+
+#eventhistory .loading {
+	color: #666;
+	margin: 1em 0;
+	padding: 1px 0 2px 24px;
+	background: url(images/loading_blue.gif) top left no-repeat;
+}
+
+#eventhistory .compare-button {
+	margin: 4px 0;
+}
+
+#event-changelog-table tbody td {
+	padding: 4px 7px;
+	vertical-align: middle;
+}
+
+#event-changelog-table tbody tr.undisclosed td.date,
+#event-changelog-table tbody tr.undisclosed td.user {
+	font-style: italic;
+}
+
+#event-changelog-table .diff {
+	width: 4em;
+	padding: 2px;
+}
+
+#event-changelog-table .revision {
+	width: 5em;
+}
+
+#event-changelog-table .date {
+	width: 11em;
+}
+
+#event-changelog-table .user {
+	width: auto;
+}
+
+#event-changelog-table .operation {
+	width: 15%;
+}
+
+#event-changelog-table .actions {
+	width: 50px;
+	text-align: right;
+	padding: 4px;
+}
+
+#event-changelog-table td a.iconbutton.restore,
+#event-changelog-table td a.iconbutton.preview {
+	background-image: url(images/calendars.png);
+	background-position: 1px -147px;
+}
+
+#event-changelog-table td a.iconbutton.restore {
+	background-image: url(images/calendars.png);
+	background-position: 1px -167px;
+}
+
+#event-changelog-table tr.first td a.iconbutton {
+	opacity: 0.3;
+	cursor: default;
+}
+
 #event-partstat .changersvp {
 	cursor: pointer;
 	color: #333;
@@ -745,7 +852,7 @@ a.miniColors-trigger {
 }
 
 div.form-section,
-#eventshow div.event-section,
+.calendarmain .eventdialog div.event-section,
 #eventtabs div.event-section {
 	margin-top: 0.2em;
 	margin-bottom: 0.6em;
@@ -757,7 +864,7 @@ div.form-section,
 	border-bottom: 2px solid #fafafa;
 }
 
-#eventshow label,
+.calendarmain .eventdialog label,
 #eventedit label,
 .form-section label {
 	display: inline-block;
@@ -830,7 +937,7 @@ td.topalign {
 
 #event-rsvp,
 #edit-attendees-notify {
-	margin: 0.3em 0;
+	margin: 0.6em 0 0.3em 0;
 	padding: 0.5em;
 }
 
@@ -1186,13 +1293,13 @@ td.topalign {
 }
 
 a.dropdown-link {
-	font-size: 12px;
+	font-size: 11px;
 	text-decoration: none;
 }
 
 a.dropdown-link:after {
 	content: ' â–¼';
-	font-size: 11px;
+	font-size: 10px;
 	color: #666;
 }
 
@@ -1201,7 +1308,10 @@ a.dropdown-link:after {
 }
 
 .ui-dialog-buttonset a.dropdown-link {
-	margin-right: 1em;
+	position: relative;
+	top: 2px;
+	margin: 0 1em;
+	color: #333;
 }
 
 #calendarsidebar .ui-datepicker-calendar {
diff --git a/plugins/calendar/skins/larry/images/calendars.png b/plugins/calendar/skins/larry/images/calendars.png
index bf84f3a..5e53cb6 100644
Binary files a/plugins/calendar/skins/larry/images/calendars.png and b/plugins/calendar/skins/larry/images/calendars.png differ
diff --git a/plugins/calendar/skins/larry/templates/calendar.html b/plugins/calendar/skins/larry/templates/calendar.html
index 4f3228c..50ad616 100644
--- a/plugins/calendar/skins/larry/templates/calendar.html
+++ b/plugins/calendar/skins/larry/templates/calendar.html
@@ -77,7 +77,7 @@
 	</ul>
 </div>
 
-<div id="eventshow" class="uidialog" aria-hidden="true">
+<div id="eventshow" class="uidialog eventdialog" aria-hidden="true">
 	<h1 id="event-title">Event Title</h1>
 	<div class="event-section" id="event-location">Location</div>
 	<div class="event-section" id="event-date">From-To</div>
@@ -136,10 +136,109 @@
 		<label><roundcube:label name="attachments" /></label>
 		<div class="event-text"></div>
 	</div>
+	<div class="event-line" id="event-created-changed">
+		<label><roundcube:label name="calendar.created" /></label>
+		<span class="event-text event-created"></span>
+		<label><roundcube:label name="calendar.changed" /></label>
+		<span class="event-text event-changed"></span>
+	</div>
 
 	<roundcube:object name="plugin.event_rsvp_buttons" id="event-rsvp" class="event-dialog-message" style="display:none" />
 </div>
 
+<div id="eventoptionsmenu" class="popupmenu" aria-hidden="true">
+	<h3 id="aria-label-eventoptions" class="voice"><roundcube:label name="calendar.eventoptions" /></h3>
+	<ul id="eventoptionsmenu-menu" class="toolbarmenu" role="menu" aria-labelledby="aria-label-eventoptions">
+		<li role="menuitem"><roundcube:button command="event-download" label="download" classAct="active" /></li>
+		<li role="menuitem"><roundcube:button command="event-sendbymail" label="send" classAct="active" /></li>
+		<roundcube:if condition="env:calendar_driver == 'kolab' && config:kolab_bonnie_api" />
+		<li role="menuitem"><roundcube:button command="event-history" type="link" label="calendar.eventhistory" classAct="active" /></li>
+		<roundcube:endif />
+	</ul>
+</div>
+
+<div id="eventdiff" class="uidialog eventdialog" aria-hidden="true">
+	<h1 class="event-title">Event Title</h1>
+	<h1 class="event-title-new event-text-new"></h1>
+	<div class="event-section event-date"></div>
+	<div class="event-section event-location">
+		<h5 class="label"><roundcube:label name="calendar.location" /></h5>
+		<div class="event-text-old"></div>
+		<div class="event-text-new"></div>
+	</div>
+	<div class="event-section event-description">
+		<h5 class="label"><roundcube:label name="calendar.description" /></h5>
+		<div class="event-text-diff" style="white-space:pre-wrap"></div>
+		<div class="event-text-old"></div>
+		<div class="event-text-new"></div>
+	</div>
+	<div class="event-section event-url">
+		<h5 class="label"><roundcube:label name="calendar.url" /></h5>
+		<div class="event-text-old"></div>
+		<div class="event-text-new"></div>
+	</div>
+	<div class="event-section event-recurrence">
+		<h5 class="label"><roundcube:label name="calendar.repeat" /></h5>
+		<div class="event-text-old"></div>
+		<div class="event-text-new"></div>
+	</div>
+	<div class="event-section event-alarms">
+		<h5 class="label"><roundcube:label name="calendar.alarms" /><span class="index"></span></h5>
+		<div class="event-text-old"></div>
+		<div class="event-text-new"></div>
+	</div>
+	<div class="event-line event-start">
+		<label><roundcube:label name="calendar.start" /></label>
+		<span class="event-text-old"></span> ⇢
+		<span class="event-text-new"></span>
+	</div>
+	<div class="event-line event-end">
+		<label><roundcube:label name="calendar.end" /></label>
+		<span class="event-text-old"></span> ⇢
+		<span class="event-text-new"></span>
+	</div>
+	<div class="event-line event-attendees">
+		<label><roundcube:label name="calendar.tabattendees" /><span class="index"></span></label>
+		<span class="event-text-old"></span> ⇢
+		<span class="event-text-new"></span>
+	</div>
+	<div class="event-line event-calendar">
+		<label><roundcube:label name="calendar.calendar" /></label>
+		<span class="event-text-old"></span> ⇢
+		<span class="event-text-new"></span>
+	</div>
+	<div class="event-line event-categories">
+		<label><roundcube:label name="calendar.category" /></label>
+		<span class="event-text-old"></span> ⇢
+		<span class="event-text-new"></span>
+	</div>
+	<div class="event-line event-status">
+		<label><roundcube:label name="calendar.status" /></label>
+		<span class="event-text-old"></span> ⇢
+		<span class="event-text-new"></span>
+	</div>
+	<div class="event-line event-free_busy">
+		<label><roundcube:label name="calendar.freebusy" /></label>
+		<span class="event-text-old"></span> ⇢
+		<span class="event-text-new"></span>
+	</div>
+	<div class="event-line event-priority">
+		<label><roundcube:label name="calendar.priority" /></label>
+		<span class="event-text-old"></span> ⇢
+		<span class="event-text-new"></span>
+	</div>
+	<div class="event-line event-sensitivity">
+		<label><roundcube:label name="calendar.sensitivity" /></label>
+		<span class="event-text-old"></span> ⇢
+		<span class="event-text-new"></span>
+	</div>
+	<div class="event-section event-attachments">
+		<label><roundcube:label name="attachments" /><span class="index"></span></label>
+		<div class="event-text-old"></div>
+		<div class="event-text-new"></div>
+	</div>
+</div>
+
 <roundcube:include file="/templates/eventedit.html" />
 
 <div id="eventresourcesdialog" class="uidialog" aria-hidden="true">
@@ -223,6 +322,11 @@
 	</div>
 </div>
 
+<div id="eventhistory" class="uidialog" aria-hidden="true">
+    <roundcube:object name="plugin.event_changelog_table" id="event-changelog-table" class="records-table" />
+    <div class="compare-button"><input type="button" class="button" value="↳ <roundcube:label name='calendar.compare' />" /></div>
+</div>
+
 <div id="calendarform" class="uidialog" aria-hidden="true">
 	<roundcube:label name="loading" />
 </div>
diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php
index 9c507a3..d267da5 100644
--- a/plugins/libcalendaring/libcalendaring.php
+++ b/plugins/libcalendaring/libcalendaring.php
@@ -746,7 +746,17 @@ class libcalendaring extends rcube_plugin
             $until = $this->gettext('forever');
         }
 
-        return rtrim($freq . $details . ', ' . $until);
+        $except = '';
+        if (is_array($rrule['EXDATE']) && !empty($rrule['EXDATE'])) {
+          $format = self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format']));
+          $exdates = array_map(
+            function($dt) use ($format) { return format_date($dt, $format); },
+            array_slice($rrule['EXDATE'], 0, 10)
+          );
+          $except = '; ' . $this->gettext('except') . ' ' . join(', ');
+        }
+
+        return rtrim($freq . $details . ', ' . $until . $except);
     }
 
     /**
diff --git a/plugins/libcalendaring/localization/en_US.inc b/plugins/libcalendaring/localization/en_US.inc
index 7e8c717..26e4ae4 100644
--- a/plugins/libcalendaring/localization/en_US.inc
+++ b/plugins/libcalendaring/localization/en_US.inc
@@ -66,6 +66,7 @@ $labels['fourth'] = 'fourth';
 $labels['last'] = 'last';
 $labels['dayofmonth'] = 'Day of month';
 $labels['addrdate'] = 'Add repeat date';
+$labels['except'] = 'except';
 
 // itip related labels
 $labels['itipinvitation'] = 'Invitation to';


commit f3b31c863d0f28596377120106f621e21f892cf3
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Tue Jul 29 14:34:03 2014 +0200

    Add utility function to render a nice diff of two texts using the FineDiff library by Raymond Hill

diff --git a/plugins/libkolab/libkolab.php b/plugins/libkolab/libkolab.php
index c32d65a..052724c 100644
--- a/plugins/libkolab/libkolab.php
+++ b/plugins/libkolab/libkolab.php
@@ -124,4 +124,15 @@ class libkolab extends rcube_plugin
 
         return $request;
     }
+
+    /**
+     * Wrapper function for generating a html diff using the FineDiff class by Raymond Hill
+     */
+    public static function html_diff($from, $to)
+    {
+      include_once __dir__ . '/vendor/finediff.php';
+
+      $diff = new FineDiff($from, $to, FineDiff::$wordGranularity);
+      return $diff->renderDiffToHTML();
+    }
 }
diff --git a/plugins/libkolab/vendor/finediff.php b/plugins/libkolab/vendor/finediff.php
new file mode 100644
index 0000000..b3c416c
--- /dev/null
+++ b/plugins/libkolab/vendor/finediff.php
@@ -0,0 +1,688 @@
+<?php
+/**
+* FINE granularity DIFF
+*
+* Computes a set of instructions to convert the content of
+* one string into another.
+*
+* Copyright (c) 2011 Raymond Hill (http://raymondhill.net/blog/?p=441)
+*
+* Licensed under The MIT License
+* 
+* Permission is hereby granted, free of charge, to any person obtaining a copy
+* of this software and associated documentation files (the "Software"), to deal
+* in the Software without restriction, including without limitation the rights
+* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+* copies of the Software, and to permit persons to whom the Software is
+* furnished to do so, subject to the following conditions:
+*
+* The above copyright notice and this permission notice shall be included in
+* all copies or substantial portions of the Software.
+*
+* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+* THE SOFTWARE.
+*
+* @copyright Copyright 2011 (c) Raymond Hill (http://raymondhill.net/blog/?p=441)
+* @link http://www.raymondhill.net/finediff/
+* @version 0.6
+* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
+*/
+
+/**
+* Usage (simplest):
+*
+*   include 'finediff.php';
+*
+*   // for the stock stack, granularity values are:
+*   // FineDiff::$paragraphGranularity = paragraph/line level
+*   // FineDiff::$sentenceGranularity = sentence level
+*   // FineDiff::$wordGranularity = word level
+*   // FineDiff::$characterGranularity = character level [default]
+*
+*   $opcodes = FineDiff::getDiffOpcodes($from_text, $to_text [, $granularityStack = null] );
+*   // store opcodes for later use...
+*
+*   ...
+*
+*   // restore $to_text from $from_text + $opcodes
+*   include 'finediff.php';
+*   $to_text = FineDiff::renderToTextFromOpcodes($from_text, $opcodes);
+*
+*   ...
+*/
+
+/**
+* Persisted opcodes (string) are a sequence of atomic opcode.
+* A single opcode can be one of the following:
+*   c | c{n} | d | d{n} | i:{c} | i{length}:{s}
+*   'c'        = copy one character from source
+*   'c{n}'     = copy n characters from source
+*   'd'        = skip one character from source
+*   'd{n}'     = skip n characters from source
+*   'i:{c}     = insert character 'c'
+*   'i{n}:{s}' = insert string s, which is of length n
+*
+* Do not exist as of now, under consideration:
+*   'm{n}:{o}  = move n characters from source o characters ahead.
+*   It would be essentially a shortcut for a delete->copy->insert
+*   command (swap) for when the inserted segment is exactly the same
+*   as the deleted one, and with only a copy operation in between.
+*   TODO: How often this case occurs? Is it worth it? Can only
+*   be done as a postprocessing method (->optimize()?)
+*/
+abstract class FineDiffOp {
+	abstract public function getFromLen();
+	abstract public function getToLen();
+	abstract public function getOpcode();
+	}
+
+class FineDiffDeleteOp extends FineDiffOp {
+	public function __construct($len) {
+		$this->fromLen = $len;
+		}
+	public function getFromLen() {
+		return $this->fromLen;
+		}
+	public function getToLen() {
+		return 0;
+		}
+	public function getOpcode() {
+		if ( $this->fromLen === 1 ) {
+			return 'd';
+			}
+		return "d{$this->fromLen}";
+		}
+	}
+
+class FineDiffInsertOp extends FineDiffOp {
+	public function __construct($text) {
+		$this->text = $text;
+		}
+	public function getFromLen() {
+		return 0;
+		}
+	public function getToLen() {
+		return strlen($this->text);
+		}
+	public function getText() {
+		return $this->text;
+		}
+	public function getOpcode() {
+		$to_len = strlen($this->text);
+		if ( $to_len === 1 ) {
+			return "i:{$this->text}";
+			}
+		return "i{$to_len}:{$this->text}";
+		}
+	}
+
+class FineDiffReplaceOp extends FineDiffOp {
+	public function __construct($fromLen, $text) {
+		$this->fromLen = $fromLen;
+		$this->text = $text;
+		}
+	public function getFromLen() {
+		return $this->fromLen;
+		}
+	public function getToLen() {
+		return strlen($this->text);
+		}
+	public function getText() {
+		return $this->text;
+		}
+	public function getOpcode() {
+		if ( $this->fromLen === 1 ) {
+			$del_opcode = 'd';
+			}
+		else {
+			$del_opcode = "d{$this->fromLen}";
+			}
+		$to_len = strlen($this->text);
+		if ( $to_len === 1 ) {
+			return "{$del_opcode}i:{$this->text}";
+			}
+		return "{$del_opcode}i{$to_len}:{$this->text}";
+		}
+	}
+
+class FineDiffCopyOp extends FineDiffOp {
+	public function __construct($len) {
+		$this->len = $len;
+		}
+	public function getFromLen() {
+		return $this->len;
+		}
+	public function getToLen() {
+		return $this->len;
+		}
+	public function getOpcode() {
+		if ( $this->len === 1 ) {
+			return 'c';
+			}
+		return "c{$this->len}";
+		}
+	public function increase($size) {
+		return $this->len += $size;
+		}
+	}
+
+/**
+* FineDiff ops
+*
+* Collection of ops
+*/
+class FineDiffOps {
+	public function appendOpcode($opcode, $from, $from_offset, $from_len) {
+		if ( $opcode === 'c' ) {
+			$edits[] = new FineDiffCopyOp($from_len);
+			}
+		else if ( $opcode === 'd' ) {
+			$edits[] = new FineDiffDeleteOp($from_len);
+			}
+		else /* if ( $opcode === 'i' ) */ {
+			$edits[] = new FineDiffInsertOp(substr($from, $from_offset, $from_len));
+			}
+		}
+	public $edits = array();
+	}
+
+/**
+* FineDiff class
+*
+* TODO: Document
+*
+*/
+class FineDiff {
+
+	/**------------------------------------------------------------------------
+	*
+	* Public section
+	*
+	*/
+
+	/**
+	* Constructor
+	* ...
+	* The $granularityStack allows FineDiff to be configurable so that
+	* a particular stack tailored to the specific content of a document can
+	* be passed.
+	*/
+	public function __construct($from_text = '', $to_text = '', $granularityStack = null) {
+		// setup stack for generic text documents by default
+		$this->granularityStack = $granularityStack ? $granularityStack : FineDiff::$characterGranularity;
+		$this->edits = array();
+		$this->from_text = $from_text;
+		$this->doDiff($from_text, $to_text);
+		}
+
+	public function getOps() {
+		return $this->edits;
+		}
+
+	public function getOpcodes() {
+		$opcodes = array();
+		foreach ( $this->edits as $edit ) {
+			$opcodes[] = $edit->getOpcode();
+			}
+		return implode('', $opcodes);
+		}
+
+	public function renderDiffToHTML() {
+		$in_offset = 0;
+		$html = '';
+		foreach ( $this->edits as $edit ) {
+			$n = $edit->getFromLen();
+			if ( $edit instanceof FineDiffCopyOp ) {
+				$html .= FineDiff::renderDiffToHTMLFromOpcode('c', $this->from_text, $in_offset, $n);
+				}
+			else if ( $edit instanceof FineDiffDeleteOp ) {
+				$html .= FineDiff::renderDiffToHTMLFromOpcode('d', $this->from_text, $in_offset, $n);
+				}
+			else if ( $edit instanceof FineDiffInsertOp ) {
+				$html .= FineDiff::renderDiffToHTMLFromOpcode('i', $edit->getText(), 0, $edit->getToLen());
+				}
+			else /* if ( $edit instanceof FineDiffReplaceOp ) */ {
+				$html .= FineDiff::renderDiffToHTMLFromOpcode('d', $this->from_text, $in_offset, $n);
+				$html .= FineDiff::renderDiffToHTMLFromOpcode('i', $edit->getText(), 0, $edit->getToLen());
+				}
+			$in_offset += $n;
+			}
+		return $html;
+		}
+
+	/**------------------------------------------------------------------------
+	* Return an opcodes string describing the diff between a "From" and a
+	* "To" string
+	*/
+	public static function getDiffOpcodes($from, $to, $granularities = null) {
+		$diff = new FineDiff($from, $to, $granularities);
+		return $diff->getOpcodes();
+		}
+
+	/**------------------------------------------------------------------------
+	* Return an iterable collection of diff ops from an opcodes string
+	*/
+	public static function getDiffOpsFromOpcodes($opcodes) {
+		$diffops = new FineDiffOps();
+		FineDiff::renderFromOpcodes(null, $opcodes, array($diffops,'appendOpcode'));
+		return $diffops->edits;
+		}
+
+	/**------------------------------------------------------------------------
+	* Re-create the "To" string from the "From" string and an "Opcodes" string
+	*/
+	public static function renderToTextFromOpcodes($from, $opcodes) {
+		return FineDiff::renderFromOpcodes($from, $opcodes, array('FineDiff','renderToTextFromOpcode'));
+		}
+
+	/**------------------------------------------------------------------------
+	* Render the diff to an HTML string
+	*/
+	public static function renderDiffToHTMLFromOpcodes($from, $opcodes) {
+		return FineDiff::renderFromOpcodes($from, $opcodes, array('FineDiff','renderDiffToHTMLFromOpcode'));
+		}
+
+	/**------------------------------------------------------------------------
+	* Generic opcodes parser, user must supply callback for handling
+	* single opcode
+	*/
+	public static function renderFromOpcodes($from, $opcodes, $callback) {
+		if ( !is_callable($callback) ) {
+			return '';
+			}
+		$out = '';
+		$opcodes_len = strlen($opcodes);
+		$from_offset = $opcodes_offset = 0;
+		while ( $opcodes_offset <  $opcodes_len ) {
+			$opcode = substr($opcodes, $opcodes_offset, 1);
+			$opcodes_offset++;
+			$n = intval(substr($opcodes, $opcodes_offset));
+			if ( $n ) {
+				$opcodes_offset += strlen(strval($n));
+				}
+			else {
+				$n = 1;
+				}
+			if ( $opcode === 'c' ) { // copy n characters from source
+				$out .= call_user_func($callback, 'c', $from, $from_offset, $n, '');
+				$from_offset += $n;
+				}
+			else if ( $opcode === 'd' ) { // delete n characters from source
+				$out .= call_user_func($callback, 'd', $from, $from_offset, $n, '');
+				$from_offset += $n;
+				}
+			else /* if ( $opcode === 'i' ) */ { // insert n characters from opcodes
+				$out .= call_user_func($callback, 'i', $opcodes, $opcodes_offset + 1, $n);
+				$opcodes_offset += 1 + $n;
+				}
+			}
+		return $out;
+		}
+
+	/**
+	* Stock granularity stacks and delimiters
+	*/
+
+	const paragraphDelimiters = "\n\r";
+	public static $paragraphGranularity = array(
+		FineDiff::paragraphDelimiters
+		);
+	const sentenceDelimiters = ".\n\r";
+	public static $sentenceGranularity = array(
+		FineDiff::paragraphDelimiters,
+		FineDiff::sentenceDelimiters
+		);
+	const wordDelimiters = " \t.\n\r";
+	public static $wordGranularity = array(
+		FineDiff::paragraphDelimiters,
+		FineDiff::sentenceDelimiters,
+		FineDiff::wordDelimiters
+		);
+	const characterDelimiters = "";
+	public static $characterGranularity = array(
+		FineDiff::paragraphDelimiters,
+		FineDiff::sentenceDelimiters,
+		FineDiff::wordDelimiters,
+		FineDiff::characterDelimiters
+		);
+
+	public static $textStack = array(
+		".",
+		" \t.\n\r",
+		""
+		);
+
+	/**------------------------------------------------------------------------
+	*
+	* Private section
+	*
+	*/
+
+	/**
+	* Entry point to compute the diff.
+	*/
+	private function doDiff($from_text, $to_text) {
+		$this->last_edit = false;
+		$this->stackpointer = 0;
+		$this->from_text = $from_text;
+		$this->from_offset = 0;
+		// can't diff without at least one granularity specifier
+		if ( empty($this->granularityStack) ) {
+			return;
+			}
+		$this->_processGranularity($from_text, $to_text);
+		}
+
+	/**
+	* This is the recursive function which is responsible for
+	* handling/increasing granularity.
+	*
+	* Incrementally increasing the granularity is key to compute the
+	* overall diff in a very efficient way.
+	*/
+	private function _processGranularity($from_segment, $to_segment) {
+		$delimiters = $this->granularityStack[$this->stackpointer++];
+		$has_next_stage = $this->stackpointer < count($this->granularityStack);
+		foreach ( FineDiff::doFragmentDiff($from_segment, $to_segment, $delimiters) as $fragment_edit ) {
+			// increase granularity
+			if ( $fragment_edit instanceof FineDiffReplaceOp && $has_next_stage ) {
+				$this->_processGranularity(
+					substr($this->from_text, $this->from_offset, $fragment_edit->getFromLen()),
+					$fragment_edit->getText()
+					);
+				}
+			// fuse copy ops whenever possible
+			else if ( $fragment_edit instanceof FineDiffCopyOp && $this->last_edit instanceof FineDiffCopyOp ) {
+				$this->edits[count($this->edits)-1]->increase($fragment_edit->getFromLen());
+				$this->from_offset += $fragment_edit->getFromLen();
+				}
+			else {
+				/* $fragment_edit instanceof FineDiffCopyOp */
+				/* $fragment_edit instanceof FineDiffDeleteOp */
+				/* $fragment_edit instanceof FineDiffInsertOp */
+				$this->edits[] = $this->last_edit = $fragment_edit;
+				$this->from_offset += $fragment_edit->getFromLen();
+				}
+			}
+		$this->stackpointer--;
+		}
+
+	/**
+	* This is the core algorithm which actually perform the diff itself,
+	* fragmenting the strings as per specified delimiters.
+	*
+	* This function is naturally recursive, however for performance purpose
+	* a local job queue is used instead of outright recursivity.
+	*/
+	private static function doFragmentDiff($from_text, $to_text, $delimiters) {
+		// Empty delimiter means character-level diffing.
+		// In such case, use code path optimized for character-level
+		// diffing.
+		if ( empty($delimiters) ) {
+			return FineDiff::doCharDiff($from_text, $to_text);
+			}
+
+		$result = array();
+
+		// fragment-level diffing
+		$from_text_len = strlen($from_text);
+		$to_text_len = strlen($to_text);
+		$from_fragments = FineDiff::extractFragments($from_text, $delimiters);
+		$to_fragments = FineDiff::extractFragments($to_text, $delimiters);
+
+		$jobs = array(array(0, $from_text_len, 0, $to_text_len));
+
+		$cached_array_keys = array();
+
+		while ( $job = array_pop($jobs) ) {
+
+			// get the segments which must be diff'ed
+			list($from_segment_start, $from_segment_end, $to_segment_start, $to_segment_end) = $job;
+
+			// catch easy cases first
+			$from_segment_length = $from_segment_end - $from_segment_start;
+			$to_segment_length = $to_segment_end - $to_segment_start;
+			if ( !$from_segment_length || !$to_segment_length ) {
+				if ( $from_segment_length ) {
+					$result[$from_segment_start * 4] = new FineDiffDeleteOp($from_segment_length);
+					}
+				else if ( $to_segment_length ) {
+					$result[$from_segment_start * 4 + 1] = new FineDiffInsertOp(substr($to_text, $to_segment_start, $to_segment_length));
+					}
+				continue;
+				}
+
+			// find longest copy operation for the current segments
+			$best_copy_length = 0;
+
+			$from_base_fragment_index = $from_segment_start;
+
+			$cached_array_keys_for_current_segment = array();
+
+			while ( $from_base_fragment_index < $from_segment_end ) {
+				$from_base_fragment = $from_fragments[$from_base_fragment_index];
+				$from_base_fragment_length = strlen($from_base_fragment);
+				// performance boost: cache array keys
+				if ( !isset($cached_array_keys_for_current_segment[$from_base_fragment]) ) {
+					if ( !isset($cached_array_keys[$from_base_fragment]) ) {
+						$to_all_fragment_indices = $cached_array_keys[$from_base_fragment] = array_keys($to_fragments, $from_base_fragment, true);
+						}
+					else {
+						$to_all_fragment_indices = $cached_array_keys[$from_base_fragment];
+						}
+					// get only indices which falls within current segment
+					if ( $to_segment_start > 0 || $to_segment_end < $to_text_len ) {
+						$to_fragment_indices = array();
+						foreach ( $to_all_fragment_indices as $to_fragment_index ) {
+							if ( $to_fragment_index < $to_segment_start ) { continue; }
+							if ( $to_fragment_index >= $to_segment_end ) { break; }
+							$to_fragment_indices[] = $to_fragment_index;
+							}
+						$cached_array_keys_for_current_segment[$from_base_fragment] = $to_fragment_indices;
+						}
+					else {
+						$to_fragment_indices = $to_all_fragment_indices;
+						}
+					}
+				else {
+					$to_fragment_indices = $cached_array_keys_for_current_segment[$from_base_fragment];
+					}
+				// iterate through collected indices
+				foreach ( $to_fragment_indices as $to_base_fragment_index ) {
+					$fragment_index_offset = $from_base_fragment_length;
+					// iterate until no more match
+					for (;;) {
+						$fragment_from_index = $from_base_fragment_index + $fragment_index_offset;
+						if ( $fragment_from_index >= $from_segment_end ) {
+							break;
+							}
+						$fragment_to_index = $to_base_fragment_index + $fragment_index_offset;
+						if ( $fragment_to_index >= $to_segment_end ) {
+							break;
+							}
+						if ( $from_fragments[$fragment_from_index] !== $to_fragments[$fragment_to_index] ) {
+							break;
+							}
+						$fragment_length = strlen($from_fragments[$fragment_from_index]);
+						$fragment_index_offset += $fragment_length;
+						}
+					if ( $fragment_index_offset > $best_copy_length ) {
+						$best_copy_length = $fragment_index_offset;
+						$best_from_start = $from_base_fragment_index;
+						$best_to_start = $to_base_fragment_index;
+						}
+					}
+				$from_base_fragment_index += strlen($from_base_fragment);
+				// If match is larger than half segment size, no point trying to find better
+				// TODO: Really?
+				if ( $best_copy_length >= $from_segment_length / 2) {
+					break;
+					}
+				// no point to keep looking if what is left is less than
+				// current best match
+				if ( $from_base_fragment_index + $best_copy_length >= $from_segment_end ) {
+					break;
+					}
+				}
+
+			if ( $best_copy_length ) {
+				$jobs[] = array($from_segment_start, $best_from_start, $to_segment_start, $best_to_start);
+				$result[$best_from_start * 4 + 2] = new FineDiffCopyOp($best_copy_length);
+				$jobs[] = array($best_from_start + $best_copy_length, $from_segment_end, $best_to_start + $best_copy_length, $to_segment_end);
+				}
+			else {
+				$result[$from_segment_start * 4 ] = new FineDiffReplaceOp($from_segment_length, substr($to_text, $to_segment_start, $to_segment_length));
+				}
+			}
+
+		ksort($result, SORT_NUMERIC);
+		return array_values($result);
+		}
+
+	/**
+	* Perform a character-level diff.
+	*
+	* The algorithm is quite similar to doFragmentDiff(), except that
+	* the code path is optimized for character-level diff -- strpos() is
+	* used to find out the longest common subequence of characters.
+	*
+	* We try to find a match using the longest possible subsequence, which
+	* is at most the length of the shortest of the two strings, then incrementally
+	* reduce the size until a match is found.
+	*
+	* I still need to study more the performance of this function. It
+	* appears that for long strings, the generic doFragmentDiff() is more
+	* performant. For word-sized strings, doCharDiff() is somewhat more
+	* performant.
+	*/
+	private static function doCharDiff($from_text, $to_text) {
+		$result = array();
+		$jobs = array(array(0, strlen($from_text), 0, strlen($to_text)));
+		while ( $job = array_pop($jobs) ) {
+			// get the segments which must be diff'ed
+			list($from_segment_start, $from_segment_end, $to_segment_start, $to_segment_end) = $job;
+			$from_segment_len = $from_segment_end - $from_segment_start;
+			$to_segment_len = $to_segment_end - $to_segment_start;
+
+			// catch easy cases first
+			if ( !$from_segment_len || !$to_segment_len ) {
+				if ( $from_segment_len ) {
+					$result[$from_segment_start * 4 + 0] = new FineDiffDeleteOp($from_segment_len);
+					}
+				else if ( $to_segment_len ) {
+					$result[$from_segment_start * 4 + 1] = new FineDiffInsertOp(substr($to_text, $to_segment_start, $to_segment_len));
+					}
+				continue;
+				}
+			if ( $from_segment_len >= $to_segment_len ) {
+				$copy_len = $to_segment_len;
+				while ( $copy_len ) {
+					$to_copy_start = $to_segment_start;
+					$to_copy_start_max = $to_segment_end - $copy_len;
+					while ( $to_copy_start <= $to_copy_start_max ) {
+						$from_copy_start = strpos(substr($from_text, $from_segment_start, $from_segment_len), substr($to_text, $to_copy_start, $copy_len));
+						if ( $from_copy_start !== false ) {
+							$from_copy_start += $from_segment_start;
+							break 2;
+							}
+						$to_copy_start++;
+						}
+					$copy_len--;
+					}
+				}
+			else {
+				$copy_len = $from_segment_len;
+				while ( $copy_len ) {
+					$from_copy_start = $from_segment_start;
+					$from_copy_start_max = $from_segment_end - $copy_len;
+					while ( $from_copy_start <= $from_copy_start_max ) {
+						$to_copy_start = strpos(substr($to_text, $to_segment_start, $to_segment_len), substr($from_text, $from_copy_start, $copy_len));
+						if ( $to_copy_start !== false ) {
+							$to_copy_start += $to_segment_start;
+							break 2;
+							}
+						$from_copy_start++;
+						}
+					$copy_len--;
+					}
+				}
+			// match found
+			if ( $copy_len ) {
+				$jobs[] = array($from_segment_start, $from_copy_start, $to_segment_start, $to_copy_start);
+				$result[$from_copy_start * 4 + 2] = new FineDiffCopyOp($copy_len);
+				$jobs[] = array($from_copy_start + $copy_len, $from_segment_end, $to_copy_start + $copy_len, $to_segment_end);
+				}
+			// no match,  so delete all, insert all
+			else {
+				$result[$from_segment_start * 4] = new FineDiffReplaceOp($from_segment_len, substr($to_text, $to_segment_start, $to_segment_len));
+				}
+			}
+		ksort($result, SORT_NUMERIC);
+		return array_values($result);
+		}
+
+	/**
+	* Efficiently fragment the text into an array according to
+	* specified delimiters.
+	* No delimiters means fragment into single character.
+	* The array indices are the offset of the fragments into
+	* the input string.
+	* A sentinel empty fragment is always added at the end.
+	* Careful: No check is performed as to the validity of the
+	* delimiters.
+	*/
+	private static function extractFragments($text, $delimiters) {
+		// special case: split into characters
+		if ( empty($delimiters) ) {
+			$chars = str_split($text, 1);
+			$chars[strlen($text)] = '';
+			return $chars;
+			}
+		$fragments = array();
+		$start = $end = 0;
+		for (;;) {
+			$end += strcspn($text, $delimiters, $end);
+			$end += strspn($text, $delimiters, $end);
+			if ( $end === $start ) {
+				break;
+				}
+			$fragments[$start] = substr($text, $start, $end - $start);
+			$start = $end;
+			}
+		$fragments[$start] = '';
+		return $fragments;
+		}
+
+	/**
+	* Stock opcode renderers
+	*/
+	private static function renderToTextFromOpcode($opcode, $from, $from_offset, $from_len) {
+		if ( $opcode === 'c' || $opcode === 'i' ) {
+			return substr($from, $from_offset, $from_len);
+			}
+		return '';
+		}
+
+	private static function renderDiffToHTMLFromOpcode($opcode, $from, $from_offset, $from_len) {
+		if ( $opcode === 'c' ) {
+			return htmlentities(substr($from, $from_offset, $from_len));
+			}
+		else if ( $opcode === 'd' ) {
+			$deletion = substr($from, $from_offset, $from_len);
+			if ( strcspn($deletion, " \n\r") === 0 ) {
+				$deletion = str_replace(array("\n","\r"), array('\n','\r'), $deletion);
+				}
+			return '<del>' . htmlentities($deletion) . '</del>';
+			}
+		else /* if ( $opcode === 'i' ) */ {
+ 			return '<ins>' . htmlentities(substr($from, $from_offset, $from_len)) . '</ins>';
+			}
+		return '';
+		}
+	}
+
diff --git a/plugins/libkolab/vendor/finediff_modifications.diff b/plugins/libkolab/vendor/finediff_modifications.diff
new file mode 100644
index 0000000..3a9ad5c
--- /dev/null
+++ b/plugins/libkolab/vendor/finediff_modifications.diff
@@ -0,0 +1,121 @@
+--- finediff.php.orig	2014-07-29 14:24:10.000000000 +0200
++++ finediff.php	2014-07-29 14:30:38.000000000 +0200
+@@ -234,25 +234,25 @@
+ 
+ 	public function renderDiffToHTML() {
+ 		$in_offset = 0;
+-		ob_start();
++		$html = '';
+ 		foreach ( $this->edits as $edit ) {
+ 			$n = $edit->getFromLen();
+ 			if ( $edit instanceof FineDiffCopyOp ) {
+-				FineDiff::renderDiffToHTMLFromOpcode('c', $this->from_text, $in_offset, $n);
++				$html .= FineDiff::renderDiffToHTMLFromOpcode('c', $this->from_text, $in_offset, $n);
+ 				}
+ 			else if ( $edit instanceof FineDiffDeleteOp ) {
+-				FineDiff::renderDiffToHTMLFromOpcode('d', $this->from_text, $in_offset, $n);
++				$html .= FineDiff::renderDiffToHTMLFromOpcode('d', $this->from_text, $in_offset, $n);
+ 				}
+ 			else if ( $edit instanceof FineDiffInsertOp ) {
+-				FineDiff::renderDiffToHTMLFromOpcode('i', $edit->getText(), 0, $edit->getToLen());
++				$html .= FineDiff::renderDiffToHTMLFromOpcode('i', $edit->getText(), 0, $edit->getToLen());
+ 				}
+ 			else /* if ( $edit instanceof FineDiffReplaceOp ) */ {
+-				FineDiff::renderDiffToHTMLFromOpcode('d', $this->from_text, $in_offset, $n);
+-				FineDiff::renderDiffToHTMLFromOpcode('i', $edit->getText(), 0, $edit->getToLen());
++				$html .= FineDiff::renderDiffToHTMLFromOpcode('d', $this->from_text, $in_offset, $n);
++				$html .= FineDiff::renderDiffToHTMLFromOpcode('i', $edit->getText(), 0, $edit->getToLen());
+ 				}
+ 			$in_offset += $n;
+ 			}
+-		return ob_get_clean();
++		return $html;
+ 		}
+ 
+ 	/**------------------------------------------------------------------------
+@@ -277,18 +277,14 @@
+ 	* Re-create the "To" string from the "From" string and an "Opcodes" string
+ 	*/
+ 	public static function renderToTextFromOpcodes($from, $opcodes) {
+-		ob_start();
+-		FineDiff::renderFromOpcodes($from, $opcodes, array('FineDiff','renderToTextFromOpcode'));
+-		return ob_get_clean();
++		return FineDiff::renderFromOpcodes($from, $opcodes, array('FineDiff','renderToTextFromOpcode'));
+ 		}
+ 
+ 	/**------------------------------------------------------------------------
+ 	* Render the diff to an HTML string
+ 	*/
+ 	public static function renderDiffToHTMLFromOpcodes($from, $opcodes) {
+-		ob_start();
+-		FineDiff::renderFromOpcodes($from, $opcodes, array('FineDiff','renderDiffToHTMLFromOpcode'));
+-		return ob_get_clean();
++		return FineDiff::renderFromOpcodes($from, $opcodes, array('FineDiff','renderDiffToHTMLFromOpcode'));
+ 		}
+ 
+ 	/**------------------------------------------------------------------------
+@@ -297,8 +293,9 @@
+ 	*/
+ 	public static function renderFromOpcodes($from, $opcodes, $callback) {
+ 		if ( !is_callable($callback) ) {
+-			return;
++			return '';
+ 			}
++		$out = '';
+ 		$opcodes_len = strlen($opcodes);
+ 		$from_offset = $opcodes_offset = 0;
+ 		while ( $opcodes_offset <  $opcodes_len ) {
+@@ -312,18 +309,19 @@
+ 				$n = 1;
+ 				}
+ 			if ( $opcode === 'c' ) { // copy n characters from source
+-				call_user_func($callback, 'c', $from, $from_offset, $n, '');
++				$out .= call_user_func($callback, 'c', $from, $from_offset, $n, '');
+ 				$from_offset += $n;
+ 				}
+ 			else if ( $opcode === 'd' ) { // delete n characters from source
+-				call_user_func($callback, 'd', $from, $from_offset, $n, '');
++				$out .= call_user_func($callback, 'd', $from, $from_offset, $n, '');
+ 				$from_offset += $n;
+ 				}
+ 			else /* if ( $opcode === 'i' ) */ { // insert n characters from opcodes
+-				call_user_func($callback, 'i', $opcodes, $opcodes_offset + 1, $n);
++				$out .= call_user_func($callback, 'i', $opcodes, $opcodes_offset + 1, $n);
+ 				$opcodes_offset += 1 + $n;
+ 				}
+ 			}
++		return $out;
+ 		}
+ 
+ 	/**
+@@ -665,24 +663,26 @@
+ 	*/
+ 	private static function renderToTextFromOpcode($opcode, $from, $from_offset, $from_len) {
+ 		if ( $opcode === 'c' || $opcode === 'i' ) {
+-			echo substr($from, $from_offset, $from_len);
++			return substr($from, $from_offset, $from_len);
+ 			}
++		return '';
+ 		}
+ 
+ 	private static function renderDiffToHTMLFromOpcode($opcode, $from, $from_offset, $from_len) {
+ 		if ( $opcode === 'c' ) {
+-			echo htmlentities(substr($from, $from_offset, $from_len));
++			return htmlentities(substr($from, $from_offset, $from_len));
+ 			}
+ 		else if ( $opcode === 'd' ) {
+ 			$deletion = substr($from, $from_offset, $from_len);
+ 			if ( strcspn($deletion, " \n\r") === 0 ) {
+ 				$deletion = str_replace(array("\n","\r"), array('\n','\r'), $deletion);
+ 				}
+-			echo '<del>', htmlentities($deletion), '</del>';
++			return '<del>' . htmlentities($deletion) . '</del>';
+ 			}
+ 		else /* if ( $opcode === 'i' ) */ {
+- 			echo '<ins>', htmlentities(substr($from, $from_offset, $from_len)), '</ins>';
++ 			return '<ins>' . htmlentities(substr($from, $from_offset, $from_len)) . '</ins>';
+ 			}
++		return '';
+ 		}
+ 	}
+ 


commit 4d9522a6548419b399090249e121c7f8b9fe4fa9
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Jul 28 12:19:02 2014 +0200

    Implement a JSON-RPC based client to the Bonnie service API (#3093)

diff --git a/plugins/libkolab/config.inc.php.dist b/plugins/libkolab/config.inc.php.dist
index fd8ac84..b043bb7 100644
--- a/plugins/libkolab/config.inc.php.dist
+++ b/plugins/libkolab/config.inc.php.dist
@@ -51,4 +51,11 @@ $rcmail_config['kolab_users_id_attrib'] = null;
 // Use these attributes when searching users in LDAP
 $rcmail_config['kolab_users_search_attrib'] = array('cn','mail','alias');
 
-
+// JSON-RPC endpoint configuration of the Bonnie web service providing historic data for groupware objects
+$rcmail_config['kolab_bonnie_api'] = array(
+    'uri'    => 'https://<kolab-hostname>:8080/api/rpc',
+    'user'   => 'webclient',
+    'pass'   => 'Welcome2KolabSystems',
+    'secret' => '8431f191707fffffff00000000cccc',
+    'debug'  => true,   // logs requests/responses to <log-dir>/bonnie
+);
diff --git a/plugins/libkolab/lib/kolab_bonnie_api.php b/plugins/libkolab/lib/kolab_bonnie_api.php
new file mode 100644
index 0000000..732d29b
--- /dev/null
+++ b/plugins/libkolab/lib/kolab_bonnie_api.php
@@ -0,0 +1,82 @@
+<?php
+
+/**
+ * Provider class for accessing historic groupware object data through the Bonnie service
+ *
+ * API Specification at https://wiki.kolabsys.com/User:Bruederli/Draft:Bonnie_Client_API
+ *
+ * @author Thomas Bruederli <bruederli at kolabsys.com>
+ *
+ * Copyright (C) 2014, Kolab Systems AG <contact at kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+class kolab_bonnie_api
+{
+    public $ready = false;
+
+    private $config = array();
+    private $client = null;
+
+
+    /**
+     * Default constructor
+     */
+    public function __construct($config)
+    {
+        $this->config = $confg;
+
+        $this->client = new kolab_bonnie_api_client($config['uri'], $config['timeout'] ?: 5, (bool)$config['debug']);
+
+        $this->client->set_secret($config['secret']);
+        $this->client->set_authentication($config['user'], $config['pass']);
+        $this->client->set_request_user(rcube::get_instance()->get_user_name());
+
+        $this->ready = !empty($config['secret']) && !empty($config['user']) && !empty($config['pass']);
+    }
+
+    /**
+     * Wrapper function for <object>.changelog() API call
+     */
+    public function changelog($type, $uid, $folder=null)
+    {
+        return $this->client->execute($type.'.changelog', array('uid' => $uid, 'folder' => $folder));
+    }
+
+    /**
+     * Wrapper function for <object>.diff() API call
+     */
+    public function diff($type, $uid, $rev, $folder=null)
+    {
+        return $this->client->execute($type.'.diff', array('uid' => $uid, 'rev' => $rev, 'folder' => $folder));
+    }
+
+    /**
+     * Wrapper function for <object>.get() API call
+     */
+    public function get($type, $uid, $rev, $folder=null)
+    {
+      return $this->client->execute($type.'.get', array('uid' => $uid, 'rev' => intval($rev), 'folder' => $folder));
+    }
+
+    /**
+     * Generic wrapper for direct API calls
+     */
+    public function _execute($method, $params = array())
+    {
+        return $this->client->execute($method, $params);
+    }
+
+}
\ No newline at end of file
diff --git a/plugins/libkolab/lib/kolab_bonnie_api_client.php b/plugins/libkolab/lib/kolab_bonnie_api_client.php
new file mode 100644
index 0000000..bc209f4
--- /dev/null
+++ b/plugins/libkolab/lib/kolab_bonnie_api_client.php
@@ -0,0 +1,239 @@
+<?php
+
+/**
+ * JSON-RPC client class with some extra features for communicating with the Bonnie API service.
+ *
+ * @author Thomas Bruederli <bruederli at kolabsys.com>
+ *
+ * Copyright (C) 2014, Kolab Systems AG <contact at kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+class kolab_bonnie_api_client
+{
+    /**
+     * URL of the RPC endpoint
+     * @var string
+     */
+    protected $url;
+
+    /**
+     * HTTP client timeout in seconds
+     * @var integer
+     */
+    protected $timeout;
+
+    /**
+     * Debug flag
+     * @var bool
+     */
+    protected $debug;
+
+    /**
+     * Username for authentication
+     * @var string
+     */
+    protected $username;
+
+    /**
+     * Password for authentication
+     * @var string
+     */
+    protected $password;
+
+    /**
+     * Secret key for request signing
+     * @var string
+     */
+    protected $secret;
+
+    /**
+     * Default HTTP headers to send to the server
+     * @var array
+     */
+    protected $headers = array(
+        'Connection' => 'close',
+        'Content-Type' => 'application/json',
+        'Accept' => 'application/json',
+    );
+
+    /**
+     * Constructor
+     *
+     * @param  string  $url      Server URL
+     * @param  integer $timeout  Request timeout
+     * @param  bool    $debug    Enabled debug logging
+     * @param  array   $headers  Custom HTTP headers
+     */
+    public function __construct($url, $timeout = 5, $debug = false, $headers = array())
+    {
+        $this->url = $url;
+        $this->timeout = $timeout;
+        $this->debug = $debug;
+        $this->headers = array_merge($this->headers, $headers);
+    }
+
+    /**
+     * Setter for secret key for request signing
+     */
+    public function set_secret($secret)
+    {
+        $this->secret = $secret;
+    }
+
+    /**
+     * Setter for the X-Request-User header
+     */
+    public function set_request_user($username)
+    {
+        $this->headers['X-Request-User'] = $username;
+    }
+
+    /**
+     * Set authentication parameters
+     *
+     * @param  string $username  Username
+     * @param  string $password  Password
+     */
+    public function set_authentication($username, $password)
+    {
+        $this->username = $username;
+        $this->password = $password;
+    }
+
+    /**
+     * Automatic mapping of procedures
+     *
+     * @param  string $method  Procedure name
+     * @param  array  $params  Procedure arguments
+     * @return mixed
+     */
+    public function __call($method, $params)
+    {
+        return $this->execute($method, $params);
+    }
+
+    /**
+     * Execute an RPC command
+     *
+     * @param  string $method  Procedure name
+     * @param  array  $params  Procedure arguments
+     * @return mixed
+     */
+    public function execute($method, array $params = array())
+    {
+        $id = mt_rand();
+
+        $payload = array(
+            'jsonrpc' => '2.0',
+            'method' => $method,
+            'id' => $id,
+        );
+
+        if (!empty($params)) {
+            $payload['params'] = $params;
+        }
+
+        $result = $this->send_request($payload, $method != 'system.keygen');
+
+        if (isset($result['id']) && $result['id'] == $id && array_key_exists('result', $result)) {
+            return $result['result'];
+        }
+        else if (isset($result['error'])) {
+            $this->_debug('ERROR', $result);
+        }
+
+        return null;
+    }
+
+    /**
+     * Do the HTTP request
+     *
+     * @param  string  $payload  Data to send
+     */
+    protected function send_request($payload, $sign = true)
+    {
+        try {
+            $payload_ = json_encode($payload);
+
+            // add request signature
+            if ($sign && !empty($this->secret)) {
+                $this->headers['X-Request-Sign'] = $this->request_signature($payload_);
+            }
+            else if ($this->headers['X-Request-Sign']) {
+                unset($this->headers['X-Request-Sign']);
+            }
+
+            $this->_debug('REQUEST', $payload, $this->headers);
+            $request = libkolab::http_request($this->url, 'POST', array('timeout' => $this->timeout));
+            $request->setHeader($this->headers);
+            $request->setAuth($this->username, $this->password);
+            $request->setBody($payload_);
+
+            $response = $request->send();
+
+            if ($response->getStatus() == 200) {
+                $result = json_decode($response->getBody(), true);
+                $this->_debug('RESPONSE', $result);
+            }
+            else {
+                throw new Exception(sprintf("HTTP %d %s", $response->getStatus(), $response->getReasonPhrase()));
+            }
+        }
+        catch (Exception $e) {
+            rcube::raise_error(array(
+                'code' => 500,
+                'type' => 'php',
+                'message' => "Bonnie API request failed: " . $e->getMessage(),
+            ), true);
+
+            return array('id' => $payload['id'], 'error' => $e->getMessage(), 'code' => -32000);
+        }
+
+        return is_array($result) ? $result : array();
+    }
+
+    /**
+     * Compute the hmac signature for the current event payload using
+     * the secret key configured for this API client
+     *
+     * @param string $data The request payload data
+     * @return string The request signature
+     */
+    protected function request_signature($data)
+    {
+        // TODO: get the session key with a system.keygen call
+        return hash_hmac('sha256', $this->headers['X-Request-User'] . ':' . $data, $this->secret);
+    }
+
+    /**
+     * Write debug log
+     */
+    protected function _debug(/* $message, $data1, data2, ...*/)
+    {
+        if (!$this->debug)
+            return;
+
+        $args = func_get_args();
+
+        $msg = array();
+        foreach ($args as $arg) {
+            $msg[] = !is_string($arg) ? var_export($arg, true) : $arg;
+        }
+
+        rcube::write_log('bonnie', join(";\n", $msg));
+    }
+
+}
\ No newline at end of file


commit 04718ed0ef44892efa5c0b135d7a3042fa496b48
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Jul 28 11:44:26 2014 +0200

    Use generic date conversion method from Rondcube utils

diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php
index 5a1a8b0..9c507a3 100644
--- a/plugins/libcalendaring/libcalendaring.php
+++ b/plugins/libcalendaring/libcalendaring.php
@@ -145,7 +145,7 @@ class libcalendaring extends rcube_plugin
         if (is_numeric($dt))
             $dt = new DateTime('@'.$dt);
         else if (is_string($dt))
-            $dt = new DateTime($dt);
+            $dt = rcube_utils::anytodatetime($dt);
 
         if ($dt instanceof DateTime && !($dt->_dateonly || $dateonly)) {
             $dt->setTimezone($this->timezone);




More information about the commits mailing list