import 'clunderscore'; import DiffMatchPatch from 'diff-match-patch'; var clDiffUtils = { cloneObject: cloneObject, offsetToPatch: offsetToPatch, patchToOffset: patchToOffset, serializeObject: serializeObject, flattenContent: flattenContent, makePatchableText: makePatchableText, restoreDiscussionOffsets: restoreDiscussionOffsets, makeContentChange: makeContentChange, applyContentChanges: applyContentChanges, getTextPatches: getTextPatches, getObjectPatches: getObjectPatches, quickPatch: quickPatch, mergeObjects: mergeObjects, mergeFlattenContent: mergeFlattenContent } var marker = '\uF111\uF222\uF333\uF444' var DIFF_DELETE = -1 var DIFF_INSERT = 1 var DIFF_EQUAL = 0 var diffMatchPatch = new DiffMatchPatch() // eslint-disable-line new-cap var diffMatchPatchStrict = new DiffMatchPatch() // eslint-disable-line new-cap diffMatchPatchStrict.Match_Threshold = 0 diffMatchPatchStrict.Patch_DeleteThreshold = 0 var diffMatchPatchPermissive = new DiffMatchPatch() // eslint-disable-line new-cap diffMatchPatchPermissive.Match_Distance = 999999999 function cloneObject (obj) { return JSON.parse(JSON.stringify(obj)) } function offsetToPatch (text, offset) { var patch = diffMatchPatchPermissive.patch_make(text, [ [0, text.slice(0, offset)], [1, marker], [0, text.slice(offset)] ])[0] var diffs = patch.diffs.cl_map(function (diff) { if (!diff[0]) { return diff[1] } else if (diff[1] === marker) { return '' } }) return { diffs: diffs, length: patch.length1, start: patch.start1 } } function patchToOffset (text, patch) { var markersLength = 0 var diffs = patch.diffs.cl_map(function (diff) { if (!diff) { markersLength += marker.length return [1, marker] } else { return [0, diff] } }) return diffMatchPatchPermissive.patch_apply([{ diffs: diffs, length1: patch.length, length2: patch.length + markersLength, start1: patch.start, start2: patch.start }], text)[0].indexOf(marker) } function flattenObject (obj) { return obj.cl_reduce(function (result, value, key) { result[key] = value[1] return result }, {}) } function flattenContent (content) { var result = ({}).cl_extend(content) result.properties = flattenObject(content.properties) result.discussions = flattenObject(content.discussions) result.comments = flattenObject(content.comments) result.text = content.text.cl_reduce(function (text, item) { switch (item.type) { case 'discussion': if (result.discussions[item.id]) { result.discussions[item.id][item.name] = text.length } return text default: return text + item[1] } }, '') return result } function getTextPatches (oldText, newText) { var diffs = diffMatchPatch.diff_main(oldText, newText) diffMatchPatch.diff_cleanupEfficiency(diffs) var patches = [] var startOffset = 0 diffs.cl_each(function (change) { var changeType = change[0] var changeText = change[1] switch (changeType) { case DIFF_EQUAL: startOffset += changeText.length break case DIFF_DELETE: changeText && patches.push({ o: startOffset, d: changeText }) break case DIFF_INSERT: changeText && patches.push({ o: startOffset, a: changeText }) startOffset += changeText.length break } }) return patches.length ? patches : undefined } function getObjectPatches (oldObject, newObjects) { var valueHash = Object.create(null) var valueArray = [] oldObject = hashObject(oldObject, valueHash, valueArray) newObjects = hashObject(newObjects, valueHash, valueArray) var diffs = diffMatchPatch.diff_main(oldObject, newObjects) var patches = [] diffs.cl_each(function (change) { var changeType = change[0] var changeHash = change[1] if (changeType === DIFF_EQUAL) { return } changeHash.split('').cl_each(function (objHash) { var obj = valueArray[objHash.charCodeAt(0)] var patch = { k: obj[0] } patch[changeType === DIFF_DELETE ? 'd' : 'a'] = obj[1] patches.push(patch) }) }) return patches.length ? patches : undefined } function makePatchableText (content, markerKeys, markerIdxMap) { var markers = [] // Sort keys to have predictable marker positions, in case of same offset var discussionKeys = Object.keys(content.discussions).sort() discussionKeys.cl_each(function (discussionId) { function addMarker (offsetName) { var markerKey = discussionId + offsetName if (discussion[offsetName] !== undefined) { var idx = markerIdxMap[markerKey] if (idx === undefined) { idx = markerKeys.length markerIdxMap[markerKey] = idx markerKeys.push({ id: discussionId, offsetName: offsetName }) } markers.push({ idx: idx, offset: discussion[offsetName] }) } } var discussion = content.discussions[discussionId] if (discussion.offset0 === discussion.offset1) { // Remove discussion offsets if markers are at the same position discussion.offset0 = discussion.offset1 = undefined } else { addMarker('offset0') addMarker('offset1') } }) var lastOffset = 0 var result = '' markers .sort(function (marker1, marker2) { return marker1.offset - marker2.offset }) .cl_each(function (marker) { result += content.text.slice(lastOffset, marker.offset) + String.fromCharCode(0xe000 + marker.idx) // Use a character from the private use area lastOffset = marker.offset }) return result + content.text.slice(lastOffset) } function stripDiscussionOffsets (objectMap) { return objectMap.cl_reduce(function (result, object, id) { result[id] = { text: object.text } return result }, {}) } function restoreDiscussionOffsets (content, markerKeys) { var len = content.text.length var maxIdx = markerKeys.length for (var i = 0; i < len; i++) { var idx = content.text.charCodeAt(i) - 0xe000 if (idx >= 0 && idx < maxIdx) { var markerKey = markerKeys[idx] content.text = content.text.slice(0, i) + content.text.slice(i + 1) var discussion = content.discussions[markerKey.id] if (discussion) { discussion[markerKey.offsetName] = i } i-- // We just removed the current character, we may have multiple markers with same offset } } } function makeContentChange (oldContent, newContent) { var markerKeys = [] var markerIdxMap = Object.create(null) var oldText = makePatchableText(oldContent, markerKeys, markerIdxMap) var newText = makePatchableText(newContent, markerKeys, markerIdxMap) var textPatches = getTextPatches(oldText, newText) textPatches && textPatches.cl_each(function (patch) { // If markers are present, replace changeText with an array of text and markers var changeText = patch.a || patch.d var textItems = [] var lastItem = '' var len = changeText.length var maxIdx = markerKeys.length for (var i = 0; i < len; i++) { var idx = changeText.charCodeAt(i) - 0xe000 if (idx >= 0 && idx < maxIdx) { var markerKey = markerKeys[idx] lastItem.length && textItems.push(lastItem) textItems.push({ type: 'discussion', name: markerKey.offsetName, id: markerKey.id }) lastItem = '' } else { lastItem += changeText[i] } } if (textItems.length) { lastItem.length && textItems.push(lastItem) if (patch.a) { patch.a = textItems } else { patch.d = textItems } } }) var propertiesPatches = getObjectPatches(oldContent.properties, newContent.properties) var discussionsPatches = getObjectPatches( stripDiscussionOffsets(oldContent.discussions), stripDiscussionOffsets(newContent.discussions) ) var commentsPatches = getObjectPatches(oldContent.comments, newContent.comments) if (textPatches || propertiesPatches || discussionsPatches || commentsPatches) { return { text: textPatches, properties: propertiesPatches, discussions: discussionsPatches, comments: commentsPatches } } } function applyContentChanges (content, contentChanges, isBackward) { function applyObjectPatches (obj, patches) { if (patches) { patches.cl_each(function (patch) { if (!patch.a ^ !isBackward) { obj[patch.k] = patch.a || patch.d } else { delete obj[patch.k] } }) } } var markerKeys = [] var markerIdxMap = Object.create(null) var result = { text: makePatchableText(content, markerKeys, markerIdxMap), properties: cloneObject(content.properties), discussions: stripDiscussionOffsets(content.discussions), comments: cloneObject(content.comments) } contentChanges.cl_each(function (contentChange) { var textPatches = contentChange.text || [] if (isBackward) { textPatches = textPatches.slice().reverse() } result.text = textPatches.cl_reduce(function (text, patch) { var isAdd = !patch.a ^ !isBackward var textChanges = patch.a || patch.d || '' // When no marker is present, textChanges is a string if (typeof textChanges === 'string') { textChanges = [textChanges] } var textChange = textChanges.cl_map(function (textChange) { if (!textChange.type) { // textChange is a string return textChange } // textChange is a marker var markerKey = textChange.id + textChange.name var idx = markerIdxMap[markerKey] if (idx === undefined) { idx = markerKeys.length markerIdxMap[markerKey] = idx markerKeys.push({ id: textChange.id, offsetName: textChange.name }) } return String.fromCharCode(0xe000 + idx) }).join('') if (!textChange) { return text } else if (isAdd) { return text.slice(0, patch.o).concat(textChange).concat(text.slice(patch.o)) } else { return text.slice(0, patch.o).concat(text.slice(patch.o + textChange.length)) } }, result.text) applyObjectPatches(result.properties, contentChange.properties) applyObjectPatches(result.discussions, contentChange.discussions) applyObjectPatches(result.comments, contentChange.comments) }) restoreDiscussionOffsets(result, markerKeys) return result } function serializeObject (obj) { return JSON.stringify(obj, function (key, value) { return Object.prototype.toString.call(value) === '[object Object]' ? Object.keys(value).sort().cl_reduce(function (sorted, key) { sorted[key] = value[key] return sorted }, {}) : value }) } function hashArray (arr, valueHash, valueArray) { var hash = [] arr.cl_each(function (obj) { var serializedObj = serializeObject(obj) var objHash = valueHash[serializedObj] if (objHash === undefined) { objHash = valueArray.length valueArray.push(obj) valueHash[serializedObj] = objHash } hash.push(objHash) }) return String.fromCharCode.apply(null, hash) } function hashObject (obj, valueHash, valueArray) { return hashArray(Object.keys(obj || {}).sort().cl_map(function (key) { return [key, obj[key]] }), valueHash, valueArray) } function mergeText (oldText, newText, serverText) { var diffs = diffMatchPatch.diff_main(oldText, newText) diffMatchPatch.diff_cleanupSemantic(diffs) var patches = diffMatchPatch.patch_make(oldText, diffs) var patchResult = diffMatchPatch.patch_apply(patches, serverText) if (!patchResult[1] .cl_some(function (changeApplied) { return !changeApplied })) { return patchResult[0] } diffs = diffMatchPatchStrict.diff_main(patchResult[0], newText) diffMatchPatch.diff_cleanupSemantic(diffs) return diffs.cl_map(function (diff) { return diff[1] }).join('') } function quickPatch (oldStr, newStr, destStr, strict) { var dmp = strict ? diffMatchPatchStrict : diffMatchPatch var diffs = dmp.diff_main(oldStr, newStr) var patches = dmp.patch_make(oldStr, diffs) var patchResult = dmp.patch_apply(patches, destStr) return patchResult[0] } function mergeObjects (oldObject, newObject, serverObject) { var mergedObject = ({}).cl_extend(newObject).cl_extend(serverObject) mergedObject.cl_each(function (value, key) { if (!oldObject[key]) { return // There might be conflict, keep the server value } var newValue = newObject[key] && serializeObject(newObject[key]) var serverValue = serverObject[key] && serializeObject(serverObject[key]) if (newValue === serverValue) { return // no conflict } var oldValue = serializeObject(oldObject[key]) if (oldValue !== newValue && !serverValue) { return // Removed on server but changed on client } if (oldValue !== serverValue && !newValue) { return // Removed on client but changed on server } if (oldValue !== newValue && oldValue === serverValue) { // Take the client value if (!newValue) { delete mergedObject[key] } else { mergedObject[key] = newObject[key] } } else if (oldValue !== serverValue && oldValue === newValue) { // Take the server value if (!serverValue) { delete mergedObject[key] } } // Take the server value otherwise }) return cloneObject(mergedObject) } function mergeFlattenContent (oldContent, newContent, serverContent) { var markerKeys = [] var markerIdxMap = Object.create(null) var oldText = makePatchableText(oldContent, markerKeys, markerIdxMap) var serverText = makePatchableText(serverContent, markerKeys, markerIdxMap) var localText = makePatchableText(newContent, markerKeys, markerIdxMap) var isServerTextChanges = oldText !== serverText var isTextSynchronized = serverText === localText var result = { text: isTextSynchronized || !isServerTextChanges ? localText : mergeText(oldText, serverText, localText), properties: mergeObjects( oldContent.properties, newContent.properties, serverContent.properties ), discussions: mergeObjects( stripDiscussionOffsets(oldContent.discussions), stripDiscussionOffsets(newContent.discussions), stripDiscussionOffsets(serverContent.discussions) ), comments: mergeObjects( oldContent.comments, newContent.comments, serverContent.comments ) } restoreDiscussionOffsets(result, markerKeys) return result } export default clDiffUtils;