/*!
* VisualEditor DataModel TransactionProcessor class.
*
* @copyright See AUTHORS.txt
*/
/**
* DataModel transaction processor.
*
* This class reads operations from a transaction and applies them one by one. It's not intended
* to be used directly; use {ve.dm.Document#commit} instead.
*
* NOTE: Instances of this class are not recyclable: you can only call .process() on them once.
*
* @class
* @param {ve.dm.Document} doc
* @param {ve.dm.Transaction} transaction
* @param {boolean} isStaging Transaction is being applied in staging mode
* @constructor
*/
ve.dm.TransactionProcessor = function VeDmTransactionProcessor( doc, transaction, isStaging ) {
// Properties
this.document = doc;
this.transaction = transaction;
this.operations = transaction.getOperations();
this.isStaging = isStaging;
this.modificationQueue = [];
this.rollbackQueue = [];
this.eventQueue = [];
// Linear model offset that we're currently at. Operations in the transaction are ordered, so
// the cursor only ever moves forward.
this.cursor = 0;
// Adjustment that needs to be added to linear model offsets in the original linear model
// to get offsets in the half-updated linear model. Arguments to queued modifications all use
// unadjusted offsets; this is needed to adjust those offsets after other modifications have been
// made to the linear model that have caused offsets to shift.
this.adjustment = 0;
// State tracking for unbalanced replace operations
this.replaceRemoveLevel = 0;
this.replaceInsertLevel = 0;
this.replaceMinInsertLevel = 0;
this.retainDepth = 0;
this.balanced = true;
};
/* Static members */
/* See ve.dm.TransactionProcessor.modifiers */
ve.dm.TransactionProcessor.modifiers = {};
/* See ve.dm.TransactionProcessor.processors */
ve.dm.TransactionProcessor.processors = {};
/* Methods */
/**
* Execute an operation.
*
* @private
* @param {Object} op Operation object to execute
* @throws {Error} Operation type is not supported
*/
ve.dm.TransactionProcessor.prototype.executeOperation = function ( op ) {
if ( Object.prototype.hasOwnProperty.call( ve.dm.TransactionProcessor.processors, op.type ) ) {
ve.dm.TransactionProcessor.processors[ op.type ].call( this, op );
} else {
throw new Error( 'Invalid operation error. Operation type is not supported: ' + op.type );
}
};
/**
* Process all operations.
*
* When all operations are done being processed, the document will be synchronized.
*
* @private
*/
ve.dm.TransactionProcessor.prototype.process = function () {
// Warning: some of this is vestigial. Before TreeModifier, things worked as follows:
// 1) executeOperation ran on each operation. This built a list of modifications,
// .modificationQueue, consisting of linear splices and attribute/annotation changes.
// 2) applyModifications processed .modificationQueue. In particular it executed the
// linear splices (invalidating the DM tree).
// 3) rebuildTree rebuilt the part of the DM tree invalidated by the linear splices.
//
// Since then, we removed annotation changes completely. And TreeModifier handles
// replacements. So for replacements:
// 1) executeOperation does very little (just a balancedness check)
// 2) applyModifications queues linear splices for rollback on error
// 3) rebuildTree is only called in the rollback case
// Ensure the pre-modification document tree has been generated
this.document.getDocumentNode();
// First process each operation to gather modifications in the modification queue.
// If an exception occurs during this stage, we don't need to do anything to recover,
// because no modifications were made yet.
for ( let i = 0; i < this.operations.length; i++ ) {
this.executeOperation( this.operations[ i ] );
}
if ( !this.balanced ) {
throw new Error( 'Unbalanced set of replace operations found' );
}
let completed;
// Apply the queued modifications
try {
completed = false;
this.applyModifications();
ve.dm.treeModifier.process( this.document, this.transaction );
completed = true;
} finally {
// Don't catch and re-throw errors so that they are reported properly
if ( !completed ) {
// Restore the linear model to its original state
if ( this.treeModifier ) {
this.treeModifier.undoLinearSplices();
}
this.rollbackModifications();
// The tree may have been left in some sort of half-baked state, so rebuild it
// from scratch
this.document.rebuildTree();
}
}
// Mark the transaction as committed
this.transaction.markAsApplied();
// Emit events in the queue
this.emitQueuedEvents();
};
/**
* Queue a modification.
*
* @private
* @param {Object} modification Object describing the modification
* @param {string} modification.type Name of a method in ve.dm.TransactionProcessor.modifiers
* @param {Array} [modification.args] Arguments to pass to this method
* @throws {Error} Unrecognized modification type
*/
ve.dm.TransactionProcessor.prototype.queueModification = function ( modification ) {
if ( typeof ve.dm.TransactionProcessor.modifiers[ modification.type ] !== 'function' ) {
throw new Error( 'Unrecognized modification type ' + modification.type );
}
this.modificationQueue.push( modification );
};
/**
* Queue an undo function. If an exception is thrown while modifying, #rollbackModifications will
* invoke these functions in reverse order.
*
* @param {Function} func Undo function to add to the queue
*/
ve.dm.TransactionProcessor.prototype.queueUndoFunction = function ( func ) {
this.rollbackQueue.push( func );
};
/**
* Apply all modifications queued through #queueModification, and add their rollback functions
* to this.rollbackQueue.
*
* @private
*/
ve.dm.TransactionProcessor.prototype.applyModifications = function () {
const modifications = this.modificationQueue;
this.modificationQueue = [];
for ( let i = 0, len = modifications.length; i < len; i++ ) {
const modifier = ve.dm.TransactionProcessor.modifiers[ modifications[ i ].type ];
modifier.apply( this, modifications[ i ].args || [] );
}
};
/**
* Roll back all modifications that have been applied so far. This invokes the callbacks returned
* by the modifier functions.
*
* @private
*/
ve.dm.TransactionProcessor.prototype.rollbackModifications = function () {
const rollbacks = this.rollbackQueue;
this.rollbackQueue = [];
for ( let i = rollbacks.length - 1; i >= 0; i-- ) {
rollbacks[ i ]();
}
};
/**
* Queue an event to be emitted on a node.
*
* Duplicate events will be ignored only if all arguments match exactly (i.e. are reference-equal).
*
* @private
* @param {ve.dm.Node} node
* @param {string} name Event name
* @param {...any} [args] Additional arguments to be passed to the event when fired
*/
ve.dm.TransactionProcessor.prototype.queueEvent = function ( node, name, ...args ) {
this.eventQueue.push( {
node,
name,
args: args.concat( this.transaction )
} );
};
/**
* Emit all events queued through #queueEvent.
*
* @private
*/
ve.dm.TransactionProcessor.prototype.emitQueuedEvents = function () {
function isDuplicate( event, otherEvent ) {
return otherEvent.node === event.node &&
otherEvent.name === event.name &&
otherEvent.args.every( ( arg, index ) => arg === event.args[ index ] );
}
const queue = this.eventQueue;
this.eventQueue = [];
queue.forEach( ( event, i ) => {
// Check if this event is a duplicate of something we've already emitted
if ( !queue.slice( 0, i ).some( ( e ) => isDuplicate( event, e ) ) ) {
event.node.emit( event.name, ...event.args );
}
} );
};
/**
* Advance the main data cursor.
*
* @private
* @param {number} increment Number of positions to increment the cursor by
*/
ve.dm.TransactionProcessor.prototype.advanceCursor = function ( increment ) {
this.cursor += increment;
};
/**
* Modifier methods.
*
* Each method executes a specific type of linear model modification, updates the model tree, and
* returns a function that undoes the linear model modification, in case we need to recover the
* previous linear model state. (The returned undo function does not undo the model tree update.)
* Methods are called in the context of a transaction processor, so they work similar to normal
* methods on the object.
*
* @class ve.dm.TransactionProcessor.modifiers
* @singleton
*/
/**
* Splice data into / out of the data array, and synchronize the tree.
*
* For efficiency, this function modifies the splice operation objects (i.e. the elements
* of the splices array). It also relies on these objects not being modified by others later.
*
* @param {Object[]} splices Array of splice operations to execute. Properties:
* {number} splices[].offset Offset to remove/insert at (unadjusted)
* {number} splices[].removeLength Number of elements to remove
* {Array} splices[].insert Data to insert; for efficiency, objects are inserted without cloning
*/
ve.dm.TransactionProcessor.modifiers.splice = function ( splices ) {
const data = this.document.data;
let lengthDiff = 0;
let i;
// We're about to do lots of things that can go wrong, so queue an undo function now
// that undoes all splices that we got to
this.queueUndoFunction( () => {
for ( let i2 = splices.length - 1; i2 >= 0; i2-- ) {
const s2 = splices[ i ];
if ( s2.removedData !== undefined ) {
data.batchSplice( s2.offset, s2.insert.length, s2.removedData );
}
}
} );
// Apply splices to the linmod and record how to undo them
for ( i = 0; i < splices.length; i++ ) {
const s = splices[ i ];
// Adjust s.offset for previous modifications that have already been synced to the tree;
// this value is used by the tree sync code later.
s.treeOffset = s.offset + this.adjustment;
// Also adjust s.offset for previous iterations of this loop (i.e. unsynced modifications);
// this is the value we need for the actual array splice.
s.offset = s.treeOffset + lengthDiff;
// Perform the splice and put the removed data in s, for the undo function
s.removedData = data.batchSplice( s.offset, s.removeLength, s.insert );
lengthDiff += s.insert.length - s.removeLength;
}
this.adjustment += lengthDiff;
};
/**
* Set an attribute at a given offset.
*
* @param {number} offset Offset in data array (unadjusted)
* @param {string} key Attribute name
* @param {any} value New attribute value
*/
ve.dm.TransactionProcessor.modifiers.setAttribute = function ( offset, key, value ) {
const data = this.document.data;
offset += this.adjustment;
const oldItem = data.getData( offset );
const oldValue = oldItem.attributes && oldItem.attributes[ key ];
data.setAttributeAtOffset( offset, key, value );
this.queueUndoFunction( () => {
data.setAttributeAtOffset( offset, key, oldValue );
} );
const node = this.document.getDocumentNode().getNodeFromOffset( offset + 1 );
// Update node element pointer
node.element = data.getData( offset );
this.queueEvent( node, 'attributeChange', key, oldValue, value );
this.queueEvent( node, 'update', this.isStaging );
};
/**
* Processing methods.
*
* Each method is specific to a type of action. Methods are called in the context of a transaction
* processor, so they work similar to normal methods on the object.
*
* @class ve.dm.TransactionProcessor.processors
* @singleton
*/
/**
* Execute a retain operation.
*
* Called within the context of a transaction processor instance; moves the cursor by op.length
*
* @param {Object} op Operation object:
* @param {number} op.length Number of elements to retain
*/
ve.dm.TransactionProcessor.processors.retain = function ( op ) {
if ( !this.balanced ) {
// Track the depth of retained data when in the middle of an unbalanced replace
const retainedData = this.document.getData( new ve.Range( this.cursor, this.cursor + op.length ) );
for ( let i = 0; i < retainedData.length; i++ ) {
const type = retainedData[ i ].type;
if ( type !== undefined ) {
this.retainDepth += type.charAt( 0 ) === '/' ? -1 : 1;
}
}
}
this.advanceCursor( op.length );
};
/**
* Execute an attribute operation.
*
* This method is called within the context of a transaction processor instance.
*
* This sets the attribute named `op.key` on the element at `this.cursor` to `op.to`, or unsets it if
* `op.to === undefined`. `op.from` is not checked against the old value, but is used instead of `op.to`
* in reverse mode. So if `op.from` is incorrect, the transaction will commit fine, but won't roll
* back correctly.
*
* @param {Object} op Operation object
* @param {string} op.key Attribute name
* @param {any} op.from Old attribute value, or undefined if not previously set
* @param {any} op.to New attribute value, or undefined to unset
*/
ve.dm.TransactionProcessor.processors.attribute = function ( op ) {
if ( !this.document.data.isElementData( this.cursor ) ) {
throw new Error( 'Invalid element error, cannot set attributes on non-element data' );
}
this.queueModification( {
type: 'setAttribute',
args: [ this.cursor, op.key, op.to ]
} );
};
/**
* Verify a replace operation (the actual processing is now done in ve.dm.TreeModifier)
*
* @param {Object} op Operation object
* @param {Array} op.remove Linear model data to remove
* @param {Array} op.insert Linear model data to insert
*/
ve.dm.TransactionProcessor.processors.replace = function ( op ) {
// Track balancedness for verification purposes only
// Walk through the remove and insert data
// and keep track of the element depth change (level)
// for each of these two separately. The model is
// only consistent if both levels are zero.
let i, type;
for ( i = 0; i < op.remove.length; i++ ) {
type = op.remove[ i ].type;
if ( type !== undefined ) {
if ( type.charAt( 0 ) === '/' ) {
// Closing element
this.replaceRemoveLevel--;
} else {
// Opening element
this.replaceRemoveLevel++;
}
}
}
for ( i = 0; i < op.insert.length; i++ ) {
type = op.insert[ i ].type;
if ( type !== undefined ) {
if ( type.charAt( 0 ) === '/' ) {
// Closing element
this.replaceInsertLevel--;
} else {
// Opening element
this.replaceInsertLevel++;
}
}
}
this.advanceCursor( op.remove.length );
this.balanced =
this.replaceRemoveLevel === 0 &&
this.replaceInsertLevel === 0 &&
this.retainDepth === 0;
};