(bug 45029) Transactions for metadata modification.

Have created builders for insertion, removal, and single element replacement.

In adding Document.getMetadata which is nearly identical to Document.getData,
the two functions have been refactored to use a common static method
getDataSlice, with this.data/this.metadata as an argument.

Document.spliceMetadata has been added. It is essentially spliceData with
the data/metadata synchronisation issue.

Metadata cursor position is now tracked in the TransactionProcessor. Cursor
advancement has been moved to a function so the metadata cursor can be reset
every time the data cursor is moved.

There were unhit bugs in the TransactionProcessor run test section, where
the data being loaded into the test documents wasn't always being deep-copied,
and the assert was looking at getData instead of getFulldata (which wouldn't
be able to test metadata changes).

Change-Id: Ieb20ab3e7827bc7ff04148f147da6728eb1eb666
This commit is contained in:
Ed Sanders 2013-02-14 15:21:53 -08:00
parent 2c719b3e74
commit 67e9d5d1dd
5 changed files with 463 additions and 25 deletions

View file

@ -426,6 +426,29 @@ ve.dm.Document.isContentData = function ( data ) {
return true;
};
/**
* Get a slice or copy of the provided data.
*
* @static
* @method
* @param {Array} sourceData Source data to slice up
* @param {ve.Range} [range] Range of data to get, all data will be given by default
* @param {boolean} [deep=false] Whether to return a deep copy (WARNING! This may be very slow)
* @returns {Array} Slice or copy of document data
*/
ve.dm.Document.getDataSlice = function ( sourceData, range, deep ) {
var end, data,
start = 0;
if ( range !== undefined ) {
start = Math.max( 0, Math.min( sourceData.length, range.start ) );
end = Math.max( 0, Math.min( sourceData.length, range.end ) );
}
// IE work-around: arr.slice( 0, undefined ) returns [] while arr.slice( 0 ) behaves correctly
data = end === undefined ? sourceData.slice( start ) : sourceData.slice( start, end );
// Return either the slice or a deep copy of the slice
return deep ? ve.copyArray( data ) : data;
};
/* Methods */
/**
@ -457,16 +480,19 @@ ve.dm.Document.prototype.commit = function ( transaction ) {
* @returns {Array} Slice or copy of document data
*/
ve.dm.Document.prototype.getData = function ( range, deep ) {
var end, data,
start = 0;
if ( range !== undefined ) {
start = Math.max( 0, Math.min( this.data.length, range.start ) );
end = Math.max( 0, Math.min( this.data.length, range.end ) );
}
// IE work-around: arr.slice( 0, undefined ) returns [] while arr.slice( 0 ) behaves correctly
data = end === undefined ? this.data.slice( start ) : this.data.slice( start, end );
// Return either the slice or a deep copy of the slice
return deep ? ve.copyArray( data ) : data;
return this.constructor.getDataSlice( this.data, range, deep );
};
/**
* Get a slice or copy of the document metadata.
*
* @method
* @param {ve.Range} [range] Range of metadata to get, all metadata will be given by default
* @param {boolean} [deep=false] Whether to return a deep copy (WARNING! This may be very slow)
* @returns {Array} Slice or copy of document metadata
*/
ve.dm.Document.prototype.getMetadata = function ( range, deep ) {
return this.constructor.getDataSlice( this.metadata, range, deep );
};
/**
@ -515,6 +541,27 @@ ve.dm.Document.prototype.spliceData = function ( offset, remove, insert ) {
return spliced;
};
/**
* Splice metadata into and/or out of the linear model.
*
* `this.metadata` will be updated accordingly.
*
* @method
* @see ve#batchSplice
* @param offset
* @param index
* @param remove
* @param insert
*/
ve.dm.Document.prototype.spliceMetadata = function ( offset, index, remove, insert ) {
var elements = this.metadata[offset];
if ( !elements ) {
this.metadata[offset] = elements = [];
}
insert = insert || [];
return ve.batchSplice( elements, index, remove, insert );
};
/**
* Get the full document data including metadata.
*

View file

@ -28,7 +28,7 @@ ve.dm.Transaction = function VeDmTransaction() {
* @param {ve.dm.Document} doc Document to create transaction for
* @param {number} offset Offset to insert at
* @param {Array} data Data to insert
* @returns {ve.dm.Transaction} Transcation that inserts data
* @returns {ve.dm.Transaction} Transaction that inserts data
*/
ve.dm.Transaction.newFromInsertion = function ( doc, offset, insertion ) {
var tx = new ve.dm.Transaction(),
@ -65,7 +65,7 @@ ve.dm.Transaction.newFromInsertion = function ( doc, offset, insertion ) {
* @method
* @param {ve.dm.Document} doc Document to create transaction for
* @param {ve.Range} range Range of data to remove
* @returns {ve.dm.Transaction} Transcation that removes data
* @returns {ve.dm.Transaction} Transaction that removes data
* @throws {Error} Invalid range
*/
ve.dm.Transaction.newFromRemoval = function ( doc, range ) {
@ -163,7 +163,7 @@ ve.dm.Transaction.newFromRemoval = function ( doc, range ) {
* @param {number} offset Offset of element
* @param {string} key Attribute name
* @param {Mixed} value New value, or undefined to remove the attribute
* @returns {ve.dm.Transaction} Transcation that changes an element
* @returns {ve.dm.Transaction} Transaction that changes an element
* @throws {Error} Cannot set attributes to non-element data
* @throws {Error} Cannot set attributes on closing element
*/
@ -255,6 +255,121 @@ ve.dm.Transaction.newFromAnnotation = function ( doc, range, method, annotation
return tx;
};
/**
* Generate a transaction that inserts metadata elements.
*
* @static
* @method
* @param {ve.dm.Document} doc Document to create transaction for
* @param {number} offset Offset of element
* @param {number} index Index of metadata cursor within element
* @param {Array} newElements New elements to insert
* @returns {ve.dm.Transaction} Transaction that inserts the metadata elements
*/
ve.dm.Transaction.newFromMetadataInsertion = function ( doc, offset, index, newElements ) {
var tx = new ve.dm.Transaction(),
data = doc.getMetadata(),
elements = data[offset] || [];
// Retain up to element
tx.pushRetain( offset );
// Retain up to metadata element (second dimension)
tx.pushRetainMetadata( index );
// Insert metadata elements
tx.pushReplaceMetadata(
[], newElements
);
// Retain up to end of metadata elements (second dimension)
tx.pushRetainMetadata( elements.length - index );
// Retain to end of document
tx.pushRetain( data.length - offset );
return tx;
};
/**
* Generate a transaction that removes metadata elements.
*
* @static
* @method
* @param {ve.dm.Document} doc Document to create transaction for
* @param {number} offset Offset of element
* @param {ve.Range} range Range of metadata to remove
* @returns {ve.dm.Transaction} Transaction that removes data
* @throws {Error} Cannot remove metadata from empty list
* @throws {Error} Range out of bounds
*/
ve.dm.Transaction.newFromMetadataRemoval = function ( doc, offset, range ) {
var selection,
tx = new ve.dm.Transaction(),
data = doc.getMetadata(),
elements = data[offset] || [];
if ( !elements.length ) {
throw new Error( 'Cannot remove metadata from empty list' );
}
if ( range.start < 0 || range.end > elements.length ) {
throw new Error( 'Range out of bounds' );
}
selection = elements.slice( range.start, range.end );
// Retain up to element
tx.pushRetain( offset );
// Retain up to metadata element (second dimension)
tx.pushRetainMetadata( range.start );
// Remove metadata elements
tx.pushReplaceMetadata(
selection, []
);
// Retain up to end of metadata elements (second dimension)
tx.pushRetainMetadata( elements.length - range.end );
// Retain to end of document
tx.pushRetain( data.length - offset );
return tx;
};
/**
* Generate a transaction that relaces a single metadata element.
*
* @static
* @method
* @param {ve.dm.Document} doc Document to create transaction for
* @param {number} offset Offset of element
* @param {number} index Index of metadata cursor within element
* @param {Object} newElement New element to insert
* @returns {ve.dm.Transaction} Transaction that removes data
* @throws {Error} Metadata index out of bounds
*/
ve.dm.Transaction.newFromMetadataElementReplacement = function ( doc, offset, index, newElement ) {
var oldElement,
tx = new ve.dm.Transaction(),
data = doc.getMetadata(),
elements = data[offset] || [];
if ( index >= elements.length ) {
throw new Error( 'Metadata index out of bounds' );
}
oldElement = elements[index];
// Retain up to element
tx.pushRetain( offset );
// Retain up to metadata element (second dimension)
tx.pushRetainMetadata( index );
// Remove metadata elements
tx.pushReplaceMetadata(
[ oldElement ], [ newElement ]
);
// Retain up to end of metadata elements (second dimension)
tx.pushRetainMetadata( elements.length - index - 1 );
// Retain to end of document
tx.pushRetain( data.length - offset );
return tx;
};
/**
* Generate a transaction that converts elements that can contain content.
*
@ -636,6 +751,31 @@ ve.dm.Transaction.prototype.pushRetain = function ( length ) {
}
};
/**
* Add a retain metadata operation.
* // TODO: this is a copy/paste of pushRetain (at the moment). Consider a refactor.
*
* @method
* @param {number} length Length of content data to retain
* @throws {Error} Cannot retain backwards.
*/
ve.dm.Transaction.prototype.pushRetainMetadata = function ( length ) {
if ( length < 0 ) {
throw new Error( 'Invalid retain length, cannot retain backwards:' + length );
}
if ( length ) {
var end = this.operations.length - 1;
if ( this.operations.length && this.operations[end].type === 'retainMetadata' ) {
this.operations[end].length += length;
} else {
this.operations.push( {
'type': 'retainMetadata',
'length': length
} );
}
}
};
/**
* Add a replace operation
*
@ -656,6 +796,26 @@ ve.dm.Transaction.prototype.pushReplace = function ( remove, insert ) {
this.lengthDifference += insert.length - remove.length;
};
/**
* Add a replace metadata operation
* // TODO: this is a copy/paste of pushRetainMetadata (at the moment). Consider a refactor.
*
* @method
* @param {Array} remove Metadata to remove
* @param {Array} insert Metadata to replace 'remove' with
*/
ve.dm.Transaction.prototype.pushReplaceMetadata = function ( remove, insert ) {
if ( remove.length === 0 && insert.length === 0 ) {
// Don't push no-ops
return;
}
this.operations.push( {
'type': 'replaceMetadata',
'remove': remove,
'insert': insert
} );
};
/**
* Add an element attribute change operation.
*

View file

@ -27,6 +27,7 @@ ve.dm.TransactionProcessor = function VeDmTransactionProcessor( doc, transaction
// Linear model offset that we're currently at. Operations in the transaction are ordered, so
// the cursor only ever moves forward.
this.cursor = 0;
this.metadataCursor = 0;
// Adjustment used to convert between linear model offsets in the original linear model and
// in the half-updated linear model.
this.adjustment = 0;
@ -101,6 +102,17 @@ ve.dm.TransactionProcessor.prototype.executeOperation = function ( op ) {
}
};
/**
* Advance the main data cursor.
*
* @method
* @param {number} increment Number of positions to increment the cursor by
*/
ve.dm.TransactionProcessor.prototype.advanceCursor = function ( increment ) {
this.cursor += increment;
this.metadataCursor = 0;
};
/**
* Process all operations.
*
@ -286,7 +298,7 @@ ve.dm.TransactionProcessor.prototype.applyChangeMarkers = function () {
/**
* Execute a retain operation.
*
* This method is called within the context of a document synchronizer instance.
* This method is called within the context of a transaction processor instance.
*
* This moves the cursor by op.length and applies annotations to the characters that the cursor
* moved over.
@ -297,13 +309,28 @@ ve.dm.TransactionProcessor.prototype.applyChangeMarkers = function () {
*/
ve.dm.TransactionProcessor.processors.retain = function ( op ) {
this.applyAnnotations( this.cursor + op.length );
this.cursor += op.length;
this.advanceCursor( op.length );
};
/**
* Execute a metadata retain operation.
*
* This method is called within the context of a transaction processor instance.
*
* This moves the metadata cursor by op.length.
*
* @method
* @param {Object} op Operation object:
* @param {number} op.length Number of elements to retain
*/
ve.dm.TransactionProcessor.processors.retainMetadata = function ( op ) {
this.metadataCursor += op.length;
};
/**
* Execute an annotate operation.
*
* This method is called within the context of a document synchronizer instance.
* This method is called within the context of a transaction processor instance.
*
* This will add an annotation to or remove an annotation from `this.set`or `this.clear`.
*
@ -334,7 +361,7 @@ ve.dm.TransactionProcessor.processors.annotate = function ( op ) {
/**
* Execute an attribute operation.
*
* This method is called within the context of a document synchronizer instance.
* 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`
@ -379,7 +406,7 @@ ve.dm.TransactionProcessor.processors.attribute = function ( op ) {
/**
* Execute a replace operation.
*
* This method is called within the context of a document synchronizer instance.
* This method is called within the context of a transaction processor instance.
*
* This replaces a range of linear model data with another at this.cursor, figures out how the model
* tree needs to be synchronized, and queues this in the DocumentSynchronizer.
@ -457,7 +484,7 @@ ve.dm.TransactionProcessor.processors.replace = function ( op ) {
this.setChangeMarker( parentOffset + this.adjustment, 'content' );
}
// Advance the cursor
this.cursor += insert.length;
this.advanceCursor( insert.length );
this.adjustment += insert.length - remove.length;
} else {
// Structural replacement
@ -477,7 +504,7 @@ ve.dm.TransactionProcessor.processors.replace = function ( op ) {
this.cursor - this.adjustment + opRemove.length
) );
prevCursor = this.cursor;
this.cursor += opInsert.length;
this.advanceCursor( opInsert.length );
// Paint the removed selection, figure out which nodes were
// covered, and add their ranges to the affected ranges list
if ( opRemove.length > 0 ) {
@ -584,3 +611,20 @@ ve.dm.TransactionProcessor.processors.replace = function ( op ) {
);
}
};
/**
* Execute a metadata replace operation.
*
* This method is called within the context of a transaction processor instance.
*
* @method
* @param {Object} op Operation object
* @param {Array} op.remove Metadata to remove
* @param {Array} op.insert Metadata to insert
*/
ve.dm.TransactionProcessor.processors.replaceMetadata = function ( op ) {
var remove = this.reversed ? op.insert : op.remove,
insert = this.reversed ? op.remove : op.insert;
this.document.spliceMetadata( this.cursor, this.metadataCursor, remove.length, insert );
};

View file

@ -1115,3 +1115,122 @@ QUnit.test( 'push*Annotating', 8, function ( assert ) {
};
runBuilderTests( assert, cases );
} );
QUnit.test( 'newFromMetadataInsertion', 2, function( assert ) {
var doc = new ve.dm.Document( ve.copyArray( ve.dm.example.withMeta ) ),
element = {
'type': 'metaInline',
'attributes': {
'style': 'comment',
'text': ' inline '
}
},
cases = {
'inserting metadata element into existing element list': {
'args': [ doc, 11, 2, [ element ] ],
'ops': [
{ 'type': 'retain', 'length': 11 },
{ 'type': 'retainMetadata', 'length': 2 },
{
'type': 'replaceMetadata',
'remove': [],
'insert': [ element ]
},
{ 'type': 'retainMetadata', 'length': 2 },
{ 'type': 'retain', 'length': 1 },
]
},
'inserting metadata element into empty list': {
'args': [ doc, 3, 0, [ element ] ],
'ops': [
{ 'type': 'retain', 'length': 3 },
{
'type': 'replaceMetadata',
'remove': [],
'insert': [ element ]
},
{ 'type': 'retain', 'length': 9 },
]
}
};
runConstructorTests( assert, ve.dm.Transaction.newFromMetadataInsertion, cases );
} );
QUnit.test( 'newFromMetadataRemoval', 4, function( assert ) {
var doc = new ve.dm.Document( ve.copyArray( ve.dm.example.withMeta ) ),
allElements = ve.dm.example.withMetaMetaData[11],
someElements = allElements.slice( 1, 3 ),
cases = {
'removing all metadata elements from metadata list': {
'args': [ doc, 11, new ve.Range( 0, 4 ) ],
'ops': [
{ 'type': 'retain', 'length': 11 },
{
'type': 'replaceMetadata',
'remove': allElements,
'insert': []
},
{ 'type': 'retain', 'length': 1 },
]
},
'removing some metadata elements from metadata list': {
'args': [ doc, 11, new ve.Range( 1, 3 ) ],
'ops': [
{ 'type': 'retain', 'length': 11 },
{ 'type': 'retainMetadata', 'length': 1 },
{
'type': 'replaceMetadata',
'remove': someElements,
'insert': []
},
{ 'type': 'retainMetadata', 'length': 1 },
{ 'type': 'retain', 'length': 1 },
]
},
'checks metadata at offset is non-empty': {
'args': [ doc, 5, new ve.Range( 1, 3 ) ],
'exception': Error
},
'checks range is valid for metadata at offset': {
'args': [ doc, 11, new ve.Range( 1, 5 ) ],
'exception': Error
}
};
runConstructorTests( assert, ve.dm.Transaction.newFromMetadataRemoval, cases );
} );
QUnit.test( 'newFromMetadataElementReplacement', 3, function( assert ) {
var doc = new ve.dm.Document( ve.copyArray( ve.dm.example.withMeta ) ),
newElement = {
'type': 'metaInline',
'attributes': {
'style': 'comment',
'text': ' inline '
}
},
oldElement = ve.dm.example.withMetaMetaData[11][3],
cases = {
'replacing metadata at end of list': {
'args': [ doc, 11, 3, newElement ],
'ops': [
{ 'type': 'retain', 'length': 11 },
{ 'type': 'retainMetadata', 'length': 3 },
{
'type': 'replaceMetadata',
'remove': [ oldElement ],
'insert': [ newElement ]
},
{ 'type': 'retain', 'length': 1 },
]
},
'checks offset is in bounds': {
'args': [ doc, 15, 0, newElement ],
'exception': Error
},
'checks metadata index is in bounds': {
'args': [ doc, 11, 5, newElement ],
'exception': Error
}
};
runConstructorTests( assert, ve.dm.Transaction.newFromMetadataElementReplacement, cases );
} );

View file

@ -40,12 +40,20 @@ QUnit.test( 'protection against double application', 3, function ( assert ) {
);
} );
QUnit.test( 'commit/rollback', 66, function ( assert ) {
QUnit.test( 'commit/rollback', 86, function ( assert ) {
var i, key, originalData, originalDoc, msg, testDocument, tx,
expectedData, expectedDocument,
bold = ve.dm.example.createAnnotation( ve.dm.example.bold ),
italic = ve.dm.example.createAnnotation( ve.dm.example.italic ),
underline = ve.dm.example.createAnnotation( ve.dm.example.underline ),
metaElementInsert = {
'type': 'metaInline',
'attributes': {
'style': 'comment',
'text': ' inline '
}
},
metaElementInsertClose = { 'type': '/metaInline' },
cases = {
'no operations': {
'calls': [],
@ -300,6 +308,66 @@ QUnit.test( 'commit/rollback', 66, function ( assert ) {
data.splice( 4, 0, 'b' );
ve.setProp( data[0], 'internal', 'changed', 'content', 1 );
}
},
'inserting metadata element into existing element list': {
'data': ve.dm.example.withMeta,
'calls': [
['pushRetain', 11 ],
['pushRetainMetadata', 2 ],
['pushReplaceMetadata', [], [ metaElementInsert ] ],
['pushRetainMetadata', 2 ],
['pushRetain', 1 ],
],
'expected': function( data ) {
data.splice( 25, 0, metaElementInsert, metaElementInsertClose );
}
},
'inserting metadata element into empty list': {
'data': ve.dm.example.withMeta,
'calls': [
['pushRetain', 3 ],
['pushReplaceMetadata', [], [ metaElementInsert ] ],
['pushRetain', 9 ],
],
'expected': function( data ) {
data.splice( 7, 0, metaElementInsert, metaElementInsertClose );
}
},
'removing all metadata elements from a metadata list': {
'data': ve.dm.example.withMeta,
'calls': [
['pushRetain', 11 ],
['pushReplaceMetadata', ve.dm.example.withMetaMetaData[11], [] ],
['pushRetain', 1 ],
],
'expected': function( data ) {
data.splice( 21, 8 );
}
},
'removing some metadata elements from metadata list': {
'data': ve.dm.example.withMeta,
'calls': [
['pushRetain', 11 ],
['pushRetainMetadata', 1 ],
['pushReplaceMetadata', ve.dm.example.withMetaMetaData[11].slice( 1, 3 ), [] ],
['pushRetainMetadata', 1 ],
['pushRetain', 1 ],
],
'expected': function( data ) {
data.splice( 23, 4 );
}
},
'replacing metadata at end of list': {
'data': ve.dm.example.withMeta,
'calls': [
['pushRetain', 11 ],
['pushRetainMetadata', 3 ],
['pushReplaceMetadata', [ ve.dm.example.withMetaMetaData[11][3] ], [ metaElementInsert ] ],
['pushRetain', 1 ],
],
'expected': function( data ) {
data.splice( 27, 2, metaElementInsert, metaElementInsertClose );
}
}
};
@ -321,15 +389,15 @@ QUnit.test( 'commit/rollback', 66, function ( assert ) {
if ( 'expected' in cases[msg] ) {
// Generate original document
originalData = cases[msg].data || ve.dm.example.data;
originalDoc = new ve.dm.Document( originalData );
originalDoc = new ve.dm.Document( ve.copyArray( originalData ) );
testDocument = new ve.dm.Document( ve.copyArray( originalData ) );
// Generate expected document
expectedData = ve.copyArray( originalData );
cases[msg].expected( expectedData );
expectedDocument = new ve.dm.Document( expectedData );
expectedDocument = new ve.dm.Document( ve.copyArray ( expectedData ) );
// Commit
ve.dm.TransactionProcessor.commit( testDocument, tx );
assert.deepEqual( testDocument.getData(), expectedData, 'commit (data): ' + msg );
assert.deepEqual( testDocument.getFullData(), expectedData, 'commit (data): ' + msg );
assert.equalNodeTree(
testDocument.getDocumentNode(),
expectedDocument.getDocumentNode(),
@ -337,7 +405,7 @@ QUnit.test( 'commit/rollback', 66, function ( assert ) {
);
// Rollback
ve.dm.TransactionProcessor.rollback( testDocument, tx );
assert.deepEqual( testDocument.getData(), originalData, 'rollback (data): ' + msg );
assert.deepEqual( testDocument.getFullData(), originalData, 'rollback (data): ' + msg );
assert.equalNodeTree(
testDocument.getDocumentNode(),
originalDoc.getDocumentNode(),