From dc750987d7f5557149cdbfbe1d9277f2cc092ddb Mon Sep 17 00:00:00 2001 From: Satvik Kumar Date: Tue, 29 Jul 2014 17:42:08 +1000 Subject: [PATCH] Operational transforms for adding and removing lists This handles transforms between Add/Remove list and other operations that modify the number of steps in the document. There are some unresolved conflicts with merging paragraphs and adding lists that return null as the transform result which required merge and split operations on lists to be resolved correctly. Added tests for the new parts of the op transform matrix and changed the op transform test code to parse any op parameter containing "length" or "position" in the name as an integer. --- webodf/lib/manifest.json | 2 + webodf/lib/ops/OperationTransformMatrix.js | 368 +++++++++++++++++++-- webodf/tests/ops/TransformationTests.js | 16 +- webodf/tests/ops/transformationtests.xml | 332 +++++++++++++++++++ 4 files changed, 668 insertions(+), 50 deletions(-) diff --git a/webodf/lib/manifest.json b/webodf/lib/manifest.json index 9fb80aadb..f9a09b3c6 100644 --- a/webodf/lib/manifest.json +++ b/webodf/lib/manifest.json @@ -522,12 +522,14 @@ "ops.OperationFactory" ], "ops.OperationTransformMatrix": [ + "ops.OpAddList", "ops.OpAddStyle", "ops.OpApplyDirectStyling", "ops.OpInsertText", "ops.OpMergeParagraph", "ops.OpMoveCursor", "ops.OpRemoveCursor", + "ops.OpRemoveList", "ops.OpRemoveStyle", "ops.OpRemoveText", "ops.OpSetParagraphStyle", diff --git a/webodf/lib/ops/OperationTransformMatrix.js b/webodf/lib/ops/OperationTransformMatrix.js index a11dd0b1a..368a7ad80 100644 --- a/webodf/lib/ops/OperationTransformMatrix.js +++ b/webodf/lib/ops/OperationTransformMatrix.js @@ -30,6 +30,13 @@ ops.OperationTransformMatrix = function OperationTransformMatrix() { "use strict"; + var /**@const*/ + INCLUSIVE = true, + /**@const*/ + EXCLUSIVE = false, + /**@type {!Object., opSpecsB:!Array.}>>}*/ + transformations; + /* Utility methods */ /** @@ -267,10 +274,171 @@ ops.OperationTransformMatrix = function OperationTransformMatrix() { return result; } + /** + * Checks whether the given position is within the range of the add list operation. + * This range check is always inclusive of the start paragraph position + * @param {!number} position + * @param {!ops.OpAddList.Spec} spec + * @param {!boolean} isInclusiveEndPosition Range check is inclusive of the end paragraph position + * @return {!boolean} + */ + function isWithinRange(position, spec, isInclusiveEndPosition) { + var withinEnd; + withinEnd = isInclusiveEndPosition ? position <= spec.endParagraphPosition : position < spec.endParagraphPosition; + + return position >= spec.startParagraphPosition && withinEnd; + } /* Transformation methods */ + /** + * @param {!ops.OpAddList.Spec} addListSpecA + * @param {!ops.OpAddList.Spec} addListSpecB + * @return {?{opSpecsA:!Array., opSpecsB:!Array.}} + */ + function transformAddListAddList(addListSpecA, addListSpecB) { + var opSpecsA = [addListSpecA], + opSpecsB = [addListSpecB]; + + //TODO: consider style names. This can't be resolved currently as there is no op to set a style on a list after creation. + // same range so this becomes a no-op + if (addListSpecA.startParagraphPosition === addListSpecB.startParagraphPosition && + addListSpecA.endParagraphPosition === addListSpecB.endParagraphPosition) { + opSpecsA = []; + opSpecsB = []; + } + + // ranges intersect + if (isWithinRange(addListSpecA.startParagraphPosition, addListSpecB, INCLUSIVE) || + isWithinRange(addListSpecA.endParagraphPosition, addListSpecB, INCLUSIVE)) { + //TODO: do something useful here once we get list merge ops and solve the conflict by merging the lists + return null; + } + + return { + opSpecsA: opSpecsA, + opSpecsB: opSpecsB + }; + } + + /** + * @param {!ops.OpAddList.Spec} addListSpec + * @param {!ops.OpInsertText.Spec} insertTextSpec + * @return {?{opSpecsA:!Array., opSpecsB:!Array.}} + */ + function transformAddListInsertText(addListSpec, insertTextSpec) { + // insert text is before the add list range so adjust the start position and end position + if (insertTextSpec.position < addListSpec.startParagraphPosition) { + addListSpec.startParagraphPosition += insertTextSpec.text.length; + addListSpec.endParagraphPosition += insertTextSpec.text.length; + } else if (isWithinRange(insertTextSpec.position, addListSpec, EXCLUSIVE)) { + // otherwise insert text is within the add list range so only shift the end of the range + addListSpec.endParagraphPosition += insertTextSpec.text.length; + } + + return { + opSpecsA: [addListSpec], + opSpecsB: [insertTextSpec] + }; + } + + /** + * @param {!ops.OpAddList.Spec} addListSpec + * @param {!ops.OpMergeParagraph.Spec} mergeParagraphSpec + * @return {?{opSpecsA:!Array., opSpecsB:!Array.}} + */ + function transformAddListMergeParagraph(addListSpec, mergeParagraphSpec) { + if (mergeParagraphSpec.sourceStartPosition === addListSpec.startParagraphPosition) { + // TODO: handle this properly once we have Merge/Split list ops as merge paragraph pulls the paragraph out of the list + return null; + } + + if (mergeParagraphSpec.sourceStartPosition < addListSpec.startParagraphPosition) { + // merge op source paragraph is before the list range so adjust the start and the end + addListSpec.startParagraphPosition -= 1; + addListSpec.endParagraphPosition -= 1; + } else if (isWithinRange(mergeParagraphSpec.sourceStartPosition, addListSpec, EXCLUSIVE)) { + // merge op is fully contained in list range so just shift the end of the list range + addListSpec.endParagraphPosition -= 1; + } else if (mergeParagraphSpec.sourceStartPosition === addListSpec.endParagraphPosition) { + // merge op source paragraph is the same as the end of the list range so shift + // the end of the list range up to the merge op destination paragraph + addListSpec.endParagraphPosition = mergeParagraphSpec.destinationStartPosition; + } + + return { + opSpecsA: [addListSpec], + opSpecsB: [mergeParagraphSpec] + }; + } + + /** + * @param {!ops.OpAddList.Spec} addListSpec + * @param {!ops.OpRemoveList.Spec} removeListSpec + * @return {?{opSpecsA:!Array., opSpecsB:!Array.}} + */ + function transformAddListRemoveList(addListSpec, removeListSpec) { + // This should never happen as a client must ensure it does not add a list where one already exists + // and remove a list that does not exist in the document. + // This does not detect an overlap where the range of the add list operation occurs after the start position of the + // removed list as we don't know the end position of the removed list. + if (isWithinRange(removeListSpec.firstParagraphPosition, addListSpec, INCLUSIVE)) { + return null; + } + + return { + opSpecsA: [addListSpec], + opSpecsB: [removeListSpec] + }; + } + + /** + * @param {!ops.OpAddList.Spec} addListSpec + * @param {!ops.OpRemoveText.Spec} removeTextSpec + * @return {?{opSpecsA:!Array., opSpecsB:!Array.}} + */ + function transformAddListRemoveText(addListSpec, removeTextSpec) { + // remove text is before the add list range so adjust the start position and end position + if (removeTextSpec.position < addListSpec.startParagraphPosition) { + addListSpec.startParagraphPosition -= removeTextSpec.length; + addListSpec.endParagraphPosition -= removeTextSpec.length; + } else if (isWithinRange(removeTextSpec.position, addListSpec, EXCLUSIVE)) { + // otherwise remove text is within the add list range so only shift the end of the range + addListSpec.endParagraphPosition -= removeTextSpec.length; + } + + return { + opSpecsA: [addListSpec], + opSpecsB: [removeTextSpec] + }; + } + + /** + * @param {!ops.OpAddList.Spec} addListSpec + * @param {!ops.OpSplitParagraph.Spec} splitParagraphSpec + * @return {?{opSpecsA:!Array., opSpecsB:!Array.}} + */ + function transformAddListSplitParagraph(addListSpec, splitParagraphSpec) { + // split op source paragraph is before the list range so adjust the start and the end + if (splitParagraphSpec.sourceParagraphPosition < addListSpec.startParagraphPosition) { + addListSpec.startParagraphPosition += 1; + addListSpec.endParagraphPosition += 1; + } else if (isWithinRange(splitParagraphSpec.sourceParagraphPosition, addListSpec, EXCLUSIVE)) { + // split op is fully contained in list range so just shift the end of the list range + addListSpec.endParagraphPosition += 1; + } else if (splitParagraphSpec.sourceParagraphPosition === addListSpec.endParagraphPosition) { + // split op source paragraph is the same as the end of the list range so shift the range + // down to the split position which is the new end paragraph + addListSpec.endParagraphPosition = splitParagraphSpec.position + 1; + } + + return { + opSpecsA: [addListSpec], + opSpecsB: [splitParagraphSpec] + }; + } + /** * @param {!ops.OpAddStyle.Spec} addStyleSpec * @param {!ops.OpRemoveStyle.Spec} removeStyleSpec @@ -601,6 +769,23 @@ ops.OperationTransformMatrix = function OperationTransformMatrix() { }; } + /** + * @param {!ops.OpInsertText.Spec} insertTextSpec + * @param {!ops.OpRemoveList.Spec} removeListSpec + * @return {?{opSpecsA:!Array., opSpecsB:!Array.}} + */ + function transformInsertTextRemoveList(insertTextSpec, removeListSpec) { + // adjust list start position only if text is inserted before the list start position + if (insertTextSpec.position < removeListSpec.firstParagraphPosition) { + removeListSpec.firstParagraphPosition += insertTextSpec.text.length; + } + + return { + opSpecsA: [insertTextSpec], + opSpecsB: [removeListSpec] + }; + } + /** * @param {!ops.OpInsertText.Spec} insertTextSpec * @param {!ops.OpRemoveText.Spec} removeTextSpec @@ -793,6 +978,28 @@ ops.OperationTransformMatrix = function OperationTransformMatrix() { }; } + /** + * @param {!ops.OpMergeParagraph.Spec} mergeParagraphSpec + * @param {!ops.OpRemoveList.Spec} removeListSpec + * @return {?{opSpecsA:!Array., opSpecsB:!Array.}} + */ + function transformMergeParagraphRemoveList(mergeParagraphSpec, removeListSpec) { + // adjust list start position only if the paragraph being merged is before the list start position + if (mergeParagraphSpec.sourceStartPosition < removeListSpec.firstParagraphPosition) { + removeListSpec.firstParagraphPosition -= 1; + } else if (mergeParagraphSpec.sourceStartPosition === removeListSpec.firstParagraphPosition) { + // TODO: unable to handle this currently as merge paragraph pulls paragraphs out of the list + // One possible solution would be to add paragraph lengths to the merge paragraph spec + // to allow this transform to know how many steps to move the anchor of the remove list op + return null; + } + + return { + opSpecsA: [mergeParagraphSpec], + opSpecsB: [removeListSpec] + }; + } + /** * @param {!ops.OpMergeParagraph.Spec} mergeParagraphSpec * @param {!ops.OpRemoveText.Spec} removeTextSpec @@ -908,6 +1115,60 @@ ops.OperationTransformMatrix = function OperationTransformMatrix() { }; } + /** + * @param {!ops.OpRemoveList.Spec} removeListSpecA + * @param {!ops.OpRemoveList.Spec} removeListSpecB + * @return {?{opSpecsA:!Array., opSpecsB:!Array.}} + */ + function transformRemoveListRemoveList(removeListSpecA, removeListSpecB) { + var opSpecsA = [removeListSpecA], + opSpecsB = [removeListSpecB]; + + if (removeListSpecA.firstParagraphPosition === removeListSpecB.firstParagraphPosition) { + opSpecsA = []; + opSpecsB = []; + } + + return { + opSpecsA: opSpecsA, + opSpecsB: opSpecsB + }; + } + + /** + * @param {!ops.OpRemoveList.Spec} removeListSpec + * @param {!ops.OpRemoveText.Spec} removeTextSpec + * @return {?{opSpecsA:!Array., opSpecsB:!Array.}} + */ + function transformRemoveListRemoveText(removeListSpec, removeTextSpec) { + // adjust list start position only if text is removed before the list start position + if (removeTextSpec.position < removeListSpec.firstParagraphPosition) { + removeListSpec.firstParagraphPosition -= removeTextSpec.length; + } + + return { + opSpecsA: [removeListSpec], + opSpecsB: [removeTextSpec] + }; + } + + /** + * @param {!ops.OpRemoveList.Spec} removeListSpec + * @param {!ops.OpSplitParagraph.Spec} splitParagraphSpec + * @return {?{opSpecsA:!Array., opSpecsB:!Array.}} + */ + function transformRemoveListSplitParagraph(removeListSpec, splitParagraphSpec) { + // adjust list start position only if the paragraph being split is before the list start position + if (splitParagraphSpec.sourceParagraphPosition < removeListSpec.firstParagraphPosition) { + removeListSpec.firstParagraphPosition += 1; + } + + return { + opSpecsA: [removeListSpec], + opSpecsB: [splitParagraphSpec] + }; + } + /** * @param {!ops.OpUpdateParagraphStyle.Spec} updateParagraphStyleSpecA * @param {!ops.OpUpdateParagraphStyle.Spec} updateParagraphStyleSpecB @@ -1449,45 +1710,42 @@ ops.OperationTransformMatrix = function OperationTransformMatrix() { }; } - - var /** - * This is the lower-left half of the sparse NxN matrix with all the - * transformation methods on the possible pairs of ops. As the matrix - * is symmetric, only that half is used. So the user of this matrix has - * to ensure the proper order of opspecs on lookup and on calling the - * picked transformation method. - * - * Each transformation method takes the two opspecs (and optionally - * a flag if the first has a higher priority, in case of tie breaking - * having to be done). The method returns a record with the two - * resulting arrays of ops, with key names "opSpecsA" and "opSpecsB". - * Those arrays could have more than the initial respective opspec - * inside, in case some additional helper opspecs are needed, or be - * empty if the opspec turned into a no-op in the transformation. - * If a transformation is not doable, the method returns "null". - * - * Some operations are added onto the stack only by the master session, - * for example AddMember, RemoveMember, and UpdateMember. These therefore need - * not be transformed against each other, since the master session is the - * only originator of these ops. Therefore, their pairing entries in the - * matrix are missing. They do however require a passUnchanged entry - * with the other ops. - * - * Here the CC signature of each transformation method: - * param {!Object} opspecA - * param {!Object} opspecB - * (param {!boolean} hasAPriorityOverB) can be left out - * return {?{opSpecsA:!Array., opSpecsB:!Array.}} - * - * Empty cells in this matrix mean there is no such transformation - * possible, and should be handled as if the method returns "null". - * - * @type {!Object., opSpecsB:!Array.}>>} - */ - transformations; + /** + * This is the lower-left half of the sparse NxN matrix with all the + * transformation methods on the possible pairs of ops. As the matrix + * is symmetric, only that half is used. So the user of this matrix has + * to ensure the proper order of opspecs on lookup and on calling the + * picked transformation method. + * + * Each transformation method takes the two opspecs (and optionally + * a flag if the first has a higher priority, in case of tie breaking + * having to be done). The method returns a record with the two + * resulting arrays of ops, with key names "opSpecsA" and "opSpecsB". + * Those arrays could have more than the initial respective opspec + * inside, in case some additional helper opspecs are needed, or be + * empty if the opspec turned into a no-op in the transformation. + * If a transformation is not doable, the method returns "null". + * + * Some operations are added onto the stack only by the master session, + * for example AddMember, RemoveMember, and UpdateMember. These therefore need + * not be transformed against each other, since the master session is the + * only originator of these ops. Therefore, their pairing entries in the + * matrix are missing. They do however require a passUnchanged entry + * with the other ops. + * + * Here the CC signature of each transformation method: + * param {!Object} opspecA + * param {!Object} opspecB + * (param {!boolean} hasAPriorityOverB) can be left out + * return {?{opSpecsA:!Array., opSpecsB:!Array.}} + * + * Empty cells in this matrix mean there is no such transformation + * possible, and should be handled as if the method returns "null". + */ transformations = { "AddCursor": { "AddCursor": passUnchanged, + "AddList": passUnchanged, "AddMember": passUnchanged, "AddStyle": passUnchanged, "ApplyDirectStyling": passUnchanged, @@ -1495,6 +1753,7 @@ ops.OperationTransformMatrix = function OperationTransformMatrix() { "MergeParagraph": passUnchanged, "MoveCursor": passUnchanged, "RemoveCursor": passUnchanged, + "RemoveList": passUnchanged, "RemoveMember": passUnchanged, "RemoveStyle": passUnchanged, "RemoveText": passUnchanged, @@ -1504,6 +1763,25 @@ ops.OperationTransformMatrix = function OperationTransformMatrix() { "UpdateMetadata": passUnchanged, "UpdateParagraphStyle": passUnchanged }, + "AddList": { + "AddList": transformAddListAddList, + "AddMember": passUnchanged, + "AddStyle": passUnchanged, + "ApplyDirectStyling": passUnchanged, + "InsertText": transformAddListInsertText, + "MergeParagraph": transformAddListMergeParagraph, + "MoveCursor": passUnchanged, + "RemoveCursor": passUnchanged, + "RemoveList": transformAddListRemoveList, + "RemoveMember": passUnchanged, + "RemoveStyle": passUnchanged, + "RemoveText": transformAddListRemoveText, + "SetParagraphStyle": passUnchanged, + "SplitParagraph": transformAddListSplitParagraph, + "UpdateMember": passUnchanged, + "UpdateMetadata": passUnchanged, + "UpdateParagraphStyle": passUnchanged + }, "AddMember": { "AddStyle": passUnchanged, "ApplyDirectStyling": passUnchanged, @@ -1511,6 +1789,7 @@ ops.OperationTransformMatrix = function OperationTransformMatrix() { "MergeParagraph": passUnchanged, "MoveCursor": passUnchanged, "RemoveCursor": passUnchanged, + "RemoveList": passUnchanged, "RemoveStyle": passUnchanged, "RemoveText": passUnchanged, "SetParagraphStyle": passUnchanged, @@ -1525,6 +1804,7 @@ ops.OperationTransformMatrix = function OperationTransformMatrix() { "MergeParagraph": passUnchanged, "MoveCursor": passUnchanged, "RemoveCursor": passUnchanged, + "RemoveList": passUnchanged, "RemoveMember": passUnchanged, "RemoveStyle": transformAddStyleRemoveStyle, "RemoveText": passUnchanged, @@ -1540,6 +1820,7 @@ ops.OperationTransformMatrix = function OperationTransformMatrix() { "MergeParagraph": transformApplyDirectStylingMergeParagraph, "MoveCursor": passUnchanged, "RemoveCursor": passUnchanged, + "RemoveList": passUnchanged, "RemoveMember": passUnchanged, "RemoveStyle": passUnchanged, "RemoveText": transformApplyDirectStylingRemoveText, @@ -1554,6 +1835,7 @@ ops.OperationTransformMatrix = function OperationTransformMatrix() { "MergeParagraph": transformInsertTextMergeParagraph, "MoveCursor": transformInsertTextMoveCursor, "RemoveCursor": passUnchanged, + "RemoveList": transformInsertTextRemoveList, "RemoveMember": passUnchanged, "RemoveStyle": passUnchanged, "RemoveText": transformInsertTextRemoveText, @@ -1567,6 +1849,7 @@ ops.OperationTransformMatrix = function OperationTransformMatrix() { "MergeParagraph": transformMergeParagraphMergeParagraph, "MoveCursor": transformMergeParagraphMoveCursor, "RemoveCursor": passUnchanged, + "RemoveList": transformMergeParagraphRemoveList, "RemoveMember": passUnchanged, "RemoveStyle": passUnchanged, "RemoveText": transformMergeParagraphRemoveText, @@ -1579,6 +1862,7 @@ ops.OperationTransformMatrix = function OperationTransformMatrix() { "MoveCursor": { "MoveCursor": passUnchanged, "RemoveCursor": transformMoveCursorRemoveCursor, + "RemoveList": passUnchanged, "RemoveMember": passUnchanged, "RemoveStyle": passUnchanged, "RemoveText": transformMoveCursorRemoveText, @@ -1591,6 +1875,7 @@ ops.OperationTransformMatrix = function OperationTransformMatrix() { "RemoveCursor": { "RemoveCursor": transformRemoveCursorRemoveCursor, "RemoveMember": passUnchanged, + "RemoveList": passUnchanged, "RemoveStyle": passUnchanged, "RemoveText": passUnchanged, "SetParagraphStyle": passUnchanged, @@ -1599,6 +1884,17 @@ ops.OperationTransformMatrix = function OperationTransformMatrix() { "UpdateMetadata": passUnchanged, "UpdateParagraphStyle": passUnchanged }, + "RemoveList": { + "RemoveList": transformRemoveListRemoveList, + "RemoveMember": passUnchanged, + "RemoveStyle": passUnchanged, + "RemoveText": transformRemoveListRemoveText, + "SetParagraphStyle": passUnchanged, + "SplitParagraph": transformRemoveListSplitParagraph, + "UpdateMember": passUnchanged, + "UpdateMetadata": passUnchanged, + "UpdateParagraphStyle": passUnchanged + }, "RemoveMember": { "RemoveStyle": passUnchanged, "RemoveText": passUnchanged, diff --git a/webodf/tests/ops/TransformationTests.js b/webodf/tests/ops/TransformationTests.js index bd5d616b5..3ccdcc09d 100644 --- a/webodf/tests/ops/TransformationTests.js +++ b/webodf/tests/ops/TransformationTests.js @@ -239,20 +239,8 @@ ops.TransformationTests = function TransformationTests(runner) { for (i = 0; i < n; i += 1) { att = atts.item(i); value = att.value; - switch(att.localName) { - case "length": - case "number": - case "position": - case "fontSize": - case "topMargin": - case "bottomMargin": - case "leftMargin": - case "rightMargin": - case "sourceParagraphPosition": - case "destinationStartPosition": - case "sourceStartPosition": - value = parseInt(value, 10); - break; + if (/(length|position)/i.test(att.localName)) { + value = parseInt(value, 10); } op[att.nodeName] = value; } diff --git a/webodf/tests/ops/transformationtests.xml b/webodf/tests/ops/transformationtests.xml index cae358a0f..3b296386e 100644 --- a/webodf/tests/ops/transformationtests.xml +++ b/webodf/tests/ops/transformationtests.xml @@ -1989,4 +1989,336 @@ 2013-08-05T12:34:07.061Z + + + SampleTextSampleText + + + + + + + SampleTextSampleText + + + Sample1TextSample2TextSample3Text + + + + + + + Sample1TextFOOSample2TextSample3Text + + + Sample1TextSample2TextSample3Text + + + + + + + Sample1TextFOOSample2TextSample3Text + + + Sample1TextSample2TextSample3Text + + + + + + + Sample1TextSample2TextFOOSample3Text + + + Sample1TextSample2TextSample3Text + + + + + + + Sample1TextSample2TextSFOOample3Text + + + Sample1TextSample2TextSample3TextSample4Text + + + + + + + Sample1TextSample2TextSample3TextSample4Text + + + Sample1TextSample2TextSample3TextSample4Text + + + + + + + Sample1TextSample2TextSample3TextSample4Text + + + Sample1TextSample2Text + + + + + + + Sample1TextSample2Text + + + Sample1TextSample2TextSample3TextSample4Text + + + + + + + Sample1TextSample2TextSample3TextSample4Text + + + Sample1TextSample2TextSample3TextSample4Text + + + + + + + Sample1TextSample2TextSample3TextSample4Text + + + Sample1TextSample2TextSample3TextSample4Text + + + + + + + Sample1TextSample2TextSample3TextSample4Text + + + Sample1TextSample2TextSample3Text + + + + + + + SampleSample2TextSample3Text + + + Sample1TextSample2TextSample3Text + + + + + + + Sample1TextTextSample3Text + + + Sample1TextSample2TextSample3Text + + + + + + + Sample1TextSample2TextText + + + Sample1TextSample2TextSample3Text + + + + + + + Sample1TextSample2TextS + + + Sample1TextSample2TextSample3TextSample4Text + + + + + + + Sample1TextSample2TextSample3TextSample4Text + + + Sample1TextSample2TextSample3TextSample4Text + + + + + + + Sample1TextSample2TextSample3TextSample4Text + + + Sample1TextSample2Text + + + + + + + Sample1TextSample2Text + + + Sample1TextSample2Text + + + + + + + Sample1TextSample2Text + + + + SampleText + + + + + + + SampleText + + + Sample1TextSample2TextSample3Text + + + + + + + Sample1TextSample2TextSample3Text + + + Sample1TextSample2Text + + + + + + + Sample1TextFOOSample2Text + + + Sample1TextSample2TextSample3Text + + + + + + + Sample1TextFOOSample2TextSample3Text + + + Sample1TextSample2TextSample3Text + + + + + + + Sample1TextSample2TextFOOSample3Text + + + Sample1TextSample2TextSample3Text + + + + + + + Sample1TextSample2TextSample3Text + + + Sample1TextSample2TextSample3Text + + + + + + + Sample1TextSample2TextSample3Text + + + Sample1TextSample2TextSample3Text + + + + + + + Sample1TextSample2TextSample3Text + + + Sample1TextSample2Text + + + + + + + TextSample2Text + + + Sample1TextSample2TextSample3Text + + + + + + + Sample1TextTextSample3Text + + + Sample1TextSample2TextSample3Text + + + + + + + Sample1TextSample2TextText + + + Sample1TextSample2TextSample3Text + + + + va + + + Sample1TextSample2TextSample3Text + + + Sample1TextSample2TextSample3Text + + + + va + + + Sample1TextSample2TextSample3Text + + + Sample1TextSample2TextSample3Text + + + + va + + + Sample1TextSample2TextSample3Text +