diff --git a/client/resources/css/darkmode.css b/client/resources/css/darkmode.css index d9fa61800..0e7c34abf 100644 --- a/client/resources/css/darkmode.css +++ b/client/resources/css/darkmode.css @@ -2402,6 +2402,14 @@ body.dark-mode .zarafa-settings-category-rules, body.dark-mode .icon_flag_Reminder, body.dark-mode .icon_large_addressbook, body.dark-mode .icon_large_refresh, +/* Undo history: the range which would be undone (see grommunio.css). */ +body.dark-mode .x-menu .x-menu-list li.x-menu-list-item.zarafa-undo-item-included a { + background-color: var(--dm-hover) !important; + color: var(--dm-fg) !important; +} + +body.dark-mode .icon_large_undo, +body.dark-mode .icon_large_redo, body.dark-mode .icon_large_print, body.dark-mode .icon_large_view, body.dark-mode .icon_contact_distlist, diff --git a/client/resources/css/grommunio.css b/client/resources/css/grommunio.css index fafaed101..3a94eb608 100644 --- a/client/resources/css/grommunio.css +++ b/client/resources/css/grommunio.css @@ -7069,4 +7069,22 @@ input[type="radio"]:focus-visible { } .x-tree-root-node { - min-width: 100%; } \ No newline at end of file + min-width: 100%; } +/* Undo history: hovering an entry marks every entry which would be undone + with it, since clicking one undoes the whole range down to it. Matches the + active-item background so the range reads as one hovered block. */ +.x-menu .x-menu-list li.x-menu-list-item.zarafa-undo-item-included a { + background-color: #f2f2f2 !important; } + +/* Undo/redo main toolbar buttons */ +.icon_large_undo { + background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMicgaGVpZ2h0PSczMicgdmlld0JveD0nLTEuNSAtMS41IDI3IDI3Jz48ZyBmaWxsPSdub25lJyBzdHJva2U9JyMwMDAnIHN0cm9rZS13aWR0aD0nMicgc3Ryb2tlLWxpbmVjYXA9J3JvdW5kJyBzdHJva2UtbGluZWpvaW49J3JvdW5kJz48cG9seWxpbmUgcG9pbnRzPScxIDQgMSAxMCA3IDEwJy8+PHBhdGggZD0nTTMuNTEgMTVhOSA5IDAgMSAwIDIuMTMtOS4zNkwxIDEwJy8+PC9nPjwvc3ZnPg==) !important; + background-repeat: no-repeat !important; + background-position: center center !important; +} + +.icon_large_redo { + background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMicgaGVpZ2h0PSczMicgdmlld0JveD0nLTEuNSAtMS41IDI3IDI3Jz48ZyBmaWxsPSdub25lJyBzdHJva2U9JyMwMDAnIHN0cm9rZS13aWR0aD0nMicgc3Ryb2tlLWxpbmVjYXA9J3JvdW5kJyBzdHJva2UtbGluZWpvaW49J3JvdW5kJz48cG9seWxpbmUgcG9pbnRzPScyMyA0IDIzIDEwIDE3IDEwJy8+PHBhdGggZD0nTTIwLjQ5IDE1YTkgOSAwIDEgMS0yLjEzLTkuMzZMMjMgMTAnLz48L2c+PC9zdmc+) !important; + background-repeat: no-repeat !important; + background-position: center center !important; +} diff --git a/client/zarafa/common/KeyMapping.js b/client/zarafa/common/KeyMapping.js index 3e3626b4e..4500ee657 100644 --- a/client/zarafa/common/KeyMapping.js +++ b/client/zarafa/common/KeyMapping.js @@ -293,8 +293,49 @@ Zarafa.common.KeyMapping = Ext.extend(Object, { // not specifying settingsCfg as we already have an entry of opening item in all views }]; + var undoRedoKeys = [{ + key: Ext.EventObject.Z, + ctrl: true, + alt: false, + shift: false, + // Don't stop the event: inside text inputs and the HTML editor + // Ctrl+Z must keep triggering the native text undo. The handler + // checks the event target itself. + stopEvent: false, + handler: this.onUndo, + scope: this, + settingsCfg: { + description: _('Undo the last action'), + category: _('All views') + }, + basic: true + },{ + key: Ext.EventObject.Y, + ctrl: true, + alt: false, + shift: false, + stopEvent: false, + handler: this.onRedo, + scope: this, + settingsCfg: { + description: _('Redo the last undone action'), + category: _('All views') + }, + basic: true + },{ + key: Ext.EventObject.Z, + ctrl: true, + alt: false, + shift: true, + stopEvent: false, + handler: this.onRedo, + scope: this + // not specifying settingsCfg as Ctrl+Y is already listed for redo + }]; + Zarafa.core.KeyMapMgr.register('global', mainTabBar); Zarafa.core.KeyMapMgr.register('global', mainToolbarKeys); + Zarafa.core.KeyMapMgr.register('global', undoRedoKeys); Zarafa.core.KeyMapMgr.register('grid', selectionKey); Zarafa.core.KeyMapMgr.register('view.mapimessage', selectionKey); @@ -343,6 +384,58 @@ Zarafa.common.KeyMapping = Ext.extend(Object, { refreshButton.handler.call(refreshButton.scope); }, + /** + * Event handler for the keydown event of the {@link Zarafa.core.KeyMap KeyMap} + * when the user wants to undo the last action. The event is ignored when + * it originates from a text input, so the native text undo keeps working. + * @param {Number} key Key code + * @param {Ext.EventObject} event The event + * @param {Ext.Component} component The component on which key event is fired. + */ + onUndo: function(key, event, component) + { + if (this.isTextEditingTarget(event)) { + return; + } + event.stopEvent(); + container.getUndoManager().undo(); + }, + + /** + * Event handler for the keydown event of the {@link Zarafa.core.KeyMap KeyMap} + * when the user wants to redo the last undone action. The event is ignored + * when it originates from a text input. + * @param {Number} key Key code + * @param {Ext.EventObject} event The event + * @param {Ext.Component} component The component on which key event is fired. + */ + onRedo: function(key, event, component) + { + if (this.isTextEditingTarget(event)) { + return; + } + event.stopEvent(); + container.getUndoManager().redo(); + }, + + /** + * Check whether a key event originates from an element in which the user + * is editing text (input, textarea or contenteditable element). Undo/redo + * shortcuts must not be intercepted there. + * @param {Ext.EventObject} event The event to check + * @return {Boolean} True when the event targets a text editing element + * @private + */ + isTextEditingTarget: function(event) + { + var target = event.getTarget(); + if (!target) { + return false; + } + var nodeName = target.nodeName ? target.nodeName.toLowerCase() : ''; + return nodeName === 'input' || nodeName === 'textarea' || target.isContentEditable === true; + }, + /** * Event handler for the keydown event of the {@link Zarafa.core.KeyMap KeyMap} * when the user wants to switch between {@link Zarafa.core.Context Contexts}. diff --git a/client/zarafa/common/categories/dialogs/CategoriesContentPanel.js b/client/zarafa/common/categories/dialogs/CategoriesContentPanel.js index 5e71dd49e..c8c76f707 100644 --- a/client/zarafa/common/categories/dialogs/CategoriesContentPanel.js +++ b/client/zarafa/common/categories/dialogs/CategoriesContentPanel.js @@ -124,6 +124,10 @@ Zarafa.common.categories.dialogs.CategoriesContentPanel = Ext.extend(Zarafa.core }, this); if (this.autoSave) { + // Record the category change in the undo history. This must be + // done explicitly since ShadowStore saves are not announced + // through the IPMStoreMgr. + container.getUndoManager().capturePropertyGesture(this.record); this.record[0].getStore().save(this.record); } diff --git a/client/zarafa/common/flags/dialogs/CustomFlagContentPanel.js b/client/zarafa/common/flags/dialogs/CustomFlagContentPanel.js index dafcaf55f..aa417b10a 100644 --- a/client/zarafa/common/flags/dialogs/CustomFlagContentPanel.js +++ b/client/zarafa/common/flags/dialogs/CustomFlagContentPanel.js @@ -94,8 +94,15 @@ Zarafa.common.flags.dialogs.CustomFlagContentPanel = Ext.extend(Zarafa.core.ui.C record.set(property, flagProperties[property]); } record.endEdit(); - record.save(); }, this); + + if (!Ext.isEmpty(this.record)) { + // Record the flag change in the undo history. This must be done + // explicitly since ShadowStore saves are not announced through + // the IPMStoreMgr. + container.getUndoManager().capturePropertyGesture(this.record); + this.record[0].getStore().save(this.record); + } this.close(); }, diff --git a/client/zarafa/common/flags/ui/FlagsMenu.js b/client/zarafa/common/flags/ui/FlagsMenu.js index fff78a115..e80379730 100644 --- a/client/zarafa/common/flags/ui/FlagsMenu.js +++ b/client/zarafa/common/flags/ui/FlagsMenu.js @@ -235,8 +235,18 @@ Zarafa.common.flags.ui.FlagsMenu = Ext.extend(Zarafa.core.ui.menu.ConditionalMen record.set(property, flagProperties[property]); } record.endEdit(); - record.save(); }, this); + + if (Ext.isEmpty(records)) { + return; + } + + // Record the flag change in the undo history. This must be done + // explicitly since ShadowStore saves are not announced through the + // IPMStoreMgr. Saving all records in one batch makes the whole + // gesture one undo entry. + container.getUndoManager().capturePropertyGesture(records); + records[0].getStore().save(records); } }); diff --git a/client/zarafa/core/Container.js b/client/zarafa/core/Container.js index 45726e77e..fe5a04675 100644 --- a/client/zarafa/core/Container.js +++ b/client/zarafa/core/Container.js @@ -336,6 +336,16 @@ Zarafa.core.Container = Ext.extend(Ext.util.Observable, { return this.notifier || (this.notifier = new Zarafa.core.ui.notifier.Notifier()); }, + /** + * Returns the {@link Zarafa.core.data.UndoManager UndoManager} instance which + * records undoable user actions and performs undo/redo operations. + * @return {Zarafa.core.data.UndoManager} the UndoManager instance. + */ + getUndoManager: function() + { + return this.undoManager || (this.undoManager = new Zarafa.core.data.UndoManager()); + }, + /** * Returns the application main panel. * @return {Zarafa.core.ui.MainViewport} the application main panel. diff --git a/client/zarafa/core/data/ProxyResponseHandler.js b/client/zarafa/core/data/ProxyResponseHandler.js index 6f625b10f..30e934674 100644 --- a/client/zarafa/core/data/ProxyResponseHandler.js +++ b/client/zarafa/core/data/ProxyResponseHandler.js @@ -173,6 +173,32 @@ Zarafa.core.data.ProxyResponseHandler = Ext.extend(Zarafa.core.data.AbstractResp return ret; }, + /** + * Handles the 'success' response. Normally this response carries no data, + * but for operations for which the client requested undo tracking (via the + * 'track_new_entryids' message action) the server includes an 'undo' + * object describing the new location of the affected items. This object + * is placed on the {@link #sendRecords} as 'undoResponse' so the + * {@link Zarafa.core.data.UndoManager UndoManager} can pick it up after + * the write has completed. + * @param {Object} response The response object belonging to the given command. + */ + doSuccess: function(response) + { + if (Ext.isEmpty(this.sendRecords)) { + return; + } + Ext.each(this.sendRecords, function(record) { + if (response && response.undo) { + record.undoResponse = response.undo; + } else { + // Never leave a stale mapping from an earlier operation on + // the record, or a later untracked save could pick it up. + delete record.undoResponse; + } + }); + }, + /** * Handles the 'error' response. This means that the Request has failed * due to a problem on the PHP-side. This will fire the {@link Ext.data.DataProxy#exception exception} diff --git a/client/zarafa/core/data/UndoManager.js b/client/zarafa/core/data/UndoManager.js new file mode 100644 index 000000000..359e26d09 --- /dev/null +++ b/client/zarafa/core/data/UndoManager.js @@ -0,0 +1,1400 @@ +Ext.namespace('Zarafa.core.data'); + +/** + * @class Zarafa.core.data.UndoManager + * @extends Ext.util.Observable + * + * The UndoManager keeps a session-wide history of undoable user actions + * (deleting, moving and copying messages, changing flags or read state, + * moving/resizing appointments, creating items) and can revert or re-apply + * them, similar to the undo/redo feature in Outlook. + * + * Actions are captured centrally by listening to the + * {@link Zarafa.core.data.IPMStoreMgr IPMStoreMgr} save events. Because every + * mutation in grommunio-web is an immediate server round-trip, undo is always + * performed as a compensating server operation (e.g. moving a deleted item + * back out of the wastebasket), never as a local rollback. + * + * Since moving a message gives it a new entryid, captured move/delete/copy + * operations ask the server to report the new entryids by attaching the + * 'track_new_entryids' message action. The server responds with an 'undo' + * object in the success feedback, which is placed on the record as + * 'undoResponse' by the {@link Zarafa.core.data.ProxyResponseHandler}. + * + * Operations with side effects that cannot be reverted (anything that sends + * an email, like meeting requests, cancellations or responses, as well as + * soft deletes and recurrence exception changes) are never recorded. + */ +Zarafa.core.data.UndoManager = Ext.extend(Ext.util.Observable, { + /** + * @cfg {Number} maxEntries The maximum number of actions kept in the + * undo history. When the limit is exceeded the oldest entry is dropped. + */ + maxEntries: 20, + + /** + * @cfg {Number} pendingTimeout Number of milliseconds after which a + * captured-but-unconfirmed operation is discarded (e.g. when the server + * never responded to the request). + */ + pendingTimeout: 60000, + + /** + * The stack of commands which can be undone. The last element is the + * most recent action. + * @property + * @type Array + */ + undoStack: undefined, + + /** + * The stack of commands which can be redone. The last element is the + * most recently undone action. + * @property + * @type Array + */ + redoStack: undefined, + + /** + * List of pending capture items: operations which have been sent to the + * server but for which no confirmation has been received yet. Only when + * the server confirms the operation is a command pushed onto the + * {@link #undoStack}. + * @property + * @type Array + * @private + */ + pendingItems: undefined, + + /** + * True while an undo or redo operation is being executed. Used to + * prevent re-entrant execution and to let the UI disable its buttons. + * @property + * @type Boolean + */ + executing: false, + + /** + * Message action types which imply that an email will be sent by the + * server while handling the operation. Such operations can never be + * undone and are not recorded. + * @property + * @type Array + * @private + */ + emailActionTypes: [ + 'cancelInvitation', 'declineMeeting', 'declineMeetingRequest', + 'acceptMeetingRequest', 'forwardMeetingRequest', + 'acceptTaskRequest', 'declineTaskRequest', + 'reply', 'replyall', 'forward', 'snooze', 'dismiss' + ], + + /** + * @constructor + * @param {Object} config Configuration object + */ + constructor: function(config) + { + config = config || {}; + Ext.apply(this, config); + + this.undoStack = []; + this.redoStack = []; + this.pendingItems = []; + // Items confirmed by the server but not yet turned into commands. + // They are buffered so that all per-record write events of a single + // gesture (the ShadowStore saves each record separately) are + // collapsed into one undo entry, see {@link #flushConfirmed}. + this.confirmedBuffer = []; + // Commands whose server confirmation arrived while an undo/redo run + // was executing; pushed onto the history once the run finishes. + this.deferredPushes = []; + this.gestureId = 0; + + this.addEvents( + /** + * @event stackchange + * Fired whenever the undo or redo stack changes, or when an + * undo/redo operation starts or finishes executing. + * @param {Zarafa.core.data.UndoManager} undoManager This undo manager + */ + 'stackchange' + ); + + Zarafa.core.data.UndoManager.superclass.constructor.call(this, config); + + Zarafa.core.data.IPMStoreMgr.on('beforerecordsave', this.onBeforeRecordSave, this); + Zarafa.core.data.IPMStoreMgr.on('afterrecordwrite', this.onAfterRecordWrite, this); + Zarafa.core.data.IPMStoreMgr.on('storeexception', this.onStoreException, this); + }, + + /* + * --------------------------------------------------------------------- + * Stack management + * --------------------------------------------------------------------- + */ + + /** + * @return {Boolean} True when there is at least one action which can be undone. + */ + canUndo: function() + { + return !this.executing && this.undoStack.length > 0; + }, + + /** + * @return {Boolean} True when there is at least one action which can be redone. + */ + canRedo: function() + { + return !this.executing && this.redoStack.length > 0; + }, + + /** + * @return {Zarafa.core.data.UndoCommand} The command which will be undone + * by the next call to {@link #undo}, or undefined. + */ + peekUndo: function() + { + return this.undoStack[this.undoStack.length - 1]; + }, + + /** + * @return {Zarafa.core.data.UndoCommand} The command which will be redone + * by the next call to {@link #redo}, or undefined. + */ + peekRedo: function() + { + return this.redoStack[this.redoStack.length - 1]; + }, + + /** + * @return {Array} The undoable commands, most recent first. + */ + getUndoCommands: function() + { + return this.undoStack.slice().reverse(); + }, + + /** + * Add a new command to the undo history. This clears the redo stack. + * When an undo/redo run is currently executing, the command is buffered + * and only added once the run finishes, so a server confirmation arriving + * mid-run cannot corrupt the stacks the run operates on. + * @param {Zarafa.core.data.UndoCommand} command The command to record + */ + push: function(command) + { + if (this.executing) { + this.deferredPushes.push(command); + return; + } + this.doPush(command); + }, + + /** + * Actually add a command to the undo history and clear the redo stack. + * @param {Zarafa.core.data.UndoCommand} command The command to record + * @private + */ + doPush: function(command) + { + this.undoStack.push(command); + if (this.undoStack.length > this.maxEntries) { + this.undoStack.shift(); + } + this.redoStack = []; + this.fireEvent('stackchange', this); + }, + + /** + * Undo the given number of most recent actions (default 1). + * The commands are executed sequentially. + * @param {Number} count (optional) The number of actions to undo + */ + undo: function(count) + { + this.execute('undo', count || 1); + }, + + /** + * Redo the given number of most recently undone actions (default 1). + * @param {Number} count (optional) The number of actions to redo + */ + redo: function(count) + { + this.execute('redo', count || 1); + }, + + /** + * Execute a number of commands from the undo or redo stack sequentially. + * Commands which executed successfully move to the opposite stack, + * commands which failed are dropped entirely and an error notification + * is shown. + * @param {String} mode Either 'undo' or 'redo' + * @param {Number} count The number of commands to execute + * @private + */ + execute: function(mode, count) + { + if (this.executing) { + return; + } + + var fromStack = mode === 'undo' ? this.undoStack : this.redoStack; + if (fromStack.length === 0) { + return; + } + + this.executing = true; + this.fireEvent('stackchange', this); + this.executeNext(mode, Math.min(count, fromStack.length)); + }, + + /** + * Execute the next command of a (multi-)undo/redo run, and schedule the + * remainder for when the command completes. + * @param {String} mode Either 'undo' or 'redo' + * @param {Number} remaining The number of commands still to execute + * @private + */ + executeNext: function(mode, remaining) + { + var fromStack = mode === 'undo' ? this.undoStack : this.redoStack; + var toStack = mode === 'undo' ? this.redoStack : this.undoStack; + + if (remaining === 0 || fromStack.length === 0) { + this.executing = false; + // Apply any commands whose confirmation arrived during the run. + if (this.deferredPushes.length > 0) { + var deferred = this.deferredPushes; + this.deferredPushes = []; + Ext.each(deferred, this.doPush, this); + } + this.fireEvent('stackchange', this); + return; + } + + var command = fromStack.pop(); + + command.execute(mode, function(success) { + if (success && command.isViable()) { + toStack.push(command); + } else if (!success) { + this.notifyFailure(mode, command); + } + this.executeNext(mode, remaining - 1); + }.createDelegate(this)); + }, + + /** + * Show an error notification for a command which could not be + * undone/redone (usually because the items were changed or removed + * from another client in the meantime). + * @param {String} mode Either 'undo' or 'redo' + * @param {Zarafa.core.data.UndoCommand} command The command which failed + * @private + */ + notifyFailure: function(mode, command) + { + var msg = mode === 'undo' ? + _('Could not undo {0}. The item may have been moved, changed or removed in the meantime.') : + _('Could not redo {0}. The item may have been moved, changed or removed in the meantime.'); + // The description embeds user-controlled text (e.g. a message + // subject), and the 'error' notifier (ToastPlugin) renders the + // message as HTML, so it must be encoded to avoid script injection. + container.getNotifier().notify('error', + mode === 'undo' ? _('Undo failed') : _('Redo failed'), + String.format(msg, Ext.util.Format.htmlEncode(command.description))); + }, + + /* + * --------------------------------------------------------------------- + * Capturing operations + * --------------------------------------------------------------------- + */ + + /** + * Event handler for the {@link Zarafa.core.data.IPMStoreMgr#beforerecordsave} + * event. Inspects the records which are about to be saved and captures + * all undoable operations among them. For operations which will change + * the entryid of the items (delete/move/copy), the 'track_new_entryids' + * message action is attached so the server reports the new location. + * + * @param {Zarafa.core.data.IPMStore} store The store which is being saved + * @param {Object} data The object describing the pending create/update/destroy records + * @private + */ + onBeforeRecordSave: function(store, data) + { + if (this.executing) { + return; + } + + this.sweepPending(); + + if (!Ext.isEmpty(data.destroy)) { + this.captureDestroy(this.toArray(data.destroy)); + } + if (!Ext.isEmpty(data.update)) { + this.captureUpdate(this.toArray(data.update)); + } + if (!Ext.isEmpty(data.create)) { + this.captureCreate(this.toArray(data.create)); + } + }, + + /** + * Capture records which are about to be deleted. Only normal deletions + * (which move the items into the wastebasket) are undoable. + * @param {Zarafa.core.data.IPMRecord[]} records The records being deleted + * @private + */ + captureDestroy: function(records) + { + var items = []; + var gesture = { id: ++this.gestureId, kind: 'delete' }; + + Ext.each(records, function(record) { + if (!this.isEligible(record) || record.phantom) { + return; + } + var actions = record.getMessageActions() || {}; + // A delete gesture must not carry an action_type at all, + // and soft deletes cannot be undone. + if (actions.soft_delete === true || !Ext.isEmpty(actions.action_type)) { + return; + } + + record.addMessageAction('track_new_entryids', true); + items.push(this.createPendingItem(record, 'delete', gesture, 'destroy')); + }, this); + + this.registerPending(items); + }, + + /** + * Capture records which are about to be updated. This covers both + * move/copy operations (via the 'action_type' message action) and plain + * property changes such as flag or read-state changes and appointment + * drag/resize operations. + * @param {Zarafa.core.data.IPMRecord[]} records The records being updated + * @private + */ + captureUpdate: function(records) + { + var moveGesture = { id: ++this.gestureId, kind: 'move' }; + var copyGesture = { id: ++this.gestureId, kind: 'copy' }; + var propsGesture = { id: ++this.gestureId, kind: 'props' }; + var items = []; + + Ext.each(records, function(record) { + if (!this.isEligible(record) || record.phantom) { + return; + } + + var actions = record.getMessageActions() || {}; + var actionType = actions.action_type; + + if (actionType === 'move' || actionType === 'copy') { + if (Ext.isEmpty(actions.destination_parent_entryid) || + Ext.isEmpty(actions.destination_store_entryid)) { + return; + } + record.addMessageAction('track_new_entryids', true); + var item = this.createPendingItem(record, + actionType, actionType === 'move' ? moveGesture : copyGesture, 'update'); + item.dest = { + parent_entryid: actions.destination_parent_entryid, + store_entryid: actions.destination_store_entryid + }; + items.push(item); + } else if (Ext.isEmpty(actionType)) { + // Plain property change: snapshot the original values of + // all modified fields so they can be restored. + var modified = record.modified; + if (Ext.isEmpty(modified) || Object.keys(modified).length === 0) { + return; + } + var item = this.createPendingItem(record, 'props', propsGesture, 'update'); + item.oldValues = {}; + item.newValues = {}; + Ext.iterate(modified, function(key) { + item.oldValues[key] = this.cloneValue(modified[key]); + item.newValues[key] = this.cloneValue(record.get(key)); + }, this); + items.push(item); + } + }, this); + + this.registerPending(items); + }, + + /** + * Capture records which are about to be created. Only appointment + * creations are recorded (e.g. quick-create by dragging a timeslot in + * the calendar); creating other items (like drafts, which are saved + * repeatedly while composing) would flood the history. + * @param {Zarafa.core.data.IPMRecord[]} records The records being created + * @private + */ + captureCreate: function(records) + { + var gesture = { id: ++this.gestureId, kind: 'create' }; + var items = []; + + Ext.each(records, function(record) { + if (!this.isEligible(record)) { + return; + } + if (!Zarafa.core.MessageClass.isClass(record.get('message_class'), 'IPM.Appointment', true)) { + return; + } + items.push(this.createPendingItem(record, 'create', gesture, 'create')); + }, this); + + this.registerPending(items); + }, + + /** + * Explicitly capture a property-change gesture for records which are + * saved through the {@link Zarafa.core.data.ShadowStore ShadowStore} + * (whose saves are not announced through the IPMStoreMgr). Must be + * called after the property changes have been applied to the records, + * but before they are saved. + * @param {Zarafa.core.data.IPMRecord|Array} records The records to capture + */ + capturePropertyGesture: function(records) + { + if (this.executing) { + return; + } + this.sweepPending(); + this.captureUpdate(this.toArray(records)); + }, + + /** + * Explicitly capture a create gesture for a record which is saved + * through the {@link Zarafa.core.data.ShadowStore ShadowStore}. + * Must be called before the record is saved. + * @param {Zarafa.core.data.IPMRecord|Array} records The records to capture + */ + captureCreateGesture: function(records) + { + if (this.executing) { + return; + } + this.sweepPending(); + this.captureCreate(this.toArray(records)); + }, + + /** + * Check whether the given record may enter the undo history at all. + * Records whose pending save will make the server send an email + * (meeting requests/responses/cancellations, task requests, ...) and + * recurrence occurrences (which are stored as exceptions inside the + * series) are never undoable. + * @param {Zarafa.core.data.IPMRecord} record The record to check + * @return {Boolean} True when the record is eligible for undo + * @private + */ + isEligible: function(record) + { + if (!record || !(record instanceof Zarafa.core.data.IPMRecord)) { + return false; + } + + // Never capture the same record twice for one server round-trip + // (e.g. when a dialog captured it explicitly and the store save is + // announced through the IPMStoreMgr as well). + if (this.pendingItems.some(function(item) { return item.record === record; })) { + return false; + } + + var actions = Ext.isFunction(record.getMessageActions) ? (record.getMessageActions() || {}) : {}; + if (actions.send === true) { + return false; + } + if (!Ext.isEmpty(actions.action_type) && this.emailActionTypes.indexOf(actions.action_type) >= 0) { + return false; + } + + // Occurrences of a recurring series are saved as exceptions inside + // the series; those cannot be undone as standalone items. + if (!Ext.isEmpty(record.get('basedate'))) { + return false; + } + + return true; + }, + + /** + * Create a pending capture item for the given record. + * @param {Zarafa.core.data.IPMRecord} record The record being captured + * @param {String} kind The operation kind ('delete', 'move', 'copy', 'create', 'props') + * @param {Object} gesture The gesture this item belongs to + * @param {String} writeAction The store write action which confirms this + * item ('destroy', 'update' or 'create') + * @return {Object} the pending item + * @private + */ + createPendingItem: function(record, kind, gesture, writeAction) + { + return { + record: record, + kind: kind, + gesture: gesture, + writeAction: writeAction, + expires: Date.now() + this.pendingTimeout, + ids: { + entryid: record.get('entryid'), + parent_entryid: record.get('parent_entryid'), + store_entryid: record.get('store_entryid'), + message_class: record.get('message_class'), + object_type: record.get('object_type') + }, + subject: record.get('subject') + }; + }, + + /** + * Add pending capture items to the list of items awaiting server + * confirmation. + * @param {Array} items The pending items + * @private + */ + registerPending: function(items) + { + if (!Ext.isEmpty(items)) { + this.pendingItems = this.pendingItems.concat(items); + } + }, + + /** + * Discard pending capture items whose confirmation never arrived. + * @private + */ + sweepPending: function() + { + if (this.pendingItems.length === 0) { + return; + } + var now = Date.now(); + this.pendingItems = this.pendingItems.filter(function(item) { + return item.expires > now; + }); + }, + + /* + * --------------------------------------------------------------------- + * Confirming operations + * --------------------------------------------------------------------- + */ + + /** + * Event handler for the {@link Zarafa.core.data.IPMStoreMgr#afterrecordwrite} + * event. Fired when the server has successfully processed a create, + * update or destroy request. Matches the written records against the + * pending capture items and converts confirmed gestures into commands + * on the undo stack. + * + * @param {Zarafa.core.data.IPMStore} store The store which was written + * @param {String} action The write action ('create', 'update', 'destroy', 'open') + * @param {Object} result The data picked out of the response + * @param {Object} res The response object + * @param {Zarafa.core.data.IPMRecord[]} records The records which were written + * @private + */ + onAfterRecordWrite: function(store, action, result, res, records) + { + if (this.pendingItems.length === 0) { + return; + } + + records = this.toArray(records); + + this.pendingItems = this.pendingItems.filter(function(item) { + if (item.writeAction === action && records.indexOf(item.record) >= 0) { + this.confirmedBuffer.push(item); + return false; + } + return true; + }, this); + + // Commands are not built here directly: a gesture that saves several + // records through the ShadowStore (batch:false) produces one write + // event per record. Buffer the confirmed items and build the commands + // once the current response has been fully processed, so all records + // of a gesture end up in a single undo entry. + if (this.confirmedBuffer.length > 0) { + this.scheduleFlush(); + } + }, + + /** + * Schedule a (single) deferred {@link #flushConfirmed} pass. All + * synchronous write events of one server response are collected before + * the pass runs. + * @private + */ + scheduleFlush: function() + { + if (!this.flushTask) { + this.flushTask = new Ext.util.DelayedTask(this.flushConfirmed, this); + } + this.flushTask.delay(0); + }, + + /** + * Turn the buffered, server-confirmed items into commands on the undo + * stack, grouping them by gesture so one user action becomes one entry. + * @private + */ + flushConfirmed: function() + { + var confirmed = this.confirmedBuffer; + this.confirmedBuffer = []; + if (confirmed.length === 0) { + return; + } + + // Group the confirmed items by gesture (preserving the order in + // which the gestures were first seen), so that one user action + // (e.g. deleting five messages) becomes one undo entry. + var gestures = {}; + var order = []; + Ext.each(confirmed, function(item) { + var key = item.gesture.id; + if (!gestures[key]) { + gestures[key] = { gesture: item.gesture, items: [] }; + order.push(key); + } + gestures[key].items.push(item); + }); + + Ext.each(order, function(key) { + var group = gestures[key]; + var command = this.buildCommand(group.gesture, group.items); + if (command) { + this.push(command); + } + }, this); + }, + + /** + * Event handler for the {@link Zarafa.core.data.IPMStoreMgr#storeexception} + * event. Discards pending capture items belonging to failed requests so a + * failed operation neither leaves a bogus entry nor blocks a later gesture + * on the same record (see {@link #isEligible}). + * @param {Zarafa.core.data.IPMStore} store The store on which the exception occurred + * @param {Ext.data.DataProxy} proxy The proxy from where the exception originated + * @param {String} type The exception type ('response' or 'remote') + * @param {String} action The action name + * @param {Object} options The request options + * @param {Object} response The response object + * @param {Object} arg Additional arguments, carrying the failed sendRecords + * @private + */ + onStoreException: function(store, proxy, type, action, options, response, arg) + { + var failed = arg && arg.sendRecords ? this.toArray(arg.sendRecords) : null; + if (!Ext.isEmpty(failed)) { + this.pendingItems = this.pendingItems.filter(function(item) { + return failed.indexOf(item.record) < 0; + }); + } + // Clean up any remaining leftovers past their expiry as a fallback + // (e.g. an exception which did not report the affected records). + this.sweepPending(); + }, + + /** + * Convert a confirmed gesture into an undoable command. + * @param {Object} gesture The gesture description + * @param {Array} items The confirmed pending items of this gesture + * @return {Zarafa.core.data.UndoCommand} the command, or false when none + * of the items could be tracked + * @private + */ + buildCommand: function(gesture, items) + { + switch (gesture.kind) { + case 'delete': + case 'move': + case 'copy': + return this.buildLocationCommand(gesture, items); + case 'create': + return this.buildCreateCommand(gesture, items); + case 'props': + return this.buildPropsCommand(gesture, items); + } + return false; + }, + + /** + * Build a {@link Zarafa.core.data.UndoLocationCommand} for a confirmed + * delete, move or copy gesture. Items for which the server could not + * report the new entryid are silently omitted. + * @param {Object} gesture The gesture description + * @param {Array} items The confirmed pending items + * @return {Zarafa.core.data.UndoLocationCommand} the command or false + * @private + */ + buildLocationCommand: function(gesture, items) + { + var commandItems = []; + + Ext.each(items, function(item) { + var response = item.record.undoResponse; + delete item.record.undoResponse; + + if (!response || !response.new_entryids) { + return; + } + var newEntryid = response.new_entryids[item.ids.entryid]; + if (Ext.isEmpty(newEntryid)) { + return; + } + + var current = { + entryid: newEntryid, + parent_entryid: response.destination_parent_entryid, + store_entryid: response.destination_store_entryid, + message_class: item.ids.message_class, + object_type: item.ids.object_type + }; + + var undoLoc, redoLoc; + if (gesture.kind === 'copy') { + // Undoing a copy moves the new copy into the wastebasket. + var wastebasket = container.getHierarchyStore().getDefaultFolder('wastebasket'); + if (!wastebasket) { + return; + } + undoLoc = { + parent_entryid: wastebasket.get('entryid'), + store_entryid: wastebasket.get('store_entryid') + }; + } else { + undoLoc = { + parent_entryid: item.ids.parent_entryid, + store_entryid: item.ids.store_entryid + }; + } + redoLoc = { + parent_entryid: response.destination_parent_entryid, + store_entryid: response.destination_store_entryid + }; + + commandItems.push({ + ids: current, + undoLoc: undoLoc, + redoLoc: redoLoc, + subject: item.subject + }); + }, this); + + if (commandItems.length === 0) { + return false; + } + + return new Zarafa.core.data.UndoLocationCommand({ + description: this.describeGesture(gesture.kind, commandItems), + items: commandItems + }); + }, + + /** + * Build a {@link Zarafa.core.data.UndoLocationCommand} for a confirmed + * create gesture: undoing a creation moves the new item into the + * wastebasket, redoing it moves it back. + * @param {Object} gesture The gesture description + * @param {Array} items The confirmed pending items + * @return {Zarafa.core.data.UndoLocationCommand} the command or false + * @private + */ + buildCreateCommand: function(gesture, items) + { + var wastebasket = container.getHierarchyStore().getDefaultFolder('wastebasket'); + if (!wastebasket) { + return false; + } + + var commandItems = []; + + Ext.each(items, function(item) { + // After a successful create the record has been realized and + // carries its server-assigned entryid. + var record = item.record; + var entryid = record.get('entryid'); + if (Ext.isEmpty(entryid)) { + return; + } + + commandItems.push({ + ids: { + entryid: entryid, + parent_entryid: record.get('parent_entryid'), + store_entryid: record.get('store_entryid'), + message_class: record.get('message_class'), + object_type: record.get('object_type') + }, + undoLoc: { + parent_entryid: wastebasket.get('entryid'), + store_entryid: wastebasket.get('store_entryid') + }, + redoLoc: { + parent_entryid: record.get('parent_entryid'), + store_entryid: record.get('store_entryid') + }, + subject: record.get('subject') + }); + }, this); + + if (commandItems.length === 0) { + return false; + } + + return new Zarafa.core.data.UndoLocationCommand({ + description: this.describeGesture('create', commandItems), + items: commandItems + }); + }, + + /** + * Build a {@link Zarafa.core.data.UndoPropsCommand} for a confirmed + * property-change gesture. + * @param {Object} gesture The gesture description + * @param {Array} items The confirmed pending items + * @return {Zarafa.core.data.UndoPropsCommand} the command or false + * @private + */ + buildPropsCommand: function(gesture, items) + { + var commandItems = []; + + Ext.each(items, function(item) { + delete item.record.undoResponse; + commandItems.push({ + ids: item.ids, + oldValues: item.oldValues, + newValues: item.newValues, + subject: item.subject + }); + }); + + if (commandItems.length === 0) { + return false; + } + + return new Zarafa.core.data.UndoPropsCommand({ + description: this.describePropsGesture(commandItems), + items: commandItems + }); + }, + + /* + * --------------------------------------------------------------------- + * Descriptions + * --------------------------------------------------------------------- + */ + + /** + * Produce a human-readable description for a location gesture, e.g. + * 'Delete "Weekly report"' or 'Move 3 items to "Archive"'. + * @param {String} kind The gesture kind ('delete', 'move', 'copy', 'create') + * @param {Array} items The command items + * @return {String} the description + * @private + */ + describeGesture: function(kind, items) + { + var count = items.length; + var subject = this.formatSubject(items[0].subject); + + switch (kind) { + case 'delete': + return count === 1 ? + String.format(_('Delete {0}'), subject) : + String.format(_('Delete {0} items'), count); + case 'move': { + var folder = this.formatFolder(items[0].redoLoc.parent_entryid); + return count === 1 ? + String.format(_('Move {0} to {1}'), subject, folder) : + String.format(_('Move {0} items to {1}'), count, folder); + } + case 'copy': { + var folder = this.formatFolder(items[0].redoLoc.parent_entryid); + return count === 1 ? + String.format(_('Copy {0} to {1}'), subject, folder) : + String.format(_('Copy {0} items to {1}'), count, folder); + } + case 'create': + return String.format(_('Create {0}'), subject); + } + return _('Action'); + }, + + /** + * Produce a human-readable description for a property-change gesture by + * classifying the set of changed properties, e.g. 'Mark as read', + * 'Change flag' or 'Change appointment time'. + * @param {Array} items The command items + * @return {String} the description + * @private + */ + describePropsGesture: function(items) + { + var count = items.length; + var subject = this.formatSubject(items[0].subject); + var keys = {}; + Ext.each(items, function(item) { + Ext.iterate(item.newValues, function(key) { + keys[key] = true; + }); + }); + var keyList = Object.keys(keys); + + var what; + if (keyList.length === 1 && keyList[0] === 'message_flags') { + var wasRead = (items[0].oldValues.message_flags & Zarafa.core.mapi.MessageFlags.MSGFLAG_READ) > 0; + what = wasRead ? _('Mark as unread') : _('Mark as read'); + return count === 1 ? + String.format(_('{0}: {1}'), what, subject) : + String.format(_('{0}: {1} items'), what, count); + } + + var isFlagChange = keyList.some(function(key) { + return key.indexOf('flag_') === 0 || key === 'reminder' || key === 'reminder_time'; + }); + var isDateChange = keyList.some(function(key) { + return key === 'startdate' || key === 'duedate' || key === 'commonstart' || key === 'commonend'; + }); + var isAppointment = Zarafa.core.MessageClass.isClass(items[0].ids.message_class, 'IPM.Appointment', true); + + if (isDateChange && isAppointment) { + what = _('Change appointment time'); + } else if (isFlagChange && !isAppointment) { + what = _('Change flag'); + } else if (keyList.indexOf('categories') >= 0) { + what = _('Change categories'); + } else { + what = _('Edit'); + } + + return count === 1 ? + String.format(_('{0}: {1}'), what, subject) : + String.format(_('{0}: {1} items'), what, count); + }, + + /** + * Format an item subject for use in a description, quoted. + * + * The quotes are added here rather than written into the translated + * templates, because _() html-encodes what it returns: a quote put in a + * template comes back as " and ends up displayed literally. Keeping + * the templates free of characters that encode leaves the description + * plain text, which is what its three display sites expect. Same reason + * {@link #formatFolder} quotes the folder name itself. + * + * @param {String} subject The subject + * @return {String} the formatted subject, quoted + * @private + */ + formatSubject: function(subject) + { + if (Ext.isEmpty(subject)) { + return '"' + _('No subject') + '"'; + } + return '"' + Ext.util.Format.ellipsis(subject, 40) + '"'; + }, + + /** + * Format a folder name for use in a description. + * @param {String} entryid The folder entryid + * @return {String} the folder display name, quoted + * @private + */ + formatFolder: function(entryid) + { + var folder = container.getHierarchyStore().getFolder(entryid); + if (folder) { + return '"' + folder.get('display_name') + '"'; + } + return _('another folder'); + }, + + /* + * --------------------------------------------------------------------- + * Utilities + * --------------------------------------------------------------------- + */ + + /** + * Normalize the given value to an array. + * @param {Mixed} value The value + * @return {Array} the value as array + * @private + */ + toArray: function(value) + { + return Array.isArray(value) ? value : [ value ]; + }, + + /** + * Clone a property value for the undo snapshot. Dates are the only + * mutable value type stored in record fields. + * @param {Mixed} value The value to clone + * @return {Mixed} the cloned value + * @private + */ + cloneValue: function(value) + { + if (Ext.isDate(value)) { + return new Date(value.getTime()); + } + return value; + } +}); + +/** + * @class Zarafa.core.data.UndoCommand + * @extends Object + * + * Base class for entries in the {@link Zarafa.core.data.UndoManager} history. + * A command knows how to revert ('undo') and re-apply ('redo') one user + * gesture by issuing compensating server operations through the + * {@link Zarafa.core.data.ShadowStore ShadowStore}. + */ +Zarafa.core.data.UndoCommand = Ext.extend(Object, { + /** + * @cfg {String} description Human-readable description of the recorded + * action, e.g. 'Delete 3 items'. Shown in the undo button tooltip and + * the history dropdown. + */ + description: '', + + /** + * @cfg {Array} items The items this command operates on. + */ + items: undefined, + + /** + * @cfg {Number} timeout Number of milliseconds to wait for the server + * before considering the execution failed. + */ + timeout: 30000, + + /** + * @constructor + * @param {Object} config Configuration object + */ + constructor: function(config) + { + Ext.apply(this, config); + }, + + /** + * @return {Boolean} True when the command still has items to operate on. + * Items are dropped when the server can no longer track them. + */ + isViable: function() + { + return !Ext.isEmpty(this.items); + }, + + /** + * Execute the command. + * @param {String} mode Either 'undo' or 'redo' + * @param {Function} callback Called with a single boolean argument + * indicating whether the execution succeeded + */ + execute: Ext.emptyFn, + + /** + * Create a detached record which represents a server item without being + * part of any visible store, to be saved via the ShadowStore. + * @param {Object} ids The item identification (entryid, parent_entryid, + * store_entryid, message_class, object_type) + * @return {Zarafa.core.data.IPMRecord} the record + * @protected + */ + createDetachedRecord: function(ids) + { + var record = Zarafa.core.data.RecordFactory.createRecordObjectByMessageClass( + ids.message_class || 'IPM.Note', { + entryid: ids.entryid, + parent_entryid: ids.parent_entryid, + store_entryid: ids.store_entryid, + message_class: ids.message_class, + object_type: ids.object_type + }, ids.entryid); + + this.fillRequiredFields(record); + + return record; + }, + + /** + * Give a detached record a value for every field its type insists on, so + * that it can be saved at all. + * + * {@link Ext.data.Store#save} drops records which fail + * {@link Ext.data.Record#isValid} on the floor: no request, no exception, + * nothing. A record is invalid while any field declared allowBlank:false is + * empty, and a detached record carries nothing but its ids — an appointment + * for instance declares startdate, duedate, commonstart and commonend that + * way, so undoing anything on one used to send no request at all and fail on + * its own timeout, reporting that the item had been changed in the meantime + * when nothing had touched it. + * + * The values go straight into {@link Ext.data.Record#data} rather than + * through set(): they exist to get the record past the check, and must not + * become part of what is written. A dirty field is sent as a property, and + * ItemModule::copy() applies whatever properties it is given to the item it + * moves — a placeholder date reaching that would overwrite the real one. + * + * @param {Zarafa.core.data.IPMRecord} record The detached record + * @private + */ + fillRequiredFields: function(record) + { + record.fields.each(function(field) { + if (field.allowBlank === false && Ext.isEmpty(record.data[field.name])) { + record.data[field.name] = this.placeholderFor(field); + } + }, this); + }, + + /** + * A non-empty value of the right shape for the given field, used only to + * satisfy {@link Ext.data.Record#isValid}. See {@link #fillRequiredFields}. + * @param {Ext.data.Field} field The field + * @return {Mixed} the placeholder + * @private + */ + placeholderFor: function(field) + { + // Ext resolves a field's type to one of Ext.data.Types, each carrying + // its name in a 'type' property. + var type = field.type ? field.type.type : 'auto'; + + switch (type) { + case 'date': + return new Date(0); + case 'int': + case 'float': + return 0; + case 'bool': + return false; + default: + // Ext.isEmpty() counts '' as empty, so a space is the smallest + // value which passes. + return ' '; + } + }, + + /** + * Save the given detached records through the ShadowStore and invoke the + * callback once the server has confirmed (or rejected) the operation. + * @param {Array} entries Array of {record, item} pairs + * @param {Function} onWritten Called with (entries, success); for + * successful writes each entry's record may carry an 'undoResponse' + * @protected + */ + saveDetachedRecords: function(entries, onWritten) + { + var shadowStore = container.getShadowStore(); + var records = []; + + Ext.each(entries, function(entry) { + shadowStore.add(entry.record); + // Make sure the record is considered modified, otherwise + // MAPIStore.save would skip it (message actions attached before + // the record was added to the store do not register it). + if (shadowStore.modified.indexOf(entry.record) === -1) { + shadowStore.modified.push(entry.record); + } + records.push(entry.record); + }); + + // The ShadowStore saves each record in its own transaction + // (batch:false), so a multi-item command produces one write (or + // exception) event per record. Wait for every record to be accounted + // for before completing, otherwise the items whose response has not + // arrived yet would be dropped from the command. + var pending = records.slice(); + var anySuccess = false; + var completed = false; + var timer; + + var cleanup = function() { + shadowStore.un('write', onWrite, this); + shadowStore.un('exception', onException, this); + clearTimeout(timer); + Ext.each(records, function(record) { + // Also drop the record from the modified list: the + // ShadowStore does not prune it on removal, and a later + // full store save would otherwise re-issue the operation. + shadowStore.modified.remove(record); + shadowStore.remove(record, true); + }); + }; + + var finish = function() { + if (completed) { + return; + } + completed = true; + cleanup.call(this); + onWritten(entries, anySuccess); + }; + + var resolve = function(record, success) { + var idx = pending.indexOf(record); + if (idx === -1) { + return; + } + pending.splice(idx, 1); + if (success) { + anySuccess = true; + } + if (pending.length === 0) { + finish.call(this); + } + }; + + var onWrite = function(store, action, result, res, writtenRecords) { + writtenRecords = Array.isArray(writtenRecords) ? writtenRecords : [ writtenRecords ]; + Ext.each(writtenRecords, function(record) { + resolve.call(this, record, true); + }, this); + }; + + var onException = function(proxy, type, action, options, response, args) { + var failedRecords = (args && args.sendRecords) ? args.sendRecords : []; + if (!Array.isArray(failedRecords)) { + failedRecords = [ failedRecords ]; + } + Ext.each(failedRecords, function(record) { + resolve.call(this, record, false); + }, this); + }; + + shadowStore.on('write', onWrite, this); + shadowStore.on('exception', onException, this); + + // Any records still unresolved when the timeout fires are treated as + // failed; anySuccess still reflects the ones which did complete. + timer = setTimeout(finish.createDelegate(this), this.timeout); + + shadowStore.save(records); + } +}); + +/** + * @class Zarafa.core.data.UndoLocationCommand + * @extends Zarafa.core.data.UndoCommand + * + * Command which undoes/redoes operations that changed the location of items: + * deletions (to the wastebasket), moves, copies and creations. Undo and redo + * are both performed as a move between the two recorded locations. Because a + * move gives an item a new entryid, every execution asks the server to track + * the new entryids and updates the recorded item location accordingly. + */ +Zarafa.core.data.UndoLocationCommand = Ext.extend(Zarafa.core.data.UndoCommand, { + /** + * Execute the command by moving all items to the target location of the + * given mode. + * @param {String} mode Either 'undo' or 'redo' + * @param {Function} callback Called with a single boolean success argument + */ + execute: function(mode, callback) + { + var entries = []; + + Ext.each(this.items, function(item) { + var target = mode === 'undo' ? item.undoLoc : item.redoLoc; + var record = this.createDetachedRecord(item.ids); + record.addMessageAction('action_type', 'move'); + record.addMessageAction('destination_parent_entryid', target.parent_entryid); + record.addMessageAction('destination_store_entryid', target.store_entryid); + record.addMessageAction('track_new_entryids', true); + entries.push({ record: record, item: item, target: target }); + }, this); + + if (entries.length === 0) { + callback(false); + return; + } + + this.saveDetachedRecords(entries, function(entries, success) { + if (!success) { + callback(false); + return; + } + + // Update the recorded locations with the new entryids the + // server reported. Items which could not be tracked are + // dropped from the command. + var viable = []; + Ext.each(entries, function(entry) { + var response = entry.record.undoResponse; + delete entry.record.undoResponse; + var newEntryid = response && response.new_entryids ? + response.new_entryids[entry.item.ids.entryid] : false; + + if (!Ext.isEmpty(newEntryid)) { + entry.item.ids.entryid = newEntryid; + entry.item.ids.parent_entryid = entry.target.parent_entryid; + entry.item.ids.store_entryid = entry.target.store_entryid; + viable.push(entry.item); + } + }); + this.items = viable; + callback(true); + }.createDelegate(this)); + } +}); + +/** + * @class Zarafa.core.data.UndoPropsCommand + * @extends Zarafa.core.data.UndoCommand + * + * Command which undoes/redoes plain property changes (flags, read state, + * categories, appointment times, ...) by re-applying the recorded old or new + * property values to the items. + */ +Zarafa.core.data.UndoPropsCommand = Ext.extend(Zarafa.core.data.UndoCommand, { + /** + * Execute the command by applying the recorded property values. + * @param {String} mode Either 'undo' or 'redo' + * @param {Function} callback Called with a single boolean success argument + */ + execute: function(mode, callback) + { + var entries = []; + + Ext.each(this.items, function(item) { + var values = mode === 'undo' ? item.oldValues : item.newValues; + var record = this.createDetachedRecord(item.ids); + + record.beginEdit(); + Ext.iterate(values, function(key, value) { + record.set(key, value); + }); + record.endEdit(); + + entries.push({ record: record, item: item }); + }, this); + + if (entries.length === 0) { + callback(false); + return; + } + + this.saveDetachedRecords(entries, function(entries, success) { + callback(success); + }); + } +}); diff --git a/client/zarafa/core/ui/MainToolbar.js b/client/zarafa/core/ui/MainToolbar.js index 799d2bcef..160a2a776 100644 --- a/client/zarafa/core/ui/MainToolbar.js +++ b/client/zarafa/core/ui/MainToolbar.js @@ -439,6 +439,44 @@ Zarafa.core.ui.MainToolbar = Ext.extend(Zarafa.core.ui.Toolbar, { addActionItems: function() { var menuItems = [{ + xtype: 'splitbutton', + id: 'zarafa-maintoolbar-undo', + scale: 'large', + overflowText: _('Undo'), + tooltip: _('Undo') + Zarafa.core.KeyMapMgr.formatShortcutHint('Ctrl + Z', true), + iconCls: 'icon_large_undo', + ref: 'undoButton', + disabled: true, + handler: this.onUndo, + scope: this, + menu: { + xtype: 'menu', + items: [], + listeners: { + beforeshow: this.onBeforeShowUndoMenu, + scope: this + } + }, + listeners: { + render: this.onRenderUndoRedoButton, + scope: this + } + },{ + xtype: 'button', + id: 'zarafa-maintoolbar-redo', + scale: 'large', + overflowText: _('Redo'), + tooltip: _('Redo') + Zarafa.core.KeyMapMgr.formatShortcutHint('Ctrl + Y', true), + iconCls: 'icon_large_redo', + ref: 'redoButton', + disabled: true, + handler: this.onRedo, + scope: this, + listeners: { + render: this.onRenderUndoRedoButton, + scope: this + } + },{ xtype: 'button', id: 'zarafa-maintoolbar-addressbook', scale: 'large', @@ -477,6 +515,164 @@ Zarafa.core.ui.MainToolbar = Ext.extend(Zarafa.core.ui.Toolbar, { Zarafa.addressbook.Actions.openAddressBook(); }, + /** + * Undo the most recent action via the {@link Zarafa.core.data.UndoManager UndoManager}. + * @private + */ + onUndo: function() + { + container.getUndoManager().undo(); + }, + + /** + * Redo the most recently undone action via the {@link Zarafa.core.data.UndoManager UndoManager}. + * @private + */ + onRedo: function() + { + container.getUndoManager().redo(); + }, + + /** + * Event handler for the render event of the undo/redo buttons. Registers + * (once) for the {@link Zarafa.core.data.UndoManager#stackchange} event + * so the buttons can reflect the current undo/redo availability. + * @private + */ + onRenderUndoRedoButton: function() + { + if (!this.undoStackChangeRegistered) { + this.undoStackChangeRegistered = true; + container.getUndoManager().on('stackchange', this.onUndoStackChange, this); + } + this.onUndoStackChange(container.getUndoManager()); + }, + + /** + * Event handler for the {@link Zarafa.core.data.UndoManager#stackchange} + * event. Enables/disables the undo and redo buttons and updates their + * tooltips with a description of the action which would be undone/redone. + * @param {Zarafa.core.data.UndoManager} undoManager The undo manager + * @private + */ + onUndoStackChange: function(undoManager) + { + var undoHint = Zarafa.core.KeyMapMgr.formatShortcutHint('Ctrl + Z', true); + var redoHint = Zarafa.core.KeyMapMgr.formatShortcutHint('Ctrl + Y', true); + + // The descriptions are plain text and are not encoded here: Ext.QuickTip + // is overridden to encode what it shows (client/extjs-mod/Ext.QuickTip.js), + // so encoding first would display the entities themselves. + if (this.undoButton && this.undoButton.rendered) { + this.undoButton.setDisabled(!undoManager.canUndo()); + var next = undoManager.peekUndo(); + this.undoButton.setTooltip(next ? + String.format(_('Undo: {0}'), next.description) + undoHint : + _('Undo') + undoHint); + } + + if (this.redoButton && this.redoButton.rendered) { + this.redoButton.setDisabled(!undoManager.canRedo()); + var redoNext = undoManager.peekRedo(); + this.redoButton.setTooltip(redoNext ? + String.format(_('Redo: {0}'), redoNext.description) + redoHint : + _('Redo') + redoHint); + } + }, + + /** + * Event handler for the beforeshow event of the undo button dropdown. + * Rebuilds the menu with the current undo history; clicking an entry + * undoes all actions up to and including that entry. + * @param {Ext.menu.Menu} menu The dropdown menu + * @private + */ + onBeforeShowUndoMenu: function(menu) + { + menu.removeAll(); + + var commands = container.getUndoManager().getUndoCommands(); + if (Ext.isEmpty(commands)) { + menu.add({ + text: _('Nothing to undo'), + disabled: true + }); + return; + } + + Ext.each(commands, function(command, index) { + menu.add({ + // Encoded here, unlike the tooltips above: a menu item renders + // its text as HTML, and the description carries user-controlled + // text such as a message subject. + text: Ext.util.Format.htmlEncode(command.description), + undoCount: index + 1, + handler: this.onUndoMenuItemClick, + scope: this, + listeners: { + activate: this.onUndoMenuItemActivate, + deactivate: this.onUndoMenuItemDeactivate, + scope: this + } + }); + }, this); + }, + + /** + * Event handler for the activate event of an entry of the undo history + * dropdown. Clicking an entry undoes every action down to it, not just that + * one, so hovering it marks the whole range which would be undone; without + * that the menu reads as though a single action could be picked out. + * @param {Ext.menu.Item} item The activated menu item + * @private + */ + onUndoMenuItemActivate: function(item) + { + if (!item.parentMenu) { + return; + } + + item.parentMenu.items.each(function(other) { + // Ext puts its own active class on the li rather than the anchor, + // and the styling hangs off that, so follow it. + if (other.container) { + other.container[other.undoCount <= item.undoCount ? 'addClass' : 'removeClass']( + 'zarafa-undo-item-included' + ); + } + }); + }, + + /** + * Event handler for the deactivate event of an entry of the undo history + * dropdown. Clears the range marked by {@link #onUndoMenuItemActivate}. + * @param {Ext.menu.Item} item The deactivated menu item + * @private + */ + onUndoMenuItemDeactivate: function(item) + { + if (!item.parentMenu) { + return; + } + + item.parentMenu.items.each(function(other) { + if (other.container) { + other.container.removeClass('zarafa-undo-item-included'); + } + }); + }, + + /** + * Event handler for a click on an entry of the undo history dropdown. + * Undoes all actions from the most recent one down to the clicked entry. + * @param {Ext.menu.Item} item The clicked menu item + * @private + */ + onUndoMenuItemClick: function(item) + { + container.getUndoManager().undo(item.undoCount); + }, + /** * This will Refresh the view and fire {@link Zarafa.core.data.ListModuleStore#reload} * @private diff --git a/client/zarafa/core/ui/RecordContentPanel.js b/client/zarafa/core/ui/RecordContentPanel.js index 1c5d6a126..7bc998803 100644 --- a/client/zarafa/core/ui/RecordContentPanel.js +++ b/client/zarafa/core/ui/RecordContentPanel.js @@ -474,6 +474,19 @@ Zarafa.core.ui.RecordContentPanel = Ext.extend(Zarafa.core.ui.ContentPanel, { record.set('isHTML', record.get('isHTML'), true); } + // Record appointment edits and creations in the undo history. + // This must be done explicitly since these records are saved + // through the ShadowStore, whose saves are not announced through + // the IPMStoreMgr. Only appointments are captured: recording + // every draft save while composing would flood the history. + if (Zarafa.core.MessageClass.isClass(record.get('message_class'), 'IPM.Appointment', true)) { + if (record.phantom) { + container.getUndoManager().captureCreateGesture(record); + } else { + container.getUndoManager().capturePropertyGesture(record); + } + } + record.save(); } }, diff --git a/client/zarafa/task/ui/TaskContextMenu.js b/client/zarafa/task/ui/TaskContextMenu.js index 42a2811eb..eaddcaee8 100644 --- a/client/zarafa/task/ui/TaskContextMenu.js +++ b/client/zarafa/task/ui/TaskContextMenu.js @@ -262,8 +262,13 @@ Zarafa.task.ui.TaskContextMenu = Ext.extend(Zarafa.core.ui.menu.ConditionalMenu, record.set(property, flagProperties[property]); } record.endEdit(); - record.save(); }, this); + + // Save all records in one batch so the gesture becomes a single + // undo entry. + if (!Ext.isEmpty(this.records)) { + this.records[0].getStore().save(this.records); + } }, /** diff --git a/server/includes/core/class.operations.php b/server/includes/core/class.operations.php index 1d23ad297..124254c27 100644 --- a/server/includes/core/class.operations.php +++ b/server/includes/core/class.operations.php @@ -3307,6 +3307,157 @@ public function deleteMessages($store, $parententryid, $entryids, $softDelete = return $result; } + /** + * Collect the PR_SEARCH_KEY of a set of messages. The search key is stable + * across folder moves (unlike the entryid, which embeds the parent folder), + * so it can be used to relocate messages after a move/copy/delete operation. + * + * @param object $store MAPI Message Store Object + * @param mixed $entryids one entryid or a list of entryids (binary) + * + * @return array mapping of hex source entryid => binary PR_SEARCH_KEY + */ + public function getMessageSearchKeys($store, $entryids) { + if (!is_array($entryids)) { + $entryids = [$entryids]; + } + $searchKeys = []; + foreach ($entryids as $entryid) { + try { + $message = mapi_msgstore_openentry($store, $entryid); + $msgProps = mapi_getprops($message, [PR_SEARCH_KEY]); + if (isset($msgProps[PR_SEARCH_KEY])) { + $searchKeys[bin2hex((string) $entryid)] = $msgProps[PR_SEARCH_KEY]; + } + } + catch (MAPIException $e) { + $e->setHandled(); + } + } + + return $searchKeys; + } + + /** + * Collect the hex entryids of the messages currently present in the given + * folder whose PR_SEARCH_KEY matches one of the supplied search keys. This + * is used to snapshot the destination folder before a move/copy/delete, so + * resolveNewEntryids() can exclude pre-existing items which share a search + * key with the item being relocated. + * + * @param object $store MAPI Message Store Object of the folder + * @param string $folderentryid entryid of the folder to inspect + * @param array $searchKeys mapping of hex source entryid => binary PR_SEARCH_KEY + * as returned by getMessageSearchKeys() + * + * @return array set of hex entryids (as array keys) currently present + */ + public function getFolderEntryidsBySearchKey($store, $folderentryid, $searchKeys) { + if (empty($searchKeys)) { + return []; + } + + try { + $folder = mapi_msgstore_openentry($store, $folderentryid); + $table = mapi_folder_getcontentstable($folder, MAPI_DEFERRED_ERRORS); + $restrictions = []; + foreach ($searchKeys as $searchKey) { + $restrictions[] = [RES_PROPERTY, [ + RELOP => RELOP_EQ, + ULPROPTAG => PR_SEARCH_KEY, + VALUE => $searchKey, + ]]; + } + mapi_table_restrict($table, count($restrictions) > 1 ? [RES_OR, $restrictions] : $restrictions[0]); + $rows = mapi_table_queryallrows($table, [PR_ENTRYID]); + } + catch (MAPIException $e) { + $e->setHandled(); + + return []; + } + + $entryids = []; + foreach ($rows as $row) { + if (isset($row[PR_ENTRYID])) { + $entryids[bin2hex((string) $row[PR_ENTRYID])] = true; + } + } + + return $entryids; + } + + /** + * Resolve the new entryids of messages that were moved or copied into the + * given destination folder, by matching their move-stable PR_SEARCH_KEY + * against the folder contents. Used to report the new location of items + * back to the client (e.g. for undo support), since a moved message gets + * a new entryid which MAPI does not report to the caller. + * + * @param object $destStore MAPI Message Store Object of the destination + * @param string $destentryid entryid of the destination folder + * @param array $searchKeys mapping of hex source entryid => binary PR_SEARCH_KEY + * as returned by getMessageSearchKeys() + * @param array $excludeEntryids set of hex entryids (as keys) which were + * already present in the destination before + * the operation and must therefore never be + * mapped, so that a pre-existing item sharing + * a PR_SEARCH_KEY (e.g. a former copy) is not + * mistaken for the item just moved/copied. + * Obtain it via getFolderEntryidsBySearchKey(). + * + * @return array mapping of hex old entryid => hex new entryid; items that + * could not be resolved are omitted + */ + public function resolveNewEntryids($destStore, $destentryid, $searchKeys, $excludeEntryids = []) { + if (empty($searchKeys)) { + return []; + } + + try { + $folder = mapi_msgstore_openentry($destStore, $destentryid); + $table = mapi_folder_getcontentstable($folder, MAPI_DEFERRED_ERRORS); + $restrictions = []; + foreach ($searchKeys as $searchKey) { + $restrictions[] = [RES_PROPERTY, [ + RELOP => RELOP_EQ, + ULPROPTAG => PR_SEARCH_KEY, + VALUE => $searchKey, + ]]; + } + mapi_table_restrict($table, count($restrictions) > 1 ? [RES_OR, $restrictions] : $restrictions[0]); + $rows = mapi_table_queryallrows($table, [PR_ENTRYID, PR_SEARCH_KEY]); + } + catch (MAPIException $e) { + $e->setHandled(); + + return []; + } + + $mapping = []; + $used = []; + foreach ($searchKeys as $oldEntryid => $searchKey) { + foreach ($rows as $i => $row) { + if (isset($used[$i]) || $row[PR_SEARCH_KEY] !== $searchKey) { + continue; + } + $newEntryid = bin2hex((string) $row[PR_ENTRYID]); + // For a copy into the same folder the original message also + // matches the restriction; never map an item onto itself, nor + // onto any item which was already present before the operation + // (a former copy sharing the same PR_SEARCH_KEY). + if ($newEntryid === $oldEntryid || isset($excludeEntryids[$newEntryid])) { + continue; + } + $used[$i] = true; + $mapping[$oldEntryid] = $newEntryid; + break; + } + } + + return $mapping; + } + /** * Copy or move messages. * diff --git a/server/includes/modules/class.itemmodule.php b/server/includes/modules/class.itemmodule.php index 9250a830d..8ce0c3a4f 100644 --- a/server/includes/modules/class.itemmodule.php +++ b/server/includes/modules/class.itemmodule.php @@ -754,13 +754,124 @@ public function delete($store, $parententryid, $entryid, $action) { $soft = $action['message_action']['soft_delete'] ?? false; $unread = $action['message_action']['non_read_notify'] ?? false; + + // When the client asked to track the new entryids (undo support), + // snapshot the target wastebasket and the items' move-stable search + // keys before the delete, so they can be relocated afterwards. + $undoInfo = $this->prepareDeleteUndo($store, $parententryid, $entryid, $action, $soft); + $result = $GLOBALS["operations"]->deleteMessages($store, $parententryid, $entryid, $soft, $unread); if ($result) { $GLOBALS["bus"]->notify(bin2hex($parententryid), TABLE_DELETE, $props); - $this->sendFeedback(true); + + $this->sendFeedback(true, $this->buildDeleteUndoFeedback($undoInfo)); } } + /** + * Snapshot the information needed to make a delete undoable (see the + * 'track_new_entryids' message action). Determines where deleteMessages() + * will move the items and remembers their move-stable PR_SEARCH_KEYs, as + * well as the items already present in the target wastebasket which share + * one of those keys. Only deletes that end up in a wastebasket (not + * hard/soft deletes) can be tracked; otherwise null is returned. + * + * Must be called before the delete, while the items still exist. + * + * @param object $store MAPI Message Store Object + * @param string $parententryid parent entryid of the message(s) + * @param mixed $entryid one entryid or a list of entryids + * @param array $action the action data, sent by the client + * @param bool $soft whether this is a soft delete + * + * @return null|array the undo snapshot, or null when not trackable + */ + protected function prepareDeleteUndo($store, $parententryid, $entryid, $action, $soft) { + if (empty($action['message_action']['track_new_entryids']) || $soft) { + return null; + } + + $msgprops = mapi_getprops($store, [PR_IPM_WASTEBASKET_ENTRYID, PR_MDB_PROVIDER]); + $wastebasketStore = null; + $wastebasketEntryid = false; + + switch ($msgprops[PR_MDB_PROVIDER] ?? '') { + case ZARAFA_SERVICE_GUID: + // Own store: items move to the own wastebasket, unless + // they are already in it (then they are hard deleted). + if (isset($msgprops[PR_IPM_WASTEBASKET_ENTRYID]) && $msgprops[PR_IPM_WASTEBASKET_ENTRYID] != $parententryid) { + $wastebasketStore = $store; + $wastebasketEntryid = $msgprops[PR_IPM_WASTEBASKET_ENTRYID]; + } + break; + + case ZARAFA_STORE_DELEGATE_GUID: + // Delegate store: items move to the default store's + // wastebasket (see Operations::deleteMessages). + $softDefault = defined('ENABLE_DEFAULT_SOFT_DELETE') ? ENABLE_DEFAULT_SOFT_DELETE : false; + if (!$softDefault && (!isset($msgprops[PR_IPM_WASTEBASKET_ENTRYID]) || $msgprops[PR_IPM_WASTEBASKET_ENTRYID] != $parententryid)) { + $defaultStore = $GLOBALS["mapisession"]->getDefaultMessageStore(); + $defaultProps = mapi_getprops($defaultStore, [PR_IPM_WASTEBASKET_ENTRYID]); + if (isset($defaultProps[PR_IPM_WASTEBASKET_ENTRYID]) && $defaultProps[PR_IPM_WASTEBASKET_ENTRYID] != $parententryid) { + $wastebasketStore = $defaultStore; + $wastebasketEntryid = $defaultProps[PR_IPM_WASTEBASKET_ENTRYID]; + } + } + break; + } + + if ($wastebasketEntryid === false) { + return null; + } + + $searchKeys = $GLOBALS["operations"]->getMessageSearchKeys($store, $entryid); + if (empty($searchKeys)) { + return null; + } + + return [ + 'store' => $wastebasketStore, + 'entryid' => $wastebasketEntryid, + 'searchKeys' => $searchKeys, + 'existing' => $GLOBALS["operations"]->getFolderEntryidsBySearchKey($wastebasketStore, $wastebasketEntryid, $searchKeys), + ]; + } + + /** + * Build the 'undo' feedback object for a completed delete from the snapshot + * taken by prepareDeleteUndo(). Any failure while resolving the new + * location is swallowed so the (already completed) delete is still reported + * as successful. + * + * @param null|array $undoInfo the snapshot returned by prepareDeleteUndo(), + * or null when the delete was not trackable + * + * @return array the feedback array, possibly containing an 'undo' key + */ + protected function buildDeleteUndoFeedback($undoInfo) { + $feedback = []; + if (empty($undoInfo)) { + return $feedback; + } + + try { + $newEntryids = $GLOBALS["operations"]->resolveNewEntryids($undoInfo['store'], $undoInfo['entryid'], $undoInfo['searchKeys'], $undoInfo['existing']); + if (!empty($newEntryids)) { + $wastebasketStoreProps = mapi_getprops($undoInfo['store'], [PR_ENTRYID]); + $feedback['undo'] = [ + 'new_entryids' => $newEntryids, + 'destination_parent_entryid' => bin2hex((string) $undoInfo['entryid']), + 'destination_store_entryid' => bin2hex((string) $wastebasketStoreProps[PR_ENTRYID]), + ]; + } + } + catch (MAPIException $e) { + $e->setHandled(); + } + + return $feedback; + } + /** * Function which returns the entryid of a default folder. * @@ -869,6 +980,18 @@ public function copy($store, $parententryid, $entryids, $action) { } } + // When the client asked to track the new entryids (undo support), + // remember the move-stable search keys before the operation so the + // items can be relocated in the destination folder afterwards, plus + // the items already present in the destination sharing those keys so + // they are not mistaken for the freshly copied/moved items. + $searchKeys = []; + $existingEntryids = []; + if (!empty($action["message_action"]["track_new_entryids"])) { + $searchKeys = $GLOBALS["operations"]->getMessageSearchKeys($store, $entryids); + $existingEntryids = $GLOBALS["operations"]->getFolderEntryidsBySearchKey($dest_store, $dest_folderentryid, $searchKeys); + } + $result = $GLOBALS["operations"]->copyMessages($store, $parententryid, $dest_store, $dest_folderentryid, $entryids, $moveMessages ? $skipCopyProperties : $this->skipCopyProperties, $moveMessages, $copyProps); if ($result) { @@ -886,7 +1009,27 @@ public function copy($store, $parententryid, $entryids, $action) { $GLOBALS["bus"]->notify(bin2hex($dest_folderentryid), TABLE_SAVE, $props); } - $this->sendFeedback($result, []); + $feedback = []; + if ($result && !empty($searchKeys)) { + // Best-effort: a failure resolving the new location must not + // turn the already completed copy/move into an error. + try { + $newEntryids = $GLOBALS["operations"]->resolveNewEntryids($dest_store, $dest_folderentryid, $searchKeys, $existingEntryids); + if (!empty($newEntryids)) { + $destStoreProps = mapi_getprops($dest_store, [PR_ENTRYID]); + $feedback['undo'] = [ + 'new_entryids' => $newEntryids, + 'destination_parent_entryid' => bin2hex($dest_folderentryid), + 'destination_store_entryid' => bin2hex((string) $destStoreProps[PR_ENTRYID]), + ]; + } + } + catch (MAPIException $e) { + $e->setHandled(); + } + } + + $this->sendFeedback($result, $feedback); } } diff --git a/server/includes/modules/class.taskitemmodule.php b/server/includes/modules/class.taskitemmodule.php index 32435ae8c..dc1127ff9 100644 --- a/server/includes/modules/class.taskitemmodule.php +++ b/server/includes/modules/class.taskitemmodule.php @@ -156,17 +156,26 @@ public function delete($store, $parententryid, $entryids, $action) { $storeprops = mapi_getprops($store, [PR_ENTRYID]); $props[PR_STORE_ENTRYID] = $storeprops[PR_ENTRYID]; + // A plain task delete moves the task to the wastebasket (see + // deleteTask), so snapshot the undo information beforehand just + // like ItemModule::delete does. Occurrence deletions and + // task-request declines are not tracked (the client never asks to + // track them, as they carry an action_type). + $soft = $action['message_action']['soft_delete'] ?? false; + $undoInfo = $this->prepareDeleteUndo($store, $parententryid, $entryids, $action, $soft); + $result = $this->deleteTask($store, $parententryid, $entryids, $action); if ($result) { if (isset($result['occurrenceDeleted']) && $result['occurrenceDeleted']) { // Occurrence deleted, update item $GLOBALS["bus"]->notify(bin2hex($parententryid), TABLE_SAVE, $props); + $this->sendFeedback(true); } else { $GLOBALS["bus"]->notify(bin2hex($parententryid), TABLE_DELETE, $props); + $this->sendFeedback(true, $this->buildDeleteUndoFeedback($undoInfo)); } - $this->sendFeedback(true); } } }