From 548d1d93b70ceb213d2693ab4520e861f9f5b0d9 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Wed, 25 Mar 2015 11:59:10 +0100 Subject: [PATCH] Display object history for tasks (#3271) --- .../drivers/kolab/tasklist_kolab_driver.php | 286 +++++++++++++++++- plugins/tasklist/drivers/tasklist_driver.php | 76 ++++- plugins/tasklist/localization/en_US.inc | 22 ++ plugins/tasklist/skins/larry/tasklist.css | 59 +++- .../skins/larry/templates/mainview.html | 79 ++++- plugins/tasklist/tasklist.js | 219 +++++++++++++- plugins/tasklist/tasklist.php | 111 ++++++- plugins/tasklist/tasklist_ui.php | 2 + 8 files changed, 827 insertions(+), 27 deletions(-) diff --git a/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php b/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php index 4fccf7e5..4a192c66 100644 --- a/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php +++ b/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php @@ -38,6 +38,7 @@ class tasklist_kolab_driver extends tasklist_driver private $folders = array(); private $tasks = array(); private $tags = array(); + private $bonnie_api = false; /** @@ -55,6 +56,11 @@ class tasklist_kolab_driver extends tasklist_driver // tasklist use fully encoded identifiers kolab_storage::$encode_ids = true; + // get configuration for the Bonnie API + if ($bonnie_config = $this->rc->config->get('kolab_bonnie_api', false)) { + $this->bonnie_api = new kolab_bonnie_api($bonnie_config); + } + $this->_read_lists(); $this->plugin->register_action('folder-acl', array($this, 'folder_acl')); @@ -152,6 +158,7 @@ class tasklist_kolab_driver extends tasklist_driver 'group' => $folder->default ? 'default' : $folder->get_namespace(), 'class' => trim($folder->get_namespace() . ($folder->default ? ' default' : '')), 'caldavuid' => $folder->get_uid(), + 'history' => !empty($this->bonnie_api), ); } @@ -658,6 +665,250 @@ class tasklist_kolab_driver extends tasklist_driver return $childs; } + /** + * Provide a list of revisions for the given task + * + * @param array $task Hash array with task properties + * @return array List of changes, each as a hash array + * @see tasklist_driver::get_task_changelog() + */ + public function get_task_changelog($prop) + { + if (empty($this->bonnie_api)) { + return false; + } + + list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($prop); + + $result = $uid && $mailbox ? $this->bonnie_api->changelog('task', $uid, $mailbox, $msguid) : null; + if (is_array($result) && $result['uid'] == $uid) { + return $result['changes']; + } + + return false; + } + + /** + * Return full data of a specific revision of an event + * + * @param mixed $task UID string or hash array with task properties + * @param mixed $rev Revision number + * + * @return array Task object as hash array + * @see tasklist_driver::get_task_revision() + */ + public function get_task_revison($prop, $rev) + { + if (empty($this->bonnie_api)) { + return false; + } + + $this->_parse_id($prop); + $uid = $prop['uid']; + $list_id = $prop['list']; + list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($prop); + + // call Bonnie API + $result = $this->bonnie_api->get('task', $uid, $rev, $mailbox, $msguid); + if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { + $format = kolab_format::factory('task'); + $format->load($result['xml']); + $rec = $format->to_array(); + $format->get_attachments($rec, true); + + if ($format->is_valid()) { + $rec['rev'] = $result['rev']; + return self::_to_rcube_task($rec, $list_id, false); + } + } + + return false; + } + + /** + * Command the backend to restore a certain revision of a task. + * This shall replace the current object with an older version. + * + * @param mixed $task UID string or hash array with task properties + * @param mixed $rev Revision number + * + * @return boolean True on success, False on failure + * @see tasklist_driver::restore_task_revision() + */ + public function restore_task_revision($prop, $rev) + { + if (empty($this->bonnie_api)) { + return false; + } + + $this->_parse_id($prop); + $uid = $prop['uid']; + $list_id = $prop['list']; + list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($prop); + + $folder = $this->get_folder($list_id); + $success = false; + + if ($folder && ($raw_msg = $this->bonnie_api->rawdata('task', $uid, $rev, $mailbox))) { + $imap = $this->rc->get_storage(); + + // insert $raw_msg as new message + if ($imap->save_message($folder->name, $raw_msg, null, false)) { + $success = true; + + // delete old revision from imap and cache + $imap->delete_message($msguid, $folder->name); + $folder->cache->set($msguid, false); + } + } + + return $success; + } + + /** + * Get a list of property changes beteen two revisions of a task object + * + * @param array $task Hash array with task properties + * @param mixed $rev Revisions: "from:to" + * + * @return array List of property changes, each as a hash array + * @see tasklist_driver::get_task_diff() + */ + public function get_task_diff($prop, $rev1, $rev2) + { + $this->_parse_id($prop); + $uid = $prop['uid']; + $list_id = $prop['list']; + list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($prop); + + // call Bonnie API + $result = $this->bonnie_api->diff('task', $uid, $rev1, $rev2, $mailbox, $msguid, $instance_id); + if (is_array($result) && $result['uid'] == $uid) { + $result['rev1'] = $rev1; + $result['rev2'] = $rev2; + + $keymap = array( + 'start' => 'start', + 'due' => 'date', + 'dstamp' => 'changed', + 'summary' => 'title', + 'alarm' => 'alarms', + 'attendee' => 'attendees', + 'attach' => 'attachments', + 'rrule' => 'recurrence', + 'percent-complete' => 'complete', + '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']]; + } + if ($change['property'] == 'priority') { + $change['property'] = 'flagged'; + $change['old'] = $change['old'] == 1 ? $this->plugin->gettext('yes') : null; + $change['new'] = $change['new'] == 1 ? $this->plugin->gettext('yes') : null; + } + // 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; + } + + /** + * Helper method to resolved the given task identifier into uid and folder + * + * @return array (uid,folder,msguid) tuple + */ + private function _resolve_task_identity($prop) + { + $mailbox = $msguid = null; + + $this->_parse_id($prop); + $uid = $prop['uid']; + $list_id = $prop['list']; + + if ($folder = $this->get_folder($list_id)) { + $mailbox = $folder->get_mailbox_id(); + + // get task object from storage in order to get the real object uid an msguid + if ($rec = $folder->get_object($uid)) { + $msguid = $rec['_msguid']; + $uid = $rec['uid']; + } + } + + return array($uid, $mailbox, $msguid); + } + + /** * Get a list of pending alarms to be displayed to the user * @@ -1232,6 +1483,7 @@ class tasklist_kolab_driver extends tasklist_driver * @param array $task Hash array with event properties: * id: Task identifier * list: List identifier + * rev: Revision (optional) * * @return array Hash array with attachment properties: * id: Attachment identifier @@ -1241,7 +1493,13 @@ class tasklist_kolab_driver extends tasklist_driver */ public function get_attachment($id, $task) { - $task = $this->get_task($task); + // get old revision of the object + if ($task['rev']) { + $task = $this->get_task_revison($task, $task['rev']); + } + else { + $task = $this->get_task($task); + } if ($task && !empty($task['attachments'])) { foreach ($task['attachments'] as $att) { @@ -1260,12 +1518,38 @@ class tasklist_kolab_driver extends tasklist_driver * @param array $task Hash array with event properties: * id: Task identifier * list: List identifier + * rev: Revision (optional) * * @return string Attachment body */ public function get_attachment_body($id, $task) { $this->_parse_id($task); + + // get old revision of event + if ($task['rev']) { + if (empty($this->bonnie_api)) { + return false; + } + + $cid = substr($id, 4); + + // call Bonnie API and get the raw mime message + list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($task); + if ($msg_raw = $this->bonnie_api->rawdata('task', $uid, $task['rev'], $mailbox, $msguid)) { + // parse the message and find the part with the matching content-id + $message = rcube_mime::parse_message($msg_raw); + foreach ((array)$message->parts as $part) { + if ($part->headers['content-id'] && trim($part->headers['content-id'], '<>') == $cid) { + return $part->body; + } + } + } + + return false; + } + + if ($storage = $this->get_folder($task['list'])) { return $storage->get_attachment($task['uid'], $id); } diff --git a/plugins/tasklist/drivers/tasklist_driver.php b/plugins/tasklist/drivers/tasklist_driver.php index 3362e7f8..be823447 100644 --- a/plugins/tasklist/drivers/tasklist_driver.php +++ b/plugins/tasklist/drivers/tasklist_driver.php @@ -242,6 +242,7 @@ abstract class tasklist_driver * * @param array Hash array with task properties: * id: Task identifier + * list: Tasklist identifer * @param boolean Remove record irreversible (mark as deleted otherwise, if supported by the backend) * @return boolean True on success, False on error */ @@ -266,6 +267,7 @@ abstract class tasklist_driver * @param array $task Hash array with event properties: * id: Task identifier * list: List identifier + * rev: Revision (optional) * * @return array Hash array with attachment properties: * id: Attachment identifier @@ -282,6 +284,7 @@ abstract class tasklist_driver * @param array $task Hash array with event properties: * id: Task identifier * list: List identifier + * rev: Revision (optional) * * @return string Attachment body */ @@ -319,7 +322,7 @@ abstract class tasklist_driver /** * Helper method to determine whether the given task is considered "complete" * - * @param array $task Hash array with event properties: + * @param array $task Hash array with event properties * @return boolean True if complete, False otherwiese */ public function is_complete($task) @@ -328,13 +331,74 @@ abstract class tasklist_driver } /** - * List availabale categories - * The default implementation reads them from config/user prefs + * Provide a list of revisions for the given task + * + * @param array $task Hash array with task properties: + * id: Task identifier + * list: List 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 + * mailbox: Destination list for 'move' type */ - public function list_categories() + public function get_task_changelog($task) { - $rcmail = rcube::get_instance(); - return $rcmail->config->get('tasklist_categories', array()); + return false; + } + + /** + * Get a list of property changes beteen two revisions of a task object + * + * @param array $task Hash array with task properties: + * id: Task identifier + * list: List identifier + * @param mixed $rev1 Old Revision + * @param mixed $rev2 New Revision + * + * @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_task_diff($task, $rev1, $rev2) + { + return false; + } + + /** + * Return full data of a specific revision of an event + * + * @param mixed $task UID string or hash array with task properties: + * id: Task identifier + * list: List identifier + * @param mixed $rev Revision number + * + * @return array Task object as hash array + * @see self::get_task() + */ + public function get_task_revison($task, $rev) + { + return false; + } + + /** + * Command the backend to restore a certain revision of a task. + * This shall replace the current object with an older version. + * + * @param mixed $task UID string or hash array with task properties: + * id: Task identifier + * list: List identifier + * @param mixed $rev Revision number + * + * @return boolean True on success, False on failure + */ + public function restore_task_revision($task, $rev) + { + return false; } /** diff --git a/plugins/tasklist/localization/en_US.inc b/plugins/tasklist/localization/en_US.inc index ee66759e..8dc2929c 100644 --- a/plugins/tasklist/localization/en_US.inc +++ b/plugins/tasklist/localization/en_US.inc @@ -50,6 +50,9 @@ $labels['status-cancelled'] = 'Cancelled'; $labels['assignedto'] = 'Assigned to'; $labels['created'] = 'Created'; $labels['changed'] = 'Last Modified'; +$labels['taskoptions'] = 'Options'; +$labels['taskhistory'] = 'History'; +$labels['compare'] = 'Compare'; $labels['all'] = 'All'; $labels['flagged'] = 'Flagged'; @@ -101,6 +104,7 @@ $labels['on'] = 'on'; $labels['at'] = 'at'; $labels['this'] = 'this'; $labels['next'] = 'next'; +$labels['yes'] = 'yes'; // messages $labels['savingdata'] = 'Saving data...'; @@ -150,6 +154,24 @@ $labels['itipcancelsubject'] = '"$title" has been canceled'; $labels['itipcancelmailbody'] = "*\$title*\n\nDue: \$date\n\nAssignees: \$attendees\n\nThe task has been cancelled by \$organizer.\n\nPlease find attached an iCalendar file with the updated task details."; $labels['saveintasklist'] = 'save in '; +// history dialog +$labels['objectchangelog'] = 'Change History'; +$labels['objectdiff'] = 'Changes from $rev1 to $rev2'; + +$labels['actionappend'] = 'Saved'; +$labels['actionmove'] = 'Moved'; +$labels['actiondelete'] = 'Deleted'; +$labels['compare'] = 'Compare'; +$labels['showrevision'] = 'Show this version'; +$labels['restore'] = 'Restore this version'; + +$labels['objectnotfound'] = 'Failed to load task data'; +$labels['objectchangelognotavailable'] = 'Change history is not available for this task'; +$labels['objectdiffnotavailable'] = 'No comparison possible for the selected revisions'; +$labels['revisionrestoreconfirm'] = 'Do you really want to restore revision $rev of this task? This will replace the current task with the old version.'; +$labels['objectrestoresuccess'] = 'Revision $rev successfully restored'; +$labels['objectrestoreerror'] = 'Failed to restore the old revision'; + // invitation handling (overrides labels from libcalendaring) $labels['itipobjectnotfound'] = 'The task referred by this message was not found in your tasks list.'; diff --git a/plugins/tasklist/skins/larry/tasklist.css b/plugins/tasklist/skins/larry/tasklist.css index bd5ec608..a39c1f25 100644 --- a/plugins/tasklist/skins/larry/tasklist.css +++ b/plugins/tasklist/skins/larry/tasklist.css @@ -642,7 +642,8 @@ ul.toolbarmenu li span.icon.taskadd, font-size: 12px; } -.taskhead .flagged { +.taskhead .flagged, +.taskshow.status-flagged h2:after { display: inline-block; width: 16px; height: 16px; @@ -657,7 +658,8 @@ ul.toolbarmenu li span.icon.taskadd, background-position: -2px -3px; } -.taskhead.flagged .flagged { +.taskhead.flagged .flagged, +.taskshow.status-flagged h2:after { background-position: -2px -23px; } @@ -839,8 +841,9 @@ ul.toolbarmenu .sortcol.by-auto a { /*** task edit form ***/ #taskedit, -#taskshow { - display:none; +#taskshow, +#taskdiff { + display: none; } #taskedit { @@ -850,15 +853,32 @@ ul.toolbarmenu .sortcol.by-auto a { margin: 0 -0.2em; } -#taskshow h2 { +.taskshow h2 { margin-top: -0.5em; } -#taskshow label { +#taskdiff h2 { + font-size: 18px; + margin: -0.3em 0 0.4em 0; +} + +.taskshow.status-completed h2 { + text-decoration: line-through; +} + +.taskshow.status-flagged h2:after { + content: " "; + position: relative; + margin-left: 0.6em; + top: 1px; + cursor: default; +} + +.taskshow label { color: #999; } -#taskshow.status-cancelled { +.taskshow.status-cancelled { background: url(images/badge_cancelled.png) top right no-repeat; } @@ -1048,10 +1068,33 @@ label.block { margin-bottom: 0.3em; } -#task-description { +.task-description { margin-bottom: 1em; } +.taskshow .task-text-old, +.taskshow .task-text-new, +.taskshow .task-text-diff { + padding: 2px; +} + +.taskshow .task-text-diff del, +.taskshow .task-text-diff ins { + text-decoration: none; + color: inherit; +} + +.taskshow .task-text-old, +.taskshow .task-text-diff del { + background-color: #fdd; + /* text-decoration: line-through; */ +} + +.taskshow .task-text-new, +.taskshow .task-text-diff ins { + background-color: #dfd; +} + #taskedit-completeness-slider { display: inline-block; margin-left: 2em; diff --git a/plugins/tasklist/skins/larry/templates/mainview.html b/plugins/tasklist/skins/larry/templates/mainview.html index f80b2f7e..dd384094 100644 --- a/plugins/tasklist/skins/larry/templates/mainview.html +++ b/plugins/tasklist/skins/larry/templates/mainview.html @@ -149,6 +149,9 @@
  • + +
  • + @@ -159,12 +162,12 @@ -
    +

    -
    +
    @@ -239,6 +242,78 @@
    + + + + + +
    diff --git a/plugins/tasklist/tasklist.js b/plugins/tasklist/tasklist.js index a01689fb..514d0602 100644 --- a/plugins/tasklist/tasklist.js +++ b/plugins/tasklist/tasklist.js @@ -282,8 +282,14 @@ function rcube_tasklist_ui(settings) setTimeout(fetch_counts, 200); }); + rcmail.addEventListener('plugin.task_render_changelog', task_render_changelog); + rcmail.addEventListener('plugin.task_show_diff', task_show_diff); + rcmail.addEventListener('plugin.task_show_revision', function(data){ task_show_dialog(null, data, true); }); + rcmail.addEventListener('plugin.close_history_dialog', close_history_dialog); + rcmail.register_command('list-sort', list_set_sort, true); rcmail.register_command('list-order', list_set_order, (settings.sort_col || 'auto') != 'auto'); + rcmail.register_command('task-history', task_history_dialog, false); $('#taskviewsortmenu .by-' + (settings.sort_col || 'auto')).attr('aria-checked', 'true').addClass('selected'); $('#taskviewsortmenu .sortorder.' + (settings.sort_order || 'asc')).attr('aria-checked', 'true').addClass('selected'); @@ -459,6 +465,7 @@ function rcube_tasklist_ui(settings) rcmail.command('menu-close', 'taskitemmenu'); } else { + rcmail.enable_command('task-history', me.tasklists[rec.list] && !!me.tasklists[rec.list].history); rcmail.command('menu-open', { menu: 'taskitemmenu', show: true }, e.target, e); menu.data('refid', id); me.selected_task = rec; @@ -1835,7 +1842,7 @@ function rcube_tasklist_ui(settings) /** * Show task details in a dialog */ - function task_show_dialog(id) + function task_show_dialog(id, data, temp) { var $dialog = $('#taskshow'), rec, list; @@ -1848,7 +1855,7 @@ function rcube_tasklist_ui(settings) return $.grep(oldies, function(cls) { return cls.indexOf('status-') === 0 }).join(' '); }); - if (!(rec = listdata[id]) || (rcmail.menu_stack && rcmail.menu_stack.length > 0)) + if (!(rec = (data || listdata[id])) || (rcmail.menu_stack && rcmail.menu_stack.length > 0)) return; me.selected_task = rec; @@ -1892,6 +1899,10 @@ function rcube_tasklist_ui(settings) $dialog.addClass('status-' + String(rec.status).toLowerCase()); } + if (rec.flagged) { + $dialog.addClass('status-flagged'); + } + if (rec.recurrence && rec.recurrence_text) { $('#task-recurrence').show().children('.task-text').html(Q(rec.recurrence_text)); } @@ -1986,7 +1997,7 @@ function rcube_tasklist_ui(settings) .html(Q(rcmail.gettext('itip' + mystatus, 'libcalendaring'))); } */ - var show_rsvp = rsvp && list.editable && !is_organizer(rec) && rec.status != 'CANCELLED'; + var show_rsvp = !temp && rsvp && list.editable && !is_organizer(rec) && rec.status != 'CANCELLED'; $('#task-rsvp')[(show_rsvp ? 'show' : 'hide')](); $('#task-rsvp .rsvp-buttons input').prop('disabled', false).filter('input[rel='+mystatus+']').prop('disabled', true); @@ -2036,6 +2047,13 @@ function rcube_tasklist_ui(settings) }, close: function() { $dialog.dialog('destroy').appendTo(document.body); + $('.libcal-rsvp-replymode').hide(); + }, + dragStart: function() { + $('.libcal-rsvp-replymode').hide(); + }, + resizeStart: function() { + $('.libcal-rsvp-replymode').hide(); }, buttons: buttons, minWidth: 500, @@ -2064,6 +2082,190 @@ function rcube_tasklist_ui(settings) return '' + dispname + ' '; } + /** + * + */ + function task_history_dialog() + { + var dialog, rec = me.selected_task; + if (!rec || !rec.id || !window.libkolab_audittrail) { + return false; + } + + // render dialog + $dialog = libkolab_audittrail.object_history_dialog({ + module: 'tasklist', + container: '#taskhistory', + title: rcmail.gettext('objectchangelog','tasklist') + ' - ' + rec.title, + + // callback function for list actions + listfunc: function(action, rev) { + var rec = $dialog.data('rec'); + saving_lock = rcmail.set_busy(true, 'loading', saving_lock); + rcmail.http_post('task', { action: action, t: { id: rec.id, list:rec.list, rev: rev } }, saving_lock); + }, + + // callback function for comparing two object revisions + comparefunc: function(rev1, rev2) { + var rec = $dialog.data('rec'); + saving_lock = rcmail.set_busy(true, 'loading', saving_lock); + rcmail.http_post('task', { action:'diff', t: { id: rec.id, list: rec.list, rev1: rev1, rev2: rev2 } }, saving_lock); + } + }); + + $dialog.data('rec', rec); + + // fetch changelog data + saving_lock = rcmail.set_busy(true, 'loading', saving_lock); + rcmail.http_post('task', { action: 'changelog', t: { id: rec.id, list: rec.list } }, saving_lock); + } + + /** + * + */ + function task_render_changelog(data) + { + var $dialog = $('#taskhistory'), + rec = $dialog.data('rec'); + + if (data === false || !data.length || !event) { + // display 'unavailable' message + $('
    ' + rcmail.gettext('objectchangelognotavailable','tasklist') + '
    ') + .insertBefore($dialog.find('.changelog-table').hide()); + return; + } + + data.module = 'tasklist'; + libkolab_audittrail.render_changelog(data, rec, me.tasklists[rec.list]); + + // set dialog size according to content + me.dialog_resize($dialog.get(0), $dialog.height(), 600); + } + + /** + * + */ + function task_show_diff(data) + { + var rec = me.selected_task, + $dialog = $("#taskdiff"); + + $dialog.find('div.form-section, h2.task-title-new').hide().data('set', false).find('.index').html(''); + $dialog.find('div.form-section.clone').remove(); + + // always show event title and date + $('.task-title', $dialog).text(rec.title).removeClass('task-text-old').show(); + + // show each property change + $.each(data.changes, function(i, change) { + var prop = change.property, r2, html = false, + row = $('div.task-' + prop, $dialog).first(); + + // special case: title + if (prop == 'title') { + $('.task-title', $dialog).addClass('task-text-old').text(change['old'] || '--'); + $('.task-title-new', $dialog).text(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; + } + + // render description text + if (prop == 'description') { + 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_ = task_attendee_html(change['old']); + if (change['new']) change.new_ = task_attendee_html($.extend({}, change['old'] || {}, change['new'])); + html = true; + } + // localize status + else if (prop == 'status') { + if (change['old']) change.old_ = rcmail.gettext('status-'+String(change['old']).toLowerCase(), 'tasklist'); + if (change['new']) change.new_ = rcmail.gettext('status-'+String(change['new']).toLowerCase(), 'tasklist'); + } + + // format attachments struct + if (prop == 'attachments') { + if (change['old']) task_show_attachments([change['old']], row.children('.task-text-old'), rec, false); + else row.children('.task-text-old').text('--'); + if (change['new']) task_show_attachments([$.extend({}, change['old'] || {}, change['new'])], row.children('.task-text-new'), rec, false); + else row.children('.task-text-new').text('--'); + // remove click handler in diff view + $('.attachmentslist li a', row).unbind('click').removeAttr('href'); + } + else if (change.diff_) { + row.children('.task-text-diff').html(change.diff_); + row.children('.task-text-old, .task-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('.task-text-old').html(change.old_ || change['old'] || '--').show(); + row.children('.task-text-new').html(change.new_ || change['new'] || '--').show(); + } + + // display index number + if (typeof change.index != 'undefined') { + row.find('.index').html('(' + change.index + ')'); + } + + row.show().data('set', true); + }); + + var buttons = {}; + buttons[rcmail.gettext('close')] = function() { + $dialog.dialog('close'); + }; + + // open jquery UI dialog + $dialog.dialog({ + modal: false, + resizable: true, + closeOnEscape: true, + title: rcmail.gettext('objectdiff','tasklist').replace('$rev1', data.rev1).replace('$rev2', data.rev2) + ' - ' + rec.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); + } + + // close the event history dialog + function close_history_dialog() + { + $('#taskhistory, #taskdiff').each(function(i, elem) { + var $dialog = $(elem); + if ($dialog.is(':ui-dialog')) + $dialog.dialog('close'); + }); + }; + /** * Opens the dialog to edit a task */ @@ -2371,17 +2573,22 @@ function rcube_tasklist_ui(settings) if (!rec.id || rec.id < 0) return false; - var qstring = '_id='+urlencode(att.id)+'&_t='+urlencode(rec.recurrence_id||rec.id)+'&_list='+urlencode(rec.list); + var query = { _id: att.id, _t: rec.recurrence_id||rec.id, _list:rec.list, _frame: 1 }; + if (rec.rev) + query._rev = event.rev; + // open attachment in frame if it's of a supported mimetype // similar as in app.js and calendar_ui.js if (att.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; } } - rcmail.goto_url('get-attachment', qstring+'&_download=1', false); + query._frame = null; + query._download = 1; + rcmail.goto_url('get-attachment', query, false); }; /** diff --git a/plugins/tasklist/tasklist.php b/plugins/tasklist/tasklist.php index 780602cf..449f43cb 100644 --- a/plugins/tasklist/tasklist.php +++ b/plugins/tasklist/tasklist.php @@ -208,7 +208,7 @@ class tasklist extends rcube_plugin $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $rec = rcube_utils::get_input_value('t', rcube_utils::INPUT_POST, true); $oldrec = $rec; - $success = $refresh = false; + $success = $refresh = $got_msg = false; // force notify if hidden + active $itip_send_option = (int)$this->rc->config->get('calendar_itip_send_option', 3); @@ -385,13 +385,115 @@ class tasklist extends rcube_plugin } } break; + + case 'changelog': + $data = $this->driver->get_task_changelog($rec); + if (is_array($data) && !empty($data)) { + $lib = $this->lib; + $dtformat = $this->rc->config->get('date_format') . ' ' . $this->rc->config->get('time_format'); + array_walk($data, function(&$change) use ($lib, $dtformat) { + if ($change['date']) { + $dt = $lib->adjust_timezone($change['date']); + if ($dt instanceof DateTime) + $change['date'] = $this->rc->format_date($dt, $dtformat, false); + } + }); + $this->rc->output->command('plugin.task_render_changelog', $data); + } + else { + $this->rc->output->command('plugin.task_render_changelog', false); + } + $got_msg = true; + break; + + case 'diff': + $data = $this->driver->get_task_diff($rec, $rec['rev1'], $rec['rev2']); + if (is_array($data)) { + // convert some properties, similar to self::_client_event() + $lib = $this->lib; + $date_format = $this->rc->config->get('date_format', 'Y-m-d'); + $time_format = $this->rc->config->get('time_format', 'H:i'); + array_walk($data['changes'], function(&$change, $i) use ($lib, $date_format, $time_format) { + // convert date cols + if (in_array($change['property'], array('date','start','created','changed'))) { + if (!empty($change['old'])) { + $dtformat = strlen($change['old']) == 10 ? $date_format : $date_format . ' ' . $time_format; + $change['old_'] = $lib->adjust_timezone($change['old'], strlen($change['old']) == 10)->format($dtformat); + } + if (!empty($change['new'])) { + $dtformat = strlen($change['new']) == 10 ? $date_format : $date_format . ' ' . $time_format; + $change['new_'] = $lib->adjust_timezone($change['new'], strlen($change['new']) == 10)->format($dtformat); + } + } + // 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'] == 'complete') { + $change['old_'] = intval($change['old']) . '%'; + $change['new_'] = intval($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'] = array_merge((array)$change['old'], $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.task_show_diff', $data); + } + else { + $this->rc->output->command('display_message', $this->gettext('objectdiffnotavailable'), 'error'); + } + $got_msg = true; + break; + + case 'show': + if ($rec = $this->driver->get_task_revison($rec, $rec['rev'])) { + $this->encode_task($rec); + $rec['readonly'] = 1; + $this->rc->output->command('plugin.task_show_revision', $rec); + } + else { + $this->rc->output->command('display_message', $this->gettext('objectnotfound'), 'error'); + } + $got_msg = true; + break; + + case 'restore': + if ($success = $this->driver->restore_task_revision($rec, $rec['rev'])) { + $refresh = $this->driver->get_task($rec); + $this->rc->output->command('display_message', $this->gettext(array('name' => 'objectrestoresuccess', 'vars' => array('rev' => $rec['rev']))), 'confirmation'); + $this->rc->output->command('plugin.close_history_dialog'); + } + else { + $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error'); + } + $got_msg = true; + break; + } if ($success) { $this->rc->output->show_message('successfullysaved', 'confirmation'); $this->update_counts($oldrec, $refresh); } - else { + else if (!$got_msg) { $this->rc->output->show_message('tasklist.errorsaving', 'error'); } @@ -1268,7 +1370,7 @@ class tasklist extends rcube_plugin $this->rc->output->set_env('autocomplete_threads', (int)$this->rc->config->get('autocomplete_threads', 0)); $this->rc->output->set_env('autocomplete_max', (int)$this->rc->config->get('autocomplete_max', 15)); $this->rc->output->set_env('autocomplete_min_length', $this->rc->config->get('autocomplete_min_length')); - $this->rc->output->add_label('autocompletechars', 'autocompletemore', 'delete', 'libcalendaring.expandattendeegroup', 'libcalendaring.expandattendeegroupnodata'); + $this->rc->output->add_label('autocompletechars', 'autocompletemore', 'delete', 'close', 'libcalendaring.expandattendeegroup', 'libcalendaring.expandattendeegroupnodata'); $this->rc->output->set_pagetitle($this->gettext('navtitle')); $this->rc->output->send('tasklist.mainview'); @@ -1396,8 +1498,9 @@ class tasklist extends rcube_plugin $task = rcube_utils::get_input_value('_t', rcube_utils::INPUT_GPC); $list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC); $id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); + $rev = rcube_utils::get_input_value('_rev', rcube_utils::INPUT_GPC); - $task = array('id' => $task, 'list' => $list); + $task = array('id' => $task, 'list' => $list, 'rev' => $rev); $attachment = $this->driver->get_attachment($id, $task); // show part page diff --git a/plugins/tasklist/tasklist_ui.php b/plugins/tasklist/tasklist_ui.php index a46aa1e0..97b16334 100644 --- a/plugins/tasklist/tasklist_ui.php +++ b/plugins/tasklist/tasklist_ui.php @@ -156,6 +156,7 @@ class tasklist_ui $this->plugin->register_handler('plugin.identity_select', array($this, 'identity_select')); $this->plugin->register_handler('plugin.edit_attendees_notify', array($this, 'edit_attendees_notify')); $this->plugin->register_handler('plugin.task_rsvp_buttons', array($this->plugin->itip, 'itip_rsvp_buttons')); + $this->plugin->register_handler('plugin.object_changelog_table', array('libkolab', 'object_changelog_table')); jqueryui::tagedit(); @@ -165,6 +166,7 @@ class tasklist_ui // include kolab folderlist widget if available if (in_array('libkolab', $this->plugin->api->loaded_plugins())) { $this->plugin->api->include_script('libkolab/js/folderlist.js'); + $this->plugin->api->include_script('libkolab/js/audittrail.js'); } }