Content merge
This commit is contained in:
		
							parent
							
								
									d258d1c9c4
								
							
						
					
					
						commit
						0280df2bbb
					
				@ -1,7 +1,7 @@
 | 
			
		||||
export default () => ({
 | 
			
		||||
  id: null,
 | 
			
		||||
  type: 'syncContent',
 | 
			
		||||
  contentRevisions: {},
 | 
			
		||||
  historyData: {},
 | 
			
		||||
  syncLocationData: {},
 | 
			
		||||
  updated: 0,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -1,482 +0,0 @@
 | 
			
		||||
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;
 | 
			
		||||
							
								
								
									
										204
									
								
								src/services/diffUtils.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								src/services/diffUtils.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,204 @@
 | 
			
		||||
import DiffMatchPatch from 'diff-match-patch';
 | 
			
		||||
import utils from './utils';
 | 
			
		||||
 | 
			
		||||
const diffMatchPatch = new DiffMatchPatch();
 | 
			
		||||
const diffMatchPatchStrict = new DiffMatchPatch();
 | 
			
		||||
diffMatchPatchStrict.Match_Threshold = 0;
 | 
			
		||||
diffMatchPatchStrict.Patch_DeleteThreshold = 0;
 | 
			
		||||
const diffMatchPatchPermissive = new DiffMatchPatch();
 | 
			
		||||
diffMatchPatchPermissive.Match_Distance = 999999999;
 | 
			
		||||
 | 
			
		||||
function makePatchableText(content, markerKeys, markerIdxMap) {
 | 
			
		||||
  const markers = [];
 | 
			
		||||
  // Sort keys to have predictable marker positions in case of same offset
 | 
			
		||||
  const discussionKeys = Object.keys(content.discussions).sort();
 | 
			
		||||
  discussionKeys.forEach((discussionId) => {
 | 
			
		||||
    const discussion = content.discussions[discussionId];
 | 
			
		||||
 | 
			
		||||
    function addMarker(offsetName) {
 | 
			
		||||
      const markerKey = discussionId + offsetName;
 | 
			
		||||
      if (discussion[offsetName] !== undefined) {
 | 
			
		||||
        let idx = markerIdxMap[markerKey];
 | 
			
		||||
        if (idx === undefined) {
 | 
			
		||||
          idx = markerKeys.length;
 | 
			
		||||
          markerIdxMap[markerKey] = idx;
 | 
			
		||||
          markerKeys.push({
 | 
			
		||||
            id: discussionId,
 | 
			
		||||
            offsetName,
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
        markers.push({
 | 
			
		||||
          idx,
 | 
			
		||||
          offset: discussion[offsetName],
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (discussion.offset0 === discussion.offset1) {
 | 
			
		||||
      // Remove discussion offsets if markers are at the same position
 | 
			
		||||
      discussion.offset0 = undefined;
 | 
			
		||||
      discussion.offset1 = undefined;
 | 
			
		||||
    } else {
 | 
			
		||||
      addMarker('offset0');
 | 
			
		||||
      addMarker('offset1');
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  let lastOffset = 0;
 | 
			
		||||
  let result = '';
 | 
			
		||||
  markers
 | 
			
		||||
    .sort((marker1, marker2) => marker1.offset - marker2.offset)
 | 
			
		||||
    .forEach((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) {
 | 
			
		||||
  const result = {};
 | 
			
		||||
  Object.keys(objectMap).forEach((id) => {
 | 
			
		||||
    result[id] = {
 | 
			
		||||
      text: objectMap[id].text,
 | 
			
		||||
    };
 | 
			
		||||
  });
 | 
			
		||||
  return result;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function restoreDiscussionOffsets(content, markerKeys) {
 | 
			
		||||
  const len = content.text.length;
 | 
			
		||||
  const maxIdx = markerKeys.length;
 | 
			
		||||
  for (let i = 0; i < len; i += 1) {
 | 
			
		||||
    const idx = content.text.charCodeAt(i) - 0xe000;
 | 
			
		||||
    if (idx >= 0 && idx < maxIdx) {
 | 
			
		||||
      const markerKey = markerKeys[idx];
 | 
			
		||||
      content.text = content.text.slice(0, i) + content.text.slice(i + 1);
 | 
			
		||||
      const discussion = content.discussions[markerKey.id];
 | 
			
		||||
      if (discussion) {
 | 
			
		||||
        discussion[markerKey.offsetName] = i;
 | 
			
		||||
      }
 | 
			
		||||
      // We just removed the current character, we may have multiple markers with same offset
 | 
			
		||||
      i -= 1;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function serializeObject(obj) {
 | 
			
		||||
  if (!obj) {
 | 
			
		||||
    return obj;
 | 
			
		||||
  }
 | 
			
		||||
  return JSON.stringify(obj, (key, value) => {
 | 
			
		||||
    if (Object.prototype.toString.call(value) !== '[object Object]') {
 | 
			
		||||
      return value;
 | 
			
		||||
    }
 | 
			
		||||
    return Object.keys(value).sort().reduce((sorted, valueKey) => {
 | 
			
		||||
      sorted[valueKey] = value[valueKey];
 | 
			
		||||
      return sorted;
 | 
			
		||||
    }, {});
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function mergeText(oldText, newText, serverText) {
 | 
			
		||||
  let diffs = diffMatchPatch.diff_main(oldText, newText);
 | 
			
		||||
  diffMatchPatch.diff_cleanupSemantic(diffs);
 | 
			
		||||
  const patches = diffMatchPatch.patch_make(oldText, diffs);
 | 
			
		||||
  const patchResult = diffMatchPatch.patch_apply(patches, serverText);
 | 
			
		||||
  if (!patchResult[1].some(changeApplied => !changeApplied)) {
 | 
			
		||||
    return patchResult[0];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  diffs = diffMatchPatchStrict.diff_main(patchResult[0], newText);
 | 
			
		||||
  diffMatchPatch.diff_cleanupSemantic(diffs);
 | 
			
		||||
  return diffs.map(diff => diff[1]).join('');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function quickPatch(oldStr, newStr, destStr, strict) {
 | 
			
		||||
  const dmp = strict ? diffMatchPatchStrict : diffMatchPatch;
 | 
			
		||||
  const diffs = dmp.diff_main(oldStr, newStr);
 | 
			
		||||
  const patches = dmp.patch_make(oldStr, diffs);
 | 
			
		||||
  const patchResult = dmp.patch_apply(patches, destStr);
 | 
			
		||||
  return patchResult[0];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function mergeValue(oldValue, newValue, serverValue) {
 | 
			
		||||
  if (!oldValue) {
 | 
			
		||||
    return serverValue; // There might be conflict, keep the server value
 | 
			
		||||
  }
 | 
			
		||||
  const newSerializedValue = serializeObject(newValue);
 | 
			
		||||
  const serverSerializedValue = serializeObject(serverValue);
 | 
			
		||||
  if (newSerializedValue === serverSerializedValue) {
 | 
			
		||||
    return serverValue; // no conflict
 | 
			
		||||
  }
 | 
			
		||||
  const oldSerializedValue = serializeObject(oldValue);
 | 
			
		||||
  if (oldSerializedValue !== newSerializedValue && !serverValue) {
 | 
			
		||||
    return newValue; // Removed on server but changed on client
 | 
			
		||||
  }
 | 
			
		||||
  if (oldSerializedValue !== serverSerializedValue && !newValue) {
 | 
			
		||||
    return serverValue; // Removed on client but changed on server
 | 
			
		||||
  }
 | 
			
		||||
  if (oldSerializedValue !== newSerializedValue && oldSerializedValue === serverSerializedValue) {
 | 
			
		||||
    return newValue; // Take the client value
 | 
			
		||||
  }
 | 
			
		||||
  return serverValue; // Take the server value otherwise
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function mergeObjects(oldObject, newObject, serverObject) {
 | 
			
		||||
  const mergedObject = {};
 | 
			
		||||
  Object.keys({
 | 
			
		||||
    ...newObject,
 | 
			
		||||
    ...serverObject,
 | 
			
		||||
  }).forEach((key) => {
 | 
			
		||||
    const mergedValue = mergeValue(oldObject[key], newObject[key], serverObject[key]);
 | 
			
		||||
    if (mergedValue != null) {
 | 
			
		||||
      mergedObject[key] = mergedValue;
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
  return utils.deepCopy(mergedObject);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function mergeContent(oldContent, newContent, serverContent) {
 | 
			
		||||
  const markerKeys = [];
 | 
			
		||||
  const markerIdxMap = Object.create(null);
 | 
			
		||||
  const oldText = makePatchableText(oldContent, markerKeys, markerIdxMap);
 | 
			
		||||
  const serverText = makePatchableText(serverContent, markerKeys, markerIdxMap);
 | 
			
		||||
  const localText = makePatchableText(newContent, markerKeys, markerIdxMap);
 | 
			
		||||
  const isServerTextChanges = oldText !== serverText;
 | 
			
		||||
  const isTextSynchronized = serverText === localText;
 | 
			
		||||
 | 
			
		||||
  const result = {
 | 
			
		||||
    text: isTextSynchronized || !isServerTextChanges
 | 
			
		||||
      ? localText
 | 
			
		||||
      : mergeText(oldText, serverText, localText),
 | 
			
		||||
    properties: mergeValue(
 | 
			
		||||
      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 {
 | 
			
		||||
  serializeObject,
 | 
			
		||||
  makePatchableText,
 | 
			
		||||
  restoreDiscussionOffsets,
 | 
			
		||||
  applyContentChanges,
 | 
			
		||||
  getTextPatches,
 | 
			
		||||
  getObjectPatches,
 | 
			
		||||
  quickPatch,
 | 
			
		||||
  mergeObjects,
 | 
			
		||||
  mergeContent,
 | 
			
		||||
};
 | 
			
		||||
@ -23,25 +23,25 @@ const tokenExpirationMargin = 10 * 60 * 1000; // 10 min
 | 
			
		||||
//   ],
 | 
			
		||||
// };
 | 
			
		||||
 | 
			
		||||
const request = (googleToken, options) => utils.request({
 | 
			
		||||
const request = (token, options) => utils.request({
 | 
			
		||||
  ...options,
 | 
			
		||||
  headers: {
 | 
			
		||||
    ...options.headers,
 | 
			
		||||
    Authorization: `Bearer ${googleToken.accessToken}`,
 | 
			
		||||
    Authorization: `Bearer ${token.accessToken}`,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  startOauth2(scopes, sub = null, silent = false) {
 | 
			
		||||
    return utils.startOauth2(
 | 
			
		||||
      'https://accounts.google.com/o/oauth2/v2/auth', {
 | 
			
		||||
        client_id: clientId,
 | 
			
		||||
        response_type: 'token',
 | 
			
		||||
        scope: scopes.join(' '),
 | 
			
		||||
        hd: appsDomain,
 | 
			
		||||
        login_hint: sub,
 | 
			
		||||
        prompt: silent ? 'none' : null,
 | 
			
		||||
      }, silent)
 | 
			
		||||
        'https://accounts.google.com/o/oauth2/v2/auth', {
 | 
			
		||||
          client_id: clientId,
 | 
			
		||||
          response_type: 'token',
 | 
			
		||||
          scope: scopes.join(' '),
 | 
			
		||||
          hd: appsDomain,
 | 
			
		||||
          login_hint: sub,
 | 
			
		||||
          prompt: silent ? 'none' : null,
 | 
			
		||||
        }, silent)
 | 
			
		||||
      // Call the tokeninfo endpoint
 | 
			
		||||
      .then(data => utils.request({
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
@ -68,28 +68,28 @@ export default {
 | 
			
		||||
        };
 | 
			
		||||
      }))
 | 
			
		||||
      // Call the tokeninfo endpoint
 | 
			
		||||
      .then(googleToken => request(googleToken, {
 | 
			
		||||
      .then(token => request(token, {
 | 
			
		||||
        method: 'GET',
 | 
			
		||||
        url: 'https://www.googleapis.com/plus/v1/people/me',
 | 
			
		||||
      }).then((res) => {
 | 
			
		||||
        // Add name to googleToken
 | 
			
		||||
        googleToken.name = res.body.displayName;
 | 
			
		||||
        const existingToken = store.getters['data/googleTokens'][googleToken.sub];
 | 
			
		||||
        // Add name to token
 | 
			
		||||
        token.name = res.body.displayName;
 | 
			
		||||
        const existingToken = store.getters['data/googleTokens'][token.sub];
 | 
			
		||||
        if (existingToken) {
 | 
			
		||||
          if (!sub) {
 | 
			
		||||
            throw new Error('Google account already linked.');
 | 
			
		||||
          }
 | 
			
		||||
          // Add isLogin and nextPageToken to googleToken
 | 
			
		||||
          googleToken.isLogin = existingToken.isLogin;
 | 
			
		||||
          googleToken.nextPageToken = existingToken.nextPageToken;
 | 
			
		||||
          // Add isLogin and nextPageToken to token
 | 
			
		||||
          token.isLogin = existingToken.isLogin;
 | 
			
		||||
          token.nextPageToken = existingToken.nextPageToken;
 | 
			
		||||
        }
 | 
			
		||||
        // Add googleToken to googleTokens
 | 
			
		||||
        store.dispatch('data/setGoogleToken', googleToken);
 | 
			
		||||
        return googleToken;
 | 
			
		||||
        // Add token to googleTokens
 | 
			
		||||
        store.dispatch('data/setGoogleToken', token);
 | 
			
		||||
        return token;
 | 
			
		||||
      }));
 | 
			
		||||
  },
 | 
			
		||||
  refreshToken(scopes, googleToken) {
 | 
			
		||||
    const sub = googleToken.sub;
 | 
			
		||||
  refreshToken(scopes, token) {
 | 
			
		||||
    const sub = token.sub;
 | 
			
		||||
    const lastToken = store.getters['data/googleTokens'][sub];
 | 
			
		||||
    const mergedScopes = [...new Set([
 | 
			
		||||
      ...scopes,
 | 
			
		||||
@ -115,9 +115,9 @@ export default {
 | 
			
		||||
          .catch(() => this.startOauth2(mergedScopes, sub));
 | 
			
		||||
      });
 | 
			
		||||
  },
 | 
			
		||||
  getChanges(googleToken) {
 | 
			
		||||
  getChanges(token) {
 | 
			
		||||
    let changes = [];
 | 
			
		||||
    return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken)
 | 
			
		||||
    return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], token)
 | 
			
		||||
      .then((refreshedToken) => {
 | 
			
		||||
        const getPage = (pageToken = '1') => request(refreshedToken, {
 | 
			
		||||
          method: 'GET',
 | 
			
		||||
@ -158,8 +158,8 @@ export default {
 | 
			
		||||
        return getPage(refreshedToken.nextPageToken);
 | 
			
		||||
      });
 | 
			
		||||
  },
 | 
			
		||||
  updateNextPageToken(googleToken, changes) {
 | 
			
		||||
    const lastToken = store.getters['data/googleTokens'][googleToken.sub];
 | 
			
		||||
  updateNextPageToken(token, changes) {
 | 
			
		||||
    const lastToken = store.getters['data/googleTokens'][token.sub];
 | 
			
		||||
    if (changes.nextPageToken !== lastToken.nextPageToken) {
 | 
			
		||||
      store.dispatch('data/setGoogleToken', {
 | 
			
		||||
        ...lastToken,
 | 
			
		||||
@ -167,8 +167,8 @@ export default {
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  saveItem(googleToken, item, syncData, ifNotTooLate = cb => res => cb(res)) {
 | 
			
		||||
    return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken)
 | 
			
		||||
  saveItem(token, item, syncData, ifNotTooLate = cb => res => cb(res)) {
 | 
			
		||||
    return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], token)
 | 
			
		||||
      // Refreshing a token can take a while if an oauth window pops up, so check if it's too late
 | 
			
		||||
      .then(ifNotTooLate((refreshedToken) => {
 | 
			
		||||
        const options = {
 | 
			
		||||
@ -229,12 +229,22 @@ export default {
 | 
			
		||||
        }));
 | 
			
		||||
      }));
 | 
			
		||||
  },
 | 
			
		||||
  removeItem(googleToken, syncData, ifNotTooLate = cb => res => cb(res)) {
 | 
			
		||||
    return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken)
 | 
			
		||||
  removeItem(token, syncData, ifNotTooLate = cb => res => cb(res)) {
 | 
			
		||||
    return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], token)
 | 
			
		||||
      // Refreshing a token can take a while if an oauth window pops up, so check if it's too late
 | 
			
		||||
      .then(ifNotTooLate(refreshedToken => request(refreshedToken, {
 | 
			
		||||
        method: 'DELETE',
 | 
			
		||||
        url: `https://www.googleapis.com/drive/v3/files/${syncData.id}`,
 | 
			
		||||
      })).then(() => syncData));
 | 
			
		||||
  },
 | 
			
		||||
  downloadFile(refreshedToken, id) {
 | 
			
		||||
    return request(refreshedToken, {
 | 
			
		||||
      method: 'GET',
 | 
			
		||||
      url: `https://www.googleapis.com/drive/v3/files/${id}?alt=media`,
 | 
			
		||||
    }).then(res => res.body);
 | 
			
		||||
  },
 | 
			
		||||
  downloadAppDataFile(token, id) {
 | 
			
		||||
    return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], token)
 | 
			
		||||
      .then(refreshedToken => this.downloadFile(refreshedToken, id));
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -86,16 +86,11 @@ utils.types.forEach((type) => {
 | 
			
		||||
  updatedMap[type] = Object.create(null);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function isContentType(type) {
 | 
			
		||||
  switch (type) {
 | 
			
		||||
    case 'content':
 | 
			
		||||
    case 'contentState':
 | 
			
		||||
    case 'syncContent':
 | 
			
		||||
      return true;
 | 
			
		||||
    default:
 | 
			
		||||
      return false;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
const contentTypes = {
 | 
			
		||||
  content: true,
 | 
			
		||||
  contentState: true,
 | 
			
		||||
  syncContent: true,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  lastTx: 0,
 | 
			
		||||
@ -171,8 +166,8 @@ export default {
 | 
			
		||||
    Object.keys(this.updatedMap).forEach((type) => {
 | 
			
		||||
      // Remove this type only if file is deleted
 | 
			
		||||
      let checker = cb => id => !storeItemMap[id] && cb(id);
 | 
			
		||||
      if (isContentType(type)) {
 | 
			
		||||
        // For content types, remove only if file is deleted
 | 
			
		||||
      if (contentTypes[type]) {
 | 
			
		||||
        // For content types, remove item only if file is deleted
 | 
			
		||||
        checker = cb => (id) => {
 | 
			
		||||
          if (!storeItemMap[id]) {
 | 
			
		||||
            const [fileId] = id.split('/');
 | 
			
		||||
@ -227,7 +222,7 @@ export default {
 | 
			
		||||
      // DB item is different from the corresponding store item
 | 
			
		||||
      this.updatedMap[dbItem.type][dbItem.id] = dbItem.updated;
 | 
			
		||||
      // Update content only if it exists in the store
 | 
			
		||||
      if (existingStoreItem || !isContentType(dbItem.type)) {
 | 
			
		||||
      if (existingStoreItem || !contentTypes[dbItem.type]) {
 | 
			
		||||
        // Put item in the store
 | 
			
		||||
        store.commit(`${dbItem.type}/setItem`, dbItem);
 | 
			
		||||
        storeItemMap[dbItem.id] = dbItem;
 | 
			
		||||
@ -236,9 +231,9 @@ export default {
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Retrieve an item from the DB.
 | 
			
		||||
   * Retrieve an item from the DB and put it in the store.
 | 
			
		||||
   */
 | 
			
		||||
  retrieveItem(id) {
 | 
			
		||||
  loadItem(id) {
 | 
			
		||||
    // Check if item is in the store
 | 
			
		||||
    const itemInStore = store.getters.allItemMap[id];
 | 
			
		||||
    if (itemInStore) {
 | 
			
		||||
@ -259,11 +254,30 @@ export default {
 | 
			
		||||
            this.updatedMap[dbItem.type][dbItem.id] = dbItem.updated;
 | 
			
		||||
            // Put item in the store
 | 
			
		||||
            store.commit(`${dbItem.type}/setItem`, dbItem);
 | 
			
		||||
            // Use deepCopy to freeze item
 | 
			
		||||
            resolve(dbItem);
 | 
			
		||||
          }
 | 
			
		||||
        };
 | 
			
		||||
      }, () => onError());
 | 
			
		||||
    });
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Unload from the store contents that haven't been opened recently
 | 
			
		||||
   */
 | 
			
		||||
  unloadContents() {
 | 
			
		||||
    const lastOpenedFileIds = store.getters['data/lastOpenedIds']
 | 
			
		||||
      .slice(0, 10).reduce((result, id) => {
 | 
			
		||||
        result[id] = true;
 | 
			
		||||
        return result;
 | 
			
		||||
      }, {});
 | 
			
		||||
    Object.keys(contentTypes).forEach((type) => {
 | 
			
		||||
      store.getters(`${type}/items`).forEach((item) => {
 | 
			
		||||
        const [fileId] = item.id.split('/');
 | 
			
		||||
        if (!lastOpenedFileIds[fileId]) {
 | 
			
		||||
          // Remove item from the store
 | 
			
		||||
          store.commit(`${type}/deleteItem`, item.id);
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -1 +1,24 @@
 | 
			
		||||
export default {};
 | 
			
		||||
import store from '../../store';
 | 
			
		||||
import googleHelper from '../helpers/googleHelper';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  downloadContent(token, fileId) {
 | 
			
		||||
    const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];
 | 
			
		||||
    return googleHelper.downloadAppDataFile(token, syncData.id)
 | 
			
		||||
      .then((content) => {
 | 
			
		||||
        if (content.updated !== syncData.updated) {
 | 
			
		||||
          store.dispatch('data/setSyncData', {
 | 
			
		||||
            ...store.getters['data/syncData'],
 | 
			
		||||
            [syncData.id]: {
 | 
			
		||||
              ...syncData,
 | 
			
		||||
              updated: content.updated,
 | 
			
		||||
            },
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
        return {
 | 
			
		||||
          history: [],
 | 
			
		||||
          ...content,
 | 
			
		||||
        };
 | 
			
		||||
      });
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -5,8 +5,6 @@ import utils from './utils';
 | 
			
		||||
import userActivitySvc from './userActivitySvc';
 | 
			
		||||
import gdriveAppDataProvider from './providers/gdriveAppDataProvider';
 | 
			
		||||
import googleHelper from './helpers/googleHelper';
 | 
			
		||||
import emptyContent from '../data/emptyContent';
 | 
			
		||||
import emptySyncContent from '../data/emptySyncContent';
 | 
			
		||||
 | 
			
		||||
const lastSyncActivityKey = 'lastSyncActivity';
 | 
			
		||||
let lastSyncActivity;
 | 
			
		||||
@ -36,6 +34,7 @@ function setLastSyncActivity() {
 | 
			
		||||
 | 
			
		||||
function getSyncProvider(syncLocation) {
 | 
			
		||||
  switch (syncLocation.provider) {
 | 
			
		||||
    case 'gdriveAppData':
 | 
			
		||||
    default:
 | 
			
		||||
      return gdriveAppDataProvider;
 | 
			
		||||
  }
 | 
			
		||||
@ -43,11 +42,21 @@ function getSyncProvider(syncLocation) {
 | 
			
		||||
 | 
			
		||||
function getSyncToken(syncLocation) {
 | 
			
		||||
  switch (syncLocation.provider) {
 | 
			
		||||
    case 'gdriveAppData':
 | 
			
		||||
    default:
 | 
			
		||||
      return store.getters['data/loginToken'];
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`)
 | 
			
		||||
  // Item does not exist, create it
 | 
			
		||||
  .catch(() => store.commit(`${type}/setItem`, {
 | 
			
		||||
    id: `${fileId}/${type}`,
 | 
			
		||||
  }));
 | 
			
		||||
const loadContent = loader('content');
 | 
			
		||||
const loadSyncContent = loader('syncContent');
 | 
			
		||||
const loadContentState = loader('contentState');
 | 
			
		||||
 | 
			
		||||
function applyChanges(changes) {
 | 
			
		||||
  const storeItemMap = { ...store.getters.allItemMap };
 | 
			
		||||
  const syncData = { ...store.getters['data/syncData'] };
 | 
			
		||||
@ -164,23 +173,51 @@ function sync() {
 | 
			
		||||
        const existingSyncData = store.getters['data/syncDataByItemId'][contentId];
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const syncOneContent = fileId => localDbSvc.retrieveItem(`${fileId}/syncContent`)
 | 
			
		||||
        .catch(() => ({ ...emptySyncContent(), id: `${fileId}/syncContent` }))
 | 
			
		||||
        .then(syncContent => localDbSvc.retrieveItem(`${fileId}/content`)
 | 
			
		||||
          .catch(() => ({ ...emptyContent(), id: `${fileId}/content` }))
 | 
			
		||||
          .then((content) => {
 | 
			
		||||
            const syncOneContentLocation = (syncLocation) => {
 | 
			
		||||
              return Promise.resolve()
 | 
			
		||||
                .then(() => {
 | 
			
		||||
                  const provider = getSyncProvider(syncLocation);
 | 
			
		||||
                  const token = getSyncToken(syncLocation);
 | 
			
		||||
                  return provider && token && provider.downloadContent()
 | 
			
		||||
                });
 | 
			
		||||
            };
 | 
			
		||||
      const syncOneContent = fileId => loadSyncContent(fileId)
 | 
			
		||||
        .then(() => loadContent(fileId))
 | 
			
		||||
        .then(() => {
 | 
			
		||||
          const getContent = () => store.state.content.itemMap[`${fileId}/content`];
 | 
			
		||||
          const getSyncContent = () => store.state.content.itemMap[`${fileId}/syncContent`];
 | 
			
		||||
 | 
			
		||||
            const syncLocations = [{ provider: null }, ...content.syncLocations];
 | 
			
		||||
            return syncOneContentLocation(syncLocations[0]);
 | 
			
		||||
          }));
 | 
			
		||||
          const syncLocations = [
 | 
			
		||||
            { id: 'main', provider: 'gdriveAppData' },
 | 
			
		||||
            ...getContent().syncLocations.filter(syncLocation => getSyncToken(syncLocation),
 | 
			
		||||
          )];
 | 
			
		||||
          const downloadedLocations = {};
 | 
			
		||||
 | 
			
		||||
          const syncOneContentLocation = () => {
 | 
			
		||||
            let result;
 | 
			
		||||
            syncLocations.some((syncLocation) => {
 | 
			
		||||
              if (!downloadedLocations[syncLocation.id]) {
 | 
			
		||||
                const provider = getSyncProvider(syncLocation);
 | 
			
		||||
                const token = getSyncToken(syncLocation);
 | 
			
		||||
                result = provider && token && provider.downloadContent(fileId, syncLocation)
 | 
			
		||||
                  .then((content) => {
 | 
			
		||||
                    const syncContent = getSyncContent();
 | 
			
		||||
                    const syncLocationData = syncContent.syncLocationData[syncLocation.id] || {
 | 
			
		||||
                      history: [],
 | 
			
		||||
                    };
 | 
			
		||||
                    let lastMergedContent;
 | 
			
		||||
                    syncLocationData.history.some((updated) => {
 | 
			
		||||
                      if (content.history.indexOf(updated) !== -1) {
 | 
			
		||||
 | 
			
		||||
                      }
 | 
			
		||||
                      return lastMergedContent;
 | 
			
		||||
                    });
 | 
			
		||||
                  })
 | 
			
		||||
                  .then(() => syncOneContentLocation());
 | 
			
		||||
              }
 | 
			
		||||
              return result;
 | 
			
		||||
            });
 | 
			
		||||
            return result;
 | 
			
		||||
          };
 | 
			
		||||
 | 
			
		||||
          return syncOneContentLocation();
 | 
			
		||||
        })
 | 
			
		||||
        .then(() => localDbSvc.unloadContents(), (err) => {
 | 
			
		||||
          localDbSvc.unloadContents();
 | 
			
		||||
          throw err;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
      // Called until no content to save
 | 
			
		||||
      const saveNextContent = ifNotTooLate(() => {
 | 
			
		||||
@ -189,7 +226,7 @@ function sync() {
 | 
			
		||||
          const updated = getContentUpdated(contentId);
 | 
			
		||||
          const existingSyncData = store.getters['data/syncDataByItemId'][contentId];
 | 
			
		||||
          if (!existingSyncData || existingSyncData.updated !== updated) {
 | 
			
		||||
            saveContentPromise = localDbSvc.retrieveItem(contentId)
 | 
			
		||||
            saveContentPromise = localDbSvc.loadItem(contentId)
 | 
			
		||||
              .then(content => googleHelper.saveItem(
 | 
			
		||||
                googleToken,
 | 
			
		||||
                // Use deepCopy to freeze objects
 | 
			
		||||
@ -299,19 +336,11 @@ localDbSvc.sync()
 | 
			
		||||
        store.dispatch('data/setLastOpenedId', currentFile.id);
 | 
			
		||||
        return Promise.resolve()
 | 
			
		||||
          // Load contentState from DB
 | 
			
		||||
          .then(() => localDbSvc.retrieveItem(`${currentFile.id}/contentState`)
 | 
			
		||||
            // contentState does not exist, create it
 | 
			
		||||
            .catch(() => store.commit('contentState/setItem', {
 | 
			
		||||
              id: `${currentFile.id}/contentState`,
 | 
			
		||||
            })))
 | 
			
		||||
          .then(() => loadContentState(currentFile.id))
 | 
			
		||||
          // Load syncContent from DB
 | 
			
		||||
          .then(() => localDbSvc.retrieveItem(`${currentFile.id}/syncContent`)
 | 
			
		||||
            // syncContent does not exist, create it
 | 
			
		||||
            .catch(() => store.commit('syncContent/setItem', {
 | 
			
		||||
              id: `${currentFile.id}/syncContent`,
 | 
			
		||||
            })))
 | 
			
		||||
          .then(() => loadSyncContent(currentFile.id))
 | 
			
		||||
          // Load content from DB
 | 
			
		||||
          .then(() => localDbSvc.retrieveItem(`${currentFile.id}/content`));
 | 
			
		||||
          .then(() => localDbSvc.loadItem(`${currentFile.id}/content`));
 | 
			
		||||
      }),
 | 
			
		||||
    {
 | 
			
		||||
      immediate: true,
 | 
			
		||||
@ -320,6 +349,14 @@ localDbSvc.sync()
 | 
			
		||||
// Sync local DB periodically
 | 
			
		||||
utils.setInterval(() => localDbSvc.sync(), 1000);
 | 
			
		||||
 | 
			
		||||
// Unload contents from memory periodically
 | 
			
		||||
utils.setInterval(() => {
 | 
			
		||||
  // Wait for sync and publish to finish
 | 
			
		||||
  if (store.state.queue.isEmpty) {
 | 
			
		||||
    localDbSvc.unloadContents();
 | 
			
		||||
  }
 | 
			
		||||
}, 5000);
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  isSyncAvailable,
 | 
			
		||||
  requestSync,
 | 
			
		||||
 | 
			
		||||
@ -25,13 +25,13 @@ const patcher = id => ({ state, commit }, data) => {
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
const localSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLocalSettings', {
 | 
			
		||||
  [propertyName]: value === undefined ? !getters.localSettings[propertyName] : value,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Local settings
 | 
			
		||||
module.getters.localSettings = getter('localSettings');
 | 
			
		||||
module.actions.patchLocalSettings = patcher('localSettings');
 | 
			
		||||
const localSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLocalSettings', {
 | 
			
		||||
  [propertyName]: value === undefined ? !getters.localSettings[propertyName] : value,
 | 
			
		||||
});
 | 
			
		||||
module.actions.toggleNavigationBar = localSettingsToggler('showNavigationBar');
 | 
			
		||||
module.actions.toggleEditor = localSettingsToggler('showEditor');
 | 
			
		||||
module.actions.toggleSidePreview = localSettingsToggler('showSidePreview');
 | 
			
		||||
@ -48,7 +48,7 @@ module.getters.lastOpened = getter('lastOpened');
 | 
			
		||||
const getLastOpenedIds = (lastOpened, rootState) => Object.keys(lastOpened)
 | 
			
		||||
  .filter(id => rootState.file.itemMap[id])
 | 
			
		||||
  .sort((id1, id2) => lastOpened[id2] - lastOpened[id1])
 | 
			
		||||
  .slice(0, 10);
 | 
			
		||||
  .slice(0, 20);
 | 
			
		||||
module.getters.lastOpenedIds = (state, getters, rootState) =>
 | 
			
		||||
  getLastOpenedIds(getters.lastOpened, rootState);
 | 
			
		||||
module.actions.setLastOpenedId = ({ getters, commit, rootState }, fileId) => {
 | 
			
		||||
@ -80,18 +80,18 @@ module.actions.setSyncData = setter('syncData');
 | 
			
		||||
module.getters.tokens = getter('tokens');
 | 
			
		||||
module.getters.googleTokens = (state, getters) => getters.tokens.google || {};
 | 
			
		||||
module.getters.loginToken = (state, getters) => {
 | 
			
		||||
  // Return the first googleToken that has the isLogin flag
 | 
			
		||||
  // Return the first google token that has the isLogin flag
 | 
			
		||||
  const googleTokens = getters.googleTokens;
 | 
			
		||||
  const loginSubs = Object.keys(googleTokens)
 | 
			
		||||
    .filter(sub => googleTokens[sub].isLogin);
 | 
			
		||||
  return googleTokens[loginSubs[0]];
 | 
			
		||||
};
 | 
			
		||||
module.actions.patchTokens = patcher('tokens');
 | 
			
		||||
module.actions.setGoogleToken = ({ getters, dispatch }, googleToken) => {
 | 
			
		||||
module.actions.setGoogleToken = ({ getters, dispatch }, token) => {
 | 
			
		||||
  dispatch('patchTokens', {
 | 
			
		||||
    google: {
 | 
			
		||||
      ...getters.googleTokens,
 | 
			
		||||
      [googleToken.sub]: googleToken,
 | 
			
		||||
      [token.sub]: token,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user