From a68982b0287abbf27b478c16038c5a58ec2e9a0e Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Tue, 29 Jul 2014 15:28:35 +0200 Subject: [PATCH] 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 --- plugins/calendar/calendar.php | 145 +++++- plugins/calendar/calendar_ui.js | 420 ++++++++++++++++-- plugins/calendar/drivers/calendar_driver.php | 74 ++- .../calendar/drivers/kolab/kolab_calendar.php | 48 +- .../calendar/drivers/kolab/kolab_driver.php | 268 +++++++++++ .../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 + .../skins/classic/templates/calendar.html | 7 + plugins/calendar/skins/larry/calendar.css | 144 +++++- .../calendar/skins/larry/images/calendars.png | Bin 2698 -> 5144 bytes .../skins/larry/templates/calendar.html | 106 ++++- plugins/libcalendaring/libcalendaring.php | 12 +- plugins/libcalendaring/localization/en_US.inc | 1 + 15 files changed, 1163 insertions(+), 110 deletions(-) diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index 343b3e5d..6c23741b 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 8b59ef3c..8d4433d4 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 = '' + dispname + ''; 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 = '' + dispname + ' '; + + line = event_attendee_html(data); + if (morelink) overflow += line; else html += line; - + // stop listing attendees if (j == 7 && event.attendees.length >= 7) { morelink = $('').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 - $('') - .attr('href', '#') - .html('More Options') - .addClass('dropdown-link') - .click(function(){ return false; }) - .insertBefore($dialog.parent().find('.ui-dialog-buttonset').children().first()); -*/ + if (!temp) { + $('') + .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 = '' + dispname + ''; + } + + 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 '' + dispname + ' '; }; // 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(''+ rcmail.gettext('loading') +''); + + // 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 = ' ' + + '', + 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 = ''; + } + + $('') + .append('' + (accessible && change.op != 'DELETE' ? + ' '+ + '' + : '')) + .append('' + Q(change.rev) + '') + .append('' + Q(change.date ? format_datetime(parseISO8601(change.date)) : '') + '') + .append('' + Q(change.user || 'undisclosed') + '') + .append('' + Q(rcmail.gettext(op_labels[change.op] || '', 'calendar') + (op_append ? ' ...' : '')) + '') + .append('' + (accessible && change.op != 'DELETE' ? actions.replace(/\{rev\}/g, change.rev) : '') + '') + .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 4c4f5161..e213a939 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; @@ -405,6 +406,77 @@ abstract class calendar_driver return false; } + /** + * 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 * diff --git a/plugins/calendar/drivers/kolab/kolab_calendar.php b/plugins/calendar/drivers/kolab/kolab_calendar.php index 706c3cd3..ba718462 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 ba634cb5..f921b530 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 bcfc7721..58c22963 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 6e165ca2..df159576 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 } @@ -840,6 +841,22 @@ class calendar_ui return $table->show($attrib); } + /** + * 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); + } + /** * */ diff --git a/plugins/calendar/localization/en_US.inc b/plugins/calendar/localization/en_US.inc index f7af6723..7d1e7d20 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 28556352..065cb68c 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 07839f05..2df1ce71 100644 --- a/plugins/calendar/skins/classic/templates/calendar.html +++ b/plugins/calendar/skins/classic/templates/calendar.html @@ -115,6 +115,13 @@